pytesscope 0.2.1__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. pytesscope-0.2.1/PKG-INFO +235 -0
  2. pytesscope-0.2.1/README.md +207 -0
  3. pytesscope-0.2.1/pyproject.toml +68 -0
  4. pytesscope-0.2.1/pytesscope/__init__.py +10 -0
  5. pytesscope-0.2.1/pytesscope/acquisition/__init__.py +5 -0
  6. pytesscope-0.2.1/pytesscope/acquisition/base.py +71 -0
  7. pytesscope-0.2.1/pytesscope/acquisition/file_source.py +180 -0
  8. pytesscope-0.2.1/pytesscope/acquisition/live_source.py +379 -0
  9. pytesscope-0.2.1/pytesscope/analysis/__init__.py +4 -0
  10. pytesscope-0.2.1/pytesscope/analysis/config.py +82 -0
  11. pytesscope-0.2.1/pytesscope/analysis/didv_fit.py +243 -0
  12. pytesscope-0.2.1/pytesscope/analysis/normalize.py +90 -0
  13. pytesscope-0.2.1/pytesscope/analysis/pileup.py +135 -0
  14. pytesscope-0.2.1/pytesscope/analysis/pipeline.py +145 -0
  15. pytesscope-0.2.1/pytesscope/analysis/psd.py +38 -0
  16. pytesscope-0.2.1/pytesscope/analysis/running_average.py +104 -0
  17. pytesscope-0.2.1/pytesscope/board_reader.py +198 -0
  18. pytesscope-0.2.1/pytesscope/controller.py +245 -0
  19. pytesscope-0.2.1/pytesscope/file_board_reader.py +94 -0
  20. pytesscope-0.2.1/pytesscope/gui/__init__.py +0 -0
  21. pytesscope-0.2.1/pytesscope/gui/assets/checkmark.png +0 -0
  22. pytesscope-0.2.1/pytesscope/gui/didv_results_formatter.py +94 -0
  23. pytesscope-0.2.1/pytesscope/gui/main_window.py +298 -0
  24. pytesscope-0.2.1/pytesscope/gui/theme.py +153 -0
  25. pytesscope-0.2.1/pytesscope/gui/widgets/__init__.py +0 -0
  26. pytesscope-0.2.1/pytesscope/gui/widgets/channel_panel.py +199 -0
  27. pytesscope-0.2.1/pytesscope/gui/widgets/control_panel.py +116 -0
  28. pytesscope-0.2.1/pytesscope/gui/widgets/didv_fit_panel.py +138 -0
  29. pytesscope-0.2.1/pytesscope/gui/widgets/display_panel.py +206 -0
  30. pytesscope-0.2.1/pytesscope/gui/widgets/pileup_cuts_panel.py +57 -0
  31. pytesscope-0.2.1/pytesscope/gui/widgets/tools_panel.py +78 -0
  32. pytesscope-0.2.1/pytesscope/gui/widgets/tools_window.py +56 -0
  33. pytesscope-0.2.1/pytesscope/launcher.py +132 -0
  34. pytesscope-0.2.1/pytesscope.egg-info/PKG-INFO +235 -0
  35. pytesscope-0.2.1/pytesscope.egg-info/SOURCES.txt +60 -0
  36. pytesscope-0.2.1/pytesscope.egg-info/dependency_links.txt +1 -0
  37. pytesscope-0.2.1/pytesscope.egg-info/entry_points.txt +2 -0
  38. pytesscope-0.2.1/pytesscope.egg-info/requires.txt +15 -0
  39. pytesscope-0.2.1/pytesscope.egg-info/top_level.txt +1 -0
  40. pytesscope-0.2.1/setup.cfg +4 -0
  41. pytesscope-0.2.1/tests/test_analysis_config.py +49 -0
  42. pytesscope-0.2.1/tests/test_board_reader.py +263 -0
  43. pytesscope-0.2.1/tests/test_channel_panel.py +138 -0
  44. pytesscope-0.2.1/tests/test_control_panel.py +150 -0
  45. pytesscope-0.2.1/tests/test_controller.py +335 -0
  46. pytesscope-0.2.1/tests/test_didv_fit.py +178 -0
  47. pytesscope-0.2.1/tests/test_didv_fit_panel.py +76 -0
  48. pytesscope-0.2.1/tests/test_didv_results_formatter.py +63 -0
  49. pytesscope-0.2.1/tests/test_display_panel.py +144 -0
  50. pytesscope-0.2.1/tests/test_file_board_reader.py +142 -0
  51. pytesscope-0.2.1/tests/test_file_source.py +319 -0
  52. pytesscope-0.2.1/tests/test_launcher.py +257 -0
  53. pytesscope-0.2.1/tests/test_live_source.py +650 -0
  54. pytesscope-0.2.1/tests/test_main_window.py +343 -0
  55. pytesscope-0.2.1/tests/test_normalize.py +70 -0
  56. pytesscope-0.2.1/tests/test_pileup.py +36 -0
  57. pytesscope-0.2.1/tests/test_pileup_cuts_panel.py +34 -0
  58. pytesscope-0.2.1/tests/test_pipeline.py +134 -0
  59. pytesscope-0.2.1/tests/test_psd.py +24 -0
  60. pytesscope-0.2.1/tests/test_running_average.py +68 -0
  61. pytesscope-0.2.1/tests/test_tools_panel.py +61 -0
  62. pytesscope-0.2.1/tests/test_tools_window.py +34 -0
