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.
- pytesscope-0.2.1/PKG-INFO +235 -0
- pytesscope-0.2.1/README.md +207 -0
- pytesscope-0.2.1/pyproject.toml +68 -0
- pytesscope-0.2.1/pytesscope/__init__.py +10 -0
- pytesscope-0.2.1/pytesscope/acquisition/__init__.py +5 -0
- pytesscope-0.2.1/pytesscope/acquisition/base.py +71 -0
- pytesscope-0.2.1/pytesscope/acquisition/file_source.py +180 -0
- pytesscope-0.2.1/pytesscope/acquisition/live_source.py +379 -0
- pytesscope-0.2.1/pytesscope/analysis/__init__.py +4 -0
- pytesscope-0.2.1/pytesscope/analysis/config.py +82 -0
- pytesscope-0.2.1/pytesscope/analysis/didv_fit.py +243 -0
- pytesscope-0.2.1/pytesscope/analysis/normalize.py +90 -0
- pytesscope-0.2.1/pytesscope/analysis/pileup.py +135 -0
- pytesscope-0.2.1/pytesscope/analysis/pipeline.py +145 -0
- pytesscope-0.2.1/pytesscope/analysis/psd.py +38 -0
- pytesscope-0.2.1/pytesscope/analysis/running_average.py +104 -0
- pytesscope-0.2.1/pytesscope/board_reader.py +198 -0
- pytesscope-0.2.1/pytesscope/controller.py +245 -0
- pytesscope-0.2.1/pytesscope/file_board_reader.py +94 -0
- pytesscope-0.2.1/pytesscope/gui/__init__.py +0 -0
- pytesscope-0.2.1/pytesscope/gui/assets/checkmark.png +0 -0
- pytesscope-0.2.1/pytesscope/gui/didv_results_formatter.py +94 -0
- pytesscope-0.2.1/pytesscope/gui/main_window.py +298 -0
- pytesscope-0.2.1/pytesscope/gui/theme.py +153 -0
- pytesscope-0.2.1/pytesscope/gui/widgets/__init__.py +0 -0
- pytesscope-0.2.1/pytesscope/gui/widgets/channel_panel.py +199 -0
- pytesscope-0.2.1/pytesscope/gui/widgets/control_panel.py +116 -0
- pytesscope-0.2.1/pytesscope/gui/widgets/didv_fit_panel.py +138 -0
- pytesscope-0.2.1/pytesscope/gui/widgets/display_panel.py +206 -0
- pytesscope-0.2.1/pytesscope/gui/widgets/pileup_cuts_panel.py +57 -0
- pytesscope-0.2.1/pytesscope/gui/widgets/tools_panel.py +78 -0
- pytesscope-0.2.1/pytesscope/gui/widgets/tools_window.py +56 -0
- pytesscope-0.2.1/pytesscope/launcher.py +132 -0
- pytesscope-0.2.1/pytesscope.egg-info/PKG-INFO +235 -0
- pytesscope-0.2.1/pytesscope.egg-info/SOURCES.txt +60 -0
- pytesscope-0.2.1/pytesscope.egg-info/dependency_links.txt +1 -0
- pytesscope-0.2.1/pytesscope.egg-info/entry_points.txt +2 -0
- pytesscope-0.2.1/pytesscope.egg-info/requires.txt +15 -0
- pytesscope-0.2.1/pytesscope.egg-info/top_level.txt +1 -0
- pytesscope-0.2.1/setup.cfg +4 -0
- pytesscope-0.2.1/tests/test_analysis_config.py +49 -0
- pytesscope-0.2.1/tests/test_board_reader.py +263 -0
- pytesscope-0.2.1/tests/test_channel_panel.py +138 -0
- pytesscope-0.2.1/tests/test_control_panel.py +150 -0
- pytesscope-0.2.1/tests/test_controller.py +335 -0
- pytesscope-0.2.1/tests/test_didv_fit.py +178 -0
- pytesscope-0.2.1/tests/test_didv_fit_panel.py +76 -0
- pytesscope-0.2.1/tests/test_didv_results_formatter.py +63 -0
- pytesscope-0.2.1/tests/test_display_panel.py +144 -0
- pytesscope-0.2.1/tests/test_file_board_reader.py +142 -0
- pytesscope-0.2.1/tests/test_file_source.py +319 -0
- pytesscope-0.2.1/tests/test_launcher.py +257 -0
- pytesscope-0.2.1/tests/test_live_source.py +650 -0
- pytesscope-0.2.1/tests/test_main_window.py +343 -0
- pytesscope-0.2.1/tests/test_normalize.py +70 -0
- pytesscope-0.2.1/tests/test_pileup.py +36 -0
- pytesscope-0.2.1/tests/test_pileup_cuts_panel.py +34 -0
- pytesscope-0.2.1/tests/test_pipeline.py +134 -0
- pytesscope-0.2.1/tests/test_psd.py +24 -0
- pytesscope-0.2.1/tests/test_running_average.py +68 -0
- pytesscope-0.2.1/tests/test_tools_panel.py +61 -0
- 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,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
|
+
...
|