From b966d64b1424a76f642fa778fcbc5ee58e7c6c73 Mon Sep 17 00:00:00 2001 From: vasilisniaouris Date: Sat, 26 Aug 2023 13:32:33 -0700 Subject: [PATCH 1/8] Reformatted `oscilloscope.py` to follow standard python formatting. --- src/applications/oscilloscope.py | 104 +++++++++++++++++-------------- 1 file changed, 58 insertions(+), 46 deletions(-) diff --git a/src/applications/oscilloscope.py b/src/applications/oscilloscope.py index 97cee8f3..77dac2ee 100644 --- a/src/applications/oscilloscope.py +++ b/src/applications/oscilloscope.py @@ -22,20 +22,20 @@ parser = argparse.ArgumentParser(description='NI DAQ (PCIx 6363) digital input terminal count rate meter.', formatter_class=argparse.ArgumentDefaultsHelpFormatter) -parser.add_argument('-d', '--daq-name', default = 'Dev1', type=str, metavar = 'daq_name', +parser.add_argument('-d', '--daq-name', default='Dev1', type=str, metavar='daq_name', help='NI DAQ Device Name') -parser.add_argument('-st', '--signal-terminal', metavar = 'terminal', default = 'PFI0', type=str, +parser.add_argument('-st', '--signal-terminal', metavar='terminal', default='PFI0', type=str, help='NI DAQ terminal connected to input digital TTL signal') -parser.add_argument('-w', '--scope-width', metavar = 'width', default = 500, type=int, +parser.add_argument('-w', '--scope-width', metavar='width', default=500, type=int, help='Number of measurements to display in window.') -parser.add_argument('-c', '--clock-rate', metavar = 'rate (Hz)', default = 100000, type=int, +parser.add_argument('-c', '--clock-rate', metavar='rate (Hz)', default=100000, type=int, help='''Specifies the clock rate in Hz. If using an external clock, you should specifiy the clock rate here so that the correct counts per second are displayed. If using the internal NI DAQ clock (default behavior), this value specifies the clock rate to use. Per the NI DAQ manual, use a suitable clock rate for the device for best performance, which is an integer multiple downsample of the digital sample clock.''') -parser.add_argument('-n', '--num-data-samples-per-batch', metavar = 'N', default = 1500, type=int, +parser.add_argument('-n', '--num-data-samples-per-batch', metavar='N', default=1500, type=int, help='''Number of data points to acquire per DAQ batch request. Note that only ONE data point is shown in the scope. After each request to the NI DAQ for data, the mean count @@ -43,30 +43,29 @@ the "num-data-samples-per-batch" should reduce your noise, but slow the response of the scope. Increase this value if the scope appears too noisy.''') -parser.add_argument('-ct', '--clock-terminal', metavar = 'terminal', default = None, type=str, +parser.add_argument('-ct', '--clock-terminal', metavar='terminal', default=None, type=str, help='''Specifies the digital input terminal to the NI DAQ to use for a clock. If None, which is the default, the internal NI DAQ clock is used.''') -parser.add_argument('-to', '--rwtimeout', metavar = 'seconds', default = 10, type=int, +parser.add_argument('-to', '--rwtimeout', metavar='seconds', default=10, type=int, help='NI DAQ read/write timeout in seconds.') -parser.add_argument('-sc', '--signal-counter', metavar = 'ctrN', default = 'ctr2', type=str, +parser.add_argument('-sc', '--signal-counter', metavar='ctrN', default='ctr2', type=str, help='NI DAQ interal counter (ctr1, ctr2, ctr3, ctr4)') -parser.add_argument('-r', '--randomtest', action = 'store_true', +parser.add_argument('-r', '--randomtest', action='store_true', help='When true, program will run showing random numbers. This is for development testing.') -parser.add_argument('-aut', '--animation-update-interval', metavar = 'milliseconds', default = 20, +parser.add_argument('-aut', '--animation-update-interval', metavar='milliseconds', default=20, help='''Sets the animation update period, t, (in milliseconds). This is the time delay between calls to acquire new data. You should be limited by the data acquisition time = N / clock_rate.''') -parser.add_argument('--console', action = 'store_true', +parser.add_argument('--console', action='store_true', help='Run as console app -- just the figure without buttons.') args = parser.parse_args() - class ScopeFigure: - def __init__(self, width=50, fig = None, ax = None): - if ax == None: + def __init__(self, width=50, fig=None, ax=None): + if ax is None: fig, ax = plt.subplots() self.fig = fig self.ax = ax @@ -77,36 +76,35 @@ def __init__(self, width=50, fig = None, ax = None): self.ydata = collections.deque(np.zeros(width)) self.line, = self.ax.plot(self.ydata) self.ax.set_ylabel('counts / sec') - self.ax.ticklabel_format(style='sci',scilimits=(-3,4),axis='y') + self.ax.ticklabel_format(style='sci', scilimits=(-3, 4), axis='y') def init(self): self.line.set_ydata(self.ydata) return self.line, - def update(self, y): self.ydata.popleft() self.ydata.append(y) - #this doesn't work with blit = True. - #there's a workaround if we need blit = true - #https://stackoverflow.com/questions/53423868/matplotlib-animation-how-to-dynamically-extend-x-limits - #need to sporadically call - #fig.canvas.resize_event() + # this doesn't work with blit = True. + # there's a workaround if we need blit = true + # https://stackoverflow.com/questions/53423868/matplotlib-animation-how-to-dynamically-extend-x-limits + # need to sporadically call + # fig.canvas.resize_event() - delta = 0.1*np.max(self.ydata) + delta = 0.1 * np.max(self.ydata) new_min = np.max([0, np.min(self.ydata) - delta]) new_max = np.max(self.ydata) + delta current_min, current_max = self.ax.get_ylim() - if (np.abs((new_min - current_min)/(current_min)) > 0.12) or (np.abs((new_max - current_max)/(current_max)) > 0.12): + if (np.abs((new_min - current_min) / (current_min)) > 0.12) or ( + np.abs((new_max - current_max) / (current_max)) > 0.12): self.ax.set_ylim(np.max([0.01, np.min(self.ydata) - delta]), np.max(self.ydata) + delta) self.line.set_ydata(self.ydata) return self.line, - -class MainApplicationView(): +class MainApplicationView: def __init__(self, main_frame): frame = Tk.Frame(main_frame) frame.pack(side=Tk.LEFT, fill=Tk.BOTH, expand=True) @@ -124,7 +122,7 @@ def __init__(self, main_frame): self.canvas.draw() -class SidePanel(): +class SidePanel: def __init__(self, root): frame = Tk.Frame(root) frame.pack(side=Tk.LEFT, fill=Tk.BOTH, expand=True) @@ -133,7 +131,8 @@ def __init__(self, root): self.stopButton = Tk.Button(frame, text="Stop") self.stopButton.pack(side="top", fill=Tk.BOTH) -class MainTkApplication(): + +class MainTkApplication: def __init__(self, data_model): self.root = Tk.Tk() @@ -150,20 +149,22 @@ def run(self): self.root.deiconify() self.root.mainloop() - def stop_scope(self, event = None): + def stop_scope(self, event=None): self.model.stop() if self.animation is not None: self.animation.pause() - def start_scope(self, event = None): + def start_scope(self, event=None): if self.animation is None: self.view.canvas.draw_idle() - self.animation = animation.FuncAnimation(self.view.scope_view.fig, - self.view.scope_view.update, - self.model.yield_count_rate, - init_func = self.view.scope_view.init, - interval=args.animation_update_interval, - blit=False) + self.animation = animation.FuncAnimation( + self.view.scope_view.fig, + self.view.scope_view.update, + self.model.yield_count_rate, + init_func=self.view.scope_view.init, + interval=args.animation_update_interval, + blit=False + ) self.model.start() self.animation.resume() @@ -177,39 +178,50 @@ def on_closing(self): logger.debug(e) pass + def build_data_model(): if args.randomtest: data_acquisition_model = datasources.RandomRateCounter() else: - data_acquisition_model = datasources.NiDaqDigitalInputRateCounter(daq_name = args.daq_name, - signal_terminal = args.signal_terminal, - clock_rate = args.clock_rate, - num_data_samples_per_batch = args.num_data_samples_per_batch, - clock_terminal = args.clock_terminal, - read_write_timeout = args.rwtimeout, - signal_counter = args.signal_counter) + data_acquisition_model = datasources.NiDaqDigitalInputRateCounter( + daq_name=args.daq_name, + signal_terminal=args.signal_terminal, + clock_rate=args.clock_rate, + num_data_samples_per_batch=args.num_data_samples_per_batch, + clock_terminal=args.clock_terminal, + read_write_timeout=args.rwtimeout, + signal_counter=args.signal_counter + ) return data_acquisition_model -def run_console(): +def run_console(): view = ScopeFigure(args.scope_width) model = build_data_model() model.start() - ani = animation.FuncAnimation(view.fig, view.update, model.yield_count_rate, - init_func = view.init, - interval=args.animation_update_interval, blit=False) + ani = animation.FuncAnimation( + view.fig, + view.update, + model.yield_count_rate, + init_func=view.init, + interval=args.animation_update_interval, + blit=False + ) plt.show() model.close() + def run_gui(): tkapp = MainTkApplication(build_data_model()) tkapp.run() + def main(): if args.console: run_console() else: run_gui() + if __name__ == '__main__': main() From 865f0ae3f82a4ad388c567515a76c40cb9577318 Mon Sep 17 00:00:00 2001 From: vasilisniaouris Date: Sat, 26 Aug 2023 14:20:43 -0700 Subject: [PATCH 2/8] Updated `oscilloscope.py` to display rolling mean line and reading text. --- src/applications/oscilloscope.py | 124 ++++++++++++++++++++++++++++--- src/qt3utils/math_utils.py | 9 +++ 2 files changed, 124 insertions(+), 9 deletions(-) create mode 100644 src/qt3utils/math_utils.py diff --git a/src/applications/oscilloscope.py b/src/applications/oscilloscope.py index 77dac2ee..2fe9515d 100644 --- a/src/applications/oscilloscope.py +++ b/src/applications/oscilloscope.py @@ -14,8 +14,9 @@ import nidaqmx -import qt3utils.nidaq -import qt3utils.datagenerators as datasources +import src.qt3utils.nidaq +import src.qt3utils.datagenerators as datasources +from src.qt3utils.math_utils import get_rolling_mean logger = logging.getLogger(__name__) @@ -52,10 +53,20 @@ help='NI DAQ interal counter (ctr1, ctr2, ctr3, ctr4)') parser.add_argument('-r', '--randomtest', action='store_true', help='When true, program will run showing random numbers. This is for development testing.') -parser.add_argument('-aut', '--animation-update-interval', metavar='milliseconds', default=20, +parser.add_argument('-aut', '--animation-update-interval', metavar='milliseconds', default=20, type=float, help='''Sets the animation update period, t, (in milliseconds). This is the time delay between calls to acquire new data. You should be limited by the data acquisition time = N / clock_rate.''') +parser.add_argument('-rmw', '--rolling-mean-window', metavar='milliseconds', default=400, type=float, + help='''Sets the displayed rolling mean window size, w, (in milliseconds). + Actual value will set as the ceil(rmw/aut) * aut.''') +parser.add_argument('-srm', '--show-rolling-mean', default='Υ', + help='''Show (Y) or hide (N) the rolling mean line.''') +parser.add_argument('-srt', '--show-reading-text', default='Υ', + help='''Show (Y) or hide (N) the reading text. + If rolling mean is enabled, rolling mean, rolling standard + deviation (STD) and expected STD (from Poison distribution) + text will also be displayed.''') parser.add_argument('--console', action='store_true', help='Run as console app -- just the figure without buttons.') @@ -64,7 +75,11 @@ class ScopeFigure: - def __init__(self, width=50, fig=None, ax=None): + def __init__(self, width: int = 50, rolling_mean_window_size: int = 20, + show_rolling_mean: bool = True, show_reading_text: bool = True, + fig: plt.Figure = None, ax: plt.Axes = None): + + # setting up the figure and main axis if ax is None: fig, ax = plt.subplots() self.fig = fig @@ -72,14 +87,58 @@ def __init__(self, width=50, fig=None, ax=None): else: self.fig = fig self.ax = ax + self.fig.set_layout_engine('compressed') + + # display settings + self.show_rolling_mean = show_rolling_mean + self.show_reading_text = show_reading_text + # setting up primary data plot self.ydata = collections.deque(np.zeros(width)) self.line, = self.ax.plot(self.ydata) - self.ax.set_ylabel('counts / sec') + self.line: plt.Line2D + + # Setting up axis preferences + # TODO: Put all axis preferences to .mplstyle and load through terminal or gui. + self.ax.set_ylabel('Reading (counts / sec)') self.ax.ticklabel_format(style='sci', scilimits=(-3, 4), axis='y') + self.ax.set_xlabel('Acquisition Number') + + if self.show_rolling_mean: + self._add_rolling_mean(rolling_mean_window_size) + if self.show_reading_text: + self._add_reading_text() + + def _add_rolling_mean(self, rolling_mean_window_size: int): + self.rolling_mean_window_size = rolling_mean_window_size + self.half_window_size = int(np.ceil(self.rolling_mean_window_size / 2)) + self.rolling_mean = self.get_rolling_mean() + self.rolling_mean_line, = self.ax.plot(self.rolling_mean) + self.rolling_mean_line: plt.Line2D + + def _add_reading_text(self): + self.text_font_size = 30 # TODO: Add to .mplstyle file + fig_width = self.fig.get_size_inches()[0] + fig_height = self.fig.get_size_inches()[1] + if self.show_rolling_mean: + self.fig.set_size_inches(fig_width, fig_height * 1.3) + displayed_values = 'NaN\nNaN\nNaN (NaN)' + displayed_labels = 'Cur. Val.\nMean\nSTD' + else: + self.fig.set_size_inches(fig_width, fig_height * 1.1) + displayed_values = 'NaN' + displayed_labels = 'Cur. Val.' + self.current_value_text: plt.Text = self.ax.text( + 1, 1.05, displayed_values, fontsize=self.text_font_size, transform=self.ax.transAxes, ha='right') + self.ax.text( + 0, 1.05, displayed_labels, fontsize=self.text_font_size, transform=self.ax.transAxes, ha='left') def init(self): self.line.set_ydata(self.ydata) + if self.show_rolling_mean: + self.rolling_mean_line.set_ydata(self.rolling_mean) + if self.show_reading_text: + self.current_value_text.set_text('NaN\nNaN\nNaN (NaN)' if self.show_rolling_mean else 'NaN') return self.line, def update(self, y): @@ -97,19 +156,65 @@ def update(self, y): new_min = np.max([0, np.min(self.ydata) - delta]) new_max = np.max(self.ydata) + delta current_min, current_max = self.ax.get_ylim() - if (np.abs((new_min - current_min) / (current_min)) > 0.12) or ( - np.abs((new_max - current_max) / (current_max)) > 0.12): + + if (np.abs((new_min - current_min) / current_min) > 0.12) \ + or (np.abs((new_max - current_max) / current_max) > 0.12): self.ax.set_ylim(np.max([0.01, np.min(self.ydata) - delta]), np.max(self.ydata) + delta) + self.line.set_ydata(self.ydata) + + if self.show_rolling_mean: + new_rm_value = self.update_rolling_mean() + self.rolling_mean_line.set_ydata(self.rolling_mean) + + if self.show_reading_text: + text = self._get_text_value(y) + if self.show_rolling_mean: + text = f'{text}\n {self._get_text_value(new_rm_value)}' + measured_stdev = self.get_new_rolling_stdev_val() + expected_stdev = np.sqrt(new_rm_value) + text = f'{text}\n {self._get_text_value(measured_stdev)} ({self._get_text_value(expected_stdev)})' + self.current_value_text.set_text(text) + return self.line, + def get_new_rolling_mean_val(self): + values_of_interest = list(self.ydata)[len(self.ydata) - 2 * self.half_window_size: + len(self.ydata)] + return np.mean(values_of_interest) + + def get_new_rolling_stdev_val(self): + values_of_interest = list(self.ydata)[len(self.ydata) - 2 * self.half_window_size: + len(self.ydata)] + return np.std(values_of_interest) + + def update_rolling_mean(self): + rm = collections.deque(self.rolling_mean[self.half_window_size: len(self.ydata) - self.half_window_size]) + rm.popleft() + new_rm_value = self.get_new_rolling_mean_val() + rm.append(new_rm_value) + self.rolling_mean[self.half_window_size: len(self.ydata) - self.half_window_size] = list(rm) + return new_rm_value + + def get_rolling_mean(self): + rolling_mean = get_rolling_mean(self.ydata, self.rolling_mean_window_size) + rolling_mean[:self.half_window_size] = np.nan + rolling_mean[len(self.ydata) - self.half_window_size:] = np.nan + return rolling_mean + + @staticmethod + def _get_text_value(value: float): + rounded_value = np.around(value, 1) + return f'{rounded_value:,}' + class MainApplicationView: def __init__(self, main_frame): frame = Tk.Frame(main_frame) frame.pack(side=Tk.LEFT, fill=Tk.BOTH, expand=True) - self.scope_view = ScopeFigure(args.scope_width) + rolling_mean_window_size = np.ceil(args.rolling_mean_window / args.animation_update_interval) + self.scope_view = ScopeFigure(args.scope_width, int(rolling_mean_window_size)) self.sidepanel = SidePanel(main_frame) self.canvas = FigureCanvasTkAgg(self.scope_view.fig, master=frame) @@ -196,7 +301,8 @@ def build_data_model(): def run_console(): - view = ScopeFigure(args.scope_width) + rolling_mean_window_size = np.ceil(args.rolling_mean_window / args.animation_update_interval) + view = ScopeFigure(args.scope_width, int(rolling_mean_window_size)) model = build_data_model() model.start() ani = animation.FuncAnimation( diff --git a/src/qt3utils/math_utils.py b/src/qt3utils/math_utils.py new file mode 100644 index 00000000..a95675ef --- /dev/null +++ b/src/qt3utils/math_utils.py @@ -0,0 +1,9 @@ +from scipy.ndimage import uniform_filter1d + + +def get_rolling_mean(data, window_size=5): + """ + Rolling mean is a good way to look at the average behavior of noisy data. + It represents the local average value within a given window. + """ + return uniform_filter1d(data, size=window_size) From d7a470290e46a1684654ab2aa0ad11f613970152 Mon Sep 17 00:00:00 2001 From: vasilisniaouris Date: Sat, 26 Aug 2023 14:21:53 -0700 Subject: [PATCH 3/8] Removed redundant imports in `oscilloscope.py`. --- src/applications/oscilloscope.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/applications/oscilloscope.py b/src/applications/oscilloscope.py index 2fe9515d..a30c492e 100644 --- a/src/applications/oscilloscope.py +++ b/src/applications/oscilloscope.py @@ -1,20 +1,14 @@ -import time import argparse import collections import tkinter as Tk import logging import numpy as np -from matplotlib.lines import Line2D import matplotlib.pyplot as plt import matplotlib.animation as animation -from matplotlib.figure import Figure from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg, NavigationToolbar2Tk -import nidaqmx - -import src.qt3utils.nidaq import src.qt3utils.datagenerators as datasources from src.qt3utils.math_utils import get_rolling_mean From 35c6e6fa1b7fb67f34908f10b0a16eb3253e73a7 Mon Sep 17 00:00:00 2001 From: vasilisniaouris Date: Sat, 26 Aug 2023 14:23:11 -0700 Subject: [PATCH 4/8] Disabled frame data-caching from in `oscilloscope.py`. --- src/applications/oscilloscope.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/applications/oscilloscope.py b/src/applications/oscilloscope.py index a30c492e..0f2e4b10 100644 --- a/src/applications/oscilloscope.py +++ b/src/applications/oscilloscope.py @@ -262,7 +262,8 @@ def start_scope(self, event=None): self.model.yield_count_rate, init_func=self.view.scope_view.init, interval=args.animation_update_interval, - blit=False + blit=False, + cache_frame_data=False, ) self.model.start() self.animation.resume() @@ -305,7 +306,8 @@ def run_console(): model.yield_count_rate, init_func=view.init, interval=args.animation_update_interval, - blit=False + blit=False, + cache_frame_data=False, ) plt.show() model.close() From 891a8fb94f0d6dcc1030bf5f0b42b3e0cb4f630a Mon Sep 17 00:00:00 2001 From: vasilisniaouris Date: Sat, 26 Aug 2023 14:31:20 -0700 Subject: [PATCH 5/8] Fixed minor import bug. --- src/applications/oscilloscope.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/applications/oscilloscope.py b/src/applications/oscilloscope.py index 0f2e4b10..8b4e1989 100644 --- a/src/applications/oscilloscope.py +++ b/src/applications/oscilloscope.py @@ -9,8 +9,8 @@ from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg, NavigationToolbar2Tk -import src.qt3utils.datagenerators as datasources -from src.qt3utils.math_utils import get_rolling_mean +import qt3utils.datagenerators as datasources +from qt3utils.math_utils import get_rolling_mean logger = logging.getLogger(__name__) From bf016d25368efa2d517ecfe83e1bb455ec3cc55f Mon Sep 17 00:00:00 2001 From: vasilisniaouris Date: Sat, 26 Aug 2023 18:37:50 -0700 Subject: [PATCH 6/8] Added numpy-styled docstrings and type hints to method parameters in `oscilloscope.py`. --- src/applications/oscilloscope.py | 66 ++++++++++++++++++++++++++++++-- 1 file changed, 62 insertions(+), 4 deletions(-) diff --git a/src/applications/oscilloscope.py b/src/applications/oscilloscope.py index 8b4e1989..dddd2984 100644 --- a/src/applications/oscilloscope.py +++ b/src/applications/oscilloscope.py @@ -2,6 +2,7 @@ import collections import tkinter as Tk import logging +from typing import Any import numpy as np import matplotlib.pyplot as plt @@ -72,6 +73,24 @@ class ScopeFigure: def __init__(self, width: int = 50, rolling_mean_window_size: int = 20, show_rolling_mean: bool = True, show_reading_text: bool = True, fig: plt.Figure = None, ax: plt.Axes = None): + """ + Initializes the ScopeFigure. + + Parameters + ---------- + width: int, default 50 + Number of points to display in the figure. + rolling_mean_window_size: int, default 20 + Size of the rolling mean window. + show_rolling_mean: bool, default True + Whether to display the rolling mean line in the figure. + show_reading_text: bool, default True + Whether to display current reading value as text. + fig : matplotlib.figure.Figure + Figure to plot the data in. + ax : matplotlib.axes.Axes + Axis to plot the data in. + """ # setting up the figure and main axis if ax is None: @@ -104,6 +123,7 @@ def __init__(self, width: int = 50, rolling_mean_window_size: int = 20, self._add_reading_text() def _add_rolling_mean(self, rolling_mean_window_size: int): + """ Adds rolling mean line to figure. """ self.rolling_mean_window_size = rolling_mean_window_size self.half_window_size = int(np.ceil(self.rolling_mean_window_size / 2)) self.rolling_mean = self.get_rolling_mean() @@ -111,6 +131,7 @@ def _add_rolling_mean(self, rolling_mean_window_size: int): self.rolling_mean_line: plt.Line2D def _add_reading_text(self): + """Adds current reading value as text on figure.""" self.text_font_size = 30 # TODO: Add to .mplstyle file fig_width = self.fig.get_size_inches()[0] fig_height = self.fig.get_size_inches()[1] @@ -128,6 +149,7 @@ def _add_reading_text(self): 0, 1.05, displayed_labels, fontsize=self.text_font_size, transform=self.ax.transAxes, ha='left') def init(self): + """ Initializes the figure data. """ self.line.set_ydata(self.ydata) if self.show_rolling_mean: self.rolling_mean_line.set_ydata(self.rolling_mean) @@ -135,8 +157,8 @@ def init(self): self.current_value_text.set_text('NaN\nNaN\nNaN (NaN)' if self.show_rolling_mean else 'NaN') return self.line, - def update(self, y): - + def update(self, y: Any): + """Updates the figure data according given a new data point.""" self.ydata.popleft() self.ydata.append(y) @@ -173,16 +195,19 @@ def update(self, y): return self.line, def get_new_rolling_mean_val(self): + """ Calculates new rolling mean value. """ values_of_interest = list(self.ydata)[len(self.ydata) - 2 * self.half_window_size: len(self.ydata)] return np.mean(values_of_interest) def get_new_rolling_stdev_val(self): + """ Calculates new rolling standard deviation. """ values_of_interest = list(self.ydata)[len(self.ydata) - 2 * self.half_window_size: len(self.ydata)] return np.std(values_of_interest) def update_rolling_mean(self): + """ Updates internal rolling mean buffer. """ rm = collections.deque(self.rolling_mean[self.half_window_size: len(self.ydata) - self.half_window_size]) rm.popleft() new_rm_value = self.get_new_rolling_mean_val() @@ -191,6 +216,7 @@ def update_rolling_mean(self): return new_rm_value def get_rolling_mean(self): + """ Computes rolling mean of current data. """ rolling_mean = get_rolling_mean(self.ydata, self.rolling_mean_window_size) rolling_mean[:self.half_window_size] = np.nan rolling_mean[len(self.ydata) - self.half_window_size:] = np.nan @@ -198,12 +224,19 @@ def get_rolling_mean(self): @staticmethod def _get_text_value(value: float): + """ Formats number for display in figure text. """ rounded_value = np.around(value, 1) return f'{rounded_value:,}' class MainApplicationView: - def __init__(self, main_frame): + """ + Represents the application view. + + Contains a matplotlib figure canvas and a sidebar. + """ + + def __init__(self, main_frame: Tk.Tk): frame = Tk.Frame(main_frame) frame.pack(side=Tk.LEFT, fill=Tk.BOTH, expand=True) @@ -222,7 +255,17 @@ def __init__(self, main_frame): class SidePanel: - def __init__(self, root): + def __init__(self, root: Tk.Tk): + """ + Represents the sidebar panel. + + Contains the start and stop buttons. + + Parameters + ---------- + root: tkinter.Tk + The main tkinter window. + """ frame = Tk.Frame(root) frame.pack(side=Tk.LEFT, fill=Tk.BOTH, expand=True) self.startButton = Tk.Button(frame, text="Start ") @@ -234,6 +277,14 @@ def __init__(self, root): class MainTkApplication: def __init__(self, data_model): + """ + The main Tkinter application window. + + Parameters + ---------- + data_model: Any + The data acquisition model that yields new data points. + """ self.root = Tk.Tk() self.model = data_model self.view = MainApplicationView(self.root) @@ -244,16 +295,19 @@ def __init__(self, data_model): self.animation = None def run(self): + """ Starts GUI. """ self.root.title("QT3Scope: NIDAQ Digital Input Count Rate") self.root.deiconify() self.root.mainloop() def stop_scope(self, event=None): + """ Stops data acquisition and pauses animation. """ self.model.stop() if self.animation is not None: self.animation.pause() def start_scope(self, event=None): + """ Starts data acquisition and animation. """ if self.animation is None: self.view.canvas.draw_idle() self.animation = animation.FuncAnimation( @@ -269,6 +323,7 @@ def start_scope(self, event=None): self.animation.resume() def on_closing(self): + """ Closes application. """ try: self.stop_scope() self.model.close() @@ -280,6 +335,7 @@ def on_closing(self): def build_data_model(): + """ Builds the data acquisition model based on arguments. """ if args.randomtest: data_acquisition_model = datasources.RandomRateCounter() else: @@ -296,6 +352,7 @@ def build_data_model(): def run_console(): + """ Runs only the matplotlib animation, no sidebar. """ rolling_mean_window_size = np.ceil(args.rolling_mean_window / args.animation_update_interval) view = ScopeFigure(args.scope_width, int(rolling_mean_window_size)) model = build_data_model() @@ -314,6 +371,7 @@ def run_console(): def run_gui(): + """ Runs Tkinter GUI version. """ tkapp = MainTkApplication(build_data_model()) tkapp.run() From 9b3dfbc5baeaf0f63ec5f9f1f661f0c6ea84f2e3 Mon Sep 17 00:00:00 2001 From: vasilisniaouris Date: Tue, 12 Sep 2023 21:37:26 -0700 Subject: [PATCH 7/8] Implemented most of Adam's suggestions upon the pull request. --- src/applications/oscilloscope.py | 79 ++++++++++++++++++++++---------- 1 file changed, 56 insertions(+), 23 deletions(-) diff --git a/src/applications/oscilloscope.py b/src/applications/oscilloscope.py index dddd2984..4ce6c3e2 100644 --- a/src/applications/oscilloscope.py +++ b/src/applications/oscilloscope.py @@ -10,7 +10,7 @@ from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg, NavigationToolbar2Tk -import qt3utils.datagenerators as datasources +import qt3utils.datagenerators from qt3utils.math_utils import get_rolling_mean logger = logging.getLogger(__name__) @@ -55,13 +55,15 @@ parser.add_argument('-rmw', '--rolling-mean-window', metavar='milliseconds', default=400, type=float, help='''Sets the displayed rolling mean window size, w, (in milliseconds). Actual value will set as the ceil(rmw/aut) * aut.''') -parser.add_argument('-srm', '--show-rolling-mean', default='Υ', - help='''Show (Y) or hide (N) the rolling mean line.''') -parser.add_argument('-srt', '--show-reading-text', default='Υ', - help='''Show (Y) or hide (N) the reading text. +parser.add_argument('-srm', '--show-rolling-mean', action='store_true', + help='''When true, the rolling mean line will be displayed in the figure.''') +parser.add_argument('-srt', '--show-reading-text', action='store_true', + help='''When True, the current reading value will be displayed as text. If rolling mean is enabled, rolling mean, rolling standard deviation (STD) and expected STD (from Poison distribution) text will also be displayed.''') +parser.add_argument('-rtfs', '--reading-text-font-size', metavar='pixels', default=12, type=float, + help='''Sets the font size of the current reading text.''') parser.add_argument('--console', action='store_true', help='Run as console app -- just the figure without buttons.') @@ -72,6 +74,7 @@ class ScopeFigure: def __init__(self, width: int = 50, rolling_mean_window_size: int = 20, show_rolling_mean: bool = True, show_reading_text: bool = True, + reading_text_font_size: float = 12, fig: plt.Figure = None, ax: plt.Axes = None): """ Initializes the ScopeFigure. @@ -86,11 +89,18 @@ def __init__(self, width: int = 50, rolling_mean_window_size: int = 20, Whether to display the rolling mean line in the figure. show_reading_text: bool, default True Whether to display current reading value as text. + reading_text_font_size: float, default 12 + Font size of the displayed current reading text. fig : matplotlib.figure.Figure Figure to plot the data in. ax : matplotlib.axes.Axes Axis to plot the data in. """ + # Constraining rolling_mean_window_size value + if rolling_mean_window_size > width: + logger.warning('Rolling mean window size must not exceed scope width.' + 'Rolling mean window size was set equal to scope width.') + rolling_mean_window_size = width # setting up the figure and main axis if ax is None: @@ -113,14 +123,14 @@ def __init__(self, width: int = 50, rolling_mean_window_size: int = 20, # Setting up axis preferences # TODO: Put all axis preferences to .mplstyle and load through terminal or gui. - self.ax.set_ylabel('Reading (counts / sec)') + self.ax.set_ylabel('Rate (counts / sec)') self.ax.ticklabel_format(style='sci', scilimits=(-3, 4), axis='y') - self.ax.set_xlabel('Acquisition Number') + self.ax.set_xlabel('Time bin (arb. units)') if self.show_rolling_mean: self._add_rolling_mean(rolling_mean_window_size) if self.show_reading_text: - self._add_reading_text() + self._add_reading_text(reading_text_font_size) def _add_rolling_mean(self, rolling_mean_window_size: int): """ Adds rolling mean line to figure. """ @@ -130,9 +140,9 @@ def _add_rolling_mean(self, rolling_mean_window_size: int): self.rolling_mean_line, = self.ax.plot(self.rolling_mean) self.rolling_mean_line: plt.Line2D - def _add_reading_text(self): + def _add_reading_text(self, reading_text_font_size: float): """Adds current reading value as text on figure.""" - self.text_font_size = 30 # TODO: Add to .mplstyle file + self.reading_text_font_size = reading_text_font_size # TODO: Add to .mplstyle file fig_width = self.fig.get_size_inches()[0] fig_height = self.fig.get_size_inches()[1] if self.show_rolling_mean: @@ -144,9 +154,11 @@ def _add_reading_text(self): displayed_values = 'NaN' displayed_labels = 'Cur. Val.' self.current_value_text: plt.Text = self.ax.text( - 1, 1.05, displayed_values, fontsize=self.text_font_size, transform=self.ax.transAxes, ha='right') + 1, 1.05, displayed_values, fontsize=self.reading_text_font_size, + transform=self.ax.transAxes, ha='right') self.ax.text( - 0, 1.05, displayed_labels, fontsize=self.text_font_size, transform=self.ax.transAxes, ha='left') + 0, 1.05, displayed_labels, fontsize=self.reading_text_font_size, + transform=self.ax.transAxes, ha='left') def init(self): """ Initializes the figure data. """ @@ -158,7 +170,7 @@ def init(self): return self.line, def update(self, y: Any): - """Updates the figure data according given a new data point.""" + """Updates the figure data with the given new data point.""" self.ydata.popleft() self.ydata.append(y) @@ -184,16 +196,19 @@ def update(self, y: Any): self.rolling_mean_line.set_ydata(self.rolling_mean) if self.show_reading_text: - text = self._get_text_value(y) + text = self._get_text_value(y) # default rounding of digits if self.show_rolling_mean: - text = f'{text}\n {self._get_text_value(new_rm_value)}' measured_stdev = self.get_new_rolling_stdev_val() expected_stdev = np.sqrt(new_rm_value) + + text = self._get_text_value(y, measured_stdev) # rounding to STD's first significant digit + text = f'{text}\n {self._get_text_value(new_rm_value, measured_stdev)}' text = f'{text}\n {self._get_text_value(measured_stdev)} ({self._get_text_value(expected_stdev)})' self.current_value_text.set_text(text) return self.line, + # TODO: Change implementation of how we calculate and update the rolling mean and stdev in every data entry. def get_new_rolling_mean_val(self): """ Calculates new rolling mean value. """ values_of_interest = list(self.ydata)[len(self.ydata) - 2 * self.half_window_size: @@ -223,10 +238,14 @@ def get_rolling_mean(self): return rolling_mean @staticmethod - def _get_text_value(value: float): + def _get_text_value(value: float, decimal_limiter: float = None): """ Formats number for display in figure text. """ - rounded_value = np.around(value, 1) - return f'{rounded_value:,}' + if decimal_limiter is None: + decimal_limiter = value * 1e-4 # defaults to 4 digits of precision + + # Round the value to the first significant digit of the decimal_limiter + rounded_value = round(value, -int(np.floor(np.log10(decimal_limiter)))) + return f'{rounded_value:e}' class MainApplicationView: @@ -241,7 +260,13 @@ def __init__(self, main_frame: Tk.Tk): frame.pack(side=Tk.LEFT, fill=Tk.BOTH, expand=True) rolling_mean_window_size = np.ceil(args.rolling_mean_window / args.animation_update_interval) - self.scope_view = ScopeFigure(args.scope_width, int(rolling_mean_window_size)) + self.scope_view = ScopeFigure( + args.scope_width, + rolling_mean_window_size, + args.show_rolling_mean, + args.show_reading_text, + args.reading_text_font_size, + ) self.sidepanel = SidePanel(main_frame) self.canvas = FigureCanvasTkAgg(self.scope_view.fig, master=frame) @@ -337,9 +362,9 @@ def on_closing(self): def build_data_model(): """ Builds the data acquisition model based on arguments. """ if args.randomtest: - data_acquisition_model = datasources.RandomRateCounter() + data_acquisition_model = qt3utils.datagenerators.RandomRateCounter() else: - data_acquisition_model = datasources.NiDaqDigitalInputRateCounter( + data_acquisition_model = qt3utils.datagenerators.NiDaqDigitalInputRateCounter( daq_name=args.daq_name, signal_terminal=args.signal_terminal, clock_rate=args.clock_rate, @@ -353,8 +378,16 @@ def build_data_model(): def run_console(): """ Runs only the matplotlib animation, no sidebar. """ - rolling_mean_window_size = np.ceil(args.rolling_mean_window / args.animation_update_interval) - view = ScopeFigure(args.scope_width, int(rolling_mean_window_size)) + rolling_mean_window_size = int( + np.ceil(args.rolling_mean_window / args.animation_update_interval)) + + view = ScopeFigure( + args.scope_width, + rolling_mean_window_size, + args.show_rolling_mean, + args.show_reading_text, + args.reading_text_font_size, + ) model = build_data_model() model.start() ani = animation.FuncAnimation( From f31839ce1b6df34cf004bbda741d8fe229362ea0 Mon Sep 17 00:00:00 2001 From: vasilisniaouris Date: Tue, 12 Sep 2023 23:17:12 -0700 Subject: [PATCH 8/8] Optimized `oscilloscope.py` rolling statistics. Pending testing. --- src/applications/oscilloscope.py | 162 +++++++++++++++++++++---------- 1 file changed, 113 insertions(+), 49 deletions(-) diff --git a/src/applications/oscilloscope.py b/src/applications/oscilloscope.py index 4ce6c3e2..c9bf51f8 100644 --- a/src/applications/oscilloscope.py +++ b/src/applications/oscilloscope.py @@ -11,7 +11,6 @@ from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg, NavigationToolbar2Tk import qt3utils.datagenerators -from qt3utils.math_utils import get_rolling_mean logger = logging.getLogger(__name__) @@ -70,6 +69,57 @@ args = parser.parse_args() +class RollingStatisticsTracker: + """ + A class that keeps track of the latest N values (window size) + and calculates the most recent rolling mean and standard deviation. + """ + def __init__(self, window_size: int): + """ + Parameters + ---------- + window_size: int + Number of values to keep track of. + """ + self._window_size = window_size + self._data = collections.deque(maxlen=window_size) + self._cum_sum = 0 + self._cum_sum_sq = 0 + self._mean = 0 + self._std = 0 + + @property + def mean(self): + return self._mean + + @property + def std(self): + return self._std + + @property + def _count(self): + return len(self._data) + + def update(self, new_value: Any): + """ + Updates the rolling statistics with a new value. + + Parameters + ---------- + new_value: Any + New value to be added to the rolling statistics. + """ + + old_value = self._data.popleft() if self._count == self._window_size else 0 + self._data.append(new_value) + + self._cum_sum += new_value - old_value + self._cum_sum_sq += (new_value ** 2) - (old_value ** 2) + + self._mean = self._cum_sum / self._count + self._std = np.sqrt(max(self._cum_sum_sq / self._count - self._mean ** 2, 0)) + + class ScopeFigure: def __init__(self, width: int = 50, rolling_mean_window_size: int = 20, @@ -97,7 +147,7 @@ def __init__(self, width: int = 50, rolling_mean_window_size: int = 20, Axis to plot the data in. """ # Constraining rolling_mean_window_size value - if rolling_mean_window_size > width: + if rolling_mean_window_size > width and show_rolling_mean: logger.warning('Rolling mean window size must not exceed scope width.' 'Rolling mean window size was set equal to scope width.') rolling_mean_window_size = width @@ -134,9 +184,17 @@ def __init__(self, width: int = 50, rolling_mean_window_size: int = 20, def _add_rolling_mean(self, rolling_mean_window_size: int): """ Adds rolling mean line to figure. """ - self.rolling_mean_window_size = rolling_mean_window_size - self.half_window_size = int(np.ceil(self.rolling_mean_window_size / 2)) - self.rolling_mean = self.get_rolling_mean() + self.half_window_size = int(np.ceil(rolling_mean_window_size / 2)) + self.rolling_mean_window_size = 2 * self.half_window_size + + initial_rolling_mean = np.zeros(len(self.ydata)) + initial_rolling_mean[:self.half_window_size] = np.nan + initial_rolling_mean[-self.half_window_size:] = np.nan + self.rolling_mean = collections.deque(initial_rolling_mean) + + self.rolling_mean_statistics_tracker = RollingStatisticsTracker( + self.rolling_mean_window_size) + self.rolling_mean_line, = self.ax.plot(self.rolling_mean) self.rolling_mean_line: plt.Line2D @@ -145,6 +203,7 @@ def _add_reading_text(self, reading_text_font_size: float): self.reading_text_font_size = reading_text_font_size # TODO: Add to .mplstyle file fig_width = self.fig.get_size_inches()[0] fig_height = self.fig.get_size_inches()[1] + if self.show_rolling_mean: self.fig.set_size_inches(fig_width, fig_height * 1.3) displayed_values = 'NaN\nNaN\nNaN (NaN)' @@ -153,6 +212,7 @@ def _add_reading_text(self, reading_text_font_size: float): self.fig.set_size_inches(fig_width, fig_height * 1.1) displayed_values = 'NaN' displayed_labels = 'Cur. Val.' + self.current_value_text: plt.Text = self.ax.text( 1, 1.05, displayed_values, fontsize=self.reading_text_font_size, transform=self.ax.transAxes, ha='right') @@ -166,13 +226,13 @@ def init(self): if self.show_rolling_mean: self.rolling_mean_line.set_ydata(self.rolling_mean) if self.show_reading_text: - self.current_value_text.set_text('NaN\nNaN\nNaN (NaN)' if self.show_rolling_mean else 'NaN') + self.current_value_text.set_text( + 'NaN\nNaN\nNaN (NaN)' if self.show_rolling_mean else 'NaN') return self.line, def update(self, y: Any): """Updates the figure data with the given new data point.""" - self.ydata.popleft() - self.ydata.append(y) + self._update_y_data(y) # this doesn't work with blit = True. # there's a workaround if we need blit = true @@ -180,6 +240,19 @@ def update(self, y: Any): # need to sporadically call # fig.canvas.resize_event() + self._update_rolling_mean() + self._update_reading_text() + self._update_ax_limits() + + return self.line, + + def _update_y_data(self, new_y: Any): + """ Updates the y data. """ + self.ydata.popleft() + self.ydata.append(new_y) + self.line.set_ydata(self.ydata) + + def _update_ax_limits(self): delta = 0.1 * np.max(self.ydata) new_min = np.max([0, np.min(self.ydata) - delta]) new_max = np.max(self.ydata) + delta @@ -189,53 +262,42 @@ def update(self, y: Any): or (np.abs((new_max - current_max) / current_max) > 0.12): self.ax.set_ylim(np.max([0.01, np.min(self.ydata) - delta]), np.max(self.ydata) + delta) - self.line.set_ydata(self.ydata) - + def _update_rolling_mean(self): + """ Updates internal rolling mean buffer. """ if self.show_rolling_mean: - new_rm_value = self.update_rolling_mean() - self.rolling_mean_line.set_ydata(self.rolling_mean) + new_y = self.ydata[-1] if len(self.ydata) > 0 else 0 - if self.show_reading_text: - text = self._get_text_value(y) # default rounding of digits - if self.show_rolling_mean: - measured_stdev = self.get_new_rolling_stdev_val() - expected_stdev = np.sqrt(new_rm_value) + self.rolling_mean_statistics_tracker.update(new_y) # update tracker first + new_mean = self.rolling_mean_statistics_tracker.mean + new_std = self.rolling_mean_statistics_tracker.std - text = self._get_text_value(y, measured_stdev) # rounding to STD's first significant digit - text = f'{text}\n {self._get_text_value(new_rm_value, measured_stdev)}' - text = f'{text}\n {self._get_text_value(measured_stdev)} ({self._get_text_value(expected_stdev)})' - self.current_value_text.set_text(text) + # instead of popleft - append, we rotate and modify in between the np.nans. + self.rolling_mean.rotate(-1) # shifts all data to the left + self.rolling_mean[self.half_window_size - 1] = np.nan # deletes oldest value + self.rolling_mean[- self.half_window_size - 1] = new_mean # adds new value - return self.line, + self.rolling_mean_line.set_ydata(self.rolling_mean) - # TODO: Change implementation of how we calculate and update the rolling mean and stdev in every data entry. - def get_new_rolling_mean_val(self): - """ Calculates new rolling mean value. """ - values_of_interest = list(self.ydata)[len(self.ydata) - 2 * self.half_window_size: - len(self.ydata)] - return np.mean(values_of_interest) + return new_mean, new_std - def get_new_rolling_stdev_val(self): - """ Calculates new rolling standard deviation. """ - values_of_interest = list(self.ydata)[len(self.ydata) - 2 * self.half_window_size: - len(self.ydata)] - return np.std(values_of_interest) + def _update_reading_text(self): + """ Updates the figure text with the current reading. """ + if self.show_reading_text: + new_y = self.ydata[-1] if len(self.ydata) > 0 else np.nan + text = self._get_text_value(new_y) # default rounding of digits - def update_rolling_mean(self): - """ Updates internal rolling mean buffer. """ - rm = collections.deque(self.rolling_mean[self.half_window_size: len(self.ydata) - self.half_window_size]) - rm.popleft() - new_rm_value = self.get_new_rolling_mean_val() - rm.append(new_rm_value) - self.rolling_mean[self.half_window_size: len(self.ydata) - self.half_window_size] = list(rm) - return new_rm_value - - def get_rolling_mean(self): - """ Computes rolling mean of current data. """ - rolling_mean = get_rolling_mean(self.ydata, self.rolling_mean_window_size) - rolling_mean[:self.half_window_size] = np.nan - rolling_mean[len(self.ydata) - self.half_window_size:] = np.nan - return rolling_mean + if self.show_rolling_mean: + new_mean = self.rolling_mean_statistics_tracker.mean + new_std = self.rolling_mean_statistics_tracker.std + std_poison = np.sqrt(new_mean) + + # rounding to STD's first significant digit + text = self._get_text_value(new_y, new_std) + text = f'{text}\n {self._get_text_value(new_mean, new_std)}' + text = (f'{text}\n {self._get_text_value(new_std)} ' + f'({self._get_text_value(std_poison)})') + + self.current_value_text.set_text(text) @staticmethod def _get_text_value(value: float, decimal_limiter: float = None): @@ -259,7 +321,9 @@ def __init__(self, main_frame: Tk.Tk): frame = Tk.Frame(main_frame) frame.pack(side=Tk.LEFT, fill=Tk.BOTH, expand=True) - rolling_mean_window_size = np.ceil(args.rolling_mean_window / args.animation_update_interval) + rolling_mean_window_size = int(np.ceil( + args.rolling_mean_window / args.animation_update_interval)) + self.scope_view = ScopeFigure( args.scope_width, rolling_mean_window_size,