spviz 0.1.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.
spviz-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,151 @@
1
+ Metadata-Version: 2.4
2
+ Name: spviz
3
+ Version: 0.1.0
4
+ Summary: TensorBoard-style data-product visualization for signal-processing pipelines
5
+ Author: Brian Day
6
+ License: MIT
7
+ Classifier: Programming Language :: Python :: 3
8
+ Classifier: Programming Language :: Python :: 3.10
9
+ Classifier: Programming Language :: Python :: 3.11
10
+ Classifier: Programming Language :: Python :: 3.12
11
+ Requires-Python: >=3.10
12
+ Description-Content-Type: text/markdown
13
+ Requires-Dist: numpy>=1.24
14
+
15
+ # spviz
16
+
17
+ `spviz` is a TensorBoard-style observer for intermediate signal-processing data products. Your application continues to own execution, scheduling, and data flow. `spviz` only taps values that the application already produced, records their semantic axes and lineage, and serves an interactive visualization afterward.
18
+
19
+ **[Explore all ten live examples](https://briday1.github.io/signal-processing-visualization/)**
20
+
21
+ 1. [Phased-array radar](https://briday1.github.io/signal-processing-visualization/radar/) — beamforming, range–Doppler processing, cell averaging, and CA-CFAR.
22
+ 2. [Microphone-array audio](https://briday1.github.io/signal-processing-visualization/audio/) — delay-and-sum steering, spectra, noise estimation, and tone tracking.
23
+ 3. [QPSK receiver](https://briday1.github.io/signal-processing-visualization/comms/) — carrier correction, matched filtering, symbol error magnitude, and decisions.
24
+ 4. [Seismic array](https://briday1.github.io/signal-processing-visualization/seismic/) — trace filtering, spectra, event-energy integration, and triggering.
25
+ 5. [Multi-lead ECG](https://briday1.github.io/signal-processing-visualization/ecg/) — baseline removal, QRS enhancement, energy integration, and peak candidates.
26
+ 6. [LFM pulse compression](https://briday1.github.io/signal-processing-visualization/pulse-compression/) — 1D chirp, complex echo, matched filtering, CA-CFAR, and detections.
27
+ 7. [Audio FIR equalizer](https://briday1.github.io/signal-processing-visualization/equalizer/) — 1D waveforms, windowed-sinc coefficients, convolution, and power spectra.
28
+ 8. [Rolling-bearing diagnostics](https://briday1.github.io/signal-processing-visualization/bearing/) — 1D vibration, resonance filtering, analytic envelope, and fault harmonics.
29
+ 9. [Acoustic source localization](https://briday1.github.io/signal-processing-visualization/localization/) — a 1D reference, 2D microphone capture, 3D steered time–frequency cube, 2D beam energy, and 1D direction score.
30
+ 10. [OFDM receiver quality](https://briday1.github.io/signal-processing-visualization/ofdm/) — a 1D I/Q capture, 3D resource grid, 2D EVM and error maps, and 1D subcarrier quality.
31
+
32
+ GitHub Actions regenerates that example from `examples/radar.py` and deploys it to Pages on every push to `main`. You can create the same serverless bundle yourself with `spviz export-static RUN_DIR OUTPUT_DIR`.
33
+
34
+ ## Install and run the radar example
35
+
36
+ ```bash
37
+ python -m venv .venv
38
+ . .venv/bin/activate
39
+ pip install -e .
40
+ python examples/radar.py
41
+ spviz serve runs/radar-demo
42
+ ```
43
+
44
+ Choose a different port with either `--port` or `-p`:
45
+
46
+ ```bash
47
+ spviz serve runs/radar-demo --port 9000
48
+ ```
49
+
50
+ Open <http://127.0.0.1:8765>. Click any product without losing the pipeline overview, permute axes, scrub or animate layers, isolate a layer, adjust the opacity of other layers, and inspect individual values. Horizontal and vertical dragging adjust the 3D stack separation within constrained inspection bounds, while double-clicking restores the home view. Two-dimensional products can switch between a heatmap and stacked 1D slices; axis order selects which dimension becomes the playable layer axis.
51
+
52
+ The viewer includes dark and light interface themes plus Spviz, Viridis, Plasma, Inferno, Magma, and Cividis color maps. The latter five use the familiar Matplotlib palette endpoints; values at or below the selected minimum remain transparent so the chosen page theme forms the visualization's low-end background.
53
+
54
+ The inspector can export the selected axis-labeled layer as PNG, the current transparent stack as PNG, an animated GIF sweep through the selected depth axis, or the complete processing chain as PNG. Exports preserve the active axis permutation, coordinates, units, color limits, log mode, transparency, and selected layer where applicable.
55
+
56
+ The pixel-density control trades fidelity for interaction speed using an explicit samples-per-displayed-axis count. Its maximum is the selected plane's largest native dimension, which requests the full plane without downsampling. The pipeline overview remains fixed at a lightweight 64 samples per axis.
57
+
58
+ The inspector aspect-ratio control offers **Data proportions** (the normal array width-to-height ratio), **Equal axes** (a square display extent), and **Fit view** (fill the available inspector area). The processing overview has a separate aspect control and defaults to data-proportional previews, so changing the full-chain presentation does not alter the selected product view.
59
+
60
+ ## Observe your existing pipeline
61
+
62
+ ```python
63
+ import numpy as np
64
+ import spviz
65
+
66
+ spviz.init("runs/my-run", name="My receiver")
67
+
68
+ # These functions belong to your application. spviz does not call them.
69
+ iq = read_receiver()
70
+ spviz.tap(iq, "Raw I/Q", axes=["channel", "pulse", "sample"], units="volts")
71
+
72
+ beamformed = beamform(iq)
73
+ spviz.tap(
74
+ beamformed,
75
+ "Beamformed I/Q",
76
+ filename="beamformed_iq.npy",
77
+ axes=["beam", "pulse", "sample"],
78
+ scale="log",
79
+ vmin=1e-4,
80
+ vmax=2.0,
81
+ operation="beamform",
82
+ inputs=iq,
83
+ )
84
+
85
+ range_doppler = process_range_doppler(beamformed)
86
+ spviz.tap(
87
+ range_doppler,
88
+ "Range–Doppler",
89
+ axes=["beam", "doppler", "range"],
90
+ operation="range + Doppler FFT",
91
+ inputs=beamformed,
92
+ )
93
+
94
+ spviz.close()
95
+ ```
96
+
97
+ `axes` names every source dimension. Arrays with one to three dimensions use all of them by default. For higher-dimensional products, explicitly choose the three spatial dimensions while preserving the full source shape:
98
+
99
+ ```python
100
+ spviz.tap(
101
+ data,
102
+ "Range–Doppler history",
103
+ axes=["frame", "beam", "doppler", "range"],
104
+ view_axes=["beam", "doppler", "range"],
105
+ coordinates={
106
+ "frame": timestamps,
107
+ "beam": {"values": look_angles, "units": "deg"},
108
+ "doppler": {"values": velocities, "units": "m/s"},
109
+ "range": {"values": ranges, "units": "m"},
110
+ },
111
+ )
112
+ ```
113
+
114
+ Coordinates may be numeric, categorical, or temporal. They are stored as separate NumPy arrays and loaded only when needed. The inspector presents permutations using axis names—not anonymous dimension numbers—and displays coordinate ranges, physical layer values, units, and coordinates for selected cells. Non-view dimensions remain part of the captured product and are indexed at zero by the current viewer.
115
+
116
+ `tap()` returns the exact object it receives, so it can also be inserted inline without changing the chain:
117
+
118
+ ```python
119
+ beamformed = spviz.tap(beamform(iq), "Beamformed", inputs=iq)
120
+ ```
121
+
122
+ `filename=` controls the `.npy` filename inside the run's `arrays/` directory. It is intentionally a filename rather than an arbitrary path, keeping runs self-contained and portable. When omitted, `spviz` derives a safe filename from the display name and adds a suffix for repeated names.
123
+
124
+ `scale=` sets the product's default visualization scale to `"linear"` (the default) or `"log"`. It initializes the inspector and is also honored by the full-chain overview. Users can still toggle the selected product interactively.
125
+
126
+ `overview_aspect=` optionally overrides only that product's top processing-graph preview with `"data"`, `"equal"`, or `"fit"`. It does not change the product inspector. Without an override, the shared overview aspect control applies.
127
+
128
+ `vmin=` and `vmax=` set a product's initial absolute display range. Values at or below `vmin` are fully transparent and then fade smoothly into the selected color map; this lets background/noise disappear into either the dark or light theme without discarding the underlying captured data. The viewer's range controls remain adjustable.
129
+
130
+ For code where wrapping a function is convenient, optional instrumentation observes its return value while leaving invocation and scheduling with the original application:
131
+
132
+ ```python
133
+ spviz.init("runs/my-run")
134
+
135
+ @spviz.instrument(name="Filtered I/Q", axes=["channel", "sample"])
136
+ def filter_bank(iq):
137
+ return existing_filter_implementation(iq)
138
+ ```
139
+
140
+ Observed runs are portable directories containing `manifest.json` and standard NumPy `.npy` files. The browser requests a resolution-limited visualization volume once, then changes layers locally for responsive interaction without loading the full source array.
141
+
142
+ ## Current scope
143
+
144
+ - NumPy arrays with arbitrary dimensionality
145
+ - Directed product lineage and operation labels
146
+ - Local, dependency-light HTTP server
147
+ - Transparent stacked-slice volume rendering
148
+ - Axis permutation, layer playback/isolation, opacity, and value inspection
149
+ - Deterministic synthetic phased-array radar example with clutter, receiver mismatch, thermal noise, windowed FFTs, an explicit cell-average noise estimate, and binary CA-CFAR detections
150
+
151
+ This is an initial foundation. Live streaming, framework adapters, timeline comparison, GPU-side capture, and richer plots are intentionally left for later versions.
spviz-0.1.0/README.md ADDED
@@ -0,0 +1,137 @@
1
+ # spviz
2
+
3
+ `spviz` is a TensorBoard-style observer for intermediate signal-processing data products. Your application continues to own execution, scheduling, and data flow. `spviz` only taps values that the application already produced, records their semantic axes and lineage, and serves an interactive visualization afterward.
4
+
5
+ **[Explore all ten live examples](https://briday1.github.io/signal-processing-visualization/)**
6
+
7
+ 1. [Phased-array radar](https://briday1.github.io/signal-processing-visualization/radar/) — beamforming, range–Doppler processing, cell averaging, and CA-CFAR.
8
+ 2. [Microphone-array audio](https://briday1.github.io/signal-processing-visualization/audio/) — delay-and-sum steering, spectra, noise estimation, and tone tracking.
9
+ 3. [QPSK receiver](https://briday1.github.io/signal-processing-visualization/comms/) — carrier correction, matched filtering, symbol error magnitude, and decisions.
10
+ 4. [Seismic array](https://briday1.github.io/signal-processing-visualization/seismic/) — trace filtering, spectra, event-energy integration, and triggering.
11
+ 5. [Multi-lead ECG](https://briday1.github.io/signal-processing-visualization/ecg/) — baseline removal, QRS enhancement, energy integration, and peak candidates.
12
+ 6. [LFM pulse compression](https://briday1.github.io/signal-processing-visualization/pulse-compression/) — 1D chirp, complex echo, matched filtering, CA-CFAR, and detections.
13
+ 7. [Audio FIR equalizer](https://briday1.github.io/signal-processing-visualization/equalizer/) — 1D waveforms, windowed-sinc coefficients, convolution, and power spectra.
14
+ 8. [Rolling-bearing diagnostics](https://briday1.github.io/signal-processing-visualization/bearing/) — 1D vibration, resonance filtering, analytic envelope, and fault harmonics.
15
+ 9. [Acoustic source localization](https://briday1.github.io/signal-processing-visualization/localization/) — a 1D reference, 2D microphone capture, 3D steered time–frequency cube, 2D beam energy, and 1D direction score.
16
+ 10. [OFDM receiver quality](https://briday1.github.io/signal-processing-visualization/ofdm/) — a 1D I/Q capture, 3D resource grid, 2D EVM and error maps, and 1D subcarrier quality.
17
+
18
+ GitHub Actions regenerates that example from `examples/radar.py` and deploys it to Pages on every push to `main`. You can create the same serverless bundle yourself with `spviz export-static RUN_DIR OUTPUT_DIR`.
19
+
20
+ ## Install and run the radar example
21
+
22
+ ```bash
23
+ python -m venv .venv
24
+ . .venv/bin/activate
25
+ pip install -e .
26
+ python examples/radar.py
27
+ spviz serve runs/radar-demo
28
+ ```
29
+
30
+ Choose a different port with either `--port` or `-p`:
31
+
32
+ ```bash
33
+ spviz serve runs/radar-demo --port 9000
34
+ ```
35
+
36
+ Open <http://127.0.0.1:8765>. Click any product without losing the pipeline overview, permute axes, scrub or animate layers, isolate a layer, adjust the opacity of other layers, and inspect individual values. Horizontal and vertical dragging adjust the 3D stack separation within constrained inspection bounds, while double-clicking restores the home view. Two-dimensional products can switch between a heatmap and stacked 1D slices; axis order selects which dimension becomes the playable layer axis.
37
+
38
+ The viewer includes dark and light interface themes plus Spviz, Viridis, Plasma, Inferno, Magma, and Cividis color maps. The latter five use the familiar Matplotlib palette endpoints; values at or below the selected minimum remain transparent so the chosen page theme forms the visualization's low-end background.
39
+
40
+ The inspector can export the selected axis-labeled layer as PNG, the current transparent stack as PNG, an animated GIF sweep through the selected depth axis, or the complete processing chain as PNG. Exports preserve the active axis permutation, coordinates, units, color limits, log mode, transparency, and selected layer where applicable.
41
+
42
+ The pixel-density control trades fidelity for interaction speed using an explicit samples-per-displayed-axis count. Its maximum is the selected plane's largest native dimension, which requests the full plane without downsampling. The pipeline overview remains fixed at a lightweight 64 samples per axis.
43
+
44
+ The inspector aspect-ratio control offers **Data proportions** (the normal array width-to-height ratio), **Equal axes** (a square display extent), and **Fit view** (fill the available inspector area). The processing overview has a separate aspect control and defaults to data-proportional previews, so changing the full-chain presentation does not alter the selected product view.
45
+
46
+ ## Observe your existing pipeline
47
+
48
+ ```python
49
+ import numpy as np
50
+ import spviz
51
+
52
+ spviz.init("runs/my-run", name="My receiver")
53
+
54
+ # These functions belong to your application. spviz does not call them.
55
+ iq = read_receiver()
56
+ spviz.tap(iq, "Raw I/Q", axes=["channel", "pulse", "sample"], units="volts")
57
+
58
+ beamformed = beamform(iq)
59
+ spviz.tap(
60
+ beamformed,
61
+ "Beamformed I/Q",
62
+ filename="beamformed_iq.npy",
63
+ axes=["beam", "pulse", "sample"],
64
+ scale="log",
65
+ vmin=1e-4,
66
+ vmax=2.0,
67
+ operation="beamform",
68
+ inputs=iq,
69
+ )
70
+
71
+ range_doppler = process_range_doppler(beamformed)
72
+ spviz.tap(
73
+ range_doppler,
74
+ "Range–Doppler",
75
+ axes=["beam", "doppler", "range"],
76
+ operation="range + Doppler FFT",
77
+ inputs=beamformed,
78
+ )
79
+
80
+ spviz.close()
81
+ ```
82
+
83
+ `axes` names every source dimension. Arrays with one to three dimensions use all of them by default. For higher-dimensional products, explicitly choose the three spatial dimensions while preserving the full source shape:
84
+
85
+ ```python
86
+ spviz.tap(
87
+ data,
88
+ "Range–Doppler history",
89
+ axes=["frame", "beam", "doppler", "range"],
90
+ view_axes=["beam", "doppler", "range"],
91
+ coordinates={
92
+ "frame": timestamps,
93
+ "beam": {"values": look_angles, "units": "deg"},
94
+ "doppler": {"values": velocities, "units": "m/s"},
95
+ "range": {"values": ranges, "units": "m"},
96
+ },
97
+ )
98
+ ```
99
+
100
+ Coordinates may be numeric, categorical, or temporal. They are stored as separate NumPy arrays and loaded only when needed. The inspector presents permutations using axis names—not anonymous dimension numbers—and displays coordinate ranges, physical layer values, units, and coordinates for selected cells. Non-view dimensions remain part of the captured product and are indexed at zero by the current viewer.
101
+
102
+ `tap()` returns the exact object it receives, so it can also be inserted inline without changing the chain:
103
+
104
+ ```python
105
+ beamformed = spviz.tap(beamform(iq), "Beamformed", inputs=iq)
106
+ ```
107
+
108
+ `filename=` controls the `.npy` filename inside the run's `arrays/` directory. It is intentionally a filename rather than an arbitrary path, keeping runs self-contained and portable. When omitted, `spviz` derives a safe filename from the display name and adds a suffix for repeated names.
109
+
110
+ `scale=` sets the product's default visualization scale to `"linear"` (the default) or `"log"`. It initializes the inspector and is also honored by the full-chain overview. Users can still toggle the selected product interactively.
111
+
112
+ `overview_aspect=` optionally overrides only that product's top processing-graph preview with `"data"`, `"equal"`, or `"fit"`. It does not change the product inspector. Without an override, the shared overview aspect control applies.
113
+
114
+ `vmin=` and `vmax=` set a product's initial absolute display range. Values at or below `vmin` are fully transparent and then fade smoothly into the selected color map; this lets background/noise disappear into either the dark or light theme without discarding the underlying captured data. The viewer's range controls remain adjustable.
115
+
116
+ For code where wrapping a function is convenient, optional instrumentation observes its return value while leaving invocation and scheduling with the original application:
117
+
118
+ ```python
119
+ spviz.init("runs/my-run")
120
+
121
+ @spviz.instrument(name="Filtered I/Q", axes=["channel", "sample"])
122
+ def filter_bank(iq):
123
+ return existing_filter_implementation(iq)
124
+ ```
125
+
126
+ Observed runs are portable directories containing `manifest.json` and standard NumPy `.npy` files. The browser requests a resolution-limited visualization volume once, then changes layers locally for responsive interaction without loading the full source array.
127
+
128
+ ## Current scope
129
+
130
+ - NumPy arrays with arbitrary dimensionality
131
+ - Directed product lineage and operation labels
132
+ - Local, dependency-light HTTP server
133
+ - Transparent stacked-slice volume rendering
134
+ - Axis permutation, layer playback/isolation, opacity, and value inspection
135
+ - Deterministic synthetic phased-array radar example with clutter, receiver mismatch, thermal noise, windowed FFTs, an explicit cell-average noise estimate, and binary CA-CFAR detections
136
+
137
+ This is an initial foundation. Live streaming, framework adapters, timeline comparison, GPU-side capture, and richer plots are intentionally left for later versions.
@@ -0,0 +1,32 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "spviz"
7
+ version = "0.1.0"
8
+ description = "TensorBoard-style data-product visualization for signal-processing pipelines"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ dependencies = ["numpy>=1.24"]
12
+ license = {text = "MIT"}
13
+ authors = [{name = "Brian Day"}]
14
+ classifiers = [
15
+ "Programming Language :: Python :: 3",
16
+ "Programming Language :: Python :: 3.10",
17
+ "Programming Language :: Python :: 3.11",
18
+ "Programming Language :: Python :: 3.12",
19
+ ]
20
+
21
+ [project.scripts]
22
+ spviz = "spviz.cli:main"
23
+
24
+ [tool.setuptools.packages.find]
25
+ where = ["src"]
26
+
27
+ [tool.setuptools.package-data]
28
+ spviz = ["web/*"]
29
+
30
+ [tool.pytest.ini_options]
31
+ testpaths = ["tests"]
32
+
spviz-0.1.0/setup.cfg ADDED
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,7 @@
1
+ """Passively observe and visualize signal-processing data products."""
2
+
3
+ from .observer import Recorder, close, get_recorder, init, instrument, tap
4
+ from .session import Session
5
+
6
+ __all__ = ["Recorder", "Session", "close", "get_recorder", "init", "instrument", "tap"]
7
+ __version__ = "0.1.0"
@@ -0,0 +1,31 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+
5
+ from .server import serve
6
+ from .static import export_static
7
+
8
+
9
+ def parser() -> argparse.ArgumentParser:
10
+ result = argparse.ArgumentParser(prog="spviz", description="Visualize signal-processing data products")
11
+ sub = result.add_subparsers(dest="command", required=True)
12
+ serve_parser = sub.add_parser("serve", help="Serve a captured run in the browser")
13
+ serve_parser.add_argument("run_dir", help="Directory containing manifest.json")
14
+ serve_parser.add_argument("--host", default="127.0.0.1")
15
+ serve_parser.add_argument("-p", "--port", default=8765, type=int, metavar="PORT", help="TCP port to listen on (default: 8765)")
16
+ export_parser = sub.add_parser("export-static", help="Export a run for static hosting")
17
+ export_parser.add_argument("run_dir", help="Directory containing manifest.json")
18
+ export_parser.add_argument("output_dir", help="Directory to create")
19
+ return result
20
+
21
+
22
+ def main(argv: list[str] | None = None) -> None:
23
+ args = parser().parse_args(argv)
24
+ if args.command == "serve":
25
+ serve(args.run_dir, args.host, args.port)
26
+ elif args.command == "export-static":
27
+ print(export_static(args.run_dir, args.output_dir))
28
+
29
+
30
+ if __name__ == "__main__":
31
+ main()
@@ -0,0 +1,189 @@
1
+ from __future__ import annotations
2
+
3
+ import atexit
4
+ import functools
5
+ import threading
6
+ import weakref
7
+ from pathlib import Path
8
+ from typing import Any, Callable, Iterable, TypeVar
9
+
10
+ import numpy as np
11
+
12
+ from .session import Session
13
+
14
+ T = TypeVar("T")
15
+ F = TypeVar("F", bound=Callable[..., Any])
16
+
17
+
18
+ class Recorder:
19
+ """Passive recorder for values produced by an application-owned pipeline.
20
+
21
+ Recorder never invokes processing stages or controls scheduling. ``tap``
22
+ returns its input unchanged after recording an observation.
23
+ """
24
+
25
+ def __init__(self, path: str | Path, *, name: str = "Signal-processing run", metadata: dict[str, Any] | None = None):
26
+ self.session = Session(path, name=name, metadata=metadata)
27
+ self.session.path.mkdir(parents=True, exist_ok=True)
28
+ (self.session.path / "arrays").mkdir(exist_ok=True)
29
+ self._objects: dict[int, tuple[weakref.ReferenceType | None, str]] = {}
30
+ self._closed = False
31
+ self._lock = threading.RLock()
32
+
33
+ @property
34
+ def path(self) -> Path:
35
+ return self.session.path
36
+
37
+ def _remember(self, value: Any, product_id: str) -> None:
38
+ try:
39
+ reference = weakref.ref(value, lambda _ref, key=id(value): self._objects.pop(key, None))
40
+ except TypeError:
41
+ reference = None
42
+ self._objects[id(value)] = (reference, product_id)
43
+
44
+ def product_for(self, value: Any) -> str | None:
45
+ known = self._objects.get(id(value))
46
+ if known is None:
47
+ return None
48
+ reference, product_id = known
49
+ if reference is not None and reference() is not value:
50
+ self._objects.pop(id(value), None)
51
+ return None
52
+ return product_id
53
+
54
+ def tap(
55
+ self,
56
+ value: T,
57
+ name: str,
58
+ *,
59
+ axes: Iterable[str] | None = None,
60
+ view_axes: Iterable[str | int] | None = None,
61
+ coordinates: dict[str | int, Any] | None = None,
62
+ filename: str | Path | None = None,
63
+ scale: str = "linear",
64
+ overview_aspect: str | None = None,
65
+ vmin: float | None = None,
66
+ vmax: float | None = None,
67
+ operation: str | None = None,
68
+ inputs: Any | Iterable[Any] | None = None,
69
+ units: str | None = None,
70
+ metadata: dict[str, Any] | None = None,
71
+ ) -> T:
72
+ """Observe ``value`` and return the exact same object."""
73
+ if self._closed:
74
+ raise RuntimeError("Cannot tap values after the recorder is closed")
75
+ if inputs is None:
76
+ input_values: list[Any] = []
77
+ elif isinstance(inputs, (list, tuple)):
78
+ input_values = list(inputs)
79
+ else:
80
+ input_values = [inputs]
81
+ upstream = [product_id for item in input_values if (product_id := self.product_for(item))]
82
+ with self._lock:
83
+ product_id = self.session.capture(
84
+ name,
85
+ value,
86
+ axes=axes,
87
+ view_axes=view_axes,
88
+ coordinates=coordinates,
89
+ filename=filename,
90
+ scale=scale,
91
+ overview_aspect=overview_aspect,
92
+ vmin=vmin,
93
+ vmax=vmax,
94
+ operation=operation,
95
+ upstream=upstream,
96
+ units=units,
97
+ metadata=metadata,
98
+ )
99
+ self._remember(value, product_id)
100
+ return value
101
+
102
+ def instrument(
103
+ self,
104
+ function: F | None = None,
105
+ *,
106
+ name: str | None = None,
107
+ axes: Iterable[str] | None = None,
108
+ view_axes: Iterable[str | int] | None = None,
109
+ coordinates: dict[str | int, Any] | None = None,
110
+ filename: str | Path | None = None,
111
+ scale: str = "linear",
112
+ overview_aspect: str | None = None,
113
+ vmin: float | None = None,
114
+ vmax: float | None = None,
115
+ operation: str | None = None,
116
+ units: str | None = None,
117
+ metadata: dict[str, Any] | None = None,
118
+ ) -> F | Callable[[F], F]:
119
+ """Observe a function's array result without changing who calls it."""
120
+ def decorate(target: F) -> F:
121
+ @functools.wraps(target)
122
+ def wrapped(*args, **kwargs):
123
+ result = target(*args, **kwargs)
124
+ observed_inputs = [item for item in (*args, *kwargs.values()) if self.product_for(item)]
125
+ self.tap(
126
+ result,
127
+ name or target.__name__,
128
+ axes=axes,
129
+ view_axes=view_axes,
130
+ coordinates=coordinates,
131
+ filename=filename,
132
+ scale=scale,
133
+ overview_aspect=overview_aspect,
134
+ vmin=vmin,
135
+ vmax=vmax,
136
+ operation=operation or target.__name__,
137
+ inputs=observed_inputs,
138
+ units=units,
139
+ metadata=metadata,
140
+ )
141
+ return result
142
+ return wrapped # type: ignore[return-value]
143
+ return decorate(function) if function is not None else decorate
144
+
145
+ def close(self) -> Path:
146
+ with self._lock:
147
+ if not self._closed:
148
+ manifest = self.session.close()
149
+ self._closed = True
150
+ return manifest
151
+ return self.path / "manifest.json"
152
+
153
+
154
+ _default: Recorder | None = None
155
+
156
+
157
+ def init(path: str | Path, *, name: str = "Signal-processing run", metadata: dict[str, Any] | None = None) -> Recorder:
158
+ """Configure process-wide passive observation for an existing application."""
159
+ global _default
160
+ if _default is not None:
161
+ _default.close()
162
+ _default = Recorder(path, name=name, metadata=metadata)
163
+ return _default
164
+
165
+
166
+ def get_recorder() -> Recorder:
167
+ if _default is None:
168
+ raise RuntimeError("Call spviz.init(...) before using spviz.tap()")
169
+ return _default
170
+
171
+
172
+ def tap(value: T, name: str, **kwargs: Any) -> T:
173
+ return get_recorder().tap(value, name, **kwargs)
174
+
175
+
176
+ def instrument(function: F | None = None, **kwargs: Any):
177
+ return get_recorder().instrument(function, **kwargs)
178
+
179
+
180
+ def close() -> Path:
181
+ return get_recorder().close()
182
+
183
+
184
+ def _close_default() -> None:
185
+ if _default is not None:
186
+ _default.close()
187
+
188
+
189
+ atexit.register(_close_default)