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.)

"""


[docs]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'])
[docs] 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)
[docs] 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
[docs] 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
[docs]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]
[docs] 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
[docs]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
[docs] 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()
[docs]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
[docs] 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
[docs]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
[docs]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 '''
[docs]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)