@@ -0,0 +1,235 @@
1
+ Metadata-Version: 2.4
2
+ Name: pytesscope
3
+ Version: 0.2.1
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
+ Provides-Extra: ni
27
+ Requires-Dist: pytesdaqx[ni]; extra == "ni"
28
+
29
+ # pytesscope
30
+
31
+ Live pulse viewer / dIdV scope for [pytesdaqx](../pytesdaqx), migrated and
32
+ refactored from the pulse viewer in `pytesdaq-obsolete`.
33
+
34
+ ## Installation
35
+
36
+ ```bash
37
+ pip install pytesscope
38
+ ```
39
+
40
+ For live acquisition against real NI/PXI hardware, install the `ni` extra
41
+ too:
42
+
43
+ ```bash
44
+ pip install pytesscope[ni]
45
+ ```
46
+
47
+ `nidaqmx` isn't pulled in by a plain install - it's pytesdaqx's own
48
+ optional `ni` extra (plenty of pytesdaqx use, e.g. temperature control or
49
+ signal generators, doesn't touch NI hardware at all), so pytesscope
50
+ doesn't force it on file-replay/`--backend fake` users either. Without it,
51
+ `pytesscope --backend nidaq ...` fails as soon as it tries to actually
52
+ open a device.
53
+
54
+ ## A Qt styling gotcha worth knowing about
55
+
56
+ A "bare declaration" stylesheet - `widget.setStyleSheet(f"background-color:
57
+ {color};")` with no selector - silently breaks QComboBox popup styling for
58
+ *any* descendant combobox, anywhere in that widget's subtree, even if the
59
+ popup styling itself lives in a completely separate stylesheet (app-level or
60
+ on the combobox directly). Reproduced with a minimal repro outside this
61
+ codebase before fixing it here. Always use `theme.scoped_background(name,
62
+ color)` / `theme.scoped_text_color(name, color)` (with
63
+ `widget.setObjectName(name)` first) instead of a bare declaration - see
64
+ `gui/theme.py` and its usages in `main_window.py`/`display_panel.py`/
65
+ `tools_window.py`/`channel_panel.py` for the pattern.
66
+
67
+ ## Status
68
+
69
+ Under active development. Current layout:
70
+
71
+ ```
72
+ pytesscope/
73
+ analysis/ Qt-free analysis pipeline (normalization, PSD, running
74
+ average, pileup rejection, dIdV pole fitting)
75
+
76
+ normalize() requires norm_list whenever norm_type != NoNorm
77
+ was actually requested; it used to silently skip the
78
+ volts-to-amps division when norm_list was missing (e.g. a
79
+ failed board/file read) and fall straight through to the
80
+ display unit's scale factor - producing a plausible but
81
+ wrong number (raw volts read as if it were current,
82
+ inflated further by uAmps' 1e6) instead of an error.
83
+ Controller.process_frame() catches this (and any other
84
+ per-frame analysis error) and reports it via on_status
85
+ instead of letting it recur silently on every poll tick.
86
+
87
+ didv_fit()'s data_array_truncated/fit_array add the
88
+ trace's baseline back after qetpy's fit (which works on
89
+ an AC-only, baseline-subtracted trace internally) -
90
+ without this they were zero-centered, which only matched
91
+ the *live* running-average trace (never baseline-
92
+ subtracted) on the exact frame the fit ran on. Since the
93
+ fit overlay is kept and redrawn on later frames too (see
94
+ MainWindow._fit_overlay), the overlay would visibly drift
95
+ away from the live trace's true DC level afterward.
96
+
97
+ A completed dIdV fit now also pauses the display (like
98
+ the old GUI): ControlPanel becomes a 3-state Start/Stop/
99
+ Resume button (yellow while paused), the poll timer stops
100
+ so the frame holding the fit result stays on screen for
101
+ inspection/zooming, and clicking Resume drops the overlay
102
+ and continues - without re-arming the data source, since
103
+ pausing never told it to stop in the first place.
104
+ file_board_reader.py FileBoardReader: BoardReader backed by a recording's
105
+ stored per-channel settings (close_loop_norm, preamp_gain,
106
+ signal-gen, TES bias) instead of a live board read - the
107
+ file-replay equivalent of board_reader.py, wired into
108
+ launcher.py's --file path. Without this, Amps/uAmps
109
+ selection during file replay had no normalization source
110
+ at all (NullBoardReader raised, but normalize()'s old
111
+ silent-skip bug masked that as bad numbers rather than a
112
+ visible error).
113
+ acquisition/ Data source adapters over pytesdaqx: LiveSource (NI-DAQ,
114
+ via DAQSession/StreamingAcquisitionLoop) and FileSource
115
+ (zarr/hdf5 replay, via StreamReader). Both produce a
116
+ common Frame (raw ADC codes + calibration coefficients).
117
+
118
+ read_frame() returning None is ambiguous by itself: for
119
+ LiveSource it means "nothing new yet, poll again"; for
120
+ FileSource it means "recording is over". FileSource
121
+ exposes is_exhausted to disambiguate - MainWindow checks
122
+ it after every poll and auto-stops with a clear status
123
+ message instead of silently going quiet forever (which
124
+ read exactly like a freeze). FileSource.start() rewinds
125
+ automatically only when already exhausted, so Stop-then-
126
+ Start still resumes mid-file rather than always
127
+ restarting from the top.
128
+
129
+ Both sources support an optional trace_length_ms knob
130
+ (ControlPanel's "Trace Length [ms]" field), but only where
131
+ it's physically meaningful: LiveSource.trace_length_supported()
132
+ is true only for a continuous-mode measurement, since a
133
+ finite/triggered one (dIdV, threshold) has a fixed record
134
+ shape - a PXI-locked dIdV trace needs an integer number of
135
+ signal-generator periods, which a plain "ms" knob could
136
+ violate. FileSource.trace_length_supported() is true only
137
+ for a native continuous Zarr stream (raw_shape_model ==
138
+ "channel_sample"); HDF5 recordings and finite/trace Zarr
139
+ streams have a fixed length baked into the file itself, so
140
+ there's nothing to adjust. When supported, FileSource reads
141
+ fixed-size partitions via read_partition() instead of
142
+ read_next(); LiveSource folds it into adc.read_block_duration
143
+ for the next start(). MainWindow re-checks support on every
144
+ measurement change and disables the control accordingly.
145
+ gui/ PySide6 GUI shell: MainWindow composition root plus
146
+ ControlPanel (measurement select + start/stop),
147
+ ChannelPanel, DisplayPanel (waveform/PSD toggle,
148
+ unit/norm controls, matplotlib canvas, dIdV fit overlay),
149
+ ToolsPanel (running avg / LP filter / read-from-board /
150
+ opens ToolsWindow), and ToolsWindow (dIdV pole-model fit
151
+ + pileup-rejection cuts, in a separate top-level window
152
+ like the old design). Widgets only emit signals; all
153
+ wiring to the Controller lives in MainWindow.
154
+
155
+ ChannelPanel is a vertical list (color swatch + checkbox)
156
+ for the first 8 channels, with a "More Channels..."
157
+ button opening a scrollable picker dialog for the rest
158
+ (channel count isn't hardcoded to 24 - it's however many
159
+ channels are passed in). Long names are middle-elided
160
+ ("Mv6BandG...insRight", not right-elided, since detector
161
+ names often differ only in a suffix) with the full name
162
+ in a tooltip; a fixed-cell button grid (the old AI0-AI7
163
+ style) doesn't work once names run 20+ characters.
164
+ controller.py Qt-free orchestrator wiring a DataSource into the
165
+ analysis pipeline. Board reads (normalization, signal
166
+ generator, TES bias) go through a BoardReader Protocol,
167
+ cached and refreshed only on explicit triggers (channel
168
+ change / norm-type change / user action) since they can
169
+ be slow.
170
+ board_reader.py PytesdaqxBoardReader: concrete BoardReader over
171
+ SQUIDService/TESBiasService. Supports "Open Loop" and
172
+ "Close Loop" normalization only (OpenLoop PreAmp+FB was
173
+ dropped - see module docstring); output_fix_gain/
174
+ preamp_fix_gain are assumed 1.0 until pytesdaqx's config
175
+ schema grows a place for them (StarCryo SQUID electronics).
176
+ launcher.py CLI entry point: `pytesscope --hardware ... --run-setup
177
+ ... --acquisition-config ... [--backend nidaq|fake]` for
178
+ live acquisition, or `pytesscope --file <acquisition>`
179
+ to replay a recorded zarr/hdf5 acquisition. Live vs. file
180
+ is chosen once at launch (a CLI arg), not a runtime GUI
181
+ toggle - a deliberate simplification now that the
182
+ config-driven design resolves channels/measurements from
183
+ YAML rather than a device combobox.
184
+
185
+ For live acquisition, also builds a
186
+ `pytesdaqx.control.coordinator.ControlCoordinator` (shared
187
+ HardwareRegistry with the board reader) and passes it to
188
+ `LiveSource`. `LiveSource` sequences a dIdV measurement's
189
+ PXI AC drive correctly: arm the AI task first, *then*
190
+ start the AO waveform, so the AI's `/ao/StartTrigger`
191
+ defines phase zero. `start_measurement_tes_ac` is a no-op
192
+ for measurements without a TES-AC waveform block, so this
193
+ is always safe to wire up even for background/threshold
194
+ runs.
195
+ ```
196
+
197
+ Qt runs headless via `QT_QPA_PLATFORM=offscreen` in tests and in this
198
+ (display-less) dev environment; `pytest-qt`'s `qtbot` fixture drives widget
199
+ interaction in tests without a real display.
200
+
201
+ ## Development
202
+
203
+ ```bash
204
+ pip install -e ".[dev]"
205
+ pytest
206
+ ```
207
+
208
+ Try the GUI shell against the fake backend (no hardware required), using
209
+ pytesdaqx's example configs:
210
+
211
+ ```bash
212
+ QT_QPA_PLATFORM=offscreen pytesscope \
213
+ --hardware ../pytesdaqx/examples/configuration/basic/hardware_fake.yaml \
214
+ --run-setup ../pytesdaqx/examples/configuration/basic/run_setup.yaml \
215
+ --acquisition-config ../pytesdaqx/examples/configuration/basic/acquisition_background.yaml \
216
+ --backend fake
217
+ ```
218
+
219
+ (Drop `QT_QPA_PLATFORM=offscreen` on a machine with a real display.)
220
+
221
+ ### Real-display runtime gotcha (Linux/conda)
222
+
223
+ On a conda-managed Linux machine, PySide6's `xcb` platform plugin (needed to
224
+ actually show a window - `offscreen` above doesn't need any of this) can
225
+ fail to start because `libxcb-cursor.so.1` isn't present, and/or because
226
+ Qt's shared libraries in the conda env aren't on the loader's search path.
227
+ If the GUI won't start outside `QT_QPA_PLATFORM=offscreen`, try:
228
+
229
+ ```bash
230
+ conda install -c conda-forge xcb-util-cursor
231
+ export LD_LIBRARY_PATH="$CONDA_PREFIX/lib:$LD_LIBRARY_PATH"
232
+ ```
233
+
234
+ (the `export` isn't persistent - add it to the env's activation script, or
235
+ your shell profile, to avoid repeating it every session).
@@ -0,0 +1,207 @@
1
+ # pytesscope
2
+
3
+ Live pulse viewer / dIdV scope for [pytesdaqx](../pytesdaqx), migrated and
4
+ refactored from the pulse viewer in `pytesdaq-obsolete`.
5
+
6
+ ## Installation
7
+
8
+ ```bash
9
+ pip install pytesscope
10
+ ```
11
+
12
+ For live acquisition against real NI/PXI hardware, install the `ni` extra
13
+ too:
14
+
15
+ ```bash
16
+ pip install pytesscope[ni]
17
+ ```
18
+
19
+ `nidaqmx` isn't pulled in by a plain install - it's pytesdaqx's own
20
+ optional `ni` extra (plenty of pytesdaqx use, e.g. temperature control or
21
+ signal generators, doesn't touch NI hardware at all), so pytesscope
22
+ doesn't force it on file-replay/`--backend fake` users either. Without it,
23
+ `pytesscope --backend nidaq ...` fails as soon as it tries to actually
24
+ open a device.
25
+
26
+ ## A Qt styling gotcha worth knowing about
27
+
28
+ A "bare declaration" stylesheet - `widget.setStyleSheet(f"background-color:
29
+ {color};")` with no selector - silently breaks QComboBox popup styling for
30
+ *any* descendant combobox, anywhere in that widget's subtree, even if the
31
+ popup styling itself lives in a completely separate stylesheet (app-level or
32
+ on the combobox directly). Reproduced with a minimal repro outside this
33
+ codebase before fixing it here. Always use `theme.scoped_background(name,
34
+ color)` / `theme.scoped_text_color(name, color)` (with
35
+ `widget.setObjectName(name)` first) instead of a bare declaration - see
36
+ `gui/theme.py` and its usages in `main_window.py`/`display_panel.py`/
37
+ `tools_window.py`/`channel_panel.py` for the pattern.
38
+
39
+ ## Status
40
+
41
+ Under active development. Current layout:
42
+
43
+ ```
44
+ pytesscope/
45
+ analysis/ Qt-free analysis pipeline (normalization, PSD, running
46
+ average, pileup rejection, dIdV pole fitting)
47
+
48
+ normalize() requires norm_list whenever norm_type != NoNorm
49
+ was actually requested; it used to silently skip the
50
+ volts-to-amps division when norm_list was missing (e.g. a
51
+ failed board/file read) and fall straight through to the
52
+ display unit's scale factor - producing a plausible but
53
+ wrong number (raw volts read as if it were current,
54
+ inflated further by uAmps' 1e6) instead of an error.
55
+ Controller.process_frame() catches this (and any other
56
+ per-frame analysis error) and reports it via on_status
57
+ instead of letting it recur silently on every poll tick.
58
+
59
+ didv_fit()'s data_array_truncated/fit_array add the
60
+ trace's baseline back after qetpy's fit (which works on
61
+ an AC-only, baseline-subtracted trace internally) -
62
+ without this they were zero-centered, which only matched
63
+ the *live* running-average trace (never baseline-
64
+ subtracted) on the exact frame the fit ran on. Since the
65
+ fit overlay is kept and redrawn on later frames too (see
66
+ MainWindow._fit_overlay), the overlay would visibly drift
67
+ away from the live trace's true DC level afterward.
68
+
69
+ A completed dIdV fit now also pauses the display (like
70
+ the old GUI): ControlPanel becomes a 3-state Start/Stop/
71
+ Resume button (yellow while paused), the poll timer stops
72
+ so the frame holding the fit result stays on screen for
73
+ inspection/zooming, and clicking Resume drops the overlay
74
+ and continues - without re-arming the data source, since
75
+ pausing never told it to stop in the first place.
76
+ file_board_reader.py FileBoardReader: BoardReader backed by a recording's
77
+ stored per-channel settings (close_loop_norm, preamp_gain,
78
+ signal-gen, TES bias) instead of a live board read - the
79
+ file-replay equivalent of board_reader.py, wired into
80
+ launcher.py's --file path. Without this, Amps/uAmps
81
+ selection during file replay had no normalization source
82
+ at all (NullBoardReader raised, but normalize()'s old
83
+ silent-skip bug masked that as bad numbers rather than a
84
+ visible error).
85
+ acquisition/ Data source adapters over pytesdaqx: LiveSource (NI-DAQ,
86
+ via DAQSession/StreamingAcquisitionLoop) and FileSource
87
+ (zarr/hdf5 replay, via StreamReader). Both produce a
88
+ common Frame (raw ADC codes + calibration coefficients).
89
+
90
+ read_frame() returning None is ambiguous by itself: for
91
+ LiveSource it means "nothing new yet, poll again"; for
92
+ FileSource it means "recording is over". FileSource
93
+ exposes is_exhausted to disambiguate - MainWindow checks
94
+ it after every poll and auto-stops with a clear status
95
+ message instead of silently going quiet forever (which
96
+ read exactly like a freeze). FileSource.start() rewinds
97
+ automatically only when already exhausted, so Stop-then-
98
+ Start still resumes mid-file rather than always
99
+ restarting from the top.
100
+
101
+ Both sources support an optional trace_length_ms knob
102
+ (ControlPanel's "Trace Length [ms]" field), but only where
103
+ it's physically meaningful: LiveSource.trace_length_supported()
104
+ is true only for a continuous-mode measurement, since a
105
+ finite/triggered one (dIdV, threshold) has a fixed record
106
+ shape - a PXI-locked dIdV trace needs an integer number of
107
+ signal-generator periods, which a plain "ms" knob could
108
+ violate. FileSource.trace_length_supported() is true only
109
+ for a native continuous Zarr stream (raw_shape_model ==
110
+ "channel_sample"); HDF5 recordings and finite/trace Zarr
111
+ streams have a fixed length baked into the file itself, so
112
+ there's nothing to adjust. When supported, FileSource reads
113
+ fixed-size partitions via read_partition() instead of
114
+ read_next(); LiveSource folds it into adc.read_block_duration
115
+ for the next start(). MainWindow re-checks support on every
116
+ measurement change and disables the control accordingly.
117
+ gui/ PySide6 GUI shell: MainWindow composition root plus
118
+ ControlPanel (measurement select + start/stop),
119
+ ChannelPanel, DisplayPanel (waveform/PSD toggle,
120
+ unit/norm controls, matplotlib canvas, dIdV fit overlay),
121
+ ToolsPanel (running avg / LP filter / read-from-board /
122
+ opens ToolsWindow), and ToolsWindow (dIdV pole-model fit
123
+ + pileup-rejection cuts, in a separate top-level window
124
+ like the old design). Widgets only emit signals; all
125
+ wiring to the Controller lives in MainWindow.
126
+
127
+ ChannelPanel is a vertical list (color swatch + checkbox)
128
+ for the first 8 channels, with a "More Channels..."
129
+ button opening a scrollable picker dialog for the rest
130
+ (channel count isn't hardcoded to 24 - it's however many
131
+ channels are passed in). Long names are middle-elided
132
+ ("Mv6BandG...insRight", not right-elided, since detector
133
+ names often differ only in a suffix) with the full name
134
+ in a tooltip; a fixed-cell button grid (the old AI0-AI7
135
+ style) doesn't work once names run 20+ characters.
136
+ controller.py Qt-free orchestrator wiring a DataSource into the
137
+ analysis pipeline. Board reads (normalization, signal
138
+ generator, TES bias) go through a BoardReader Protocol,
139
+ cached and refreshed only on explicit triggers (channel
140
+ change / norm-type change / user action) since they can
141
+ be slow.
142
+ board_reader.py PytesdaqxBoardReader: concrete BoardReader over
143
+ SQUIDService/TESBiasService. Supports "Open Loop" and
144
+ "Close Loop" normalization only (OpenLoop PreAmp+FB was
145
+ dropped - see module docstring); output_fix_gain/
146
+ preamp_fix_gain are assumed 1.0 until pytesdaqx's config
147
+ schema grows a place for them (StarCryo SQUID electronics).
148
+ launcher.py CLI entry point: `pytesscope --hardware ... --run-setup
149
+ ... --acquisition-config ... [--backend nidaq|fake]` for
150
+ live acquisition, or `pytesscope --file <acquisition>`
151
+ to replay a recorded zarr/hdf5 acquisition. Live vs. file
152
+ is chosen once at launch (a CLI arg), not a runtime GUI
153
+ toggle - a deliberate simplification now that the
154
+ config-driven design resolves channels/measurements from
155
+ YAML rather than a device combobox.
156
+
157
+ For live acquisition, also builds a
158
+ `pytesdaqx.control.coordinator.ControlCoordinator` (shared
159
+ HardwareRegistry with the board reader) and passes it to
160
+ `LiveSource`. `LiveSource` sequences a dIdV measurement's
161
+ PXI AC drive correctly: arm the AI task first, *then*
162
+ start the AO waveform, so the AI's `/ao/StartTrigger`
163
+ defines phase zero. `start_measurement_tes_ac` is a no-op
164
+ for measurements without a TES-AC waveform block, so this
165
+ is always safe to wire up even for background/threshold
166
+ runs.
167
+ ```
168
+
169
+ Qt runs headless via `QT_QPA_PLATFORM=offscreen` in tests and in this
170
+ (display-less) dev environment; `pytest-qt`'s `qtbot` fixture drives widget
171
+ interaction in tests without a real display.
172
+
173
+ ## Development
174
+
175
+ ```bash
176
+ pip install -e ".[dev]"
177
+ pytest
178
+ ```
179
+
180
+ Try the GUI shell against the fake backend (no hardware required), using
181
+ pytesdaqx's example configs:
182
+
183
+ ```bash
184
+ QT_QPA_PLATFORM=offscreen pytesscope \
185
+ --hardware ../pytesdaqx/examples/configuration/basic/hardware_fake.yaml \
186
+ --run-setup ../pytesdaqx/examples/configuration/basic/run_setup.yaml \
187
+ --acquisition-config ../pytesdaqx/examples/configuration/basic/acquisition_background.yaml \
188
+ --backend fake
189
+ ```
190
+
191
+ (Drop `QT_QPA_PLATFORM=offscreen` on a machine with a real display.)
192
+
193
+ ### Real-display runtime gotcha (Linux/conda)
194
+
195
+ On a conda-managed Linux machine, PySide6's `xcb` platform plugin (needed to
196
+ actually show a window - `offscreen` above doesn't need any of this) can
197
+ fail to start because `libxcb-cursor.so.1` isn't present, and/or because
198
+ Qt's shared libraries in the conda env aren't on the loader's search path.
199
+ If the GUI won't start outside `QT_QPA_PLATFORM=offscreen`, try:
200
+
201
+ ```bash
202
+ conda install -c conda-forge xcb-util-cursor
203
+ export LD_LIBRARY_PATH="$CONDA_PREFIX/lib:$LD_LIBRARY_PATH"
204
+ ```
205
+
206
+ (the `export` isn't persistent - add it to the env's activation script, or
207
+ your shell profile, to avoid repeating it every session).
@@ -0,0 +1,68 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "pytesscope"
7
+ version = "0.2.1"
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
+ # nidaqmx isn't a core pytesdaqx dependency - it's pytesdaqx's own optional
40
+ # "ni" extra, since plenty of pytesdaqx use doesn't touch NI hardware at
41
+ # all. pytesscope's live-acquisition path always needs it, though, so
42
+ # expose the same extra here rather than making every install pull in
43
+ # nidaqmx (and require the driver) just to replay a file or use --backend
44
+ # fake.
45
+ ni = [
46
+ "pytesdaqx[ni]",
47
+ ]
48
+
49
+ [project.scripts]
50
+ pytesscope = "pytesscope.launcher:main"
51
+
52
+ [tool.setuptools.packages.find]
53
+ where = ["."]
54
+ include = ["pytesscope*"]
55
+
56
+ [tool.setuptools.package-data]
57
+ pytesscope = ["gui/assets/*.png"]
58
+
59
+ [tool.pytest.ini_options]
60
+ testpaths = ["tests"]
61
+ python_files = ["test_*.py"]
62
+
63
+ [tool.ruff]
64
+ line-length = 100
65
+ target-version = "py311"
66
+
67
+ [tool.ruff.lint]
68
+ select = ["E9", "F", "I"]
@@ -0,0 +1,10 @@
1
+ """Live pulse viewer and dIdV scope for pytesdaqx."""
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+
5
+ try:
6
+ __version__ = version("pytesscope")
7
+ except PackageNotFoundError: # Source tree imported before installation.
8
+ __version__ = "0+unknown"
9
+
10
+ __all__ = ["__version__"]
@@ -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,71 @@
1
+ """Common data-source interface shared by live acquisition and file replay.
2
+
3
+ Both :class:`~pytesscope.acquisition.live_source.LiveSource` and
4
+ :class:`~pytesscope.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:`pytesscope.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(
38
+ self,
39
+ measurement: str,
40
+ *,
41
+ overrides: dict[str, Any] | None = None,
42
+ active_channels: list[str] | None = None,
43
+ ) -> None:
44
+ """Begin producing frames for the given measurement.
45
+
46
+ ``active_channels``, when given, scopes any step-level hardware
47
+ control (notably a dIdV measurement's TES AC drive) to just those
48
+ detector channels rather than every channel configured in
49
+ run_setup.yaml - e.g. so only the channel(s) selected in the GUI's
50
+ channel panel actually get their AC waveform turned on. ``None``
51
+ means "every run channel", matching the underlying pytesdaqx
52
+ default.
53
+ """
54
+ ...
55
+
56
+ def read_frame(self, timeout: float | None = 0.0) -> Frame | None:
57
+ """Return the next available frame.
58
+
59
+ ``None`` means "nothing available right now" for a live source
60
+ (safe to poll again later) or "exhausted" for a file source
61
+ (nothing more will ever come).
62
+ """
63
+ ...
64
+
65
+ def stop(self) -> None:
66
+ """Stop producing frames; safe to call again with :meth:`start`."""
67
+ ...
68
+
69
+ def close(self) -> None:
70
+ """Release underlying resources; the source cannot be reused after this."""
71
+ ...