listmode.plot — listmode 0.5.0 documentation

# Source code for listmode.plot

    import matplotlib.pyplot as plt

    import numpy as np

    import listmode.data as dat
    import listmode.exceptions as ex
    from listmode import misc

    styyl = {'axes.titlesize': 30,
             'axes.labelsize': 16,
             'lines.linewidth': 3,
             'lines.markersize': 10,
             'xtick.labelsize': 12,
             'ytick.labelsize': 12}
    plt.style.use(styyl)

    '''
    styles = ['bmh', 'classic', 'dark_background', 'fast', 'fivethirtyeight', 'ggplot', 'grayscale',
              'seaborn-bright', 'seaborn-colorblind', 'seaborn-dark-palette', 'seaborn-dark', 'seaborn-darkgrid',
              'seaborn-deep', 'seaborn-muted', 'seaborn-notebook', 'seaborn-paper', 'seaborn-pastel', 'seaborn-poster',
              'seaborn-talk', 'seaborn-ticks', 'seaborn-white', 'seaborn-whitegrid',
              'seaborn', 'Solarize_Light2', 'tableau-colorblind10', '_classic_test']
    '''

    """
    The plot module contains the Plot and OnlinePlot classes, which are the main interface to plotting and histogramming
    data. The full workflow goes as follows: the Plot class initializes Axes objects and The Filter object used in the plot.
    Axes define what data is plotted and how many axes there is in the data defines which filter is used. The filter is
    responsible of producing the histogram using input data and gates defined in the configuration.

    Plot class also sets up plot specific matplotlib setup, styles etc.

    (The OnlinePlot class is a lighter class that collects incremental data into its filter and returns it on demand.
    OnlinePlot cannot have its plot config changed, but it  does not need a reference to Data class so it  can be
    filled with any data source. It is not. Yet.)

    """

    class Plot:
        """
        Plot is a manager class for handling a histogram to plot. A plot itself is a combination of its axes and its filter.
        Axes objects are responsible for the calibration, limits and unit labels of the plot. A plot is filled by feeding
        it with data chunks via its update-method. Filter is the actual histogramming function collecting output of each
        chunk. Plot class can return the data and axes information as numpy array via get_data-method and title, legend and
        axis label strings via its get_plot_labels-method.

        Plot configuration dictionary defines the data in the plot as well as the plotting parameters, such as labels,
        scales etc. The Plot class uses only the information in plot_cfg list and the name of the config. Only one plot can
        be defined per Plot object. However multiple plots can be stacked into the plot_cfg list of the plot configuration.
        If all of the plots are 1d and have same axes they can be plotted into a single figure.

        Creating figures and handling how to stack plots into figures is done explicitly by the user. Two Plot instances can
        be compared for equality to help with stacking. The comparison returns True only if the two plots can be shown in
        the same axes.

        """

        def __init__(self, canvas_cfg, det_cfg, time_slice=None, plot_idx=0):
            """
            On initialization the plot class needs to have both plot and detector configurations. These will be parsed to
            set up the axes, filters and plot.

            :param canvas_cfg:  Plot configuration dictionary. It should only have 1 plot_cfg item for one plot. In addition
                                a canvas_cfg holds information on the name of the plot and plotting directives for
                                matplotlib.
            :param det_cfg:     Detector config object.
            :param time_slice:  Optional initial time slice. Defaults to None, in which case data is plotted as it is added
                                with the update method. This parameter is good to have when plotting time data from disk
                                since time axes plots are faster when full extent of the data axis is known beforehand.
            :param plot_idx:     The index of the plot if several are given

            """

            self.canvas_cfg = canvas_cfg.copy()
            plot_cfg = canvas_cfg['plot_cfg']
            if isinstance(plot_cfg, list):
                self.plot_cfg = plot_cfg[plot_idx]
            else:
                self.plot_cfg = plot_cfg
            self.canvas_cfg['plot_cfg'] = self.plot_cfg
            self.det_cfg = det_cfg
            axisdata = [x['data'] for x in self.plot_cfg['axes']]

            for adata in axisdata:
                if not adata in ('time', 'energy'):  # extra data
                    # bitmask data cannot be plotted
                    xtra_idx = [x['name'] for x in self.det_cfg.det['extras']].index(adata)
                    if issubclass(dat.process_dict[det_cfg.det['extras'][xtra_idx]['aggregate']], dat.process_dict['bit']):
                        raise ex.ListModePlotError('Attempted to plot bitmask data!')

            self.cal = dat.load_calibration(self.det_cfg)
            self.time_slice = time_slice

            # Main difference between different plots is whether they are 1d or 2d plots, or whether plotcfg['axes']
            # is a list of one or two dicts.
            self.two_d = len(self.plot_cfg['axes']) == 2

            # Get the gates defined in the plot
            self.gates = []

            for gate_info in self.plot_cfg['gates']:
                # bitmask gates need to be defined with bitmask flag on, hence we check if aggregate is a subclass of bit
                # processor
                bitmask = False
                if gate_info['data'] not in ('time', 'energy'):  # extra data
                    xtra_idx = [x['name'] for x in self.det_cfg.det['extras']].index(gate_info['data'])
                    if issubclass(dat.process_dict[det_cfg.det['extras'][xtra_idx]['aggregate']], dat.process_dict['bit']):
                        bitmask=True
                self.gates.append(Gate(gate_info, self.det_cfg.det, self.det_cfg.cal, bitmask=bitmask))

            # Then define the axes
            self.axes = []
            for axis_info in self.plot_cfg['axes']:
                self.axes.append(Axis(axis_info, self.det_cfg, time_slice))

            # finally the filter and axis labeling
            # axis labels units are produced dynamically by Axis class to correctly show calibrated and raw data.

            self.labels = []
            if not self.two_d:
                self.filter = Filter1d(self.axes)
                ch_name = det_cfg.det['ch_cfg'][self.axes[0].det_ch]['name']
                # for 1d plots the labels are generic, because different plots can be stacked into one figure.
                self.labels.append('{} '.format(self.axes[0].dtype.capitalize()))

                # y should always be counts, right?
                self.labels.append('Counts')
                self.legend = '{} {}'.format(ch_name, self.plot_cfg['plot_name'])
            else:
                self.filter = Filter2d(self.axes)
                # For 2d case the channel names are included. They must be hunted down using data configuration and
                # channel mask, unless the axis is time.
                for axis in self.axes:
                    if axis.time_like:
                        self.labels.append('{} '.format(axis.dtype.capitalize()))
                    else:
                        self.labels.append('{} {} '.format(det_cfg.det['ch_cfg'][axis.det_ch]['name'], axis.dtype))
                # for 2d-plots the legend is the label of the colorbar and is taken from plot name
                self.legend = '{}'.format(self.plot_cfg['plot_name'])
            # plot title
            self.title = '{} {}'.format(det_cfg.det['name'], canvas_cfg['name'])

        def update(self, data_dict):
            """
            Update method runs the relevant data through all the gates to produce a final mask and runs the masked data into
            axes (for axis limit updates) and filter (for histogramming).

            :param data_dict:
            :return:
            """

            # Here the channel mask needs to be taken into account!

            datas = []
            mask = np.ones((data_dict['time'].shape[0],), dtype='bool')

            for gate in self.gates:
                # all gates are in 'and' mode, but individual gates add their ranges in 'or' mode
                mask = gate.update(data_dict, mask)  # gate updates mask

            for axis in self.axes:  # Gated data is extracted into datas list
                if axis.dtype != 'time':
                    datas.append(data_dict[axis.dtype][mask, axis.ch_map[axis.channel]])
                    axis.update(datas[-1])  # updates the axis limits
                else:
                    datas.append(data_dict[axis.dtype][mask])
                    axis.update(datas[-1])  # updates the axis limits
            self.filter.update(datas)

        def get_data(self, calibrate=True):
            """
            Returns the histogram as numpy array along with bins for each axis and text for legend/export filename.

            :param calibrate: Return calibrated bins
            :return:
            """
            if calibrate:
                bins = [axis.edges for axis in self.axes]
            else:
                bins = [axis.bins for axis in self.axes]
            return self.filter.histo, bins

        def get_plot_labels(self, calibrate=True):
            """
            Returns title legend and axis labels.

            :param calibrate:
            :return:
            """
            out = self.labels.copy()
            for i in range(len(self.axes)):
                if calibrate:
                    out[i] = out[i]+self.axes[i].unit
                else:
                    out[i] = out[i]+self.axes[i].raw_unit
            return self.title, self.legend, out

        def __eq__(self, other):
            # only defined for other Plots
            if isinstance(other, Plot):
                # two-d plots cannot be plotted into the same figure
                if self.two_d or other.two_d:
                    return False
                # ok if x-axis is the same
                return self.axes[0].dtype == other.axes[0].dtype
            return NotImplemented

    class Gate:
        """
        Gate is a simple class defining a single filter for data streamed through it's update method. It is defined by
        gate_info dictionary with following keys:
        "channel":  The channel the gate is for.
        "dtype":     The data type the gate is for.
        "range":    A list of ranges defining where the gate is passing through (if null or coincident) or blocking (if
                    anticoincident). Each range is a list of start and stop values in calibrated units.
        "coinc":    Defines coincidence (positive integer), anticoincidence (negative integer) or null coincidence. A null
                    gate will still limit the plot axis and is thus implicitly handled as coincident if it is defined for
                    one of the plot axes.

        """

        def __init__(self, gate_info, det_cfg, cal, bitmask=False):
            """

            :param gate_info: the gate_info dict
            :param bitmask: If this is set, the data is bitmask data and range is ignored. Data is within range if the bit
            at index 'channel' is set.
            """
            # unpacking dict for easy access and to prevent problems with mutability
            self.channel = gate_info['channel']

            self.dtype = gate_info['data']
            self.bitmask = bitmask

            if not bitmask:
                # inverse calibration of the range. Range is applied into raw values
                self.range = [dat.ipoly2(roi, *cal[self.dtype][self.channel, :]) for roi in gate_info['range']]
            else:
                self.chbit = 2**self.channel
            self.coinc = gate_info['coinc']

            # Check validity and set the proper detector channel to data channel mapping
            if self.dtype in ('time', 'energy'):
                self.data_ch = self.channel
                self.ch_map = np.arange(len(det_cfg['ch_list']))
            else:
                # some sort of extra data. Find the index by matching
                xtra_idx = [x['name'] for x in det_cfg['extras']].index(self.dtype)

                # check that the gate setup is reasonable -> does the detector channel exist in the extra data.
                if not det_cfg['extras'][xtra_idx]['ch_mask'][self.channel]:
                    raise ex.ListModePlotError('Attempted to gate on a nonexisting extra data channel!')

                # map detector ch to data ch
                self.ch_map = np.cumsum(det_cfg['extras'][xtra_idx]['ch_mask'])
                self.data_ch = self.ch_map[self.channel]

        def update(self, data_dict, mask):
            """
            Update runs the data_dict through the gate selection and modifies the input mask.

            :param data_dict: Full data dict of the chunk
            :param mask: A mask defining events that pass. The mask is modified in-place.
            :return:
            """
            # magic is done here
            rmask = np.zeros_like(mask)
            if not self.bitmask:
                for roi in self.range:
                    rmask = np.logical_or(rmask,
                                          np.logical_and(data_dict[self.dtype][:, self.data_ch] >= roi[0],
                                                          data_dict[self.dtype][:, self.data_ch] < roi[1]))
            else:
                rmask = (data_dict[self.dtype] & self.chbit > 0)[:, 0]  # for some reason a dimension is added -> strip
            if self.coinc < 0:  # anticoincidence
                mask = np.logical_and(mask, np.logical_not(rmask))
            elif self.coinc > 0:  # coincidence
                mask = np.logical_and(mask, rmask)
            return mask

    class Axis:
        """
        Axis info is a class handling a single data axis in a plot. Axis takes care of binning, calibration, tick spacing
        and labeling of the plot. For this to happen, Axis needs not only axis configuration but also detector configuration
        to know about the data it is showing.

        Axis is not meant to be used directly. It is a part of Plot.

        """

        def __init__(self, axis_info, det_cfg, time_slice=None):
            """
            Axis is binned on init using the gate information/time_slice if present. The binning is done in raw data units
            (self.bins) and calculated from bins into calibrated units (self.edges). Axis info is given in calibrated
            units to be human readable so all gates are calculated back to raw values. This may cause errors if calibration
            is not valid for full range.

            Note that bitmask type of data cannot be plotted on an axis and an error is raised.

            :param axis_info: The axis_info dict defining the axis.
            :param det_cfg: Data properties are retrieved from the detector config
            :param time_slice: Needed only for time axis limits.

            """

            # unpack data from the dict, because it is mutable
            self.channel = axis_info['channel']
            self.ch_list = det_cfg.det['ch_list']
            self.ch_mask = np.ones_like(self.ch_list, dtype='bool')
            self.ch_map = self.ch_mask.cumsum() - 1
            self.dtype = axis_info['data']
            if self.dtype == 'time':
                timestr = axis_info['timebase']

            self.bin_width = axis_info['bin_width']  # bin width is always in raw units
            if self.dtype in ('time', 'energy'):
                self.det_ch = self.channel
            else:
                # some sort of extra data. Find the index by matching
                xtra_idx = [x['name'] for x in det_cfg.det['extras']].index(self.dtype)
                # check that the data can be plotted. Checking aggregate from extra data.
                if issubclass(dat.process_dict[det_cfg.det['extras'][xtra_idx]['aggregate']], dat.process_dict['bit']):
                    raise ex.ListModePlotError('Attempted to plot bitmask data!')

                #idx_map = np.cumsum(det_cfg.det['extras'][xtra_idx]['ch_mask']) - 1  # map between extra and detector channels
                #temp = self.det_ch[det_cfg.det['extras'][xtra_idx]['ch_mask']]
                #self.det_ch = self.ch_list[det_cfg.det['extras'][xtra_idx]['ch_mask'][self.channel]]
                self.det_ch = self.ch_map[self.channel]

            self.limits = None  # If limits have not been set the axes will adjust between min and max values in the data.
            self.min = 0  # minimum and maximum values in the filtered selected data
            self.max = 2

            # Dirty flag is set to True when bins have changed. Tested by Filter via has_changed-method) and will trigger
            # recalculation of histogram. Will be set to False once tested.
            self.dirty = False

            # stupidly there is three kinds of data, even if two would be enough
            if self.dtype != 'time':  # Anything not time, energy an extras are identical, but in different config.
                self.time_like = False  # Flag for time-like axes (updates handled differently)
                if self.dtype == 'energy':
                    self.unit = '[{}]'.format(det_cfg.det['events']['unit'])
                    self.raw_unit = '[{}]'.format(det_cfg.det['events']['raw_unit'])
                else:
                    self.unit = '[{}]'.format(det_cfg.det['extras'][xtra_idx]['unit'])
                    self.raw_unit = '[{}]'.format(det_cfg.det['extras'][xtra_idx]['raw_unit'])
                    self.ch_mask[:] = det_cfg.det['extras'][xtra_idx]['ch_mask']
                    self.ch_map = self.ch_mask.cumsum() - 1
                # get calibration for data/channel
                print('dtype', self.dtype)
                print('map', self.ch_map)
                print('channel', self.channel)
                self.cal = det_cfg.cal[self.dtype][self.ch_map[self.channel], :]

                # The range is in calibrated units. Uncalibrating.
                if axis_info['range'] is not None:
                    self.limits = dat.ipoly2(np.array(axis_info['range']), *self.cal)

            else:  # time axis is special and is set up here
                self.time_like = True
                timebase, temp = misc.parse_timebase(timestr)

                # timebase is handled as calibration
                self.cal = np.array((0., 1/timebase, 0.))
                self.unit = '[{}]'.format(timestr)
                self.raw_unit = '[ns]'
                self.bin_width = dat.ipoly2(self.bin_width, *self.cal)  # bin width in timebase units

                if time_slice is not None:  # time_slice is always in nanoseconds
                    self.limits = time_slice
            self._calculate_bins()

        def _calculate_bins(self):
            """
            Recalculates bins and associated edges. Should be called only when plot range changes. This should trigger a
            reshape of the histogram in Filter via the dirty flag.

            Bins define the left edge of every bin plus the right edge of the last bin. The range of bins is built to fully
            encompass the limits.

            :return:

            """
            # bins are built to fully encompass the limits and add one more value to the end to define the right side edge.
            if self.limits is not None:
                self.bins = np.arange(np.floor(self.limits[0]/self.bin_width)*self.bin_width,
                                      np.ceil(self.limits[1]/self.bin_width)*self.bin_width + self.bin_width,
                                      self.bin_width)
            else:
                self.bins = np.arange(np.floor(self.min/self.bin_width)*self.bin_width,
                                      np.ceil(self.max/self.bin_width)*self.bin_width + self.bin_width,
                                      self.bin_width)

            self.edges = dat.poly2(self.bins, *self.cal)
            self.dirty = True

        def has_changed(self):
            temp = self.dirty
            self.dirty = False
            return temp

        def update(self, data):
            """
            Histogram is updated with the filtered selected data in a list called datas.

            :param data: Numpy array of data values.
            :return:
            """
            if self.limits is None:
                datamin = data.min()
                datamax = data.max()
                if datamin < self.min or datamax > self.max:
                    self.min = min(self.min, datamin)
                    self.max = max(self.max, datamax)
                    self._calculate_bins()

    class Filter1d:
        """
        Filter collects the histogram. It defines its range by the axes.

        """

        def __init__(self, axes):
            # any non-empty monotonically increasing array of at least two entries is good as original bins, but it does
            # make sense to use 0 and 1 as the edges. It defines the only bin with 0 counts.
            self.bins = [np.arange(0, 2) for _x in range(len(axes))]
            self.histo = np.zeros((1,))  # there is no data
            self.axes = axes
            self._build()
            self._histogram = np.histogram  # different numpy function for 1d and 2d data
            self.two_d = False

        def _build(self):
            """
            Rebuilds the histogram if bins have changed.

            :return:
            """
            new_bins = [self.axes[0].bins]
            old_histo = self.histo.copy()
            i1 = (new_bins[0] == self.bins[0][0]).argmax()
            i2 = (new_bins[0] == self.bins[0][-1]).argmax()
            self.histo = np.zeros((new_bins[0].shape[0],))
            self.histo[i1:i2 + 1] = old_histo
            self.bins = new_bins

        def update(self, datas):
            """
            Histogram is updated with the filtered selected data in a list called datas.

            :param datas: List of numpy arrays of data values.
            :return:
            """
            # first check if axes have changed. This is done via a dirty bit in the axes class
            flag = False
            for axis in self.axes:
                flag = flag or axis.has_changed()
            if flag:
                self._build()

            if not self.two_d:
                histo_tuple, _x = self._histogram(*datas, self.bins[0])
                self.histo[:-1] += histo_tuple
            else:
                histo_tuple, _x, _y = self._histogram(*datas, self.bins)
                self.histo[:-1, :-1] += histo_tuple

    class Filter2d(Filter1d):
        """
        In 2d-filter the __init__ and _build are overridden to handle two axes.
        """
        def __init__(self, axes):
            super().__init__(axes)
            self.two_d = True
            self._histogram = np.histogram2d
            self.histo = np.zeros((1, 1))  # there is no data

        def _build(self):
            """
            Rebuilds the histogram if axes have changed.

            :return:

            """

            new_bins = [self.axes[0].bins, self.axes[1].bins]
            old_histo = self.histo.copy()
            i1 = (new_bins[0] == self.bins[0][0]).argmax()
            i2 = (new_bins[0] == self.bins[0][-1]).argmax()
            j1 = (new_bins[1] == self.bins[1][0]).argmax()
            j2 = (new_bins[1] == self.bins[1][-1]).argmax()
            self.histo = np.zeros((new_bins[0].shape[0], new_bins[1].shape[0]))
            self.histo[i1:i2 + 1, j1:j2 + 1] = old_histo
            self.bins = new_bins
            self._histogram = np.histogram2d

    def data_plot(data, plot_list, time_slice=None, calibrate=True, plot=False):
        """
        Demo function to produce data from a plot config dictionary.
        """
        pass
        '''
        edges = []
        values = []
        labels = []
        temp_list = []
        for canvas_idx, canvas in enumerate(plot_list):
            temp_list.append(plot_types[canvas['plot_type']](canvas, data.config, time_slice, calibrate))

        looping = True

        while looping:  # data_block[-1]:
            data_block, looping = data.get_data_block(time_slice)

            for temp_plot in temp_list:
                temp_plot.update(data_block)

        for temp_plot in temp_list:
            temp = temp_plot.get()
            edges.extend(temp[0])
            values.extend(temp[1])
            labels.extend(temp[2])
            if plot:
                temp_plot.plot()
        if plot:
            plt.show()
        return edges, values, labels
        '''

    def get_ticks(max_x, numticks=30):
        """
        Tries to divide the numticks to the axis in a smart way. Probably not used atm.

        :param max_x:
        :param numticks:
        :return: The ticks in a numpy array
        """

        if max_x / numticks > 1:
            tick_mag = int(np.round(np.log10(max_x / numticks / 10)))
            tick_size = np.round(np.floor(max_x / numticks), -tick_mag)
        else:
            tick_size = 1

        return np.arange(0.0, max_x, tick_size)