pytesdaqx-scope 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.
- pytesdaqx_scope-0.1.0/PKG-INFO +197 -0
- pytesdaqx_scope-0.1.0/README.md +171 -0
- pytesdaqx_scope-0.1.0/pyproject.toml +59 -0
- pytesdaqx_scope-0.1.0/pytesdaqx_scope/__init__.py +1 -0
- pytesdaqx_scope-0.1.0/pytesdaqx_scope/acquisition/__init__.py +5 -0
- pytesdaqx_scope-0.1.0/pytesdaqx_scope/acquisition/base.py +56 -0
- pytesdaqx_scope-0.1.0/pytesdaqx_scope/acquisition/file_source.py +170 -0
- pytesdaqx_scope-0.1.0/pytesdaqx_scope/acquisition/live_source.py +149 -0
- pytesdaqx_scope-0.1.0/pytesdaqx_scope/analysis/__init__.py +4 -0
- pytesdaqx_scope-0.1.0/pytesdaqx_scope/analysis/config.py +82 -0
- pytesdaqx_scope-0.1.0/pytesdaqx_scope/analysis/didv_fit.py +207 -0
- pytesdaqx_scope-0.1.0/pytesdaqx_scope/analysis/normalize.py +90 -0
- pytesdaqx_scope-0.1.0/pytesdaqx_scope/analysis/pileup.py +135 -0
- pytesdaqx_scope-0.1.0/pytesdaqx_scope/analysis/pipeline.py +135 -0
- pytesdaqx_scope-0.1.0/pytesdaqx_scope/analysis/psd.py +38 -0
- pytesdaqx_scope-0.1.0/pytesdaqx_scope/analysis/running_average.py +104 -0
- pytesdaqx_scope-0.1.0/pytesdaqx_scope/board_reader.py +147 -0
- pytesdaqx_scope-0.1.0/pytesdaqx_scope/controller.py +208 -0
- pytesdaqx_scope-0.1.0/pytesdaqx_scope/file_board_reader.py +94 -0
- pytesdaqx_scope-0.1.0/pytesdaqx_scope/gui/__init__.py +0 -0
- pytesdaqx_scope-0.1.0/pytesdaqx_scope/gui/assets/checkmark.png +0 -0
- pytesdaqx_scope-0.1.0/pytesdaqx_scope/gui/didv_results_formatter.py +94 -0
- pytesdaqx_scope-0.1.0/pytesdaqx_scope/gui/main_window.py +280 -0
- pytesdaqx_scope-0.1.0/pytesdaqx_scope/gui/theme.py +153 -0
- pytesdaqx_scope-0.1.0/pytesdaqx_scope/gui/widgets/__init__.py +0 -0
- pytesdaqx_scope-0.1.0/pytesdaqx_scope/gui/widgets/channel_panel.py +199 -0
- pytesdaqx_scope-0.1.0/pytesdaqx_scope/gui/widgets/control_panel.py +116 -0
- pytesdaqx_scope-0.1.0/pytesdaqx_scope/gui/widgets/didv_fit_panel.py +138 -0
- pytesdaqx_scope-0.1.0/pytesdaqx_scope/gui/widgets/display_panel.py +206 -0
- pytesdaqx_scope-0.1.0/pytesdaqx_scope/gui/widgets/pileup_cuts_panel.py +57 -0
- pytesdaqx_scope-0.1.0/pytesdaqx_scope/gui/widgets/tools_panel.py +78 -0
- pytesdaqx_scope-0.1.0/pytesdaqx_scope/gui/widgets/tools_window.py +56 -0
- pytesdaqx_scope-0.1.0/pytesdaqx_scope/launcher.py +123 -0
- pytesdaqx_scope-0.1.0/pytesdaqx_scope.egg-info/PKG-INFO +197 -0
- pytesdaqx_scope-0.1.0/pytesdaqx_scope.egg-info/SOURCES.txt +60 -0
- pytesdaqx_scope-0.1.0/pytesdaqx_scope.egg-info/dependency_links.txt +1 -0
- pytesdaqx_scope-0.1.0/pytesdaqx_scope.egg-info/entry_points.txt +2 -0
- pytesdaqx_scope-0.1.0/pytesdaqx_scope.egg-info/requires.txt +12 -0
- pytesdaqx_scope-0.1.0/pytesdaqx_scope.egg-info/top_level.txt +1 -0
- pytesdaqx_scope-0.1.0/setup.cfg +4 -0
- pytesdaqx_scope-0.1.0/tests/test_analysis_config.py +49 -0
- pytesdaqx_scope-0.1.0/tests/test_board_reader.py +184 -0
- pytesdaqx_scope-0.1.0/tests/test_channel_panel.py +138 -0
- pytesdaqx_scope-0.1.0/tests/test_control_panel.py +150 -0
- pytesdaqx_scope-0.1.0/tests/test_controller.py +222 -0
- pytesdaqx_scope-0.1.0/tests/test_didv_fit.py +114 -0
- pytesdaqx_scope-0.1.0/tests/test_didv_fit_panel.py +76 -0
- pytesdaqx_scope-0.1.0/tests/test_didv_results_formatter.py +63 -0
- pytesdaqx_scope-0.1.0/tests/test_display_panel.py +144 -0
- pytesdaqx_scope-0.1.0/tests/test_file_board_reader.py +142 -0
- pytesdaqx_scope-0.1.0/tests/test_file_source.py +319 -0
- pytesdaqx_scope-0.1.0/tests/test_launcher.py +142 -0
- pytesdaqx_scope-0.1.0/tests/test_live_source.py +236 -0
- pytesdaqx_scope-0.1.0/tests/test_main_window.py +275 -0
- pytesdaqx_scope-0.1.0/tests/test_normalize.py +70 -0
- pytesdaqx_scope-0.1.0/tests/test_pileup.py +36 -0
- pytesdaqx_scope-0.1.0/tests/test_pileup_cuts_panel.py +34 -0
- pytesdaqx_scope-0.1.0/tests/test_pipeline.py +101 -0
- pytesdaqx_scope-0.1.0/tests/test_psd.py +24 -0
- pytesdaqx_scope-0.1.0/tests/test_running_average.py +68 -0
- pytesdaqx_scope-0.1.0/tests/test_tools_panel.py +61 -0
- pytesdaqx_scope-0.1.0/tests/test_tools_window.py +34 -0
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pytesdaqx-scope
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Live pulse viewer and dIdV scope for pytesdaqx
|
|
5
|
+
Author: TESSERACT DAQ developers
|
|
6
|
+
Classifier: Development Status :: 3 - Alpha
|
|
7
|
+
Classifier: Intended Audience :: Science/Research
|
|
8
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
12
|
+
Classifier: Topic :: Scientific/Engineering :: Physics
|
|
13
|
+
Requires-Python: <3.13,>=3.11
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
Requires-Dist: pytesdaqx
|
|
16
|
+
Requires-Dist: numpy<2.0,>=1.26
|
|
17
|
+
Requires-Dist: scipy
|
|
18
|
+
Requires-Dist: matplotlib
|
|
19
|
+
Requires-Dist: qetpy
|
|
20
|
+
Requires-Dist: astropy
|
|
21
|
+
Requires-Dist: PySide6
|
|
22
|
+
Provides-Extra: dev
|
|
23
|
+
Requires-Dist: pytest; extra == "dev"
|
|
24
|
+
Requires-Dist: pytest-qt; extra == "dev"
|
|
25
|
+
Requires-Dist: ruff; extra == "dev"
|
|
26
|
+
|
|
27
|
+
# pytesdaqx-scope
|
|
28
|
+
|
|
29
|
+
Live pulse viewer / dIdV scope for [pytesdaqx](../pytesdaqx), migrated and
|
|
30
|
+
refactored from the pulse viewer in `pytesdaq-obsolete`.
|
|
31
|
+
|
|
32
|
+
## A Qt styling gotcha worth knowing about
|
|
33
|
+
|
|
34
|
+
A "bare declaration" stylesheet - `widget.setStyleSheet(f"background-color:
|
|
35
|
+
{color};")` with no selector - silently breaks QComboBox popup styling for
|
|
36
|
+
*any* descendant combobox, anywhere in that widget's subtree, even if the
|
|
37
|
+
popup styling itself lives in a completely separate stylesheet (app-level or
|
|
38
|
+
on the combobox directly). Reproduced with a minimal repro outside this
|
|
39
|
+
codebase before fixing it here. Always use `theme.scoped_background(name,
|
|
40
|
+
color)` / `theme.scoped_text_color(name, color)` (with
|
|
41
|
+
`widget.setObjectName(name)` first) instead of a bare declaration - see
|
|
42
|
+
`gui/theme.py` and its usages in `main_window.py`/`display_panel.py`/
|
|
43
|
+
`tools_window.py`/`channel_panel.py` for the pattern.
|
|
44
|
+
|
|
45
|
+
## Status
|
|
46
|
+
|
|
47
|
+
Under active development. Current layout:
|
|
48
|
+
|
|
49
|
+
```
|
|
50
|
+
pytesdaqx_scope/
|
|
51
|
+
analysis/ Qt-free analysis pipeline (normalization, PSD, running
|
|
52
|
+
average, pileup rejection, dIdV pole fitting)
|
|
53
|
+
|
|
54
|
+
normalize() requires norm_list whenever norm_type != NoNorm
|
|
55
|
+
was actually requested; it used to silently skip the
|
|
56
|
+
volts-to-amps division when norm_list was missing (e.g. a
|
|
57
|
+
failed board/file read) and fall straight through to the
|
|
58
|
+
display unit's scale factor - producing a plausible but
|
|
59
|
+
wrong number (raw volts read as if it were current,
|
|
60
|
+
inflated further by uAmps' 1e6) instead of an error.
|
|
61
|
+
Controller.process_frame() catches this (and any other
|
|
62
|
+
per-frame analysis error) and reports it via on_status
|
|
63
|
+
instead of letting it recur silently on every poll tick.
|
|
64
|
+
|
|
65
|
+
didv_fit()'s data_array_truncated/fit_array add the
|
|
66
|
+
trace's baseline back after qetpy's fit (which works on
|
|
67
|
+
an AC-only, baseline-subtracted trace internally) -
|
|
68
|
+
without this they were zero-centered, which only matched
|
|
69
|
+
the *live* running-average trace (never baseline-
|
|
70
|
+
subtracted) on the exact frame the fit ran on. Since the
|
|
71
|
+
fit overlay is kept and redrawn on later frames too (see
|
|
72
|
+
MainWindow._fit_overlay), the overlay would visibly drift
|
|
73
|
+
away from the live trace's true DC level afterward.
|
|
74
|
+
|
|
75
|
+
A completed dIdV fit now also pauses the display (like
|
|
76
|
+
the old GUI): ControlPanel becomes a 3-state Start/Stop/
|
|
77
|
+
Resume button (yellow while paused), the poll timer stops
|
|
78
|
+
so the frame holding the fit result stays on screen for
|
|
79
|
+
inspection/zooming, and clicking Resume drops the overlay
|
|
80
|
+
and continues - without re-arming the data source, since
|
|
81
|
+
pausing never told it to stop in the first place.
|
|
82
|
+
file_board_reader.py FileBoardReader: BoardReader backed by a recording's
|
|
83
|
+
stored per-channel settings (close_loop_norm, preamp_gain,
|
|
84
|
+
signal-gen, TES bias) instead of a live board read - the
|
|
85
|
+
file-replay equivalent of board_reader.py, wired into
|
|
86
|
+
launcher.py's --file path. Without this, Amps/uAmps
|
|
87
|
+
selection during file replay had no normalization source
|
|
88
|
+
at all (NullBoardReader raised, but normalize()'s old
|
|
89
|
+
silent-skip bug masked that as bad numbers rather than a
|
|
90
|
+
visible error).
|
|
91
|
+
acquisition/ Data source adapters over pytesdaqx: LiveSource (NI-DAQ,
|
|
92
|
+
via DAQSession/StreamingAcquisitionLoop) and FileSource
|
|
93
|
+
(zarr/hdf5 replay, via StreamReader). Both produce a
|
|
94
|
+
common Frame (raw ADC codes + calibration coefficients).
|
|
95
|
+
|
|
96
|
+
read_frame() returning None is ambiguous by itself: for
|
|
97
|
+
LiveSource it means "nothing new yet, poll again"; for
|
|
98
|
+
FileSource it means "recording is over". FileSource
|
|
99
|
+
exposes is_exhausted to disambiguate - MainWindow checks
|
|
100
|
+
it after every poll and auto-stops with a clear status
|
|
101
|
+
message instead of silently going quiet forever (which
|
|
102
|
+
read exactly like a freeze). FileSource.start() rewinds
|
|
103
|
+
automatically only when already exhausted, so Stop-then-
|
|
104
|
+
Start still resumes mid-file rather than always
|
|
105
|
+
restarting from the top.
|
|
106
|
+
|
|
107
|
+
Both sources support an optional trace_length_ms knob
|
|
108
|
+
(ControlPanel's "Trace Length [ms]" field), but only where
|
|
109
|
+
it's physically meaningful: LiveSource.trace_length_supported()
|
|
110
|
+
is true only for a continuous-mode measurement, since a
|
|
111
|
+
finite/triggered one (dIdV, threshold) has a fixed record
|
|
112
|
+
shape - a PXI-locked dIdV trace needs an integer number of
|
|
113
|
+
signal-generator periods, which a plain "ms" knob could
|
|
114
|
+
violate. FileSource.trace_length_supported() is true only
|
|
115
|
+
for a native continuous Zarr stream (raw_shape_model ==
|
|
116
|
+
"channel_sample"); HDF5 recordings and finite/trace Zarr
|
|
117
|
+
streams have a fixed length baked into the file itself, so
|
|
118
|
+
there's nothing to adjust. When supported, FileSource reads
|
|
119
|
+
fixed-size partitions via read_partition() instead of
|
|
120
|
+
read_next(); LiveSource folds it into adc.read_block_duration
|
|
121
|
+
for the next start(). MainWindow re-checks support on every
|
|
122
|
+
measurement change and disables the control accordingly.
|
|
123
|
+
gui/ PySide6 GUI shell: MainWindow composition root plus
|
|
124
|
+
ControlPanel (measurement select + start/stop),
|
|
125
|
+
ChannelPanel, DisplayPanel (waveform/PSD toggle,
|
|
126
|
+
unit/norm controls, matplotlib canvas, dIdV fit overlay),
|
|
127
|
+
ToolsPanel (running avg / LP filter / read-from-board /
|
|
128
|
+
opens ToolsWindow), and ToolsWindow (dIdV pole-model fit
|
|
129
|
+
+ pileup-rejection cuts, in a separate top-level window
|
|
130
|
+
like the old design). Widgets only emit signals; all
|
|
131
|
+
wiring to the Controller lives in MainWindow.
|
|
132
|
+
|
|
133
|
+
ChannelPanel is a vertical list (color swatch + checkbox)
|
|
134
|
+
for the first 8 channels, with a "More Channels..."
|
|
135
|
+
button opening a scrollable picker dialog for the rest
|
|
136
|
+
(channel count isn't hardcoded to 24 - it's however many
|
|
137
|
+
channels are passed in). Long names are middle-elided
|
|
138
|
+
("Mv6BandG...insRight", not right-elided, since detector
|
|
139
|
+
names often differ only in a suffix) with the full name
|
|
140
|
+
in a tooltip; a fixed-cell button grid (the old AI0-AI7
|
|
141
|
+
style) doesn't work once names run 20+ characters.
|
|
142
|
+
controller.py Qt-free orchestrator wiring a DataSource into the
|
|
143
|
+
analysis pipeline. Board reads (normalization, signal
|
|
144
|
+
generator, TES bias) go through a BoardReader Protocol,
|
|
145
|
+
cached and refreshed only on explicit triggers (channel
|
|
146
|
+
change / norm-type change / user action) since they can
|
|
147
|
+
be slow.
|
|
148
|
+
board_reader.py PytesdaqxBoardReader: concrete BoardReader over
|
|
149
|
+
SQUIDService/TESBiasService. Supports "Open Loop" and
|
|
150
|
+
"Close Loop" normalization only (OpenLoop PreAmp+FB was
|
|
151
|
+
dropped - see module docstring); output_fix_gain/
|
|
152
|
+
preamp_fix_gain are assumed 1.0 until pytesdaqx's config
|
|
153
|
+
schema grows a place for them (StarCryo SQUID electronics).
|
|
154
|
+
launcher.py CLI entry point: `pytesdaqx-scope --hardware ... --run-setup
|
|
155
|
+
... --acquisition-config ... [--backend nidaq|fake]` for
|
|
156
|
+
live acquisition, or `pytesdaqx-scope --file <acquisition>`
|
|
157
|
+
to replay a recorded zarr/hdf5 acquisition. Live vs. file
|
|
158
|
+
is chosen once at launch (a CLI arg), not a runtime GUI
|
|
159
|
+
toggle - a deliberate simplification now that the
|
|
160
|
+
config-driven design resolves channels/measurements from
|
|
161
|
+
YAML rather than a device combobox.
|
|
162
|
+
|
|
163
|
+
For live acquisition, also builds a
|
|
164
|
+
`pytesdaqx.control.coordinator.ControlCoordinator` (shared
|
|
165
|
+
HardwareRegistry with the board reader) and passes it to
|
|
166
|
+
`LiveSource`. `LiveSource` sequences a dIdV measurement's
|
|
167
|
+
PXI AC drive correctly: arm the AI task first, *then*
|
|
168
|
+
start the AO waveform, so the AI's `/ao/StartTrigger`
|
|
169
|
+
defines phase zero. `start_measurement_tes_ac` is a no-op
|
|
170
|
+
for measurements without a TES-AC waveform block, so this
|
|
171
|
+
is always safe to wire up even for background/threshold
|
|
172
|
+
runs.
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
Qt runs headless via `QT_QPA_PLATFORM=offscreen` in tests and in this
|
|
176
|
+
(display-less) dev environment; `pytest-qt`'s `qtbot` fixture drives widget
|
|
177
|
+
interaction in tests without a real display.
|
|
178
|
+
|
|
179
|
+
## Development
|
|
180
|
+
|
|
181
|
+
```bash
|
|
182
|
+
pip install -e ".[dev]"
|
|
183
|
+
pytest
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
Try the GUI shell against the fake backend (no hardware required), using
|
|
187
|
+
pytesdaqx's example configs:
|
|
188
|
+
|
|
189
|
+
```bash
|
|
190
|
+
QT_QPA_PLATFORM=offscreen pytesdaqx-scope \
|
|
191
|
+
--hardware ../pytesdaqx/examples/configuration/basic/hardware_fake.yaml \
|
|
192
|
+
--run-setup ../pytesdaqx/examples/configuration/basic/run_setup.yaml \
|
|
193
|
+
--acquisition-config ../pytesdaqx/examples/configuration/basic/acquisition_background.yaml \
|
|
194
|
+
--backend fake
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
(Drop `QT_QPA_PLATFORM=offscreen` on a machine with a real display.)
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
# pytesdaqx-scope
|
|
2
|
+
|
|
3
|
+
Live pulse viewer / dIdV scope for [pytesdaqx](../pytesdaqx), migrated and
|
|
4
|
+
refactored from the pulse viewer in `pytesdaq-obsolete`.
|
|
5
|
+
|
|
6
|
+
## A Qt styling gotcha worth knowing about
|
|
7
|
+
|
|
8
|
+
A "bare declaration" stylesheet - `widget.setStyleSheet(f"background-color:
|
|
9
|
+
{color};")` with no selector - silently breaks QComboBox popup styling for
|
|
10
|
+
*any* descendant combobox, anywhere in that widget's subtree, even if the
|
|
11
|
+
popup styling itself lives in a completely separate stylesheet (app-level or
|
|
12
|
+
on the combobox directly). Reproduced with a minimal repro outside this
|
|
13
|
+
codebase before fixing it here. Always use `theme.scoped_background(name,
|
|
14
|
+
color)` / `theme.scoped_text_color(name, color)` (with
|
|
15
|
+
`widget.setObjectName(name)` first) instead of a bare declaration - see
|
|
16
|
+
`gui/theme.py` and its usages in `main_window.py`/`display_panel.py`/
|
|
17
|
+
`tools_window.py`/`channel_panel.py` for the pattern.
|
|
18
|
+
|
|
19
|
+
## Status
|
|
20
|
+
|
|
21
|
+
Under active development. Current layout:
|
|
22
|
+
|
|
23
|
+
```
|
|
24
|
+
pytesdaqx_scope/
|
|
25
|
+
analysis/ Qt-free analysis pipeline (normalization, PSD, running
|
|
26
|
+
average, pileup rejection, dIdV pole fitting)
|
|
27
|
+
|
|
28
|
+
normalize() requires norm_list whenever norm_type != NoNorm
|
|
29
|
+
was actually requested; it used to silently skip the
|
|
30
|
+
volts-to-amps division when norm_list was missing (e.g. a
|
|
31
|
+
failed board/file read) and fall straight through to the
|
|
32
|
+
display unit's scale factor - producing a plausible but
|
|
33
|
+
wrong number (raw volts read as if it were current,
|
|
34
|
+
inflated further by uAmps' 1e6) instead of an error.
|
|
35
|
+
Controller.process_frame() catches this (and any other
|
|
36
|
+
per-frame analysis error) and reports it via on_status
|
|
37
|
+
instead of letting it recur silently on every poll tick.
|
|
38
|
+
|
|
39
|
+
didv_fit()'s data_array_truncated/fit_array add the
|
|
40
|
+
trace's baseline back after qetpy's fit (which works on
|
|
41
|
+
an AC-only, baseline-subtracted trace internally) -
|
|
42
|
+
without this they were zero-centered, which only matched
|
|
43
|
+
the *live* running-average trace (never baseline-
|
|
44
|
+
subtracted) on the exact frame the fit ran on. Since the
|
|
45
|
+
fit overlay is kept and redrawn on later frames too (see
|
|
46
|
+
MainWindow._fit_overlay), the overlay would visibly drift
|
|
47
|
+
away from the live trace's true DC level afterward.
|
|
48
|
+
|
|
49
|
+
A completed dIdV fit now also pauses the display (like
|
|
50
|
+
the old GUI): ControlPanel becomes a 3-state Start/Stop/
|
|
51
|
+
Resume button (yellow while paused), the poll timer stops
|
|
52
|
+
so the frame holding the fit result stays on screen for
|
|
53
|
+
inspection/zooming, and clicking Resume drops the overlay
|
|
54
|
+
and continues - without re-arming the data source, since
|
|
55
|
+
pausing never told it to stop in the first place.
|
|
56
|
+
file_board_reader.py FileBoardReader: BoardReader backed by a recording's
|
|
57
|
+
stored per-channel settings (close_loop_norm, preamp_gain,
|
|
58
|
+
signal-gen, TES bias) instead of a live board read - the
|
|
59
|
+
file-replay equivalent of board_reader.py, wired into
|
|
60
|
+
launcher.py's --file path. Without this, Amps/uAmps
|
|
61
|
+
selection during file replay had no normalization source
|
|
62
|
+
at all (NullBoardReader raised, but normalize()'s old
|
|
63
|
+
silent-skip bug masked that as bad numbers rather than a
|
|
64
|
+
visible error).
|
|
65
|
+
acquisition/ Data source adapters over pytesdaqx: LiveSource (NI-DAQ,
|
|
66
|
+
via DAQSession/StreamingAcquisitionLoop) and FileSource
|
|
67
|
+
(zarr/hdf5 replay, via StreamReader). Both produce a
|
|
68
|
+
common Frame (raw ADC codes + calibration coefficients).
|
|
69
|
+
|
|
70
|
+
read_frame() returning None is ambiguous by itself: for
|
|
71
|
+
LiveSource it means "nothing new yet, poll again"; for
|
|
72
|
+
FileSource it means "recording is over". FileSource
|
|
73
|
+
exposes is_exhausted to disambiguate - MainWindow checks
|
|
74
|
+
it after every poll and auto-stops with a clear status
|
|
75
|
+
message instead of silently going quiet forever (which
|
|
76
|
+
read exactly like a freeze). FileSource.start() rewinds
|
|
77
|
+
automatically only when already exhausted, so Stop-then-
|
|
78
|
+
Start still resumes mid-file rather than always
|
|
79
|
+
restarting from the top.
|
|
80
|
+
|
|
81
|
+
Both sources support an optional trace_length_ms knob
|
|
82
|
+
(ControlPanel's "Trace Length [ms]" field), but only where
|
|
83
|
+
it's physically meaningful: LiveSource.trace_length_supported()
|
|
84
|
+
is true only for a continuous-mode measurement, since a
|
|
85
|
+
finite/triggered one (dIdV, threshold) has a fixed record
|
|
86
|
+
shape - a PXI-locked dIdV trace needs an integer number of
|
|
87
|
+
signal-generator periods, which a plain "ms" knob could
|
|
88
|
+
violate. FileSource.trace_length_supported() is true only
|
|
89
|
+
for a native continuous Zarr stream (raw_shape_model ==
|
|
90
|
+
"channel_sample"); HDF5 recordings and finite/trace Zarr
|
|
91
|
+
streams have a fixed length baked into the file itself, so
|
|
92
|
+
there's nothing to adjust. When supported, FileSource reads
|
|
93
|
+
fixed-size partitions via read_partition() instead of
|
|
94
|
+
read_next(); LiveSource folds it into adc.read_block_duration
|
|
95
|
+
for the next start(). MainWindow re-checks support on every
|
|
96
|
+
measurement change and disables the control accordingly.
|
|
97
|
+
gui/ PySide6 GUI shell: MainWindow composition root plus
|
|
98
|
+
ControlPanel (measurement select + start/stop),
|
|
99
|
+
ChannelPanel, DisplayPanel (waveform/PSD toggle,
|
|
100
|
+
unit/norm controls, matplotlib canvas, dIdV fit overlay),
|
|
101
|
+
ToolsPanel (running avg / LP filter / read-from-board /
|
|
102
|
+
opens ToolsWindow), and ToolsWindow (dIdV pole-model fit
|
|
103
|
+
+ pileup-rejection cuts, in a separate top-level window
|
|
104
|
+
like the old design). Widgets only emit signals; all
|
|
105
|
+
wiring to the Controller lives in MainWindow.
|
|
106
|
+
|
|
107
|
+
ChannelPanel is a vertical list (color swatch + checkbox)
|
|
108
|
+
for the first 8 channels, with a "More Channels..."
|
|
109
|
+
button opening a scrollable picker dialog for the rest
|
|
110
|
+
(channel count isn't hardcoded to 24 - it's however many
|
|
111
|
+
channels are passed in). Long names are middle-elided
|
|
112
|
+
("Mv6BandG...insRight", not right-elided, since detector
|
|
113
|
+
names often differ only in a suffix) with the full name
|
|
114
|
+
in a tooltip; a fixed-cell button grid (the old AI0-AI7
|
|
115
|
+
style) doesn't work once names run 20+ characters.
|
|
116
|
+
controller.py Qt-free orchestrator wiring a DataSource into the
|
|
117
|
+
analysis pipeline. Board reads (normalization, signal
|
|
118
|
+
generator, TES bias) go through a BoardReader Protocol,
|
|
119
|
+
cached and refreshed only on explicit triggers (channel
|
|
120
|
+
change / norm-type change / user action) since they can
|
|
121
|
+
be slow.
|
|
122
|
+
board_reader.py PytesdaqxBoardReader: concrete BoardReader over
|
|
123
|
+
SQUIDService/TESBiasService. Supports "Open Loop" and
|
|
124
|
+
"Close Loop" normalization only (OpenLoop PreAmp+FB was
|
|
125
|
+
dropped - see module docstring); output_fix_gain/
|
|
126
|
+
preamp_fix_gain are assumed 1.0 until pytesdaqx's config
|
|
127
|
+
schema grows a place for them (StarCryo SQUID electronics).
|
|
128
|
+
launcher.py CLI entry point: `pytesdaqx-scope --hardware ... --run-setup
|
|
129
|
+
... --acquisition-config ... [--backend nidaq|fake]` for
|
|
130
|
+
live acquisition, or `pytesdaqx-scope --file <acquisition>`
|
|
131
|
+
to replay a recorded zarr/hdf5 acquisition. Live vs. file
|
|
132
|
+
is chosen once at launch (a CLI arg), not a runtime GUI
|
|
133
|
+
toggle - a deliberate simplification now that the
|
|
134
|
+
config-driven design resolves channels/measurements from
|
|
135
|
+
YAML rather than a device combobox.
|
|
136
|
+
|
|
137
|
+
For live acquisition, also builds a
|
|
138
|
+
`pytesdaqx.control.coordinator.ControlCoordinator` (shared
|
|
139
|
+
HardwareRegistry with the board reader) and passes it to
|
|
140
|
+
`LiveSource`. `LiveSource` sequences a dIdV measurement's
|
|
141
|
+
PXI AC drive correctly: arm the AI task first, *then*
|
|
142
|
+
start the AO waveform, so the AI's `/ao/StartTrigger`
|
|
143
|
+
defines phase zero. `start_measurement_tes_ac` is a no-op
|
|
144
|
+
for measurements without a TES-AC waveform block, so this
|
|
145
|
+
is always safe to wire up even for background/threshold
|
|
146
|
+
runs.
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
Qt runs headless via `QT_QPA_PLATFORM=offscreen` in tests and in this
|
|
150
|
+
(display-less) dev environment; `pytest-qt`'s `qtbot` fixture drives widget
|
|
151
|
+
interaction in tests without a real display.
|
|
152
|
+
|
|
153
|
+
## Development
|
|
154
|
+
|
|
155
|
+
```bash
|
|
156
|
+
pip install -e ".[dev]"
|
|
157
|
+
pytest
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
Try the GUI shell against the fake backend (no hardware required), using
|
|
161
|
+
pytesdaqx's example configs:
|
|
162
|
+
|
|
163
|
+
```bash
|
|
164
|
+
QT_QPA_PLATFORM=offscreen pytesdaqx-scope \
|
|
165
|
+
--hardware ../pytesdaqx/examples/configuration/basic/hardware_fake.yaml \
|
|
166
|
+
--run-setup ../pytesdaqx/examples/configuration/basic/run_setup.yaml \
|
|
167
|
+
--acquisition-config ../pytesdaqx/examples/configuration/basic/acquisition_background.yaml \
|
|
168
|
+
--backend fake
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
(Drop `QT_QPA_PLATFORM=offscreen` on a machine with a real display.)
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "pytesdaqx-scope"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Live pulse viewer and dIdV scope for pytesdaqx"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.11,<3.13"
|
|
11
|
+
authors = [
|
|
12
|
+
{name = "TESSERACT DAQ developers"}
|
|
13
|
+
]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 3 - Alpha",
|
|
16
|
+
"Intended Audience :: Science/Research",
|
|
17
|
+
"Operating System :: POSIX :: Linux",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Programming Language :: Python :: 3.11",
|
|
20
|
+
"Programming Language :: Python :: 3.12",
|
|
21
|
+
"Topic :: Scientific/Engineering :: Physics",
|
|
22
|
+
]
|
|
23
|
+
dependencies = [
|
|
24
|
+
"pytesdaqx",
|
|
25
|
+
"numpy>=1.26,<2.0",
|
|
26
|
+
"scipy",
|
|
27
|
+
"matplotlib",
|
|
28
|
+
"qetpy",
|
|
29
|
+
"astropy",
|
|
30
|
+
"PySide6",
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
[project.optional-dependencies]
|
|
34
|
+
dev = [
|
|
35
|
+
"pytest",
|
|
36
|
+
"pytest-qt",
|
|
37
|
+
"ruff",
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
[project.scripts]
|
|
41
|
+
pytesdaqx-scope = "pytesdaqx_scope.launcher:main"
|
|
42
|
+
|
|
43
|
+
[tool.setuptools.packages.find]
|
|
44
|
+
where = ["."]
|
|
45
|
+
include = ["pytesdaqx_scope*"]
|
|
46
|
+
|
|
47
|
+
[tool.setuptools.package-data]
|
|
48
|
+
pytesdaqx_scope = ["gui/assets/*.png"]
|
|
49
|
+
|
|
50
|
+
[tool.pytest.ini_options]
|
|
51
|
+
testpaths = ["tests"]
|
|
52
|
+
python_files = ["test_*.py"]
|
|
53
|
+
|
|
54
|
+
[tool.ruff]
|
|
55
|
+
line-length = 100
|
|
56
|
+
target-version = "py311"
|
|
57
|
+
|
|
58
|
+
[tool.ruff.lint]
|
|
59
|
+
select = ["E9", "F", "I"]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Common data-source interface shared by live acquisition and file replay.
|
|
2
|
+
|
|
3
|
+
Both :class:`~pytesdaqx_scope.acquisition.live_source.LiveSource` and
|
|
4
|
+
:class:`~pytesdaqx_scope.acquisition.file_source.FileSource` produce
|
|
5
|
+
:class:`Frame` objects, so the controller and analysis pipeline never need
|
|
6
|
+
to know which one produced a given frame.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from typing import Any, Protocol
|
|
13
|
+
|
|
14
|
+
import numpy as np
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class Frame:
|
|
19
|
+
"""One event/read-block of raw ADC-code traces, ready for analysis.
|
|
20
|
+
|
|
21
|
+
``data`` is always in raw ADC codes (never pre-calibrated), so
|
|
22
|
+
:meth:`pytesdaqx_scope.analysis.pipeline.AnalysisPipeline.process` can
|
|
23
|
+
treat live and file-replay frames identically via ``calibration_coeffs``.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
data: np.ndarray # [nb_channels, nb_samples]
|
|
27
|
+
detector_channels: list[str]
|
|
28
|
+
sample_rate_hz: float
|
|
29
|
+
measurement: str
|
|
30
|
+
calibration_coeffs: list[list[float] | None] | None = None
|
|
31
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class DataSource(Protocol):
|
|
35
|
+
"""Common interface for live and file-replay data sources."""
|
|
36
|
+
|
|
37
|
+
def start(self, measurement: str, *, overrides: dict[str, Any] | None = None) -> None:
|
|
38
|
+
"""Begin producing frames for the given measurement."""
|
|
39
|
+
...
|
|
40
|
+
|
|
41
|
+
def read_frame(self, timeout: float | None = 0.0) -> Frame | None:
|
|
42
|
+
"""Return the next available frame.
|
|
43
|
+
|
|
44
|
+
``None`` means "nothing available right now" for a live source
|
|
45
|
+
(safe to poll again later) or "exhausted" for a file source
|
|
46
|
+
(nothing more will ever come).
|
|
47
|
+
"""
|
|
48
|
+
...
|
|
49
|
+
|
|
50
|
+
def stop(self) -> None:
|
|
51
|
+
"""Stop producing frames; safe to call again with :meth:`start`."""
|
|
52
|
+
...
|
|
53
|
+
|
|
54
|
+
def close(self) -> None:
|
|
55
|
+
"""Release underlying resources; the source cannot be reused after this."""
|
|
56
|
+
...
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""File-replay data source over pytesdaqx's StreamReader (zarr/hdf5)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from pytesdaqx.io import StreamReader
|
|
9
|
+
|
|
10
|
+
from .base import Frame
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class FileSource:
|
|
14
|
+
"""Steps sequentially through a recorded acquisition, one record at a time.
|
|
15
|
+
|
|
16
|
+
Wraps :class:`pytesdaqx.io.StreamReader`, always requesting raw ADC
|
|
17
|
+
codes (``units="adc"``) so calibration flows through
|
|
18
|
+
:mod:`pytesdaqx_scope.analysis` the same way as for
|
|
19
|
+
:class:`~pytesdaqx_scope.acquisition.live_source.LiveSource` frames.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
def __init__(
|
|
23
|
+
self,
|
|
24
|
+
path: str | Path,
|
|
25
|
+
*,
|
|
26
|
+
streams: str | int | list[str | int] | None = None,
|
|
27
|
+
measurement_types: str | list[str] | None = None,
|
|
28
|
+
channels: list[str] | None = None,
|
|
29
|
+
) -> None:
|
|
30
|
+
self._reader = StreamReader(
|
|
31
|
+
path,
|
|
32
|
+
streams=streams,
|
|
33
|
+
measurement_types=measurement_types,
|
|
34
|
+
restricted="all",
|
|
35
|
+
)
|
|
36
|
+
self._channels = channels
|
|
37
|
+
self._exhausted = False
|
|
38
|
+
#: Samples per read when in partition mode; None means "use the
|
|
39
|
+
#: file's own read-block/trace boundaries via read_next()" (default).
|
|
40
|
+
self._partition_length_samples: int | None = None
|
|
41
|
+
self._partition_cursor = 0
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def is_exhausted(self) -> bool:
|
|
45
|
+
"""True once :meth:`read_frame` has hit the end of the recording.
|
|
46
|
+
|
|
47
|
+
``read_frame`` returning ``None`` is ambiguous on its own - for
|
|
48
|
+
:class:`~pytesdaqx_scope.acquisition.live_source.LiveSource` it just
|
|
49
|
+
means "nothing new yet, poll again"; for a file it means "nothing
|
|
50
|
+
more will ever come". Callers (e.g. the GUI's poll loop) should check
|
|
51
|
+
this after a ``None`` read to tell the two apart, rather than
|
|
52
|
+
silently polling forever with no feedback.
|
|
53
|
+
"""
|
|
54
|
+
return self._exhausted
|
|
55
|
+
|
|
56
|
+
def peek_metadata(self) -> dict[str, Any]:
|
|
57
|
+
"""Static resource metadata (detector_channels, measurement, ...).
|
|
58
|
+
|
|
59
|
+
Uses a separate, throwaway reader under the hood, so calling this
|
|
60
|
+
does not disturb :meth:`read_frame`'s sequential position.
|
|
61
|
+
"""
|
|
62
|
+
return self._reader.get_metadata()
|
|
63
|
+
|
|
64
|
+
def trace_length_supported(self, measurement: str | None = None) -> bool:
|
|
65
|
+
"""Whether :meth:`set_trace_length_ms` has any effect on this file.
|
|
66
|
+
|
|
67
|
+
Only true for a native continuous Zarr stream (``raw_shape_model ==
|
|
68
|
+
"channel_sample"``): HDF5 recordings and finite/trace Zarr streams
|
|
69
|
+
(dIdV, threshold) have a fixed record length baked into the file
|
|
70
|
+
itself, so there is nothing to adjust. ``measurement`` is accepted
|
|
71
|
+
(and ignored) only so callers can treat this and
|
|
72
|
+
``LiveSource.trace_length_supported`` the same way - a single file
|
|
73
|
+
source is fixed to whichever stream(s) it was opened with.
|
|
74
|
+
"""
|
|
75
|
+
del measurement
|
|
76
|
+
return self._reader.raw_shape_model == "channel_sample"
|
|
77
|
+
|
|
78
|
+
def set_trace_length_ms(self, trace_length_ms: float | None) -> None:
|
|
79
|
+
"""Set the partition length read per :meth:`read_frame` call, in ms.
|
|
80
|
+
|
|
81
|
+
Only meaningful when :meth:`trace_length_supported` is true;
|
|
82
|
+
otherwise this is a no-op. ``None`` reverts to the file's own
|
|
83
|
+
read-block boundaries (:meth:`read_next`). Resets the partition
|
|
84
|
+
read position to the start.
|
|
85
|
+
"""
|
|
86
|
+
if not self.trace_length_supported():
|
|
87
|
+
return
|
|
88
|
+
if trace_length_ms is None:
|
|
89
|
+
self._partition_length_samples = None
|
|
90
|
+
else:
|
|
91
|
+
samples = round(trace_length_ms * 1e-3 * self._reader.sample_rate_hz)
|
|
92
|
+
self._partition_length_samples = max(1, samples)
|
|
93
|
+
self._partition_cursor = 0
|
|
94
|
+
|
|
95
|
+
def get_detector_settings(self) -> dict[str, dict[str, Any]]:
|
|
96
|
+
"""Per-channel stored settings: close_loop_norm, preamp_gain,
|
|
97
|
+
signal_gen_current/frequency, tes_bias, etc. - used by
|
|
98
|
+
:class:`~pytesdaqx_scope.file_board_reader.FileBoardReader` in place
|
|
99
|
+
of a live board read. Like :meth:`peek_metadata`, this doesn't
|
|
100
|
+
disturb :meth:`read_frame`'s sequential position.
|
|
101
|
+
"""
|
|
102
|
+
return self._reader.get_detector_settings()
|
|
103
|
+
|
|
104
|
+
def start(self, measurement: str, *, overrides: dict[str, Any] | None = None) -> None:
|
|
105
|
+
# File replay has no separate "arm" step; read_frame() drives the
|
|
106
|
+
# reader. Stop-then-Start resumes from the current position (pause/
|
|
107
|
+
# resume), but Start after running off the end has nothing to resume
|
|
108
|
+
# from, so it means "replay from the top" instead.
|
|
109
|
+
del measurement, overrides
|
|
110
|
+
if self._exhausted:
|
|
111
|
+
self.rewind()
|
|
112
|
+
|
|
113
|
+
def read_frame(self, timeout: float | None = 0.0) -> Frame | None:
|
|
114
|
+
del timeout # file reads are effectively instantaneous
|
|
115
|
+
if self._partition_length_samples is not None:
|
|
116
|
+
return self._read_partition_frame()
|
|
117
|
+
|
|
118
|
+
try:
|
|
119
|
+
data, info = self._reader.read_next(channels=self._channels, units="adc")
|
|
120
|
+
except StopIteration:
|
|
121
|
+
self._exhausted = True
|
|
122
|
+
return None
|
|
123
|
+
return self._frame_from_read(data, info)
|
|
124
|
+
|
|
125
|
+
def _read_partition_frame(self) -> Frame | None:
|
|
126
|
+
try:
|
|
127
|
+
data, info = self._reader.read_partition(
|
|
128
|
+
partition_start_index=self._partition_cursor,
|
|
129
|
+
partition_length_samples=self._partition_length_samples,
|
|
130
|
+
channels=self._channels,
|
|
131
|
+
units="adc",
|
|
132
|
+
)
|
|
133
|
+
except ValueError:
|
|
134
|
+
# read_partition() raises when a request runs past the end of
|
|
135
|
+
# the stream, rather than the StopIteration read_next() uses -
|
|
136
|
+
# both mean the same thing here: nothing more to read.
|
|
137
|
+
self._exhausted = True
|
|
138
|
+
return None
|
|
139
|
+
self._partition_cursor += self._partition_length_samples
|
|
140
|
+
return self._frame_from_read(data, info)
|
|
141
|
+
|
|
142
|
+
def _frame_from_read(self, data, info: dict[str, Any]) -> Frame:
|
|
143
|
+
detector_channels = list(info.get("detector_channels") or [])
|
|
144
|
+
calibration_by_name = {
|
|
145
|
+
row["detector_channel"]: row.get("adc_conversion_coefficients")
|
|
146
|
+
for row in info.get("channel_map") or []
|
|
147
|
+
if "detector_channel" in row
|
|
148
|
+
}
|
|
149
|
+
calibration_coeffs = [calibration_by_name.get(name) for name in detector_channels]
|
|
150
|
+
|
|
151
|
+
return Frame(
|
|
152
|
+
data=data,
|
|
153
|
+
detector_channels=detector_channels,
|
|
154
|
+
sample_rate_hz=float(info.get("sample_rate_hz") or self._reader.sample_rate_hz),
|
|
155
|
+
measurement=str(info.get("measurement") or info.get("measurement_type") or ""),
|
|
156
|
+
calibration_coeffs=calibration_coeffs,
|
|
157
|
+
metadata=info,
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
def rewind(self) -> None:
|
|
161
|
+
self._reader.rewind()
|
|
162
|
+
self._exhausted = False
|
|
163
|
+
self._partition_cursor = 0
|
|
164
|
+
|
|
165
|
+
def stop(self) -> None:
|
|
166
|
+
# Nothing to stop mid-stream; rewind() is the explicit reset.
|
|
167
|
+
pass
|
|
168
|
+
|
|
169
|
+
def close(self) -> None:
|
|
170
|
+
self._reader.close()
|