ezmsg-tools 0.3.2__tar.gz → 0.5.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {ezmsg_tools-0.3.2 → ezmsg_tools-0.5.0}/PKG-INFO +3 -3
- {ezmsg_tools-0.3.2 → ezmsg_tools-0.5.0}/pyproject.toml +11 -6
- ezmsg_tools-0.5.0/src/ezmsg/tools/__version__.py +24 -0
- ezmsg_tools-0.5.0/src/ezmsg/tools/_entry.py +42 -0
- ezmsg_tools-0.5.0/src/ezmsg/tools/chmeta.py +87 -0
- ezmsg_tools-0.5.0/src/ezmsg/tools/perfmon/__init__.py +12 -0
- ezmsg_tools-0.5.0/src/ezmsg/tools/plot/__init__.py +56 -0
- ezmsg_tools-0.5.0/src/ezmsg/tools/plot/describe.py +277 -0
- ezmsg_tools-0.5.0/src/ezmsg/tools/plot/shmem_sweep.py +310 -0
- ezmsg_tools-0.5.0/src/ezmsg/tools/shmem/aux_meta.py +208 -0
- {ezmsg_tools-0.3.2 → ezmsg_tools-0.5.0}/src/ezmsg/tools/shmem/shmem.py +172 -6
- {ezmsg_tools-0.3.2 → ezmsg_tools-0.5.0}/src/ezmsg/tools/shmem/shmem_mirror.py +173 -1
- ezmsg_tools-0.5.0/src/ezmsg/tools/sigmon/__init__.py +12 -0
- {ezmsg_tools-0.3.2 → ezmsg_tools-0.5.0}/src/ezmsg/tools/sigmon/cli.py +18 -13
- {ezmsg_tools-0.3.2 → ezmsg_tools-0.5.0}/src/ezmsg/tools/viewer/cli.py +23 -17
- ezmsg_tools-0.5.0/tests/test_plot_describe.py +222 -0
- ezmsg_tools-0.5.0/tests/test_shmem_aux_meta.py +436 -0
- {ezmsg_tools-0.3.2 → ezmsg_tools-0.5.0}/tests/test_shmem_mirror.py +11 -2
- ezmsg_tools-0.5.0/tests/test_shmem_sweep.py +79 -0
- ezmsg_tools-0.3.2/src/ezmsg/tools/__version__.py +0 -34
- ezmsg_tools-0.3.2/src/ezmsg/tools/sigmon/__init__.py +0 -0
- ezmsg_tools-0.3.2/src/ezmsg/tools/viewer/__init__.py +0 -0
- {ezmsg_tools-0.3.2 → ezmsg_tools-0.5.0}/.github/workflows/docs.yml +0 -0
- {ezmsg_tools-0.3.2 → ezmsg_tools-0.5.0}/.github/workflows/python-publish.yml +0 -0
- {ezmsg_tools-0.3.2 → ezmsg_tools-0.5.0}/.github/workflows/python-tests.yml +0 -0
- {ezmsg_tools-0.3.2 → ezmsg_tools-0.5.0}/.gitignore +0 -0
- {ezmsg_tools-0.3.2 → ezmsg_tools-0.5.0}/.pre-commit-config.yaml +0 -0
- {ezmsg_tools-0.3.2 → ezmsg_tools-0.5.0}/LICENSE +0 -0
- {ezmsg_tools-0.3.2 → ezmsg_tools-0.5.0}/README.md +0 -0
- {ezmsg_tools-0.3.2 → ezmsg_tools-0.5.0}/docs/Makefile +0 -0
- {ezmsg_tools-0.3.2 → ezmsg_tools-0.5.0}/docs/make.bat +0 -0
- {ezmsg_tools-0.3.2 → ezmsg_tools-0.5.0}/docs/source/_templates/autosummary/module.rst +0 -0
- {ezmsg_tools-0.3.2 → ezmsg_tools-0.5.0}/docs/source/api/index.rst +0 -0
- {ezmsg_tools-0.3.2 → ezmsg_tools-0.5.0}/docs/source/conf.py +0 -0
- {ezmsg_tools-0.3.2 → ezmsg_tools-0.5.0}/docs/source/index.md +0 -0
- {ezmsg_tools-0.3.2 → ezmsg_tools-0.5.0}/scripts_nbs/generator/eeg_generator_graph.py +0 -0
- {ezmsg_tools-0.3.2 → ezmsg_tools-0.5.0}/scripts_nbs/orphan/run_attach.py +0 -0
- {ezmsg_tools-0.3.2 → ezmsg_tools-0.5.0}/scripts_nbs/profiler/ecog_preproc.py +0 -0
- {ezmsg_tools-0.3.2 → ezmsg_tools-0.5.0}/scripts_nbs/profiler/parse_profiler.ipynb +0 -0
- {ezmsg_tools-0.3.2 → ezmsg_tools-0.5.0}/scripts_nbs/profiler/profiler.py +0 -0
- {ezmsg_tools-0.3.2 → ezmsg_tools-0.5.0}/src/ezmsg/tools/__init__.py +0 -0
- {ezmsg_tools-0.3.2 → ezmsg_tools-0.5.0}/src/ezmsg/tools/dag.py +0 -0
- {ezmsg_tools-0.3.2 → ezmsg_tools-0.5.0}/src/ezmsg/tools/perfmon/cli.py +0 -0
- {ezmsg_tools-0.3.2/src/ezmsg/tools/perfmon → ezmsg_tools-0.5.0/src/ezmsg/tools/shmem}/__init__.py +0 -0
- {ezmsg_tools-0.3.2 → ezmsg_tools-0.5.0}/src/ezmsg/tools/sigmon/dag_widget.py +0 -0
- {ezmsg_tools-0.3.2/src/ezmsg/tools/shmem → ezmsg_tools-0.5.0/src/ezmsg/tools/viewer}/__init__.py +0 -0
- {ezmsg_tools-0.3.2 → ezmsg_tools-0.5.0}/tests/test_shmem_sink.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ezmsg-tools
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.5.0
|
|
4
4
|
Summary: ezmsg namespace package containing tools for working with ezmsg graphs.
|
|
5
5
|
Author-email: Chadwick Boulay <chadwick.boulay@gmail.com>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -21,13 +21,13 @@ Requires-Dist: typer>=0.15.1; extra == 'perfmon'
|
|
|
21
21
|
Provides-Extra: sigmon
|
|
22
22
|
Requires-Dist: ezmsg-qt>=0.2.1; extra == 'sigmon'
|
|
23
23
|
Requires-Dist: pandas; extra == 'sigmon'
|
|
24
|
-
Requires-Dist: phosphor>=0.
|
|
24
|
+
Requires-Dist: phosphor>=0.8.0; extra == 'sigmon'
|
|
25
25
|
Requires-Dist: pygraphviz>=1.14; extra == 'sigmon'
|
|
26
26
|
Requires-Dist: pyside6>=6.7; extra == 'sigmon'
|
|
27
27
|
Requires-Dist: typer>=0.15.1; extra == 'sigmon'
|
|
28
28
|
Provides-Extra: viewer
|
|
29
29
|
Requires-Dist: ezmsg-qt>=0.2.1; extra == 'viewer'
|
|
30
|
-
Requires-Dist: phosphor>=0.
|
|
30
|
+
Requires-Dist: phosphor>=0.8.0; extra == 'viewer'
|
|
31
31
|
Requires-Dist: pyside6>=6.7; extra == 'viewer'
|
|
32
32
|
Requires-Dist: typer>=0.15.1; extra == 'viewer'
|
|
33
33
|
Description-Content-Type: text/markdown
|
|
@@ -18,8 +18,8 @@ dependencies = [
|
|
|
18
18
|
dev = [
|
|
19
19
|
"pre-commit>=4.0.0",
|
|
20
20
|
"scipy>=1.14.1",
|
|
21
|
-
"ezmsg-sigproc>=2.
|
|
22
|
-
"ezmsg-simbiophys>=1.
|
|
21
|
+
"ezmsg-sigproc>=2.36.0",
|
|
22
|
+
"ezmsg-simbiophys>=1.8.0",
|
|
23
23
|
{include-group = "lint"},
|
|
24
24
|
{include-group = "test"},
|
|
25
25
|
]
|
|
@@ -54,19 +54,19 @@ sigmon = [
|
|
|
54
54
|
"pygraphviz>=1.14",
|
|
55
55
|
"typer>=0.15.1",
|
|
56
56
|
"ezmsg-qt>=0.2.1",
|
|
57
|
-
"phosphor>=0.
|
|
57
|
+
"phosphor>=0.8.0",
|
|
58
58
|
"pandas",
|
|
59
59
|
]
|
|
60
60
|
viewer = [
|
|
61
61
|
"PySide6>=6.7",
|
|
62
62
|
"typer>=0.15.1",
|
|
63
63
|
"ezmsg-qt>=0.2.1",
|
|
64
|
-
"phosphor>=0.
|
|
64
|
+
"phosphor>=0.8.0",
|
|
65
65
|
]
|
|
66
66
|
|
|
67
67
|
[project.scripts]
|
|
68
|
-
ezmsg-performance-monitor = "ezmsg.tools.perfmon
|
|
69
|
-
ezmsg-signal-monitor = "ezmsg.tools.sigmon
|
|
68
|
+
ezmsg-performance-monitor = "ezmsg.tools.perfmon:main"
|
|
69
|
+
ezmsg-signal-monitor = "ezmsg.tools.sigmon:main"
|
|
70
70
|
|
|
71
71
|
[build-system]
|
|
72
72
|
requires = ["hatchling", "hatch-vcs"]
|
|
@@ -99,5 +99,10 @@ known-first-party = ["ezmsg.tools"]
|
|
|
99
99
|
known-third-party = ["ezmsg"]
|
|
100
100
|
|
|
101
101
|
[tool.uv.sources]
|
|
102
|
+
# Local path sources are a developer convenience and must not be committed: CI
|
|
103
|
+
# has no sibling checkouts, so a path here fails resolution for every job,
|
|
104
|
+
# including ones that never touch the package (`uv sync --only-group docs`).
|
|
105
|
+
# Add them locally with `uv add ../phosphor --editable --frozen` and drop the
|
|
106
|
+
# change before committing.
|
|
102
107
|
# Uncomment to use development version of ezmsg from git
|
|
103
108
|
#ezmsg = { git = "https://github.com/ezmsg-org/ezmsg.git", branch = "dev" }
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# file generated by vcs-versioning
|
|
2
|
+
# don't change, don't track in version control
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"__version__",
|
|
7
|
+
"__version_tuple__",
|
|
8
|
+
"version",
|
|
9
|
+
"version_tuple",
|
|
10
|
+
"__commit_id__",
|
|
11
|
+
"commit_id",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
version: str
|
|
15
|
+
__version__: str
|
|
16
|
+
__version_tuple__: tuple[int | str, ...]
|
|
17
|
+
version_tuple: tuple[int | str, ...]
|
|
18
|
+
commit_id: str | None
|
|
19
|
+
__commit_id__: str | None
|
|
20
|
+
|
|
21
|
+
__version__ = version = '0.5.0'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 5, 0)
|
|
23
|
+
|
|
24
|
+
__commit_id__ = commit_id = None
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Launching a console script whose dependencies live in an optional extra.
|
|
2
|
+
|
|
3
|
+
Console scripts are installed unconditionally -- ``[project.scripts]`` has no
|
|
4
|
+
notion of extras -- so ``pip install ezmsg-tools`` puts commands on the PATH
|
|
5
|
+
whose imports are not satisfied. Run one and you get a bare
|
|
6
|
+
``ModuleNotFoundError: dash`` with no hint that an extra exists or what it is
|
|
7
|
+
called.
|
|
8
|
+
|
|
9
|
+
The alternative would be promoting those dependencies to the core install, so
|
|
10
|
+
that a Dash web app drags in Qt and a GPU stack for everyone. A clear message
|
|
11
|
+
is the cheaper fix.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import importlib
|
|
15
|
+
import os
|
|
16
|
+
import sys
|
|
17
|
+
import typing
|
|
18
|
+
|
|
19
|
+
__all__ = ["run_cli"]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def run_cli(module: str, extra: str) -> typing.NoReturn:
|
|
23
|
+
"""Import ``module`` and call its ``main()``, or explain what is missing.
|
|
24
|
+
|
|
25
|
+
:param module: Dotted path of the CLI module to run.
|
|
26
|
+
:param extra: The extra that declares this command's dependencies.
|
|
27
|
+
"""
|
|
28
|
+
try:
|
|
29
|
+
cli = importlib.import_module(module)
|
|
30
|
+
except ImportError as exc:
|
|
31
|
+
command = os.path.basename(sys.argv[0]) or module
|
|
32
|
+
missing = getattr(exc, "name", None)
|
|
33
|
+
# Name the module that was actually missing rather than assuming the
|
|
34
|
+
# extra is the whole story: an ImportError from inside the CLI is a
|
|
35
|
+
# different problem, and saying which one it was keeps this honest.
|
|
36
|
+
detail = f" (could not import {missing!r})" if missing else ""
|
|
37
|
+
raise SystemExit(
|
|
38
|
+
f"{command} needs the optional '{extra}' dependencies{detail}.\n"
|
|
39
|
+
f"Install them with:\n\n"
|
|
40
|
+
f" pip install 'ezmsg-tools[{extra}]'\n"
|
|
41
|
+
) from exc
|
|
42
|
+
sys.exit(cli.main())
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Turning a structured ``ch`` coordinate axis into per-channel display names.
|
|
2
|
+
|
|
3
|
+
An AxisArray's ``ch`` axis usually carries a structured array with one row per
|
|
4
|
+
channel. What is *in* those rows depends on the acquisition system: an LSL or
|
|
5
|
+
NWB source typically offers a ``label``; a Blackrock source additionally offers
|
|
6
|
+
``bank`` and ``elec``, which is what its users actually read off the front panel.
|
|
7
|
+
A plot needs one string per channel, so something has to choose.
|
|
8
|
+
|
|
9
|
+
This module makes that choice explicit and configurable rather than hard-coding
|
|
10
|
+
one system's convention. ``label`` is the default because it is the field most
|
|
11
|
+
sources populate and the one most likely to be meaningful to whoever recorded
|
|
12
|
+
the data; a GUI that knows better can ask for other fields.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import typing
|
|
16
|
+
|
|
17
|
+
import numpy as np
|
|
18
|
+
|
|
19
|
+
__all__ = ["available_fields", "channel_names"]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def available_fields(ch_axis_data: typing.Optional[np.ndarray]) -> typing.List[str]:
|
|
23
|
+
"""Field names present on a structured ``ch`` axis, for building a chooser."""
|
|
24
|
+
if ch_axis_data is None:
|
|
25
|
+
return []
|
|
26
|
+
return list((ch_axis_data.dtype.fields or {}).keys())
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _field_text(row_value: typing.Any) -> str:
|
|
30
|
+
"""One field of one channel as display text, or "" if it carries nothing.
|
|
31
|
+
|
|
32
|
+
Numeric fields use 0 as their "unset" value -- a Blackrock ``elec`` of 0
|
|
33
|
+
means no electrode, not electrode zero -- so it is treated as absent. Bytes
|
|
34
|
+
arrive from numpy ``S`` dtypes and are decoded leniently: a mangled label is
|
|
35
|
+
still more use than an exception.
|
|
36
|
+
"""
|
|
37
|
+
if isinstance(row_value, bytes):
|
|
38
|
+
row_value = row_value.decode("utf8", errors="replace")
|
|
39
|
+
if isinstance(row_value, np.generic):
|
|
40
|
+
row_value = row_value.item()
|
|
41
|
+
if isinstance(row_value, bytes):
|
|
42
|
+
row_value = row_value.decode("utf8", errors="replace")
|
|
43
|
+
if isinstance(row_value, (int, float)) and not isinstance(row_value, bool):
|
|
44
|
+
return "" if row_value == 0 else f"{row_value:g}"
|
|
45
|
+
return str(row_value).strip()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def channel_names(
|
|
49
|
+
ch_axis_data: typing.Optional[np.ndarray],
|
|
50
|
+
n_ch: typing.Optional[int] = None,
|
|
51
|
+
*,
|
|
52
|
+
fields: typing.Sequence[str] = ("label",),
|
|
53
|
+
sep: str = "-",
|
|
54
|
+
fallback: str = "ch{index}",
|
|
55
|
+
) -> typing.List[str]:
|
|
56
|
+
"""Per-channel display names from a structured ``ch`` axis.
|
|
57
|
+
|
|
58
|
+
:param ch_axis_data: The axis's structured array, or None if the source
|
|
59
|
+
provided no ``ch`` axis.
|
|
60
|
+
:param n_ch: Channel count, used only when ``ch_axis_data`` is None or
|
|
61
|
+
unstructured. Otherwise the axis's own length governs.
|
|
62
|
+
:param fields: Field names to join, in order. Fields absent from the dtype
|
|
63
|
+
are skipped; fields present but empty for a given channel are skipped for
|
|
64
|
+
that channel only, so a partially-populated column degrades per-row
|
|
65
|
+
rather than for the whole array.
|
|
66
|
+
:param sep: Joins the field values, e.g. ``("bank", "elec")`` -> ``"A-1"``.
|
|
67
|
+
:param fallback: Format string used when no requested field yields anything
|
|
68
|
+
for a channel. ``{index}`` is the channel's index.
|
|
69
|
+
|
|
70
|
+
:return: One name per channel. Never empty strings, so a caller can render
|
|
71
|
+
the result without further guarding.
|
|
72
|
+
"""
|
|
73
|
+
if ch_axis_data is None or ch_axis_data.dtype.fields is None:
|
|
74
|
+
n = int(n_ch or 0) if ch_axis_data is None else int(ch_axis_data.shape[0])
|
|
75
|
+
return [fallback.format(index=i) for i in range(n)]
|
|
76
|
+
|
|
77
|
+
present = [f for f in fields if f in ch_axis_data.dtype.fields]
|
|
78
|
+
n = int(ch_axis_data.shape[0])
|
|
79
|
+
if not present:
|
|
80
|
+
return [fallback.format(index=i) for i in range(n)]
|
|
81
|
+
|
|
82
|
+
columns = [ch_axis_data[f] for f in present]
|
|
83
|
+
names = []
|
|
84
|
+
for i in range(n):
|
|
85
|
+
parts = [t for t in (_field_text(col[i]) for col in columns) if t]
|
|
86
|
+
names.append(sep.join(parts) if parts else fallback.format(index=i))
|
|
87
|
+
return names
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""Performance monitor: a Dash app over ezmsg's profiler output.
|
|
2
|
+
|
|
3
|
+
The console script points here rather than at :mod:`.cli` so that a missing
|
|
4
|
+
``perfmon`` extra produces an explanation instead of a traceback -- see
|
|
5
|
+
:mod:`ezmsg.tools._entry`.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from .._entry import run_cli
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def main() -> None:
|
|
12
|
+
run_cli("ezmsg.tools.perfmon.cli", "perfmon")
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Putting ezmsg streams onto phosphor plots.
|
|
2
|
+
|
|
3
|
+
:mod:`.describe` is the pure half -- given dims, axes and attrs, work out what
|
|
4
|
+
is being plotted -- and imports neither Qt nor phosphor, so it is usable from a
|
|
5
|
+
topic subscriber, a shared-memory mirror, or a test with neither.
|
|
6
|
+
:mod:`.shmem_sweep` is the Qt widget built on it, and needs the ``viewer`` or
|
|
7
|
+
``sigmon`` extra.
|
|
8
|
+
|
|
9
|
+
``ShmemSweepWidget`` is resolved lazily so that importing this package, or
|
|
10
|
+
anything under it, does not pull in Qt. Eagerly importing it here would make
|
|
11
|
+
``from ezmsg.tools.plot.describe import ...`` fail without phosphor installed,
|
|
12
|
+
since importing a submodule runs its parent's ``__init__`` first -- which would
|
|
13
|
+
put a GPU stack behind a module that deliberately has no rendering dependency
|
|
14
|
+
at all.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import typing
|
|
18
|
+
|
|
19
|
+
from .describe import (
|
|
20
|
+
METRIC_KINDS,
|
|
21
|
+
SWEEP_RENDERABLE_METRICS,
|
|
22
|
+
MetricSpec,
|
|
23
|
+
StreamShape,
|
|
24
|
+
UnsupportedMetricError,
|
|
25
|
+
describe_axisarray,
|
|
26
|
+
describe_mirror,
|
|
27
|
+
flatten_for_plot,
|
|
28
|
+
metric_axis,
|
|
29
|
+
require_sweep_renderable,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
if typing.TYPE_CHECKING: # pragma: no cover - import for type checkers only
|
|
33
|
+
from .shmem_sweep import ShmemSweepWidget
|
|
34
|
+
|
|
35
|
+
__all__ = [
|
|
36
|
+
"METRIC_KINDS",
|
|
37
|
+
"SWEEP_RENDERABLE_METRICS",
|
|
38
|
+
"MetricSpec",
|
|
39
|
+
"ShmemSweepWidget",
|
|
40
|
+
"StreamShape",
|
|
41
|
+
"UnsupportedMetricError",
|
|
42
|
+
"describe_axisarray",
|
|
43
|
+
"describe_mirror",
|
|
44
|
+
"flatten_for_plot",
|
|
45
|
+
"metric_axis",
|
|
46
|
+
"require_sweep_renderable",
|
|
47
|
+
]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def __getattr__(name: str) -> typing.Any:
|
|
51
|
+
"""Resolve the Qt widget on first use (PEP 562)."""
|
|
52
|
+
if name == "ShmemSweepWidget":
|
|
53
|
+
from .shmem_sweep import ShmemSweepWidget
|
|
54
|
+
|
|
55
|
+
return ShmemSweepWidget
|
|
56
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
"""Reading a stream's shape well enough to plot it.
|
|
2
|
+
|
|
3
|
+
Every consumer that puts ezmsg data on a phosphor widget has to answer the same
|
|
4
|
+
questions -- how many channels, at what rate, what are they called, and is this
|
|
5
|
+
a signal or an envelope -- and each has been answering them slightly
|
|
6
|
+
differently. This module answers them once.
|
|
7
|
+
|
|
8
|
+
It deliberately does not import phosphor or Qt: the inputs are dims, axes and
|
|
9
|
+
attrs, and the outputs are plain numbers and arrays. That keeps it usable from a
|
|
10
|
+
topic subscriber, from a shared-memory mirror, and from a test with neither.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import typing
|
|
16
|
+
|
|
17
|
+
import numpy as np
|
|
18
|
+
|
|
19
|
+
from ..chmeta import channel_names
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"METRIC_AXIS_CANDIDATES",
|
|
23
|
+
"METRIC_KINDS",
|
|
24
|
+
"SWEEP_RENDERABLE_METRICS",
|
|
25
|
+
"MetricSpec",
|
|
26
|
+
"StreamShape",
|
|
27
|
+
"UnsupportedMetricError",
|
|
28
|
+
"describe_axisarray",
|
|
29
|
+
"describe_mirror",
|
|
30
|
+
"flatten_for_plot",
|
|
31
|
+
"metric_axis",
|
|
32
|
+
"require_sweep_renderable",
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
# Axis names an upstream aggregator might use for its per-sample tuple.
|
|
36
|
+
# ezmsg-sigproc's BinnedAggregate calls it "metric" by default but the name is a
|
|
37
|
+
# setting, so recognising a couple of obvious alternatives costs nothing.
|
|
38
|
+
METRIC_AXIS_CANDIDATES = ("metric", "minmax", "bound", "stat")
|
|
39
|
+
|
|
40
|
+
# Label tuples we recognise, and what to call the thing they describe. Keyed on
|
|
41
|
+
# labels rather than width because width says nothing: (min, max) and
|
|
42
|
+
# (mean, std) are both 2-wide and mean entirely different things, and drawing
|
|
43
|
+
# one as the other is silently wrong rather than visibly broken.
|
|
44
|
+
#
|
|
45
|
+
# Adding a kind here is the cheap half. The expensive half is teaching a
|
|
46
|
+
# renderer to draw it -- see SWEEP_RENDERABLE_METRICS.
|
|
47
|
+
METRIC_KINDS: dict[tuple[str, ...], str] = {
|
|
48
|
+
("min", "max"): "minmax",
|
|
49
|
+
("mean", "std"): "mean_std",
|
|
50
|
+
("mean", "sem"): "mean_sem",
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
# What a sweep plot can actually draw today.
|
|
54
|
+
#
|
|
55
|
+
# "minmax" maps onto phosphor's envelope input directly: the pair *is* the band,
|
|
56
|
+
# so the existing column reduction (min of mins, max of maxes) is correct.
|
|
57
|
+
#
|
|
58
|
+
# A dispersion pair like "mean_std" needs different drawing -- a semi-transparent
|
|
59
|
+
# band from mean-std to mean+std with an opaque line at the mean -- and
|
|
60
|
+
# different column reduction, since averaging a mean is not the same as taking
|
|
61
|
+
# extremes. Recognised here so it fails with an explanation instead of being
|
|
62
|
+
# drawn as if it were an envelope.
|
|
63
|
+
SWEEP_RENDERABLE_METRICS = frozenset({"minmax"})
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class MetricSpec(typing.NamedTuple):
|
|
67
|
+
"""A trailing per-sample tuple: what it is called and what it holds."""
|
|
68
|
+
|
|
69
|
+
axis: str
|
|
70
|
+
"""Name of the trailing axis."""
|
|
71
|
+
|
|
72
|
+
labels: tuple[str, ...]
|
|
73
|
+
"""Its coordinate values, lowercased, in order."""
|
|
74
|
+
|
|
75
|
+
kind: str
|
|
76
|
+
"""The entry in :data:`METRIC_KINDS` these labels matched."""
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class UnsupportedMetricError(NotImplementedError):
|
|
80
|
+
"""A recognised metric axis that no renderer here can draw yet."""
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class StreamShape(typing.NamedTuple):
|
|
84
|
+
"""What a plot needs to know about an incoming stream."""
|
|
85
|
+
|
|
86
|
+
n_channels: int
|
|
87
|
+
"""Channels, excluding any envelope axis."""
|
|
88
|
+
|
|
89
|
+
srate: float
|
|
90
|
+
"""Samples per second of the *pushed* stream. For an envelope this is the
|
|
91
|
+
bucket rate, not the rate before decimation -- which is what a sweep buffer
|
|
92
|
+
must be sized with, or its ring is longer than the data arriving to fill
|
|
93
|
+
it."""
|
|
94
|
+
|
|
95
|
+
channel_labels: list[str] | None
|
|
96
|
+
"""One name per channel, or None if the stream does not say."""
|
|
97
|
+
|
|
98
|
+
metric: MetricSpec | None
|
|
99
|
+
"""The trailing per-sample tuple, if the stream carries one."""
|
|
100
|
+
|
|
101
|
+
unit: str | None
|
|
102
|
+
"""The signal's amplitude unit, if it declares one."""
|
|
103
|
+
|
|
104
|
+
@property
|
|
105
|
+
def envelope(self) -> bool:
|
|
106
|
+
"""Whether each sample carries a (min, max) pair -- phosphor's envelope."""
|
|
107
|
+
return self.metric is not None and self.metric.kind == "minmax"
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def metric_axis(dims: typing.Sequence[str], axes: typing.Mapping[str, typing.Any]) -> MetricSpec | None:
|
|
111
|
+
"""Describe the trailing per-sample tuple, or None if there is not one.
|
|
112
|
+
|
|
113
|
+
Recognised by *labels*, not by name or width. The name only narrows the
|
|
114
|
+
search; the labels are what distinguish a (min, max) envelope from a
|
|
115
|
+
(mean, std) dispersion pair, which is the same shape and must not be drawn
|
|
116
|
+
the same way.
|
|
117
|
+
|
|
118
|
+
Returns a spec for any tuple in :data:`METRIC_KINDS`, including ones no
|
|
119
|
+
renderer here supports yet -- describing a stream is not the same as being
|
|
120
|
+
able to draw it, and a caller that only wants to know what arrived should
|
|
121
|
+
not have to catch an exception. See :func:`require_sweep_renderable` for
|
|
122
|
+
the capability check.
|
|
123
|
+
"""
|
|
124
|
+
if not dims:
|
|
125
|
+
return None
|
|
126
|
+
name = dims[-1]
|
|
127
|
+
if name not in METRIC_AXIS_CANDIDATES:
|
|
128
|
+
return None
|
|
129
|
+
data = _axis_data(axes.get(name))
|
|
130
|
+
if data is None:
|
|
131
|
+
return None
|
|
132
|
+
labels = tuple(str(v).lower() for v in data)
|
|
133
|
+
kind = METRIC_KINDS.get(labels)
|
|
134
|
+
return None if kind is None else MetricSpec(axis=name, labels=labels, kind=kind)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def require_sweep_renderable(shape: StreamShape) -> None:
|
|
138
|
+
"""Raise if a sweep plot cannot draw this stream's metric axis.
|
|
139
|
+
|
|
140
|
+
:raises UnsupportedMetricError: for a recognised metric a sweep cannot draw.
|
|
141
|
+
"""
|
|
142
|
+
metric = shape.metric
|
|
143
|
+
if metric is None or metric.kind in SWEEP_RENDERABLE_METRICS:
|
|
144
|
+
return
|
|
145
|
+
raise UnsupportedMetricError(
|
|
146
|
+
f"stream carries a {metric.kind!r} metric axis {metric.labels} on {metric.axis!r}, "
|
|
147
|
+
f"which a sweep plot cannot draw yet (supported: {sorted(SWEEP_RENDERABLE_METRICS)}). "
|
|
148
|
+
"Aggregate the stream differently upstream, or add rendering for it."
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _axis_data(axis: typing.Any) -> np.ndarray | None:
|
|
153
|
+
"""Coordinate values of an axis given either as a dict or an ezmsg object."""
|
|
154
|
+
if axis is None:
|
|
155
|
+
return None
|
|
156
|
+
if isinstance(axis, dict):
|
|
157
|
+
data = axis.get("data")
|
|
158
|
+
else:
|
|
159
|
+
data = getattr(axis, "data", None)
|
|
160
|
+
return None if data is None else np.asarray(data)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _axis_gain(axis: typing.Any) -> float | None:
|
|
164
|
+
if axis is None:
|
|
165
|
+
return None
|
|
166
|
+
gain = axis.get("gain") if isinstance(axis, dict) else getattr(axis, "gain", None)
|
|
167
|
+
return None if gain in (None, 0) else float(gain)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _describe(
|
|
171
|
+
dims: typing.Sequence[str],
|
|
172
|
+
axes: typing.Mapping[str, typing.Any],
|
|
173
|
+
attrs: typing.Mapping[str, typing.Any],
|
|
174
|
+
shape: typing.Sequence[int],
|
|
175
|
+
srate: float | None,
|
|
176
|
+
*,
|
|
177
|
+
time_axis: str = "time",
|
|
178
|
+
label_fields: typing.Sequence[str] = ("label",),
|
|
179
|
+
) -> StreamShape:
|
|
180
|
+
dims = list(dims)
|
|
181
|
+
metric = metric_axis(dims, axes)
|
|
182
|
+
metric_name = metric.axis if metric is not None else None
|
|
183
|
+
|
|
184
|
+
# Channel count is everything that is neither time nor the metric tuple.
|
|
185
|
+
n_channels = 1
|
|
186
|
+
for name, size in zip(dims, shape):
|
|
187
|
+
if name in (time_axis, metric_name):
|
|
188
|
+
continue
|
|
189
|
+
n_channels *= int(size)
|
|
190
|
+
|
|
191
|
+
if srate is None:
|
|
192
|
+
gain = _axis_gain(axes.get(time_axis))
|
|
193
|
+
srate = 1.0 / gain if gain else 0.0
|
|
194
|
+
|
|
195
|
+
ch_data = _axis_data(axes.get("ch"))
|
|
196
|
+
labels = None
|
|
197
|
+
if ch_data is not None and ch_data.dtype.fields is not None:
|
|
198
|
+
labels = channel_names(ch_data, n_channels, fields=label_fields)
|
|
199
|
+
|
|
200
|
+
unit = attrs.get("unit") if attrs else None
|
|
201
|
+
return StreamShape(
|
|
202
|
+
n_channels=max(1, n_channels),
|
|
203
|
+
srate=float(srate or 0.0),
|
|
204
|
+
channel_labels=labels,
|
|
205
|
+
metric=metric,
|
|
206
|
+
unit=None if unit is None else str(unit),
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def describe_axisarray(
|
|
211
|
+
msg: typing.Any,
|
|
212
|
+
*,
|
|
213
|
+
time_axis: str = "time",
|
|
214
|
+
label_fields: typing.Sequence[str] = ("label",),
|
|
215
|
+
) -> StreamShape:
|
|
216
|
+
"""Describe a stream from one of its ``AxisArray`` messages."""
|
|
217
|
+
return _describe(
|
|
218
|
+
msg.dims,
|
|
219
|
+
msg.axes,
|
|
220
|
+
getattr(msg, "attrs", None) or {},
|
|
221
|
+
msg.data.shape,
|
|
222
|
+
None,
|
|
223
|
+
time_axis=time_axis,
|
|
224
|
+
label_fields=label_fields,
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def describe_mirror(
|
|
229
|
+
mirror: typing.Any,
|
|
230
|
+
*,
|
|
231
|
+
time_axis: str = "time",
|
|
232
|
+
label_fields: typing.Sequence[str] = ("label",),
|
|
233
|
+
) -> StreamShape | None:
|
|
234
|
+
"""Describe a stream from a connected :class:`EZShmMirror`.
|
|
235
|
+
|
|
236
|
+
Returns None until the writer has published both a valid buffer header and
|
|
237
|
+
its metadata -- the two arrive independently, and a description built from
|
|
238
|
+
only one of them would be missing either the shape or the names.
|
|
239
|
+
"""
|
|
240
|
+
meta = mirror.meta
|
|
241
|
+
if meta is None or not meta.bvalid or meta.ndim < 2:
|
|
242
|
+
return None
|
|
243
|
+
axes = mirror.axes
|
|
244
|
+
if axes is None:
|
|
245
|
+
return None
|
|
246
|
+
shape = tuple(int(v) for v in meta.shape[: meta.ndim])
|
|
247
|
+
# dims and meta.shape describe the same ordering -- the sink records the
|
|
248
|
+
# order the ring actually holds, not the order the message arrived in.
|
|
249
|
+
return _describe(
|
|
250
|
+
list(mirror.dims or []),
|
|
251
|
+
axes,
|
|
252
|
+
mirror.attrs or {},
|
|
253
|
+
shape,
|
|
254
|
+
float(meta.srate),
|
|
255
|
+
time_axis=time_axis,
|
|
256
|
+
label_fields=label_fields,
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def flatten_for_plot(data: np.ndarray, shape: StreamShape) -> np.ndarray:
|
|
261
|
+
"""Reshape a block to what a plot's ``push_data`` expects.
|
|
262
|
+
|
|
263
|
+
``(n_samples, n_channels, k)`` when the stream carries a k-wide metric
|
|
264
|
+
tuple, ``(n_samples, n_channels)`` otherwise, with any other dimensions
|
|
265
|
+
folded into channels.
|
|
266
|
+
|
|
267
|
+
The metric case is the reason this exists. Folding a ``(time, ch, 2)``
|
|
268
|
+
block into ``(time, ch * 2)`` -- which is what a naive ``reshape`` does --
|
|
269
|
+
renders as twice as many traces, alternating the two metrics, with every
|
|
270
|
+
channel label off by a factor of two. It looks like data, so nothing
|
|
271
|
+
complains.
|
|
272
|
+
"""
|
|
273
|
+
width = len(shape.metric.labels) if shape.metric is not None else None
|
|
274
|
+
tail = (shape.n_channels,) if width is None else (shape.n_channels, width)
|
|
275
|
+
if data.size == 0:
|
|
276
|
+
return data.reshape((0,) + tail)
|
|
277
|
+
return data.reshape((data.shape[0],) + tail)
|