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 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,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
+ ...
@@ -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()
@@ -0,0 +1,4 @@
1
+ from .config import AnalysisConfig
2
+ from .pipeline import AnalysisPipeline, AnalysisResult
3
+
4
+ __all__ = ["AnalysisConfig", "AnalysisPipeline", "AnalysisResult"]