diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000000000000000000000000000000000000..048ecad98fef115b886f8864403a1df8bd06f60b --- /dev/null +++ b/.dockerignore @@ -0,0 +1,7 @@ +# .dockerignore +__pycache__ +*.pyc +*.pyo +*.pyd +.env +venv/ \ No newline at end of file diff --git a/.gitignore b/.gitignore index 322defa6fdc9b68312bed01446fe11bf4dece7c8..80cfd07ca36787690e95c744a0bc70c261bea306 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ /doc/build/** /doc/source/_autogen/** /dist/ +/.run/ .pytest_cache pytest_report.xml .coverage diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000000000000000000000000000000000000..6b22ec9fc033604f37bc20d7ca486029205bd3be --- /dev/null +++ b/Dockerfile @@ -0,0 +1,34 @@ +FROM ghcr.io/astral-sh/uv:python3.9-bookworm + +# Set environment variables to prevent Python from writing .pyc files and buffering stdout/stderr +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONUNBUFFERED=1 +# To mimmick CI for tests +ENV GITLAB_CI=True + +RUN apt-get update && \ + apt-get install -y --no-install-recommends \ + portaudio19-dev \ + neovim \ + && rm -rf /var/lib/apt/lists/* + +RUN uv pip install --system hatch + +WORKDIR /home/root/ + +RUN git clone -b master --single-branch https://git.rwth-aachen.de/qutech/qutil.git qutil + +WORKDIR qutil + +RUN uv pip install --system -e .[complete] + +WORKDIR /home/root/ + +RUN git clone -b main --single-branch https://git.rwth-aachen.de/qutech/python-spectrometer.git pyspeck + +WORKDIR pyspeck + +RUN uv pip install --system -e .[complete] + +# Set the entry point to run pytest +CMD ["pytest --doctest-modules"] diff --git a/README.md b/README.md index 28f89c445552e7001103b5ae45b9e735b6d29cbf..ed044f5608cdbd999b1b773a1431b7f00d7706f8 100644 --- a/README.md +++ b/README.md @@ -56,7 +56,7 @@ If you have already downloaded/cloned the package yourself you can use `python - Please file an issue if any of these instructions does not work. ## Documentation -Some of the development of this package took place during a course taught at the II. Institute of Physics at RWTH Aachen University in the winter semester 2022/23. Targeting applied research topics too specific for lectures but too general for lab courses, several modules intended for self-learning were developed, one of which focuses on "characterizing and avoiding noise and interference in instrumentation". The material can be found here: +Some of the development of this package took place during a course taught at the II. Institute of Physics at RWTH Aachen University in the winter semester 2022/23. Targeting applied research topics too specific for lectures but too general for lab courses, several modules intended for self-learning were developed, one of which focuses on "characterizing and avoiding noise and interference in instrumentation". The material can be found here: - [Part 1](https://iffmd.fz-juelich.de/s/6sxq2OgNO), - [Part 2](https://iffmd.fz-juelich.de/s/7LQ7xCCNJ). @@ -74,7 +74,7 @@ Make sure the dependencies are installed via ```sh python -m pip install -e .[doc] ``` -in the top-level directory. +in the top-level directory. To check if everything works for a clean install (requires hatch to be installed), run ```sh @@ -93,5 +93,44 @@ or to check if everything works for a clean install (requires hatch to be instal python -m hatch run tests:run ``` + +### Docker + +0. Make sure docker is installed and running: + 1. `pamac install docker docker-buildx` + 2. `(sudo) docker buildx install` + 2. `(sudo) systemctl status docker` + + Example output: + + ```sh + ● docker.service - Docker Application Container Engine + Loaded: loaded (/usr/lib/systemd/system/docker.service; disabled; preset: disabled) + Active: active (running) since Tue 2025-02-11 20:09:55 CET; 1 week 1 day ago + Invocation: 609d5a409daf4e99b7b3b8da9305776d + TriggeredBy: ● docker.socket + Docs: https://docs.docker.com + Main PID: 54128 (dockerd) + Tasks: 22 + Memory: 38.8M (peak: 1.2G, swap: 21.3M, swap peak: 23.9M) + CPU: 1min 2.133s + CGroup: /system.slice/docker.service + └─54128 /usr/bin/dockerd -H fd:// --containerd=/run/containerd/containerd.sock + ``` + +1. Build the docker image: + ```sh + (sudo) docker build -t pyspeck-dev . + ``` +2. Run the image... + 1. ... either running the tests and exiting: + ```sh + (sudo) docker run --rm pyspeck-dev + ``` + 2. ... or entering an interactive console: + ```sh + (sudo) docker run --rm -it pyspeck-dev /bin/bash + ``` + ## Releases Releases on Gitlab, PyPI, and Zenodo are automatically created and pushed whenever a commit is tagged matching [CalVer](https://calver.org/) in the form `vYYYY.MM.MICRO` or `vYYYY.0M.MICRO`. diff --git a/pyproject.toml b/pyproject.toml index bc4f6d44f7d7915e857b71f5ed055e43a3956953..e3175e4630989293048a6d51d3cd47b59f880801 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,6 +24,7 @@ classifiers = [ "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", "Topic :: Scientific/Engineering :: Physics", "Topic :: Utilities", ] @@ -104,5 +105,7 @@ max-line-length = 99 addopts = "-ra" filterwarnings = [ "ignore:simanneal:UserWarning", - "ignore:Qutip:UserWarning" + "ignore:Qutip:UserWarning", + # plt.pause() in headless mode + 'ignore:FigureCanvasAgg is non-interactive:UserWarning' ] diff --git a/src/python_spectrometer/__init__.py b/src/python_spectrometer/__init__.py index 5ae0c9f3624103c5088d637a1b72dd20c5074fd6..a6319b875357cefede1bf2eecefedc201e98eadc 100644 --- a/src/python_spectrometer/__init__.py +++ b/src/python_spectrometer/__init__.py @@ -115,7 +115,8 @@ In this short demonstration, we reproduce the example from ... yield x + self.rng.normal(scale=np.sqrt(noise_power), ... size=time.shape) >>> spect = Spectrometer(MyDAQ(), savepath=tempfile.mkdtemp(), -... plot_cumulative=True, plot_amplitude=False) +... plot_cumulative=True, plot_amplitude=False, +... threaded_acquisition=False) >>> spect.take('2 Vrms', fs=10e3, n_pts=1e5, nperseg=1024, ... amp=2*np.sqrt(2)) diff --git a/src/python_spectrometer/_audio_manager.py b/src/python_spectrometer/_audio_manager.py index a1d25bd5608bc6d33e58dbbca70e1e2169e7b2dc..144c92cc9f6562b9153556b0ebce89ee37df93a9 100644 --- a/src/python_spectrometer/_audio_manager.py +++ b/src/python_spectrometer/_audio_manager.py @@ -1,5 +1,5 @@ """ This module contains methods for using auditory channels to interface to humans """ - +import os import queue import threading import time @@ -20,10 +20,16 @@ def _waveform_playback_target(waveform_queue: queue.Queue, stop_flag: threading. pyaudio_instance = pyaudio.PyAudio() - stream = pyaudio_instance.open(format=pyaudio.paFloat32, - channels=1, - rate=bitrate, - output=True) + try: + stream = pyaudio_instance.open(format=pyaudio.paFloat32, + channels=1, + rate=bitrate, + output=True) + except OSError: + if os.environ.get('GITLAB_CI', 'false').lower() == 'true': + return pyaudio_instance.terminate() + else: + raise last_waveform = None repeats = 0 diff --git a/src/python_spectrometer/_plot_manager.py b/src/python_spectrometer/_plot_manager.py index d9ed82f8f97db8a13a1e1154a26204709283156d..24030f2eb792fc8b7b98f76f676a3f571a48ae6d 100644 --- a/src/python_spectrometer/_plot_manager.py +++ b/src/python_spectrometer/_plot_manager.py @@ -2,17 +2,16 @@ import contextlib import os import warnings -from functools import cached_property +import weakref from itertools import compress -from typing import (Dict, Any, Optional, Mapping, Tuple, ContextManager, Literal, Iterable, - Union, List) +from typing import (Dict, Any, Optional, Mapping, Tuple, ContextManager, Iterable, Union, List, + Literal) import matplotlib.pyplot as plt import numpy as np from matplotlib import gridspec, scale from qutil.misc import filter_warnings from qutil.plotting import assert_interactive_figure -from qutil.typecheck import check_literals from scipy import integrate, signal _keyT = Union[int, str, Tuple[int, str]] @@ -21,16 +20,19 @@ _styleT = Union[None, _styleT, List[_styleT]] class PlotManager: + __instances = weakref.WeakSet() + # TODO: blit? PLOT_TYPES = ('main', 'cumulative', 'time') LINE_TYPES = ('processed', 'raw') + TIMER_INTERVAL: int = 20 # ms def __init__(self, data: Dict[_keyT, Any], plot_raw: bool = False, plot_timetrace: bool = False, plot_cumulative: bool = False, plot_negative_frequencies: bool = True, plot_absolute_frequencies: bool = True, plot_amplitude: bool = True, plot_density: bool = True, plot_cumulative_normalized: bool = False, plot_style: _styleT = 'fast', - plot_update_mode: str = 'never', plot_dB_scale: bool = False, prop_cycle=None, + plot_dB_scale: bool = False, threaded_acquisition: bool = True, prop_cycle=None, raw_unit: str = 'V', processed_unit: Optional[str] = None, uses_windowed_estimator: bool = True, complex_data: Optional[bool] = False, figure_kw: Optional[Mapping] = None, subplot_kw: Optional[Mapping] = None, @@ -48,8 +50,8 @@ class PlotManager: self._plot_density = plot_density self._plot_cumulative_normalized = plot_cumulative_normalized self._plot_style = plot_style - self._plot_update_mode = plot_update_mode self._plot_dB_scale = plot_dB_scale + self._threaded_acquisition = threaded_acquisition self._processed_unit = processed_unit if processed_unit is not None else raw_unit # For dB scale plots, default to the first spectrum acquired. @@ -60,7 +62,9 @@ class PlotManager: self.uses_windowed_estimator = uses_windowed_estimator self._complex_data = complex_data + self._fig = None self._leg = None + self._timer = None self.axes = {key: dict.fromkeys(self.LINE_TYPES) for key in self.PLOT_TYPES} self.lines = dict() self.figure_kw = figure_kw or dict() @@ -68,29 +72,69 @@ class PlotManager: self.gridspec_kw = gridspec_kw or dict() self.legend_kw = legend_kw or dict() + self.legend_kw.setdefault('loc', 'upper right') + self.figure_kw.setdefault('num', f'Spectrometer {len(self.__instances) + 1}') if not any('layout' in key for key in self.figure_kw.keys()): - self.figure_kw['layout'] = 'tight' - if self.subplot_kw.pop('sharex', None) is False: + # depending on the matplotlib version, this setting is either + # layout='tight' or tight_layout=True + self.figure_kw.setdefault('layout', 'tight') + if self.subplot_kw.pop('sharex', None) is not None: warnings.warn('sharex in subplot_kw not negotiable, dropping', UserWarning) - @cached_property + # Keep track of instances that are alive for figure counting + self.__instances.add(self) + # TODO: this somehow never executes + weakref.finalize(self, self.__instances.discard, self) + + def is_fig_open(self) -> bool: + """Is the figure currently open, pending all events?""" + # Need to flush possible close events before we can be sure! + if self._fig is not None: + self._fig.canvas.flush_events() + return self._fig is not None and plt.fignum_exists(self.figure_kw['num']) + + @property def fig(self): """The figure hosting the plots.""" + if self.is_fig_open(): + return self._fig + try: - fig = plt.figure(**self.figure_kw) + self._fig = plt.figure(**self.figure_kw) except TypeError: if layout := self.figure_kw.pop('layout', False): # matplotlib < 3.5 doesn't support layout kwarg yet self.figure_kw[f'{layout}_layout'] = True elif layout is False: raise - fig = plt.figure(**self.figure_kw) + self._fig = plt.figure(**self.figure_kw) + + assert_interactive_figure(self._fig) + + def on_close(event): + self._fig = None + + if self._timer is not None: + self._timer.stop() + self._timer = None + + # Clean up possible leftovers from before. + self.destroy_axes() + self.update_line_attrs(self.plots_to_draw, self.lines_to_draw, self.shown, stale=True) + + # If the window is closed, remove the figure from the cache so that it can be recreated and + # stop the timer to delete any remaining callbacks + self._fig.canvas.mpl_connect('close_event', on_close) - # If the window is closed, remove the figure from the cache so that it can be recreated - fig.canvas.mpl_connect('close_event', lambda _: self.__dict__.pop('fig', None)) + self.setup_figure() + return self._fig - assert_interactive_figure(fig) - return fig + @property + def timer(self): + """A timer object associated with the figure.""" + if self._timer is None: + self._timer = self.fig.canvas.new_timer(self.TIMER_INTERVAL) + return self._timer @property def ax(self): @@ -112,7 +156,7 @@ class PlotManager: @property def shown(self) -> Tuple[Tuple[int, str], ...]: return tuple(key for key, val in self.lines.items() - if not val['main']['processed']['hidden']) + if val['main']['processed']['hidden'] is False) @property def lines_to_draw(self) -> Tuple[str, ...]: @@ -140,7 +184,8 @@ class PlotManager: if val != self._plot_raw: self._plot_raw = val self.update_line_attrs(self.plots_to_draw, ['raw'], stale=True, hidden=not val) - if 'fig' in self.__dict__: + if self.is_fig_open(): + # Only update the figure if it's already been created self.setup_figure() @property @@ -154,7 +199,7 @@ class PlotManager: if val != self._plot_cumulative: self._plot_cumulative = val self.update_line_attrs(['cumulative'], self.lines_to_draw, stale=True, hidden=not val) - if 'fig' in self.__dict__: + if self.is_fig_open(): self.setup_figure() @property @@ -170,7 +215,7 @@ class PlotManager: if val != self._plot_timetrace: self._plot_timetrace = val self.update_line_attrs(['time'], self.lines_to_draw, stale=True, hidden=not val) - if 'fig' in self.__dict__: + if self.is_fig_open(): self.setup_figure() @property @@ -184,7 +229,7 @@ class PlotManager: if val != self._plot_negative_frequencies: self._plot_negative_frequencies = val self.update_line_attrs(['main', 'cumulative'], self.lines_to_draw, stale=True) - if 'fig' in self.__dict__: + if self.is_fig_open(): self.setup_figure() @property @@ -206,7 +251,7 @@ class PlotManager: keys=[key for key in self.shown if 'freq' in self._data[key]['settings']], stale=True ) - if 'fig' in self.__dict__: + if self.is_fig_open(): self.setup_figure() @property @@ -225,7 +270,7 @@ class PlotManager: if val != self._plot_amplitude: self._plot_amplitude = val self.update_line_attrs(['main', 'cumulative'], self.lines_to_draw, stale=True) - if 'fig' in self.__dict__: + if self.is_fig_open(): self.setup_figure() @property @@ -239,7 +284,7 @@ class PlotManager: if val != self._plot_density: self._plot_density = val self.update_line_attrs(['main', 'cumulative'], self.lines_to_draw, stale=True) - if 'fig' in self.__dict__: + if self.is_fig_open(): self.setup_figure() @property @@ -253,7 +298,7 @@ class PlotManager: if val != self._plot_cumulative_normalized: self._plot_cumulative_normalized = val self.update_line_attrs(['cumulative'], self.lines_to_draw, stale=True) - if 'fig' in self.__dict__: + if self.is_fig_open(): self.setup_figure() @property @@ -271,20 +316,9 @@ class PlotManager: self._plot_style = val self.destroy_axes() self.update_line_attrs(self.plots_to_draw, self.lines_to_draw, stale=True) - if 'fig' in self.__dict__: + if self.is_fig_open(): self.setup_figure() - @property - def plot_update_mode(self) -> str: - """Setting influencing how often the matplotlib event queue is - flushed.""" - return self._plot_update_mode - - @plot_update_mode.setter - @check_literals - def plot_update_mode(self, mode: Literal['fast', 'always', 'never']): - self._plot_update_mode = mode - @property def plot_dB_scale(self) -> bool: """Plot data as dB relative to a reference spectrum. @@ -298,9 +332,20 @@ class PlotManager: if val != self._plot_dB_scale: self._plot_dB_scale = val self.update_line_attrs(['main', 'cumulative'], self.lines_to_draw, stale=True) - if 'fig' in self.__dict__: + if self.is_fig_open(): self.setup_figure() + @property + def threaded_acquisition(self) -> bool: + """Acquire data in a separate thread.""" + return self._threaded_acquisition + + @threaded_acquisition.setter + def threaded_acquisition(self, val: bool): + val = bool(val) + if val != self._threaded_acquisition: + self._threaded_acquisition = val + @property def reference_spectrum(self) -> Optional[Tuple[int, str]]: """Spectrum taken as a reference for the dB scale. @@ -310,6 +355,10 @@ class PlotManager: return list(self._data)[0] return self._reference_spectrum + @reference_spectrum.setter + def reference_spectrum(self, val: Tuple[int, str]): + self._reference_spectrum = val + @property def processed_unit(self) -> str: """The unit displayed for processed data.""" @@ -320,7 +369,7 @@ class PlotManager: val = str(val) if val != self._processed_unit: self._processed_unit = val - if 'fig' in self.__dict__: + if self.is_fig_open(): self.setup_figure() @property @@ -508,16 +557,8 @@ class PlotManager: continue def update_figure(self): - if 'fig' not in self.__dict__: - # Need to completely create/restore figure - self.__dict__.pop('fig', None) - self.destroy_axes() - self.update_line_attrs(self.plots_to_draw, self.lines_to_draw, self.shown, stale=True) - self.setup_figure() - - # Flush out all idle events - if self.plot_update_mode in {'always'}: - self.fig.canvas.flush_events() + # Flush out all idle events, necessary for some reason in sequential mode + self.fig.canvas.flush_events() # First set new axis scales and x-limits, then update the lines (since the cumulative # spectrum plot changes dynamically with the limits). Once all lines are drawn, update @@ -538,10 +579,8 @@ class PlotManager: if self._leg is not None: self._leg.remove() - # Needed to force update during a loop for instance self.fig.canvas.draw_idle() - if self.plot_update_mode in {'always', 'fast'}: - self.fig.canvas.flush_events() + self.fig.canvas.flush_events() def update_lines(self): for key in self.shown: @@ -591,7 +630,7 @@ class PlotManager: for k in self.shown ), default=None) - with filter_warnings('ignore', UserWarning): + with filter_warnings(action='ignore', category=UserWarning): # ignore warnings issued for empty plots with log scales self.axes['main']['processed'].set_xlim(left, right) diff --git a/src/python_spectrometer/core.py b/src/python_spectrometer/core.py index 5a599aa40c089dc075a8a41ce9a071c12b47dec7..ec130ba01dfdf244528339bc277db7bfecc0838d 100644 --- a/src/python_spectrometer/core.py +++ b/src/python_spectrometer/core.py @@ -7,7 +7,7 @@ import warnings from datetime import datetime from pathlib import Path from pprint import pprint -from queue import Queue, LifoQueue +from queue import Queue, Empty, LifoQueue from threading import Thread, Event from typing import (Any, Callable, Dict, Generator, Iterator, List, Literal, Mapping, Optional, Sequence, Tuple, Union, cast) @@ -19,9 +19,10 @@ from matplotlib import colors from matplotlib.axes import Axes from matplotlib.figure import Figure from matplotlib.legend import Legend -from qutil import io +from qutil import io, misc from qutil.functools import cached_property, chain, partial from qutil.itertools import count +from qutil.plotting import is_using_mpl_gui_backend from qutil.plotting import live_view from qutil.signal_processing.real_space import Id, welch from qutil.typecheck import check_literals @@ -237,14 +238,14 @@ class Spectrometer: >>> def spectrum(f, A=1e-4, exp=1.5, **_): ... return A/f**exp >>> daq = QoptColoredNoise(spectrum) - >>> spect = Spectrometer(daq, savepath=mkdtemp()) + >>> spect = Spectrometer(daq, savepath=mkdtemp(), threaded_acquisition=False) >>> spect.take('a comment', f_max=2000, A=2e-4) >>> spect.print_keys() - (0, 'a comment') + - (0, 'a comment') >>> spect.take('more comments', df=0.1, f_max=2000) >>> spect.print_keys() - (0, 'a comment') - (1, 'more comments') + - (0, 'a comment') + - (1, 'more comments') Hide and show functionality: @@ -259,7 +260,7 @@ class Spectrometer: ... spect.savepath / 'foo', daq ... ) >>> spect_loaded.print_keys() - (0, 'a comment') + - (0, 'a comment') >>> spect.print_settings('a comment') Settings for key (0, 'a comment'): {'A': 0.0002, @@ -273,10 +274,36 @@ class Spectrometer: 'noverlap': 2000, 'nperseg': 4000} + Use threaded acquisition to avoid blocking the interpreter and keep + the figure responsive: + + >>> spect.threaded_acquisition = True + >>> spect.take(n_avg=5, delay=True, progress=False) + + When the spectrometer is still acquiring, starting a new acquisition + errors. + + >>> spect.take() + Traceback (most recent call last): + ... + RuntimeError: Spectrometer is currently acquiring. + + To check the acquisition status, use either the :attr:`acquiring` + attribute or block until ready (defeats the purpose though). + + >>> spect.block_until_ready() + >>> spect.take(n_avg=5, delay=True, progress=False) + >>> spect.acquiring + True + >>> spect.block_until_ready() + >>> spect.acquiring + False + Use the audio interface to listen to the noise: - >>> spect_with_audio = Spectrometer(daq, savepath=mkdtemp(), play_sound=True) - >>> spect_with_audio.take('a comment', f_max=20000, A=2e-4) # doctest: +SKIP + >>> spect_with_audio = Spectrometer(daq, savepath=mkdtemp(), play_sound=True, + ... threaded_acquisition=False) + >>> spect_with_audio.take('a comment', f_max=20000, A=2e-4) >>> spect_with_audio.audio_stream.stop() Instead of taking spectra one-by-one, it is also possible to @@ -300,6 +327,8 @@ class Spectrometer: If the :attr:`plot_timetrace` flag is set, another live view for the real-time data is instanatiated: + >>> import matplotlib.pyplot as plt + >>> plt.pause(1e-3) # for doctest >>> view.stop() >>> speck.plot_timetrace = True >>> freq_view, time_view = speck.live_view() @@ -308,6 +337,7 @@ class Spectrometer: :meth:`~qutil:qutil.plotting.live_view.LiveViewBase.stop` or simply by closing the figure. + >>> plt.pause(1e-3) # for doctest >>> freq_view.stop() Interrupting one live view will also kill the other. @@ -331,6 +361,8 @@ class Spectrometer: ... proxy.stop() ... proxy.process.terminate() + >>> plt.close('all') + """ _OLD_PARAMETER_NAMES = { 'plot_cumulative_power': 'plot_cumulative', @@ -341,8 +373,8 @@ class Spectrometer: # Expose plot properties from plot manager _to_expose = ('fig', 'ax', 'ax_raw', 'leg', 'plot_raw', 'plot_timetrace', 'plot_cumulative', 'plot_negative_frequencies', 'plot_absolute_frequencies', 'plot_amplitude', - 'plot_density', 'plot_cumulative_normalized', 'plot_style', 'plot_update_mode', - 'plot_dB_scale', 'reference_spectrum', 'processed_unit') + 'plot_density', 'plot_cumulative_normalized', 'plot_style', 'plot_dB_scale', + 'threaded_acquisition', 'reference_spectrum', 'processed_unit') # type checkers fig: Figure @@ -358,8 +390,8 @@ class Spectrometer: plot_density: bool plot_cumulative_normalized: bool plot_style: _styleT - plot_update_mode: Optional[Literal['fast', 'always', 'never']] plot_dB_scale: bool + threaded_acquisition: bool reference_spectrum: _keyT processed_unit: str @@ -387,6 +419,7 @@ class Spectrometer: self._data: Dict[Tuple[int, str], Dict] = {} self._savepath: Optional[Path] = None + self._acquiring = False self.daq = daq self.procfn = chain(*procfn) if np.iterable(procfn) else chain(procfn or Id) @@ -395,12 +428,11 @@ class Spectrometer: savepath = Path.home() / 'python_spectrometer' / datetime.now().strftime('%Y-%m-%d') self.savepath = savepath self.compress = compress - self.threaded_acquisition = threaded_acquisition - if plot_update_mode is None: - plot_update_mode = 'never' if self.threaded_acquisition else 'fast' + if plot_update_mode is not None: + warnings.warn('plot_update_mode is deprecated and has no effect', DeprecationWarning) if purge_raw_data: warnings.warn('Enabling purge raw data might break some plotting features!', - UserWarning, stacklevel=2) + UserWarning) self.purge_raw_data = purge_raw_data if psd_estimator is None: @@ -423,7 +455,7 @@ class Spectrometer: plot_cumulative, plot_negative_frequencies, plot_absolute_frequencies, plot_amplitude, plot_density, plot_cumulative_normalized, - plot_style, plot_update_mode, plot_dB_scale, + plot_style, plot_dB_scale, threaded_acquisition, prop_cycle, raw_unit, processed_unit, uses_windowed_estimator, complex_data, figure_kw, subplot_kw, gridspec_kw, legend_kw) @@ -481,6 +513,11 @@ class Spectrometer: def savepath(self, path): self._savepath = io.to_global_path(path) + @property + def acquiring(self) -> bool: + """Indicates if the spectrometer is currently acquiring data.""" + return self._acquiring + @cached_property def audio_stream(self) -> WaveformPlaybackManager: """Manages audio waveform playback.""" @@ -572,7 +609,7 @@ class Spectrometer: def _repr_keys(self, *keys) -> str: if not keys: keys = self.keys() - return '\n'.join((str(key) for key in sorted(self.keys()) if key in keys)) + return ' - ' + '\n - '.join((str(key) for key in sorted(self.keys()) if key in keys)) @mock.patch.multiple('pickle', Unpickler=dill.Unpickler, Pickler=dill.Pickler) def _savefn(self, file: _pathT, **kwargs): @@ -605,6 +642,12 @@ class Spectrometer: return compatible_kwargs + def _assert_ready(self): + if not isinstance(self.daq, DAQ): + raise ReadonlyError('Cannot take new data since no DAQ backend given') + if self.acquiring: + raise RuntimeError('Spectrometer is currently acquiring.') + def _process_data(self, timetrace_raw, **settings) -> Dict[str, Any]: S_raw, f_raw, _ = welch(timetrace_raw, **settings) S_processed, f_processed, timetrace_processed = self.psd_estimator( @@ -622,21 +665,74 @@ class Spectrometer: settings=DAQSettings(settings)) return data - def _handle_fetched(self, fetched_data, key: _keyT, **settings): + def _handle_fetched(self, key: _keyT, fetched_data, **settings): processed_data = self._process_data(fetched_data, **settings) # TODO: This could fail if the iterator was empty and processed_data was never assigned self._data[key].update(_merge_data_dicts(self._data[key], processed_data)) self.set_reference_spectrum(self.reference_spectrum) - self.show(key) + if self._plot_manager.is_fig_open(): + self.show(key) + else: + raise KeyboardInterrupt('Spectrometer was closed before data acquisition finished') + + def _handle_final(self, key: _keyT, metadata: Any): + if self.play_sound: + self.play(key) - def _take_threaded(self, iterator: Iterator, progress: bool, key: _keyT, n_avg: int, - **settings): - """Acquire data in a separate thread.""" + if self.purge_raw_data: + del self._data[key]['timetrace_raw'] + del self._data[key]['timetrace_processed'] + del self._data[key]['f_raw'] + del self._data[key]['S_raw'] + self._data[key]['S_processed'] = np.mean(self._data[key]['S_processed'], axis=0)[None] + + self._data[key].update(measurement_metadata=metadata) + self._savefn(self._data[key]['filepath'], **self._data[key]) + + def _take_threaded(self, progress: bool, key: _keyT, n_avg: int, **settings): + """Acquire data in a separate thread. + + The :meth:`.daq.base.DAQ.acquire` iterator is incremented in a + background thread and fed into a :class:`~queue.Queue`. The + data is fetched and plotted in a callback that is periodically + triggered by a timer connected to the figure. + + See Also + -------- + :attr:`._plot_manager.PlotManager.timer` + :attr:`._plot_manager.PlotManager.TIMER_INTERVAL` + + """ + + def update_plot(): + try: + result = queue.get(block=False) + except Empty: + return + try: + if isinstance(result, StopIteration): + self._handle_final(key, result.value) + self._acquiring = False + # Signal the timer that we've stopped. Removes the callback + return False + elif isinstance(result, Exception): + # Make sure we are left in a reproducible state + self.drop(key) + self._acquiring = False + raise RuntimeError('Something went wrong during data acquisition') from result + else: + self._handle_fetched(key, result, n_avg=n_avg, **settings) + finally: + queue.task_done() - def task(): - for _ in progressbar(count(), disable=not progress, total=n_avg, + def acquire(): + iterator = self.daq.acquire(n_avg=n_avg, **settings) + for i in progressbar(count(), disable=not progress, total=n_avg, desc=f'Acquiring {n_avg} spectra with key {key}'): + if stop_flag.is_set(): + print('Acquisition interrupted.') + break try: item = next(iterator) except Exception as error: @@ -645,66 +741,53 @@ class Spectrometer: else: queue.put(item) - queue = Queue() - thread = Thread(target=task) - thread.start() + # The plot_update callback does not run on a noninteractive backend, + # so need to reset the flag at the thread's exit. + if not INTERACTIVE: + self._acquiring = False - fetched_data = sentinel = object() + def on_close(event): + stop_flag.set() + self._acquiring = False - while thread.is_alive(): - while queue.empty(): - self.fig.canvas.start_event_loop(20e-3) + INTERACTIVE = is_using_mpl_gui_backend(self.fig) - result = queue.get() - try: - if isinstance(result, StopIteration): - return result.value - elif isinstance(result, Exception): - # Make sure we are left in a reproducible state - self.drop(key) + queue = Queue() + stop_flag = Event() + thread = Thread(target=acquire, daemon=True) + thread.start() - msg = 'Something went wrong during data acquisition' - if fetched_data is not sentinel: - msg = msg + (f'. {self.daq.acquire} last returned the following data:\n ' - f'{fetched_data}') + # Stop data acquisition when the figure is closed + self.fig.canvas.mpl_connect('close_event', on_close) - raise RuntimeError(msg) from result - else: - fetched_data = result - self._handle_fetched(fetched_data, key, n_avg=n_avg, **settings) - finally: - queue.task_done() + # Run the timer that periodically checks for new data and updates the plot + self._plot_manager.timer.add_callback(update_plot) + self._plot_manager.timer.start() + self._acquiring = True - def _take_sequential(self, iterator: Iterator, progress: bool, key: _keyT, n_avg: int, - **settings): + def _take_sequential(self, progress: bool, key: _keyT, n_avg: int, **settings): """Acquire data in the main thread.""" - - sentinel = object() - fetched_data = sentinel - - for i in progressbar(count(), disable=not progress, total=n_avg, + # It's not necessary to set the _acquiring flag because this is + # blocking anyway. + iterator = self.daq.acquire(n_avg=n_avg, **settings) + for _ in progressbar(count(), disable=not progress, total=n_avg, desc=f'Acquiring {n_avg} spectra with key {key}'): try: fetched_data = next(iterator) except StopIteration as stop: - return stop.value + self._handle_final(key, stop.value) + break except Exception as error: # Make sure we are left in a reproducible state self.drop(key) - - msg = 'Something went wrong during data acquisition' - if fetched_data is not sentinel: - msg = msg + (f'. {self.daq.acquire} last returned the following data:\n ' - f'{fetched_data}') - - raise RuntimeError(msg) from error + raise RuntimeError('Something went wrong during data acquisition') from error else: - self._handle_fetched(fetched_data, key, n_avg=n_avg, **settings) + self._handle_fetched(key, fetched_data, n_avg=n_avg, **settings) def take(self, comment: str = '', progress: bool = True, **settings): """Acquire a spectrum with given settings and comment. - There are default parameter names that manage data acqusition + There are default parameter names that manage data acquisition settings by way of a dictionary subclass, :class:`.daq.settings.DAQSettings`. These are checked for consistency at runtime, since it is for example not possible to @@ -719,15 +802,14 @@ class Spectrometer: comment : str An explanatory comment that helps identify the spectrum. progress : bool - Show a progressbar for the outer repetitions of data acqusition. - Default True. + Show a progressbar for the outer repetitions of data + acqusition. Default True. **settings Keyword argument settings for the data acquisition and possibly data processing using :attr:`procfn` or :attr:`fourier_procfn`. """ - if not isinstance(self.daq, DAQ): - raise ReadonlyError('Cannot take new data since no DAQ backend given') + self._assert_ready() if (key := (self._index, comment)) in self._data: raise KeyError(f'Key {key} already exists. Choose a different comment.') @@ -744,25 +826,10 @@ class Spectrometer: self._plot_manager.update_figure() self._plot_manager.add_new_line_entry(key) - iterator = self.daq.acquire(**settings) - if self.threaded_acquisition: - measurement_metadata = self._take_threaded(iterator, progress, key, **settings) + self._take_threaded(progress, key, **settings) else: - measurement_metadata = self._take_sequential(iterator, progress, key, **settings) - - if self.play_sound: - self.play(key) - - self._data[key].update(measurement_metadata=measurement_metadata) - if self.purge_raw_data: - del self._data[key]['timetrace_raw'] - del self._data[key]['timetrace_processed'] - del self._data[key]['f_raw'] - del self._data[key]['S_raw'] - self._data[key]['S_processed'] = np.mean(self._data[key]['S_processed'], axis=0)[None] - - self._savefn(filepath, **self._data[key]) + self._take_sequential(progress, key, **settings) take.__doc__ = (take.__doc__.replace(8*' ', '') + '\n\nDAQ Parameters' @@ -979,8 +1046,7 @@ class Spectrometer: if repetitions > 1: data = np.repeat(data[None, :], repetitions, axis=0).flatten() - if self.audio_stream is not None: - self.audio_stream.notify(data.flatten().astype("float32"), fs) + self.audio_stream.notify(data.flatten().astype("float32"), fs) def live_view( self, @@ -1024,8 +1090,7 @@ class Spectrometer: # data, the cleaner option would arguably be to subclass and add # another subplot. - if not isinstance(self.daq, DAQ): - raise ReadonlyError('Cannot take new data since no DAQ backend given') + self._assert_ready() # Since (up to) two views need to obtain data from a single source, we feed the data # into separate queues from a third thread. The views then obtain the data from those @@ -1068,6 +1133,7 @@ class Spectrometer: for event in set_events: if event is not None: event.set() + self._acquiring = False def get_live_view(cls, *args, **kwargs): if not in_process: @@ -1088,10 +1154,10 @@ class Spectrometer: T = settings['n_pts'] / settings['fs'] if np.issubdtype(self.daq.DTYPE, np.complexfloating): - xscale = _asinh_scale_maybe() + freq_xscale = _asinh_scale_maybe() xlim = np.array([-settings['f_max'], settings['f_max']]) else: - xscale = 'log' + freq_xscale = 'log' xlim = np.array([settings['f_min'], settings['f_max']]) if self.plot_absolute_frequencies: xlim += settings.get('freq', 0) @@ -1099,9 +1165,9 @@ class Spectrometer: if live_view_kw is None: live_view_kw = {} + live_view_kw.setdefault('blocking_queue', True) live_view_kw.setdefault('autoscale', 'c') live_view_kw.setdefault('autoscale_interval', None) - live_view_kw.setdefault('xscale', xscale) fixed_kw = dict( plot_line=True, xlim=xlim, ylim=(0, (max_rows - 1) * T), @@ -1109,13 +1175,13 @@ class Spectrometer: units={'x': 'Hz', 'y': 's', 'c': self.processed_unit + r'$/\sqrt{{Hz}}$'}, img_kw=dict(norm=colors.LogNorm(vmin=0.1, vmax=10), cmap='Blues') ) - freq_kw = live_view_kw | fixed_kw + freq_kw = {'xscale': freq_xscale} | live_view_kw | fixed_kw if any(freq_kw[key] != val for key, val in live_view_kw.items()): warnings.warn('Overrode some keyword arguments for FrequencyLiveView', UserWarning) # The view(s) get data from these queues and subsequently put them into their own stop_event = Event() - get_queues = [Queue()] + get_queues = [LifoQueue(maxsize=int(live_view_kw['blocking_queue']))] views = [get_live_view(FrequencyLiveView, put_frequency_data, get_queue=get_queues[0], **freq_kw)] @@ -1126,7 +1192,7 @@ class Spectrometer: if any(time_kw[key] != val for key, val in live_view_kw.items()): warnings.warn('Overrode some keyword arguments for TimeLiveView', UserWarning) - get_queues.append(LifoQueue()) + get_queues.append(LifoQueue(maxsize=int(live_view_kw['blocking_queue']))) views.append(get_live_view(TimeLiveView, put_time_data, get_queue=get_queues[1], **time_kw)) @@ -1164,8 +1230,27 @@ class Spectrometer: else: view.block_until_ready() + self._acquiring = True return views + def block_until_ready(self, timeout: float = float('inf')): + """Block the interpreter until acquisition is complete. + + This is a convenience function to ensure all GUI events are + processed before another acquisition is started. + + Spins the event loop to allow the figure to remain responsive. + + Parameters + ---------- + timeout : + Optionally specify a timeout after which a :class:`TimeoutError` + is raised. + """ + with misc.timeout(timeout, raise_exc=True) as exceeded: + while self.acquiring and not exceeded: + self.fig.canvas.start_event_loop(self._plot_manager.TIMER_INTERVAL * 1e-3) + def reprocess_data(self, *comment_or_index: _keyT, save: Literal[False, True, 'overwrite'] = False, @@ -1231,11 +1316,12 @@ class Spectrometer: return key = self._parse_keys(comment_or_index)[0] if key != self.reference_spectrum: - self._plot_manager._reference_spectrum = key + self._plot_manager.reference_spectrum = key if self.plot_dB_scale: self._plot_manager.update_line_attrs(['main', 'cumulative'], self._plot_manager.lines_to_draw, stale=True) + if self._plot_manager.is_fig_open(): self._plot_manager.setup_figure() @staticmethod diff --git a/src/python_spectrometer/daq/simulator.py b/src/python_spectrometer/daq/simulator.py index 125b14ecf9618dc946a350905c837c99dd6b0615..ed8ebc179a4c6a793133a422a629b6e9506344f8 100644 --- a/src/python_spectrometer/daq/simulator.py +++ b/src/python_spectrometer/daq/simulator.py @@ -8,6 +8,7 @@ Examples ... savepath=tempfile.mkdtemp()) >>> speck.take('a test', fs=10e3) #doctest: +ELLIPSIS ... +>>> speck.block_until_ready() # for doctest Add an artificial time delay to mimick finite data acquisition time: >>> speck.take('delayed', n_avg=3, delay=True) #doctest: +ELLIPSIS diff --git a/src/python_spectrometer/daq/zurich_instruments.py b/src/python_spectrometer/daq/zurich_instruments.py index 9bb5cb1167be84bd823589cf4df504ec806b33f5..876fd1f64d95327030e7464da5f043f87eac581f 100644 --- a/src/python_spectrometer/daq/zurich_instruments.py +++ b/src/python_spectrometer/daq/zurich_instruments.py @@ -54,7 +54,7 @@ from typing import Any, Dict, Mapping, Optional, Type, Union import numpy as np from packaging import version -from qutil.domains import BoundedSet, DiscreteInterval +from qutil.domains import DiscreteInterval, ExponentialDiscreteInterval from scipy.special import gamma from zhinst import toolkit @@ -166,8 +166,7 @@ class ZurichInstrumentsMFLIDAQ(_ZurichInstrumentsDevice): class MFLIDAQSettings(DAQSettings): CLOCKBASE = self.device.clockbase() # TODO: always the same for each instrument? - ALLOWED_FS = BoundedSet(CLOCKBASE / 70 / 2 ** np.arange(24), - precision=DAQSettings.PRECISION) + ALLOWED_FS = ExponentialDiscreteInterval(-23, 0, base=2, prefactor=CLOCKBASE / 70) DEFAULT_FS = CLOCKBASE / 70 / 2**6 return MFLIDAQSettings @@ -411,8 +410,8 @@ class ZurichInstrumentsMFLIScope(_ZurichInstrumentsDevice): CLOCKBASE = self.device.clockbase() # TODO: always the same for each instrument? ALLOWED_N_PTS = DiscreteInterval(2 ** 12, 2 ** 14, precision=DAQSettings.PRECISION) - ALLOWED_FS = BoundedSet(CLOCKBASE / 2 ** np.arange(17), - precision=DAQSettings.PRECISION) + ALLOWED_FS = ExponentialDiscreteInterval(-16, 0, prefactor=CLOCKBASE, base=2, + precision=DAQSettings.PRECISION) DEFAULT_FS = CLOCKBASE / 2 ** 8 return MFLIScopeSettings diff --git a/tests/test_audio.py b/tests/test_audio.py index 404a9bccec3fcb65041ae13a2ccc879ecd7855c9..159f878301d48c853d6541f0b053e6008940a484 100644 --- a/tests/test_audio.py +++ b/tests/test_audio.py @@ -20,8 +20,9 @@ def import_or_mock_pyaudio(): def test_audio(import_or_mock_pyaudio): spect = Spectrometer(QoptColoredNoise(), savepath=mkdtemp(), - play_sound=True) + play_sound=True, threaded_acquisition=False) spect.take('with sound', f_max=20000, A=2e-4) + spect.block_until_ready() assert 'audio_stream' in spect.__dict__ assert spect.audio_stream.playback_thread is not None diff --git a/tests/test_live_view.py b/tests/test_live_view.py index 73d5408b900c04c058d12e95e371da2503f85343..ab10cd1437626e21cc3f6253888f4229ebfb2400 100644 --- a/tests/test_live_view.py +++ b/tests/test_live_view.py @@ -21,24 +21,34 @@ def start_animation(fig): fig.canvas.callbacks.process( 'draw_event', DrawEvent('draw_event', fig.canvas, fig.canvas.get_renderer()) ) + else: + fig.canvas.flush_events() def advance_frames(view: LiveViewT, n_frames: int): # Need to manually trigger events in headless mode. Else does nothing. - if not is_using_mpl_gui_backend(view.fig): - # Start the animation - for _ in range(n_frames): + for _ in range(n_frames): + if not is_using_mpl_gui_backend(view.fig): for fun, args, kwargs in view.animation.event_source.callbacks: fun(*args, **kwargs) + else: + view.fig.canvas.flush_events() + + +def stop_view(view): + try: + view.stop() + except RuntimeError: + # Thread did not join. Try to trigger event loop once. + advance_frames(view, 1) + view.stop() def close_figure(fig): if not is_using_mpl_gui_backend(fig): # Need to manually trigger the close event because the event loop isn't running fig.canvas.callbacks.process('close_event', CloseEvent('close_event', fig.canvas)) - else: - # Required to have a figure that's up-to-date - fig.canvas.flush_events() + # remove figure from registry plt.close(fig) @@ -59,12 +69,12 @@ def spectrometer(): return Spectrometer(daq.QoptColoredNoise(), savepath=mkdtemp()) -@pytest.fixture(params=[True, False]) +@pytest.fixture(params=[True, False], ids=['timetrace', 'no_timetrace']) def plot_timetrace(request): return request.param -@pytest.fixture(params=[True, False]) +@pytest.fixture(params=[True, False], ids=['in_child_process', 'in_main_process']) def in_process(request): return request.param @@ -98,22 +108,39 @@ def started(spectrometer, plot_timetrace, in_process, in_gitlab_ci): @pytest.fixture def stopped(started: list[LiveViewT], in_process: bool): view = random.choice(started) - view.stop() + stop_view(view) if not in_process and len(started) > 1: - view = started[1 - started.index(view)] - if is_using_mpl_gui_backend(view.fig): - view.fig.canvas.start_event_loop(100e-3) - else: - advance_frames(view, 1) + advance_frames(started[1 - started.index(view)], 1) stopped = started return stopped -def test_started(started, plot_timetrace, in_process): +@pytest.fixture +def closed(started: list[LiveViewT], in_process: bool): + view = random.choice(started) + + if in_process: + view.close_event.set() + else: + close_figure(view.fig) + + if not in_process and len(started) > 1: + advance_frames(started[1 - started.index(view)], 1) + else: + # Give the process time to process all events + time.sleep(250e-3) + + closed = started + yield closed + + +def test_started(spectrometer, started, plot_timetrace, in_process): views = started + assert spectrometer.acquiring + if plot_timetrace: assert len(views) == 2 if not in_process: @@ -135,8 +162,12 @@ def test_started(started, plot_timetrace, in_process): assert not statement_timed_out(lambda: not view.stop_event.is_set(), timeout=1) -def test_stopped(stopped): - views = stopped +@pytest.mark.parametrize("view_state", ["stopped", "closed"]) +def test_finished(spectrometer, view_state, request, plot_timetrace, in_process, in_gitlab_ci): + """Test the state of view(s) and spectrometer after stopping and closing.""" + views = request.getfixturevalue(view_state) + + assert not spectrometer.acquiring for view in views: assert not statement_timed_out(lambda: view.is_running() is None, timeout=10) diff --git a/tests/test_serialization.py b/tests/test_serialization.py index 83a87b9bb5c069ce0085da0762aca9394f0333fd..595b9376842a1a886cc246d3cce39ee3b05952fd 100644 --- a/tests/test_serialization.py +++ b/tests/test_serialization.py @@ -2,13 +2,11 @@ import os import pathlib import random import string -from pathlib import Path from tempfile import mkdtemp -from typing import Any, Generator import pytest - from python_spectrometer import Spectrometer, daq +from qutil.plotting import is_using_mpl_gui_backend def remove_file_if_exists(file): @@ -25,22 +23,51 @@ def remove_dir_if_exists(d): pass +def advance_frames(spect: Spectrometer, n_frames: int): + # Need to manually trigger events in headless mode. Else does nothing. + if not is_using_mpl_gui_backend(spect.fig): + # Run callbacks + for _ in range(n_frames): + for fun, args, kwargs in spect._plot_manager.timer.callbacks: + fun(*args, **kwargs) + + +@pytest.fixture(params=[True, False]) +def relative_paths(request): + return request.param + + @pytest.fixture(params=[True, False]) -def spectrometer(monkeypatch, request) -> Generator[Spectrometer, Any, None]: - # patch input to answer overwrite queries with "yes" +def threaded_acquisition(request): + return request.param + + +@pytest.fixture(scope='function') +def spectrometer(monkeypatch, relative_paths: bool, threaded_acquisition: bool): + # patch input to answer overwrite queries with "yes". Disable this when debugging :) monkeypatch.setattr('builtins.input', lambda: 'y') speck = Spectrometer(daq.QoptColoredNoise(), savepath=pathlib.Path(cwd := mkdtemp(), 'test_data'), plot_cumulative=True, - relative_paths=request.param) + threaded_acquisition=threaded_acquisition, + relative_paths=relative_paths) speck.savepath.mkdir(parents=True, exist_ok=True) try: os.chdir(speck.savepath) - speck.take('foo') - speck.take('baz', fs=1e3, nperseg=400) + # XXX: need to block to avoid timing issues when running tests + speck.take('foo', progress=False) + speck.block_until_ready() + if threaded_acquisition: + # Advance two frames in headless mode, otherwise data is not saved. + advance_frames(speck, 2) + + speck.take('baz', fs=1e3, nperseg=400, progress=False) + speck.block_until_ready() + if threaded_acquisition: + advance_frames(speck, 2) yield speck finally: @@ -52,7 +79,7 @@ def spectrometer(monkeypatch, request) -> Generator[Spectrometer, Any, None]: @pytest.fixture -def serialized(spectrometer: Spectrometer) -> Generator[Path, Any, None]: +def serialized(spectrometer: Spectrometer): stem = ''.join(random.choices(string.ascii_letters, k=10)) try: @@ -69,10 +96,13 @@ def serialized(spectrometer: Spectrometer) -> Generator[Path, Any, None]: remove_file_if_exists(spectrometer.savepath / f'{stem}{ext}') -def test_saving(spectrometer: Spectrometer): +def test_saving(spectrometer: Spectrometer, relative_paths: bool): assert spectrometer.savepath.exists() for file in spectrometer.files: - assert os.path.exists(file) + if relative_paths: + assert os.path.exists(spectrometer.savepath / file) + else: + assert os.path.exists(file) def test_serialization(spectrometer: Spectrometer): @@ -88,9 +118,9 @@ def test_serialization(spectrometer: Spectrometer): def test_deserialization(serialized: pathlib.Path): - speck = Spectrometer.recall_from_disk(serialized) - for data, comment in zip(speck, ['foo', 'baz']): + deserialized = Spectrometer.recall_from_disk(serialized) + for data, comment in zip(deserialized, ['foo', 'baz']): assert data['comment'] == comment - assert speck['baz']['settings']['fs'] == 1e3 - assert speck['baz']['settings']['nperseg'] == 400 - assert speck.plot_cumulative is True + assert deserialized['baz']['settings']['fs'] == 1e3 + assert deserialized['baz']['settings']['nperseg'] == 400 + assert deserialized.plot_cumulative is True