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)