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.
Files changed (62) hide show
  1. pytesdaqx_scope-0.1.0/PKG-INFO +197 -0
  2. pytesdaqx_scope-0.1.0/README.md +171 -0
  3. pytesdaqx_scope-0.1.0/pyproject.toml +59 -0
  4. pytesdaqx_scope-0.1.0/pytesdaqx_scope/__init__.py +1 -0
  5. pytesdaqx_scope-0.1.0/pytesdaqx_scope/acquisition/__init__.py +5 -0
  6. pytesdaqx_scope-0.1.0/pytesdaqx_scope/acquisition/base.py +56 -0
  7. pytesdaqx_scope-0.1.0/pytesdaqx_scope/acquisition/file_source.py +170 -0
  8. pytesdaqx_scope-0.1.0/pytesdaqx_scope/acquisition/live_source.py +149 -0
  9. pytesdaqx_scope-0.1.0/pytesdaqx_scope/analysis/__init__.py +4 -0
  10. pytesdaqx_scope-0.1.0/pytesdaqx_scope/analysis/config.py +82 -0
  11. pytesdaqx_scope-0.1.0/pytesdaqx_scope/analysis/didv_fit.py +207 -0
  12. pytesdaqx_scope-0.1.0/pytesdaqx_scope/analysis/normalize.py +90 -0
  13. pytesdaqx_scope-0.1.0/pytesdaqx_scope/analysis/pileup.py +135 -0
  14. pytesdaqx_scope-0.1.0/pytesdaqx_scope/analysis/pipeline.py +135 -0
  15. pytesdaqx_scope-0.1.0/pytesdaqx_scope/analysis/psd.py +38 -0
  16. pytesdaqx_scope-0.1.0/pytesdaqx_scope/analysis/running_average.py +104 -0
  17. pytesdaqx_scope-0.1.0/pytesdaqx_scope/board_reader.py +147 -0
  18. pytesdaqx_scope-0.1.0/pytesdaqx_scope/controller.py +208 -0
  19. pytesdaqx_scope-0.1.0/pytesdaqx_scope/file_board_reader.py +94 -0
  20. pytesdaqx_scope-0.1.0/pytesdaqx_scope/gui/__init__.py +0 -0
  21. pytesdaqx_scope-0.1.0/pytesdaqx_scope/gui/assets/checkmark.png +0 -0
  22. pytesdaqx_scope-0.1.0/pytesdaqx_scope/gui/didv_results_formatter.py +94 -0
  23. pytesdaqx_scope-0.1.0/pytesdaqx_scope/gui/main_window.py +280 -0
  24. pytesdaqx_scope-0.1.0/pytesdaqx_scope/gui/theme.py +153 -0
  25. pytesdaqx_scope-0.1.0/pytesdaqx_scope/gui/widgets/__init__.py +0 -0
  26. pytesdaqx_scope-0.1.0/pytesdaqx_scope/gui/widgets/channel_panel.py +199 -0
  27. pytesdaqx_scope-0.1.0/pytesdaqx_scope/gui/widgets/control_panel.py +116 -0
  28. pytesdaqx_scope-0.1.0/pytesdaqx_scope/gui/widgets/didv_fit_panel.py +138 -0
  29. pytesdaqx_scope-0.1.0/pytesdaqx_scope/gui/widgets/display_panel.py +206 -0
  30. pytesdaqx_scope-0.1.0/pytesdaqx_scope/gui/widgets/pileup_cuts_panel.py +57 -0
  31. pytesdaqx_scope-0.1.0/pytesdaqx_scope/gui/widgets/tools_panel.py +78 -0
  32. pytesdaqx_scope-0.1.0/pytesdaqx_scope/gui/widgets/tools_window.py +56 -0
  33. pytesdaqx_scope-0.1.0/pytesdaqx_scope/launcher.py +123 -0
  34. pytesdaqx_scope-0.1.0/pytesdaqx_scope.egg-info/PKG-INFO +197 -0
  35. pytesdaqx_scope-0.1.0/pytesdaqx_scope.egg-info/SOURCES.txt +60 -0
  36. pytesdaqx_scope-0.1.0/pytesdaqx_scope.egg-info/dependency_links.txt +1 -0
  37. pytesdaqx_scope-0.1.0/pytesdaqx_scope.egg-info/entry_points.txt +2 -0
  38. pytesdaqx_scope-0.1.0/pytesdaqx_scope.egg-info/requires.txt +12 -0
  39. pytesdaqx_scope-0.1.0/pytesdaqx_scope.egg-info/top_level.txt +1 -0
  40. pytesdaqx_scope-0.1.0/setup.cfg +4 -0
  41. pytesdaqx_scope-0.1.0/tests/test_analysis_config.py +49 -0
  42. pytesdaqx_scope-0.1.0/tests/test_board_reader.py +184 -0
  43. pytesdaqx_scope-0.1.0/tests/test_channel_panel.py +138 -0
  44. pytesdaqx_scope-0.1.0/tests/test_control_panel.py +150 -0
  45. pytesdaqx_scope-0.1.0/tests/test_controller.py +222 -0
  46. pytesdaqx_scope-0.1.0/tests/test_didv_fit.py +114 -0
  47. pytesdaqx_scope-0.1.0/tests/test_didv_fit_panel.py +76 -0
  48. pytesdaqx_scope-0.1.0/tests/test_didv_results_formatter.py +63 -0
  49. pytesdaqx_scope-0.1.0/tests/test_display_panel.py +144 -0
  50. pytesdaqx_scope-0.1.0/tests/test_file_board_reader.py +142 -0
  51. pytesdaqx_scope-0.1.0/tests/test_file_source.py +319 -0
  52. pytesdaqx_scope-0.1.0/tests/test_launcher.py +142 -0
  53. pytesdaqx_scope-0.1.0/tests/test_live_source.py +236 -0
  54. pytesdaqx_scope-0.1.0/tests/test_main_window.py +275 -0
  55. pytesdaqx_scope-0.1.0/tests/test_normalize.py +70 -0
  56. pytesdaqx_scope-0.1.0/tests/test_pileup.py +36 -0
  57. pytesdaqx_scope-0.1.0/tests/test_pileup_cuts_panel.py +34 -0
  58. pytesdaqx_scope-0.1.0/tests/test_pipeline.py +101 -0
  59. pytesdaqx_scope-0.1.0/tests/test_psd.py +24 -0
  60. pytesdaqx_scope-0.1.0/tests/test_running_average.py +68 -0
  61. pytesdaqx_scope-0.1.0/tests/test_tools_panel.py +61 -0
  62. 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,5 @@
1
+ from .base import DataSource, Frame
2
+ from .file_source import FileSource
3
+ from .live_source import LiveSource
4
+
5
+ __all__ = ["DataSource", "Frame", "FileSource", "LiveSource"]
@@ -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()