pytesscope 0.2.1__py3-none-any.whl
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/__init__.py +10 -0
- pytesscope/acquisition/__init__.py +5 -0
- pytesscope/acquisition/base.py +71 -0
- pytesscope/acquisition/file_source.py +180 -0
- pytesscope/acquisition/live_source.py +379 -0
- pytesscope/analysis/__init__.py +4 -0
- pytesscope/analysis/config.py +82 -0
- pytesscope/analysis/didv_fit.py +243 -0
- pytesscope/analysis/normalize.py +90 -0
- pytesscope/analysis/pileup.py +135 -0
- pytesscope/analysis/pipeline.py +145 -0
- pytesscope/analysis/psd.py +38 -0
- pytesscope/analysis/running_average.py +104 -0
- pytesscope/board_reader.py +198 -0
- pytesscope/controller.py +245 -0
- pytesscope/file_board_reader.py +94 -0
- pytesscope/gui/__init__.py +0 -0
- pytesscope/gui/assets/checkmark.png +0 -0
- pytesscope/gui/didv_results_formatter.py +94 -0
- pytesscope/gui/main_window.py +298 -0
- pytesscope/gui/theme.py +153 -0
- pytesscope/gui/widgets/__init__.py +0 -0
- pytesscope/gui/widgets/channel_panel.py +199 -0
- pytesscope/gui/widgets/control_panel.py +116 -0
- pytesscope/gui/widgets/didv_fit_panel.py +138 -0
- pytesscope/gui/widgets/display_panel.py +206 -0
- pytesscope/gui/widgets/pileup_cuts_panel.py +57 -0
- pytesscope/gui/widgets/tools_panel.py +78 -0
- pytesscope/gui/widgets/tools_window.py +56 -0
- pytesscope/launcher.py +132 -0
- pytesscope-0.2.1.dist-info/METADATA +235 -0
- pytesscope-0.2.1.dist-info/RECORD +35 -0
- pytesscope-0.2.1.dist-info/WHEEL +5 -0
- pytesscope-0.2.1.dist-info/entry_points.txt +2 -0
- pytesscope-0.2.1.dist-info/top_level.txt +1 -0
pytesscope/__init__.py
ADDED
|
@@ -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
|
+
...
|
|
@@ -0,0 +1,180 @@
|
|
|
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:`pytesscope.analysis` the same way as for
|
|
19
|
+
:class:`~pytesscope.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:`~pytesscope.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:`~pytesscope.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(
|
|
105
|
+
self,
|
|
106
|
+
measurement: str,
|
|
107
|
+
*,
|
|
108
|
+
overrides: dict[str, Any] | None = None,
|
|
109
|
+
active_channels: list[str] | None = None,
|
|
110
|
+
) -> None:
|
|
111
|
+
# File replay has no separate "arm" step; read_frame() drives the
|
|
112
|
+
# reader. Stop-then-Start resumes from the current position (pause/
|
|
113
|
+
# resume), but Start after running off the end has nothing to resume
|
|
114
|
+
# from, so it means "replay from the top" instead. active_channels
|
|
115
|
+
# is a live-acquisition concept (which channels get hardware control
|
|
116
|
+
# applied, e.g. dIdV AC drive) - meaningless for a recording that
|
|
117
|
+
# already exists, so it's accepted only for DataSource protocol
|
|
118
|
+
# compatibility.
|
|
119
|
+
del measurement, overrides, active_channels
|
|
120
|
+
if self._exhausted:
|
|
121
|
+
self.rewind()
|
|
122
|
+
|
|
123
|
+
def read_frame(self, timeout: float | None = 0.0) -> Frame | None:
|
|
124
|
+
del timeout # file reads are effectively instantaneous
|
|
125
|
+
if self._partition_length_samples is not None:
|
|
126
|
+
return self._read_partition_frame()
|
|
127
|
+
|
|
128
|
+
try:
|
|
129
|
+
data, info = self._reader.read_next(channels=self._channels, units="adc")
|
|
130
|
+
except StopIteration:
|
|
131
|
+
self._exhausted = True
|
|
132
|
+
return None
|
|
133
|
+
return self._frame_from_read(data, info)
|
|
134
|
+
|
|
135
|
+
def _read_partition_frame(self) -> Frame | None:
|
|
136
|
+
try:
|
|
137
|
+
data, info = self._reader.read_partition(
|
|
138
|
+
partition_start_index=self._partition_cursor,
|
|
139
|
+
partition_length_samples=self._partition_length_samples,
|
|
140
|
+
channels=self._channels,
|
|
141
|
+
units="adc",
|
|
142
|
+
)
|
|
143
|
+
except ValueError:
|
|
144
|
+
# read_partition() raises when a request runs past the end of
|
|
145
|
+
# the stream, rather than the StopIteration read_next() uses -
|
|
146
|
+
# both mean the same thing here: nothing more to read.
|
|
147
|
+
self._exhausted = True
|
|
148
|
+
return None
|
|
149
|
+
self._partition_cursor += self._partition_length_samples
|
|
150
|
+
return self._frame_from_read(data, info)
|
|
151
|
+
|
|
152
|
+
def _frame_from_read(self, data, info: dict[str, Any]) -> Frame:
|
|
153
|
+
detector_channels = list(info.get("detector_channels") or [])
|
|
154
|
+
calibration_by_name = {
|
|
155
|
+
row["detector_channel"]: row.get("adc_conversion_coefficients")
|
|
156
|
+
for row in info.get("channel_map") or []
|
|
157
|
+
if "detector_channel" in row
|
|
158
|
+
}
|
|
159
|
+
calibration_coeffs = [calibration_by_name.get(name) for name in detector_channels]
|
|
160
|
+
|
|
161
|
+
return Frame(
|
|
162
|
+
data=data,
|
|
163
|
+
detector_channels=detector_channels,
|
|
164
|
+
sample_rate_hz=float(info.get("sample_rate_hz") or self._reader.sample_rate_hz),
|
|
165
|
+
measurement=str(info.get("measurement") or info.get("measurement_type") or ""),
|
|
166
|
+
calibration_coeffs=calibration_coeffs,
|
|
167
|
+
metadata=info,
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
def rewind(self) -> None:
|
|
171
|
+
self._reader.rewind()
|
|
172
|
+
self._exhausted = False
|
|
173
|
+
self._partition_cursor = 0
|
|
174
|
+
|
|
175
|
+
def stop(self) -> None:
|
|
176
|
+
# Nothing to stop mid-stream; rewind() is the explicit reset.
|
|
177
|
+
pass
|
|
178
|
+
|
|
179
|
+
def close(self) -> None:
|
|
180
|
+
self._reader.close()
|
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
"""Live data source over pytesdaqx's DAQSession, with dIdV/PXI AC-drive sequencing."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import queue
|
|
6
|
+
import threading
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from pytesdaqx.acquisition.segment import Segment
|
|
10
|
+
from pytesdaqx.acquisition.session import DAQSession
|
|
11
|
+
from pytesdaqx.config.durations import parse_duration
|
|
12
|
+
|
|
13
|
+
from .base import Frame
|
|
14
|
+
|
|
15
|
+
try:
|
|
16
|
+
# nidaqmx is only actually installed on a machine with real NI hardware
|
|
17
|
+
# (pytesdaqx itself imports it lazily for the same reason - see
|
|
18
|
+
# NIDAQAIBackend); this module has to tolerate its absence too, since
|
|
19
|
+
# LiveSource is exercised against FakeAcquisitionBackend in tests/dev.
|
|
20
|
+
from nidaqmx.error_codes import DAQmxErrors
|
|
21
|
+
|
|
22
|
+
_SAMPLES_NOT_YET_AVAILABLE = DAQmxErrors.SAMPLES_NOT_YET_AVAILABLE
|
|
23
|
+
except ImportError:
|
|
24
|
+
_SAMPLES_NOT_YET_AVAILABLE = None
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class LiveSource:
|
|
28
|
+
"""Drives one measurement's live acquisition on a background thread.
|
|
29
|
+
|
|
30
|
+
A GUI polls :meth:`read_frame` (e.g. from a ``QTimer``) instead of
|
|
31
|
+
blocking its event loop; frames are dropped (not queued indefinitely)
|
|
32
|
+
if the consumer falls behind, matching a live-scope's "show the latest
|
|
33
|
+
trace" semantics rather than a recorder's "keep everything."
|
|
34
|
+
|
|
35
|
+
A dIdV measurement whose TES AC drive lives on a PXI AO device needs a
|
|
36
|
+
specific sequence (per pytesdaqx's ``ControlCoordinator`` docs): arm the
|
|
37
|
+
AI task *first*, then start the AO waveform, so the AI's
|
|
38
|
+
``/ao/StartTrigger`` defines phase zero. Passing a ``control_coordinator``
|
|
39
|
+
gets this ordering; ``ControlCoordinator.start_measurement_tes_ac`` is
|
|
40
|
+
itself a no-op for measurements with no TES-AC waveform block, so it's
|
|
41
|
+
always safe to pass one even when most selected measurements are plain
|
|
42
|
+
background/threshold acquisitions.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
def __init__(
|
|
46
|
+
self,
|
|
47
|
+
resolved_config: Any,
|
|
48
|
+
*,
|
|
49
|
+
backend: Any = None,
|
|
50
|
+
detector_channels: list[str] | None = None,
|
|
51
|
+
max_buffered_frames: int = 2,
|
|
52
|
+
control_coordinator: Any = None,
|
|
53
|
+
) -> None:
|
|
54
|
+
self._session = DAQSession.from_resolved_config(
|
|
55
|
+
resolved_config, backend=backend, detector_channels=detector_channels
|
|
56
|
+
)
|
|
57
|
+
self._coordinator = control_coordinator
|
|
58
|
+
self._frame_queue: queue.Queue[Frame] = queue.Queue(maxsize=max_buffered_frames)
|
|
59
|
+
self._stop_event = threading.Event()
|
|
60
|
+
self._thread: threading.Thread | None = None
|
|
61
|
+
self._trace_length_ms: float | None = None
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def is_running(self) -> bool:
|
|
65
|
+
return self._thread is not None and self._thread.is_alive()
|
|
66
|
+
|
|
67
|
+
def trace_length_supported(self, measurement: str) -> bool:
|
|
68
|
+
"""Whether :meth:`set_trace_length_ms` has any effect for this measurement.
|
|
69
|
+
|
|
70
|
+
Only true for a continuous-mode measurement. A finite/triggered
|
|
71
|
+
measurement (dIdV, threshold) has physical constraints on its trace
|
|
72
|
+
length - a PXI-locked dIdV trace needs an integer number of
|
|
73
|
+
signal-generator periods - that a plain "trace length in ms"
|
|
74
|
+
control could violate, so those stay fixed to whatever
|
|
75
|
+
acquisition.yaml specifies.
|
|
76
|
+
"""
|
|
77
|
+
adc_cfg = (self._session.measurements.get(measurement, {}) or {}).get("adc", {}) or {}
|
|
78
|
+
mode = str(adc_cfg.get("mode", "continuous")).strip().lower()
|
|
79
|
+
return mode == "continuous"
|
|
80
|
+
|
|
81
|
+
def set_trace_length_ms(self, trace_length_ms: float | None) -> None:
|
|
82
|
+
"""Override the read_block_duration used the next time :meth:`start` runs.
|
|
83
|
+
|
|
84
|
+
Only takes effect for a measurement where
|
|
85
|
+
:meth:`trace_length_supported` is true; silently has no effect
|
|
86
|
+
otherwise (rather than erroring, since the GUI already disables the
|
|
87
|
+
control in that case - this just guards against stale state).
|
|
88
|
+
"""
|
|
89
|
+
self._trace_length_ms = trace_length_ms
|
|
90
|
+
|
|
91
|
+
def start(
|
|
92
|
+
self,
|
|
93
|
+
measurement: str,
|
|
94
|
+
*,
|
|
95
|
+
overrides: dict[str, Any] | None = None,
|
|
96
|
+
active_channels: list[str] | None = None,
|
|
97
|
+
) -> None:
|
|
98
|
+
if self.is_running:
|
|
99
|
+
raise RuntimeError("LiveSource is already running; call stop() first")
|
|
100
|
+
|
|
101
|
+
effective_overrides = dict(overrides or {})
|
|
102
|
+
if self._trace_length_ms is not None and self.trace_length_supported(measurement):
|
|
103
|
+
adc_overrides = dict(effective_overrides.get("adc", {}))
|
|
104
|
+
adc_overrides.setdefault("read_block_duration", self._trace_length_ms * 1e-3)
|
|
105
|
+
effective_overrides["adc"] = adc_overrides
|
|
106
|
+
|
|
107
|
+
effective_overrides = self._expand_trace_periods(measurement, effective_overrides)
|
|
108
|
+
|
|
109
|
+
# Must be read before _force_continuous_pxi_ac rewrites adc.mode to
|
|
110
|
+
# "continuous" below - by then this measurement is indistinguishable
|
|
111
|
+
# from a plain continuous one, so discard-count has to be resolved
|
|
112
|
+
# against the pre-rewrite (declared) mode.
|
|
113
|
+
discard_initial_segments = self._discard_initial_segments(measurement, effective_overrides)
|
|
114
|
+
|
|
115
|
+
if self._uses_pxi_locked_ac(measurement, effective_overrides):
|
|
116
|
+
effective_overrides = self._force_continuous_pxi_ac(measurement, effective_overrides)
|
|
117
|
+
|
|
118
|
+
self._stop_event.clear()
|
|
119
|
+
self._thread = threading.Thread(
|
|
120
|
+
target=self._run,
|
|
121
|
+
args=(measurement, effective_overrides, discard_initial_segments, active_channels),
|
|
122
|
+
daemon=True,
|
|
123
|
+
)
|
|
124
|
+
self._thread.start()
|
|
125
|
+
|
|
126
|
+
def _expand_trace_periods(self, measurement: str, overrides: dict[str, Any]) -> dict[str, Any]:
|
|
127
|
+
"""Expand ``adc.trace_periods`` into a literal ``adc.trace_duration``.
|
|
128
|
+
|
|
129
|
+
NIDAQAIBackend only understands a literal trace_duration/n_samples
|
|
130
|
+
(see :meth:`trace_length_supported`'s docstring) - unlike a
|
|
131
|
+
pytesdaqx-run acquisition.yaml, nothing expands ``trace_periods``
|
|
132
|
+
before it reaches ``DAQSession.configure_measurement()`` here, so an
|
|
133
|
+
acquisition.yaml author would otherwise have to hand-compute
|
|
134
|
+
periods/frequency_hz themselves. A PXI-locked dIdV trace must hold
|
|
135
|
+
an exact whole number of waveform periods, hence the integer
|
|
136
|
+
requirement (matches pytesdaqx-run's own validation).
|
|
137
|
+
"""
|
|
138
|
+
base_adc = dict((self._session.measurements.get(measurement, {}) or {}).get("adc", {}) or {})
|
|
139
|
+
override_adc = dict(overrides.get("adc", {}) or {})
|
|
140
|
+
merged_adc = {**base_adc, **override_adc}
|
|
141
|
+
|
|
142
|
+
periods = merged_adc.get("trace_periods")
|
|
143
|
+
if periods is None:
|
|
144
|
+
return overrides
|
|
145
|
+
if any(key in merged_adc for key in ("trace_duration", "samples_per_trace", "n_samples")):
|
|
146
|
+
# An explicit duration/sample-count already given wins;
|
|
147
|
+
# trace_periods is only a convenience when nothing more
|
|
148
|
+
# specific was provided.
|
|
149
|
+
return overrides
|
|
150
|
+
|
|
151
|
+
try:
|
|
152
|
+
periods_int = int(periods)
|
|
153
|
+
except (TypeError, ValueError) as exc:
|
|
154
|
+
raise ValueError(
|
|
155
|
+
f"measurement {measurement!r}: adc.trace_periods must be a positive integer "
|
|
156
|
+
f"(PXI phase-locked dIdV needs a whole number of waveform periods per trace); "
|
|
157
|
+
f"got {periods!r}. Use e.g. trace_periods: 2 or 4."
|
|
158
|
+
) from exc
|
|
159
|
+
if periods_int != periods or periods_int <= 0:
|
|
160
|
+
raise ValueError(
|
|
161
|
+
f"measurement {measurement!r}: adc.trace_periods must be a positive integer; "
|
|
162
|
+
f"got {periods!r}. Use e.g. trace_periods: 2 or 4."
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
base_waveform = dict((self._session.measurements.get(measurement, {}) or {}).get("waveform", {}) or {})
|
|
166
|
+
override_waveform = dict(overrides.get("waveform", {}) or {})
|
|
167
|
+
frequency_hz = {**base_waveform, **override_waveform}.get("frequency_hz")
|
|
168
|
+
if not frequency_hz:
|
|
169
|
+
raise ValueError(
|
|
170
|
+
f"measurement {measurement!r}: adc.trace_periods requires waveform.frequency_hz "
|
|
171
|
+
"to compute a trace duration"
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
new_overrides = dict(overrides)
|
|
175
|
+
new_adc_overrides = dict(new_overrides.get("adc", {}))
|
|
176
|
+
new_adc_overrides["trace_duration"] = periods_int / float(frequency_hz)
|
|
177
|
+
new_overrides["adc"] = new_adc_overrides
|
|
178
|
+
return new_overrides
|
|
179
|
+
|
|
180
|
+
def _merged_adc(self, measurement: str, overrides: dict[str, Any] | None) -> dict[str, Any]:
|
|
181
|
+
base_adc = dict((self._session.measurements.get(measurement, {}) or {}).get("adc", {}) or {})
|
|
182
|
+
override_adc = dict((overrides or {}).get("adc", {}) or {})
|
|
183
|
+
return {**base_adc, **override_adc}
|
|
184
|
+
|
|
185
|
+
def _uses_pxi_locked_ac(self, measurement: str, overrides: dict[str, Any] | None) -> bool:
|
|
186
|
+
"""Whether this is a PXI phase-locked TES-AC (dIdV-style) measurement
|
|
187
|
+
that should run as one continuous, never-rearmed AI task rather than
|
|
188
|
+
a genuinely one-shot finite DAQmx task.
|
|
189
|
+
|
|
190
|
+
Matches pytesdaqx's own "preferred, phase-locked, exact-period dIdV
|
|
191
|
+
path" (see the pxi_loopback example set): for TES AC drive with no
|
|
192
|
+
explicit hardware trigger, pytesdaqx-run keeps one continuous AI task
|
|
193
|
+
running for the whole measurement and slices fixed-length,
|
|
194
|
+
whole-period reads from it, rather than physically retriggering a
|
|
195
|
+
finite task per trace. `type: didv`/`mode: finite` is the
|
|
196
|
+
schema-correct way to *declare* this measurement's data as
|
|
197
|
+
trace-shaped, but literally executing it as a true DAQmx finite task
|
|
198
|
+
per read (what this class used to do) means recreating the AO
|
|
199
|
+
square-wave task on every read to keep it phase-locked - slow/racy
|
|
200
|
+
enough that some traces get captured before the newly restarted AO
|
|
201
|
+
output has actually begun, i.e. exactly "on for some traces, off for
|
|
202
|
+
others" instead of a clean, consistent square wave.
|
|
203
|
+
"""
|
|
204
|
+
adc_cfg = self._merged_adc(measurement, overrides)
|
|
205
|
+
mode = str(adc_cfg.get("mode", "continuous")).strip().lower()
|
|
206
|
+
if mode != "finite":
|
|
207
|
+
return False
|
|
208
|
+
if adc_cfg.get("trigger"):
|
|
209
|
+
return False # a genuine hardware trigger still needs a real retrigger per read
|
|
210
|
+
measurement_cfg = self._session.measurements.get(measurement, {}) or {}
|
|
211
|
+
override_waveform = (overrides or {}).get("waveform")
|
|
212
|
+
return bool(measurement_cfg.get("waveform")) or bool(override_waveform)
|
|
213
|
+
|
|
214
|
+
def _force_continuous_pxi_ac(self, measurement: str, overrides: dict[str, Any]) -> dict[str, Any]:
|
|
215
|
+
"""Override adc.mode to continuous and translate the configured
|
|
216
|
+
trace length into read_block_duration, for a measurement
|
|
217
|
+
:meth:`_uses_pxi_locked_ac` selected.
|
|
218
|
+
|
|
219
|
+
The AC waveform then only needs to start once (see :meth:`_run`),
|
|
220
|
+
exactly like the already-working plain continuous/background path -
|
|
221
|
+
one continuous, PXI-reference-clock-coherent AI task, read repeatedly
|
|
222
|
+
without ever closing/reconfiguring it.
|
|
223
|
+
"""
|
|
224
|
+
adc_cfg = self._merged_adc(measurement, overrides)
|
|
225
|
+
duration = adc_cfg.get("trace_duration", adc_cfg.get("read_block_duration", adc_cfg.get("segment_duration")))
|
|
226
|
+
parsed = parse_duration(duration) if duration is not None else None
|
|
227
|
+
if not isinstance(parsed, (int, float)):
|
|
228
|
+
# No literal duration to translate (e.g. only samples_per_trace
|
|
229
|
+
# was given, or duration is a pytesdaqx sentinel like "auto") -
|
|
230
|
+
# leave mode/rearm behavior alone rather than guess.
|
|
231
|
+
return overrides
|
|
232
|
+
|
|
233
|
+
new_overrides = dict(overrides)
|
|
234
|
+
new_adc_overrides = dict(new_overrides.get("adc", {}))
|
|
235
|
+
new_adc_overrides["mode"] = "continuous"
|
|
236
|
+
new_adc_overrides["read_block_duration"] = float(parsed)
|
|
237
|
+
new_overrides["adc"] = new_adc_overrides
|
|
238
|
+
return new_overrides
|
|
239
|
+
|
|
240
|
+
def _discard_initial_segments(self, measurement: str, overrides: dict[str, Any] | None) -> int:
|
|
241
|
+
"""How many segments to silently drop right after starting a PXI
|
|
242
|
+
phase-locked AC measurement, before the first one reaches the GUI.
|
|
243
|
+
|
|
244
|
+
Matches pytesdaqx-run's own default (``discard_initial_segments: 1``,
|
|
245
|
+
see ``yaml_run.py``'s ``_run_pxi_phase_locked_stream``): the
|
|
246
|
+
PXIe-4322's AO task regenerates its waveform buffer starting from
|
|
247
|
+
whatever DC value was held before the wave task started, so the very
|
|
248
|
+
first segment after :meth:`start_measurement_tes_ac` can capture that
|
|
249
|
+
transition rather than a clean, settled period of the square wave -
|
|
250
|
+
a real hardware characteristic of the AO's regenerative buffer, not
|
|
251
|
+
a software startup race, so it doesn't go away by tightening
|
|
252
|
+
sequencing. Configurable via ``adc.discard_initial_segments`` or
|
|
253
|
+
``waveform.discard_initial_segments``, like pytesdaqx-run.
|
|
254
|
+
"""
|
|
255
|
+
if not self._uses_pxi_locked_ac(measurement, overrides):
|
|
256
|
+
return 0
|
|
257
|
+
adc_cfg = self._merged_adc(measurement, overrides)
|
|
258
|
+
base_waveform = dict((self._session.measurements.get(measurement, {}) or {}).get("waveform", {}) or {})
|
|
259
|
+
override_waveform = dict((overrides or {}).get("waveform", {}) or {})
|
|
260
|
+
waveform_cfg = {**base_waveform, **override_waveform}
|
|
261
|
+
|
|
262
|
+
value = adc_cfg.get("discard_initial_segments", waveform_cfg.get("discard_initial_segments", 1))
|
|
263
|
+
return max(0, int(value))
|
|
264
|
+
|
|
265
|
+
def _rearm_each_segment(self, measurement: str, overrides: dict[str, Any] | None) -> bool:
|
|
266
|
+
"""Whether the DAQmx task must be closed and reconfigured before
|
|
267
|
+
every read, not just the first.
|
|
268
|
+
|
|
269
|
+
A `mode: continuous` task keeps running and can be read repeatedly
|
|
270
|
+
(read_segment() just pulls the next chunk). A `mode: finite` task is
|
|
271
|
+
genuinely one-shot: it stops for good after its first n_samples, and
|
|
272
|
+
a second read on it raises a DAQmx "read beyond final sample" error.
|
|
273
|
+
Matches pytesdaqx-run's own `rearm_each_segment` rule (yaml_run.py)
|
|
274
|
+
for the same reason. :meth:`_uses_pxi_locked_ac` measurements never
|
|
275
|
+
reach here in `finite` mode - :meth:`start` already rewrote their
|
|
276
|
+
overrides to `continuous` - so this only fires for a genuinely
|
|
277
|
+
one-shot/triggered acquisition with no PXI AC waveform involved.
|
|
278
|
+
"""
|
|
279
|
+
mode = str(self._merged_adc(measurement, overrides).get("mode", "continuous")).strip().lower()
|
|
280
|
+
return mode in {"finite", "triggered"}
|
|
281
|
+
|
|
282
|
+
def _run(
|
|
283
|
+
self,
|
|
284
|
+
measurement: str,
|
|
285
|
+
overrides: dict[str, Any] | None,
|
|
286
|
+
discard_remaining: int = 0,
|
|
287
|
+
active_channels: list[str] | None = None,
|
|
288
|
+
) -> None:
|
|
289
|
+
# active_channels scopes step controls/TES-AC drive to just these
|
|
290
|
+
# detector channels (e.g. the GUI's checked channel selection)
|
|
291
|
+
# instead of every channel configured in run_setup.yaml -
|
|
292
|
+
# ControlCoordinator.resolve_detector_channels(None) otherwise
|
|
293
|
+
# defaults to all of them.
|
|
294
|
+
step = {"measurement": measurement, "active_channels": active_channels}
|
|
295
|
+
rearm_each_segment = self._rearm_each_segment(measurement, overrides)
|
|
296
|
+
has_hardware_trigger = bool(self._merged_adc(measurement, overrides).get("trigger"))
|
|
297
|
+
try:
|
|
298
|
+
if self._coordinator is not None:
|
|
299
|
+
# Apply any step controls now, but defer TES AC drive until
|
|
300
|
+
# after the AI task is armed (see class docstring).
|
|
301
|
+
self._coordinator.prepare_measurement(step, start_tes_ac=False)
|
|
302
|
+
|
|
303
|
+
self._session.configure_measurement(measurement, overrides=overrides)
|
|
304
|
+
|
|
305
|
+
if self._coordinator is not None:
|
|
306
|
+
self._coordinator.start_measurement_tes_ac(step)
|
|
307
|
+
|
|
308
|
+
while not self._stop_event.is_set():
|
|
309
|
+
try:
|
|
310
|
+
segment = self._session.backend.read_segment()
|
|
311
|
+
except Exception as exc:
|
|
312
|
+
# A self-/hardware-triggered acquisition (e.g. a
|
|
313
|
+
# threshold measurement) genuinely may not see its
|
|
314
|
+
# trigger condition within one read's timeout - that's
|
|
315
|
+
# normal, recurring, expected behavior while waiting for
|
|
316
|
+
# an event, not a fault. DAQmx's own SAMPLES_NOT_YET_
|
|
317
|
+
# AVAILABLE is precisely "the read's timeout elapsed
|
|
318
|
+
# before enough samples existed" (as opposed to, say, a
|
|
319
|
+
# buffer overrun or a disconnected device), so retry the
|
|
320
|
+
# still-armed task's read rather than crashing the
|
|
321
|
+
# acquisition thread outright. Anything else - or a
|
|
322
|
+
# measurement with no trigger at all, where a read
|
|
323
|
+
# timeout would mean something is actually wrong -
|
|
324
|
+
# still propagates.
|
|
325
|
+
if (
|
|
326
|
+
has_hardware_trigger
|
|
327
|
+
and _SAMPLES_NOT_YET_AVAILABLE is not None
|
|
328
|
+
and getattr(exc, "error_code", None) == _SAMPLES_NOT_YET_AVAILABLE
|
|
329
|
+
):
|
|
330
|
+
continue
|
|
331
|
+
raise
|
|
332
|
+
if discard_remaining > 0:
|
|
333
|
+
discard_remaining -= 1
|
|
334
|
+
else:
|
|
335
|
+
self._on_segment(segment)
|
|
336
|
+
if rearm_each_segment and not self._stop_event.is_set():
|
|
337
|
+
self._session.backend.close()
|
|
338
|
+
self._session.configure_measurement(measurement, overrides=overrides)
|
|
339
|
+
finally:
|
|
340
|
+
if self._coordinator is not None:
|
|
341
|
+
self._coordinator.cleanup_measurement(step)
|
|
342
|
+
|
|
343
|
+
def _on_segment(self, segment: Segment) -> None:
|
|
344
|
+
frame = Frame(
|
|
345
|
+
data=segment.data,
|
|
346
|
+
detector_channels=segment.detector_channels,
|
|
347
|
+
sample_rate_hz=segment.sample_rate_hz or 0.0,
|
|
348
|
+
measurement=segment.measurement,
|
|
349
|
+
calibration_coeffs=segment.metadata.get("adc_conversion_coefficients"),
|
|
350
|
+
metadata=segment.metadata,
|
|
351
|
+
)
|
|
352
|
+
try:
|
|
353
|
+
self._frame_queue.put_nowait(frame)
|
|
354
|
+
except queue.Full:
|
|
355
|
+
try:
|
|
356
|
+
self._frame_queue.get_nowait()
|
|
357
|
+
except queue.Empty:
|
|
358
|
+
pass
|
|
359
|
+
self._frame_queue.put_nowait(frame)
|
|
360
|
+
|
|
361
|
+
def read_frame(self, timeout: float | None = 0.0) -> Frame | None:
|
|
362
|
+
try:
|
|
363
|
+
if timeout is None:
|
|
364
|
+
return self._frame_queue.get()
|
|
365
|
+
if timeout <= 0:
|
|
366
|
+
return self._frame_queue.get_nowait()
|
|
367
|
+
return self._frame_queue.get(timeout=timeout)
|
|
368
|
+
except queue.Empty:
|
|
369
|
+
return None
|
|
370
|
+
|
|
371
|
+
def stop(self) -> None:
|
|
372
|
+
self._stop_event.set()
|
|
373
|
+
if self._thread is not None:
|
|
374
|
+
self._thread.join(timeout=5.0)
|
|
375
|
+
self._thread = None
|
|
376
|
+
|
|
377
|
+
def close(self) -> None:
|
|
378
|
+
self.stop()
|
|
379
|
+
self._session.close()
|