pytesdaqx-scope 0.1.0__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.
- pytesdaqx_scope/__init__.py +1 -0
- pytesdaqx_scope/acquisition/__init__.py +5 -0
- pytesdaqx_scope/acquisition/base.py +56 -0
- pytesdaqx_scope/acquisition/file_source.py +170 -0
- pytesdaqx_scope/acquisition/live_source.py +149 -0
- pytesdaqx_scope/analysis/__init__.py +4 -0
- pytesdaqx_scope/analysis/config.py +82 -0
- pytesdaqx_scope/analysis/didv_fit.py +207 -0
- pytesdaqx_scope/analysis/normalize.py +90 -0
- pytesdaqx_scope/analysis/pileup.py +135 -0
- pytesdaqx_scope/analysis/pipeline.py +135 -0
- pytesdaqx_scope/analysis/psd.py +38 -0
- pytesdaqx_scope/analysis/running_average.py +104 -0
- pytesdaqx_scope/board_reader.py +147 -0
- pytesdaqx_scope/controller.py +208 -0
- pytesdaqx_scope/file_board_reader.py +94 -0
- pytesdaqx_scope/gui/__init__.py +0 -0
- pytesdaqx_scope/gui/assets/checkmark.png +0 -0
- pytesdaqx_scope/gui/didv_results_formatter.py +94 -0
- pytesdaqx_scope/gui/main_window.py +280 -0
- pytesdaqx_scope/gui/theme.py +153 -0
- pytesdaqx_scope/gui/widgets/__init__.py +0 -0
- pytesdaqx_scope/gui/widgets/channel_panel.py +199 -0
- pytesdaqx_scope/gui/widgets/control_panel.py +116 -0
- pytesdaqx_scope/gui/widgets/didv_fit_panel.py +138 -0
- pytesdaqx_scope/gui/widgets/display_panel.py +206 -0
- pytesdaqx_scope/gui/widgets/pileup_cuts_panel.py +57 -0
- pytesdaqx_scope/gui/widgets/tools_panel.py +78 -0
- pytesdaqx_scope/gui/widgets/tools_window.py +56 -0
- pytesdaqx_scope/launcher.py +123 -0
- pytesdaqx_scope-0.1.0.dist-info/METADATA +197 -0
- pytesdaqx_scope-0.1.0.dist-info/RECORD +35 -0
- pytesdaqx_scope-0.1.0.dist-info/WHEEL +5 -0
- pytesdaqx_scope-0.1.0.dist-info/entry_points.txt +2 -0
- pytesdaqx_scope-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Common data-source interface shared by live acquisition and file replay.
|
|
2
|
+
|
|
3
|
+
Both :class:`~pytesdaqx_scope.acquisition.live_source.LiveSource` and
|
|
4
|
+
:class:`~pytesdaqx_scope.acquisition.file_source.FileSource` produce
|
|
5
|
+
:class:`Frame` objects, so the controller and analysis pipeline never need
|
|
6
|
+
to know which one produced a given frame.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from typing import Any, Protocol
|
|
13
|
+
|
|
14
|
+
import numpy as np
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class Frame:
|
|
19
|
+
"""One event/read-block of raw ADC-code traces, ready for analysis.
|
|
20
|
+
|
|
21
|
+
``data`` is always in raw ADC codes (never pre-calibrated), so
|
|
22
|
+
:meth:`pytesdaqx_scope.analysis.pipeline.AnalysisPipeline.process` can
|
|
23
|
+
treat live and file-replay frames identically via ``calibration_coeffs``.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
data: np.ndarray # [nb_channels, nb_samples]
|
|
27
|
+
detector_channels: list[str]
|
|
28
|
+
sample_rate_hz: float
|
|
29
|
+
measurement: str
|
|
30
|
+
calibration_coeffs: list[list[float] | None] | None = None
|
|
31
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class DataSource(Protocol):
|
|
35
|
+
"""Common interface for live and file-replay data sources."""
|
|
36
|
+
|
|
37
|
+
def start(self, measurement: str, *, overrides: dict[str, Any] | None = None) -> None:
|
|
38
|
+
"""Begin producing frames for the given measurement."""
|
|
39
|
+
...
|
|
40
|
+
|
|
41
|
+
def read_frame(self, timeout: float | None = 0.0) -> Frame | None:
|
|
42
|
+
"""Return the next available frame.
|
|
43
|
+
|
|
44
|
+
``None`` means "nothing available right now" for a live source
|
|
45
|
+
(safe to poll again later) or "exhausted" for a file source
|
|
46
|
+
(nothing more will ever come).
|
|
47
|
+
"""
|
|
48
|
+
...
|
|
49
|
+
|
|
50
|
+
def stop(self) -> None:
|
|
51
|
+
"""Stop producing frames; safe to call again with :meth:`start`."""
|
|
52
|
+
...
|
|
53
|
+
|
|
54
|
+
def close(self) -> None:
|
|
55
|
+
"""Release underlying resources; the source cannot be reused after this."""
|
|
56
|
+
...
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""File-replay data source over pytesdaqx's StreamReader (zarr/hdf5)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from pytesdaqx.io import StreamReader
|
|
9
|
+
|
|
10
|
+
from .base import Frame
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class FileSource:
|
|
14
|
+
"""Steps sequentially through a recorded acquisition, one record at a time.
|
|
15
|
+
|
|
16
|
+
Wraps :class:`pytesdaqx.io.StreamReader`, always requesting raw ADC
|
|
17
|
+
codes (``units="adc"``) so calibration flows through
|
|
18
|
+
:mod:`pytesdaqx_scope.analysis` the same way as for
|
|
19
|
+
:class:`~pytesdaqx_scope.acquisition.live_source.LiveSource` frames.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
def __init__(
|
|
23
|
+
self,
|
|
24
|
+
path: str | Path,
|
|
25
|
+
*,
|
|
26
|
+
streams: str | int | list[str | int] | None = None,
|
|
27
|
+
measurement_types: str | list[str] | None = None,
|
|
28
|
+
channels: list[str] | None = None,
|
|
29
|
+
) -> None:
|
|
30
|
+
self._reader = StreamReader(
|
|
31
|
+
path,
|
|
32
|
+
streams=streams,
|
|
33
|
+
measurement_types=measurement_types,
|
|
34
|
+
restricted="all",
|
|
35
|
+
)
|
|
36
|
+
self._channels = channels
|
|
37
|
+
self._exhausted = False
|
|
38
|
+
#: Samples per read when in partition mode; None means "use the
|
|
39
|
+
#: file's own read-block/trace boundaries via read_next()" (default).
|
|
40
|
+
self._partition_length_samples: int | None = None
|
|
41
|
+
self._partition_cursor = 0
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def is_exhausted(self) -> bool:
|
|
45
|
+
"""True once :meth:`read_frame` has hit the end of the recording.
|
|
46
|
+
|
|
47
|
+
``read_frame`` returning ``None`` is ambiguous on its own - for
|
|
48
|
+
:class:`~pytesdaqx_scope.acquisition.live_source.LiveSource` it just
|
|
49
|
+
means "nothing new yet, poll again"; for a file it means "nothing
|
|
50
|
+
more will ever come". Callers (e.g. the GUI's poll loop) should check
|
|
51
|
+
this after a ``None`` read to tell the two apart, rather than
|
|
52
|
+
silently polling forever with no feedback.
|
|
53
|
+
"""
|
|
54
|
+
return self._exhausted
|
|
55
|
+
|
|
56
|
+
def peek_metadata(self) -> dict[str, Any]:
|
|
57
|
+
"""Static resource metadata (detector_channels, measurement, ...).
|
|
58
|
+
|
|
59
|
+
Uses a separate, throwaway reader under the hood, so calling this
|
|
60
|
+
does not disturb :meth:`read_frame`'s sequential position.
|
|
61
|
+
"""
|
|
62
|
+
return self._reader.get_metadata()
|
|
63
|
+
|
|
64
|
+
def trace_length_supported(self, measurement: str | None = None) -> bool:
|
|
65
|
+
"""Whether :meth:`set_trace_length_ms` has any effect on this file.
|
|
66
|
+
|
|
67
|
+
Only true for a native continuous Zarr stream (``raw_shape_model ==
|
|
68
|
+
"channel_sample"``): HDF5 recordings and finite/trace Zarr streams
|
|
69
|
+
(dIdV, threshold) have a fixed record length baked into the file
|
|
70
|
+
itself, so there is nothing to adjust. ``measurement`` is accepted
|
|
71
|
+
(and ignored) only so callers can treat this and
|
|
72
|
+
``LiveSource.trace_length_supported`` the same way - a single file
|
|
73
|
+
source is fixed to whichever stream(s) it was opened with.
|
|
74
|
+
"""
|
|
75
|
+
del measurement
|
|
76
|
+
return self._reader.raw_shape_model == "channel_sample"
|
|
77
|
+
|
|
78
|
+
def set_trace_length_ms(self, trace_length_ms: float | None) -> None:
|
|
79
|
+
"""Set the partition length read per :meth:`read_frame` call, in ms.
|
|
80
|
+
|
|
81
|
+
Only meaningful when :meth:`trace_length_supported` is true;
|
|
82
|
+
otherwise this is a no-op. ``None`` reverts to the file's own
|
|
83
|
+
read-block boundaries (:meth:`read_next`). Resets the partition
|
|
84
|
+
read position to the start.
|
|
85
|
+
"""
|
|
86
|
+
if not self.trace_length_supported():
|
|
87
|
+
return
|
|
88
|
+
if trace_length_ms is None:
|
|
89
|
+
self._partition_length_samples = None
|
|
90
|
+
else:
|
|
91
|
+
samples = round(trace_length_ms * 1e-3 * self._reader.sample_rate_hz)
|
|
92
|
+
self._partition_length_samples = max(1, samples)
|
|
93
|
+
self._partition_cursor = 0
|
|
94
|
+
|
|
95
|
+
def get_detector_settings(self) -> dict[str, dict[str, Any]]:
|
|
96
|
+
"""Per-channel stored settings: close_loop_norm, preamp_gain,
|
|
97
|
+
signal_gen_current/frequency, tes_bias, etc. - used by
|
|
98
|
+
:class:`~pytesdaqx_scope.file_board_reader.FileBoardReader` in place
|
|
99
|
+
of a live board read. Like :meth:`peek_metadata`, this doesn't
|
|
100
|
+
disturb :meth:`read_frame`'s sequential position.
|
|
101
|
+
"""
|
|
102
|
+
return self._reader.get_detector_settings()
|
|
103
|
+
|
|
104
|
+
def start(self, measurement: str, *, overrides: dict[str, Any] | None = None) -> None:
|
|
105
|
+
# File replay has no separate "arm" step; read_frame() drives the
|
|
106
|
+
# reader. Stop-then-Start resumes from the current position (pause/
|
|
107
|
+
# resume), but Start after running off the end has nothing to resume
|
|
108
|
+
# from, so it means "replay from the top" instead.
|
|
109
|
+
del measurement, overrides
|
|
110
|
+
if self._exhausted:
|
|
111
|
+
self.rewind()
|
|
112
|
+
|
|
113
|
+
def read_frame(self, timeout: float | None = 0.0) -> Frame | None:
|
|
114
|
+
del timeout # file reads are effectively instantaneous
|
|
115
|
+
if self._partition_length_samples is not None:
|
|
116
|
+
return self._read_partition_frame()
|
|
117
|
+
|
|
118
|
+
try:
|
|
119
|
+
data, info = self._reader.read_next(channels=self._channels, units="adc")
|
|
120
|
+
except StopIteration:
|
|
121
|
+
self._exhausted = True
|
|
122
|
+
return None
|
|
123
|
+
return self._frame_from_read(data, info)
|
|
124
|
+
|
|
125
|
+
def _read_partition_frame(self) -> Frame | None:
|
|
126
|
+
try:
|
|
127
|
+
data, info = self._reader.read_partition(
|
|
128
|
+
partition_start_index=self._partition_cursor,
|
|
129
|
+
partition_length_samples=self._partition_length_samples,
|
|
130
|
+
channels=self._channels,
|
|
131
|
+
units="adc",
|
|
132
|
+
)
|
|
133
|
+
except ValueError:
|
|
134
|
+
# read_partition() raises when a request runs past the end of
|
|
135
|
+
# the stream, rather than the StopIteration read_next() uses -
|
|
136
|
+
# both mean the same thing here: nothing more to read.
|
|
137
|
+
self._exhausted = True
|
|
138
|
+
return None
|
|
139
|
+
self._partition_cursor += self._partition_length_samples
|
|
140
|
+
return self._frame_from_read(data, info)
|
|
141
|
+
|
|
142
|
+
def _frame_from_read(self, data, info: dict[str, Any]) -> Frame:
|
|
143
|
+
detector_channels = list(info.get("detector_channels") or [])
|
|
144
|
+
calibration_by_name = {
|
|
145
|
+
row["detector_channel"]: row.get("adc_conversion_coefficients")
|
|
146
|
+
for row in info.get("channel_map") or []
|
|
147
|
+
if "detector_channel" in row
|
|
148
|
+
}
|
|
149
|
+
calibration_coeffs = [calibration_by_name.get(name) for name in detector_channels]
|
|
150
|
+
|
|
151
|
+
return Frame(
|
|
152
|
+
data=data,
|
|
153
|
+
detector_channels=detector_channels,
|
|
154
|
+
sample_rate_hz=float(info.get("sample_rate_hz") or self._reader.sample_rate_hz),
|
|
155
|
+
measurement=str(info.get("measurement") or info.get("measurement_type") or ""),
|
|
156
|
+
calibration_coeffs=calibration_coeffs,
|
|
157
|
+
metadata=info,
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
def rewind(self) -> None:
|
|
161
|
+
self._reader.rewind()
|
|
162
|
+
self._exhausted = False
|
|
163
|
+
self._partition_cursor = 0
|
|
164
|
+
|
|
165
|
+
def stop(self) -> None:
|
|
166
|
+
# Nothing to stop mid-stream; rewind() is the explicit reset.
|
|
167
|
+
pass
|
|
168
|
+
|
|
169
|
+
def close(self) -> None:
|
|
170
|
+
self._reader.close()
|
|
@@ -0,0 +1,149 @@
|
|
|
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
|
+
|
|
12
|
+
from .base import Frame
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class LiveSource:
|
|
16
|
+
"""Drives one measurement's live acquisition on a background thread.
|
|
17
|
+
|
|
18
|
+
A GUI polls :meth:`read_frame` (e.g. from a ``QTimer``) instead of
|
|
19
|
+
blocking its event loop; frames are dropped (not queued indefinitely)
|
|
20
|
+
if the consumer falls behind, matching a live-scope's "show the latest
|
|
21
|
+
trace" semantics rather than a recorder's "keep everything."
|
|
22
|
+
|
|
23
|
+
A dIdV measurement whose TES AC drive lives on a PXI AO device needs a
|
|
24
|
+
specific sequence (per pytesdaqx's ``ControlCoordinator`` docs): arm the
|
|
25
|
+
AI task *first*, then start the AO waveform, so the AI's
|
|
26
|
+
``/ao/StartTrigger`` defines phase zero. Passing a ``control_coordinator``
|
|
27
|
+
gets this ordering; ``ControlCoordinator.start_measurement_tes_ac`` is
|
|
28
|
+
itself a no-op for measurements with no TES-AC waveform block, so it's
|
|
29
|
+
always safe to pass one even when most selected measurements are plain
|
|
30
|
+
background/threshold acquisitions.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
def __init__(
|
|
34
|
+
self,
|
|
35
|
+
resolved_config: Any,
|
|
36
|
+
*,
|
|
37
|
+
backend: Any = None,
|
|
38
|
+
detector_channels: list[str] | None = None,
|
|
39
|
+
max_buffered_frames: int = 2,
|
|
40
|
+
control_coordinator: Any = None,
|
|
41
|
+
) -> None:
|
|
42
|
+
self._session = DAQSession.from_resolved_config(
|
|
43
|
+
resolved_config, backend=backend, detector_channels=detector_channels
|
|
44
|
+
)
|
|
45
|
+
self._coordinator = control_coordinator
|
|
46
|
+
self._frame_queue: queue.Queue[Frame] = queue.Queue(maxsize=max_buffered_frames)
|
|
47
|
+
self._stop_event = threading.Event()
|
|
48
|
+
self._thread: threading.Thread | None = None
|
|
49
|
+
self._trace_length_ms: float | None = None
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def is_running(self) -> bool:
|
|
53
|
+
return self._thread is not None and self._thread.is_alive()
|
|
54
|
+
|
|
55
|
+
def trace_length_supported(self, measurement: str) -> bool:
|
|
56
|
+
"""Whether :meth:`set_trace_length_ms` has any effect for this measurement.
|
|
57
|
+
|
|
58
|
+
Only true for a continuous-mode measurement. A finite/triggered
|
|
59
|
+
measurement (dIdV, threshold) has physical constraints on its trace
|
|
60
|
+
length - a PXI-locked dIdV trace needs an integer number of
|
|
61
|
+
signal-generator periods - that a plain "trace length in ms"
|
|
62
|
+
control could violate, so those stay fixed to whatever
|
|
63
|
+
acquisition.yaml specifies.
|
|
64
|
+
"""
|
|
65
|
+
adc_cfg = (self._session.measurements.get(measurement, {}) or {}).get("adc", {}) or {}
|
|
66
|
+
mode = str(adc_cfg.get("mode", "continuous")).strip().lower()
|
|
67
|
+
return mode == "continuous"
|
|
68
|
+
|
|
69
|
+
def set_trace_length_ms(self, trace_length_ms: float | None) -> None:
|
|
70
|
+
"""Override the read_block_duration used the next time :meth:`start` runs.
|
|
71
|
+
|
|
72
|
+
Only takes effect for a measurement where
|
|
73
|
+
:meth:`trace_length_supported` is true; silently has no effect
|
|
74
|
+
otherwise (rather than erroring, since the GUI already disables the
|
|
75
|
+
control in that case - this just guards against stale state).
|
|
76
|
+
"""
|
|
77
|
+
self._trace_length_ms = trace_length_ms
|
|
78
|
+
|
|
79
|
+
def start(self, measurement: str, *, overrides: dict[str, Any] | None = None) -> None:
|
|
80
|
+
if self.is_running:
|
|
81
|
+
raise RuntimeError("LiveSource is already running; call stop() first")
|
|
82
|
+
|
|
83
|
+
effective_overrides = dict(overrides or {})
|
|
84
|
+
if self._trace_length_ms is not None and self.trace_length_supported(measurement):
|
|
85
|
+
adc_overrides = dict(effective_overrides.get("adc", {}))
|
|
86
|
+
adc_overrides.setdefault("read_block_duration", self._trace_length_ms * 1e-3)
|
|
87
|
+
effective_overrides["adc"] = adc_overrides
|
|
88
|
+
|
|
89
|
+
self._stop_event.clear()
|
|
90
|
+
self._thread = threading.Thread(target=self._run, args=(measurement, effective_overrides), daemon=True)
|
|
91
|
+
self._thread.start()
|
|
92
|
+
|
|
93
|
+
def _run(self, measurement: str, overrides: dict[str, Any] | None) -> None:
|
|
94
|
+
step = {"measurement": measurement}
|
|
95
|
+
try:
|
|
96
|
+
if self._coordinator is not None:
|
|
97
|
+
# Apply any step controls now, but defer TES AC drive until
|
|
98
|
+
# after the AI task is armed (see class docstring).
|
|
99
|
+
self._coordinator.prepare_measurement(step, start_tes_ac=False)
|
|
100
|
+
|
|
101
|
+
self._session.configure_measurement(measurement, overrides=overrides)
|
|
102
|
+
|
|
103
|
+
if self._coordinator is not None:
|
|
104
|
+
self._coordinator.start_measurement_tes_ac(step)
|
|
105
|
+
|
|
106
|
+
while not self._stop_event.is_set():
|
|
107
|
+
segment = self._session.backend.read_segment()
|
|
108
|
+
self._on_segment(segment)
|
|
109
|
+
finally:
|
|
110
|
+
if self._coordinator is not None:
|
|
111
|
+
self._coordinator.cleanup_measurement(step)
|
|
112
|
+
|
|
113
|
+
def _on_segment(self, segment: Segment) -> None:
|
|
114
|
+
frame = Frame(
|
|
115
|
+
data=segment.data,
|
|
116
|
+
detector_channels=segment.detector_channels,
|
|
117
|
+
sample_rate_hz=segment.sample_rate_hz or 0.0,
|
|
118
|
+
measurement=segment.measurement,
|
|
119
|
+
calibration_coeffs=segment.metadata.get("adc_conversion_coefficients"),
|
|
120
|
+
metadata=segment.metadata,
|
|
121
|
+
)
|
|
122
|
+
try:
|
|
123
|
+
self._frame_queue.put_nowait(frame)
|
|
124
|
+
except queue.Full:
|
|
125
|
+
try:
|
|
126
|
+
self._frame_queue.get_nowait()
|
|
127
|
+
except queue.Empty:
|
|
128
|
+
pass
|
|
129
|
+
self._frame_queue.put_nowait(frame)
|
|
130
|
+
|
|
131
|
+
def read_frame(self, timeout: float | None = 0.0) -> Frame | None:
|
|
132
|
+
try:
|
|
133
|
+
if timeout is None:
|
|
134
|
+
return self._frame_queue.get()
|
|
135
|
+
if timeout <= 0:
|
|
136
|
+
return self._frame_queue.get_nowait()
|
|
137
|
+
return self._frame_queue.get(timeout=timeout)
|
|
138
|
+
except queue.Empty:
|
|
139
|
+
return None
|
|
140
|
+
|
|
141
|
+
def stop(self) -> None:
|
|
142
|
+
self._stop_event.set()
|
|
143
|
+
if self._thread is not None:
|
|
144
|
+
self._thread.join(timeout=5.0)
|
|
145
|
+
self._thread = None
|
|
146
|
+
|
|
147
|
+
def close(self) -> None:
|
|
148
|
+
self.stop()
|
|
149
|
+
self._session.close()
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Typed analysis configuration.
|
|
2
|
+
|
|
3
|
+
Replaces the old ``pytesdaq.analyzer.Analyzer``'s dict-based
|
|
4
|
+
``_analysis_config`` plus its ~20-keyword-argument ``update_analysis_config``
|
|
5
|
+
method with a single dataclass and an ``update(**changes)`` method that
|
|
6
|
+
ignores ``None`` values (so callers only pass the fields that changed).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class AnalysisConfig:
|
|
17
|
+
"""Analysis pipeline configuration.
|
|
18
|
+
|
|
19
|
+
Per-channel fields (``rshunt``, ``rp``, ``r0``, ``dt``, ``add_180phase``,
|
|
20
|
+
``tes_bias``, ``signal_gen_current``, ``signal_gen_frequency``) accept
|
|
21
|
+
either a single value (applied to all channels) or a per-channel
|
|
22
|
+
sequence; :mod:`pytesdaqx_scope.analysis.didv_fit` broadcasts scalars.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
unit: str = "ADC"
|
|
26
|
+
norm_type: str = "NoNorm"
|
|
27
|
+
norm_list: list[float] | None = None
|
|
28
|
+
|
|
29
|
+
calc_psd: bool = False
|
|
30
|
+
|
|
31
|
+
enable_running_avg: bool = False
|
|
32
|
+
reset_running_avg: bool = False
|
|
33
|
+
nb_events_avg: int = 1
|
|
34
|
+
|
|
35
|
+
enable_lowpass_filter: bool = False
|
|
36
|
+
lowpass_cutoff: float = 50.0
|
|
37
|
+
|
|
38
|
+
enable_pileup_rejection: bool = False
|
|
39
|
+
pileup_cuts: dict[str, int] | None = None
|
|
40
|
+
|
|
41
|
+
fit_didv: bool = False
|
|
42
|
+
didv_1pole: bool = False
|
|
43
|
+
didv_2pole: bool = False
|
|
44
|
+
didv_3pole: bool = False
|
|
45
|
+
didv_measurement: str | None = None
|
|
46
|
+
|
|
47
|
+
signal_gen_current: Any = None
|
|
48
|
+
signal_gen_frequency: Any = None
|
|
49
|
+
tes_bias: Any = None
|
|
50
|
+
rshunt: Any = 0.005
|
|
51
|
+
rp: Any = 0.003
|
|
52
|
+
r0: Any = 0.1
|
|
53
|
+
dt: Any = 2e-6
|
|
54
|
+
add_180phase: Any = False
|
|
55
|
+
|
|
56
|
+
#: Set of field names whose change should force a running-average reset.
|
|
57
|
+
_RESET_AVG_ON_CHANGE = frozenset(
|
|
58
|
+
{"norm_type", "unit", "calc_psd", "enable_pileup_rejection", "pileup_cuts"}
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
def update(self, **changes: Any) -> "AnalysisConfig":
|
|
62
|
+
"""Apply the given (non-``None``) field changes in place.
|
|
63
|
+
|
|
64
|
+
Mirrors the old ``update_analysis_config`` semantics: a field left
|
|
65
|
+
as ``None`` is left untouched, and changing normalization/unit/PSD/
|
|
66
|
+
pileup settings forces ``reset_running_avg``.
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
for key, value in changes.items():
|
|
70
|
+
if value is None:
|
|
71
|
+
continue
|
|
72
|
+
if not hasattr(self, key):
|
|
73
|
+
raise AttributeError(f"Unknown analysis config field {key!r}")
|
|
74
|
+
setattr(self, key, value)
|
|
75
|
+
|
|
76
|
+
if self.norm_type == "NoNorm":
|
|
77
|
+
self.norm_list = None
|
|
78
|
+
|
|
79
|
+
if any(changes.get(name) is not None for name in self._RESET_AVG_ON_CHANGE):
|
|
80
|
+
self.reset_running_avg = True
|
|
81
|
+
|
|
82
|
+
return self
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
"""dIdV small-signal pole-model fitting, via qetpy.
|
|
2
|
+
|
|
3
|
+
Ported from ``pytesdaq.analyzer.Analyzer.fit_didv``. Unlike the original,
|
|
4
|
+
this module has no knowledge of GUI/analysis-config state: callers pass
|
|
5
|
+
explicit, already-resolved per-channel parameters.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
import numpy as np
|
|
13
|
+
import qetpy as qp
|
|
14
|
+
from scipy import signal
|
|
15
|
+
|
|
16
|
+
_UNIT_NORM = {"Amps": 1.0, "uAmps": 1e6, "pAmps": 1e12}
|
|
17
|
+
|
|
18
|
+
#: Post-fit low-pass cutoff applied to the truncated/averaged trace for display.
|
|
19
|
+
_DISPLAY_LOWPASS_HZ = 30_000
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _broadcast(value: Any, nb_channels: int, name: str) -> list[Any]:
|
|
23
|
+
if isinstance(value, (list, tuple, np.ndarray)):
|
|
24
|
+
if len(value) < nb_channels:
|
|
25
|
+
raise ValueError(f"{name!r} has fewer entries than channels ({len(value)} < {nb_channels})")
|
|
26
|
+
return list(value)
|
|
27
|
+
return [value] * nb_channels
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def fit_didv(
|
|
31
|
+
traces: np.ndarray,
|
|
32
|
+
sample_rate: float,
|
|
33
|
+
*,
|
|
34
|
+
signal_gen_frequency: Any,
|
|
35
|
+
signal_gen_current: Any,
|
|
36
|
+
rshunt: Any,
|
|
37
|
+
rp: Any,
|
|
38
|
+
r0: Any,
|
|
39
|
+
dt: Any,
|
|
40
|
+
add_180phase: Any,
|
|
41
|
+
tes_bias: Any,
|
|
42
|
+
didv_1pole: Any,
|
|
43
|
+
didv_2pole: Any,
|
|
44
|
+
didv_3pole: Any,
|
|
45
|
+
unit: str = "Amps",
|
|
46
|
+
mask: np.ndarray | None = None,
|
|
47
|
+
add_autocuts: bool = True,
|
|
48
|
+
) -> tuple[np.ndarray, dict]:
|
|
49
|
+
"""Fit a 1/2/3-pole small-signal dIdV model per channel.
|
|
50
|
+
|
|
51
|
+
Arguments
|
|
52
|
+
---------
|
|
53
|
+
traces:
|
|
54
|
+
3D array ``[nb_events, nb_channels, nb_samples]`` or 2D array
|
|
55
|
+
``[nb_events, nb_samples]`` (single channel), in the unit given by
|
|
56
|
+
``unit``.
|
|
57
|
+
sample_rate:
|
|
58
|
+
Sample rate in Hz.
|
|
59
|
+
signal_gen_frequency, signal_gen_current, rshunt, rp, r0, dt,
|
|
60
|
+
add_180phase, tes_bias, didv_1pole, didv_2pole, didv_3pole:
|
|
61
|
+
Scalar or per-channel sequence. See ``qetpy.DIDV`` for units/meaning
|
|
62
|
+
(frequency in Hz, current in Amps, resistances in Ohms, ``dt`` in
|
|
63
|
+
seconds).
|
|
64
|
+
unit:
|
|
65
|
+
Unit of ``traces``: ``"Amps"``, ``"uAmps"``, or ``"pAmps"``.
|
|
66
|
+
mask:
|
|
67
|
+
Optional boolean array ``[nb_channels, nb_events]`` selecting which
|
|
68
|
+
events to use per channel.
|
|
69
|
+
add_autocuts:
|
|
70
|
+
Apply ``qetpy.autocuts_didv`` before fitting.
|
|
71
|
+
|
|
72
|
+
Returns
|
|
73
|
+
-------
|
|
74
|
+
data_array_truncated:
|
|
75
|
+
2D array ``[nb_channels, nb_truncated_samples]``: mean, low-pass-
|
|
76
|
+
filtered trace actually used for the fit, at its true baseline (not
|
|
77
|
+
zero-centered) - qetpy's internal fit works on the AC-only,
|
|
78
|
+
baseline-subtracted trace, but the baseline is added back here so
|
|
79
|
+
this lines up with the physical bias point ordinary (non-fit)
|
|
80
|
+
frames are displayed at.
|
|
81
|
+
didv_data_dict:
|
|
82
|
+
``{"fit_array": ndarray, "results": list[dict]}`` — ``fit_array``
|
|
83
|
+
matches ``data_array_truncated`` in shape; ``results[i]`` is the
|
|
84
|
+
``qetpy`` fit result dict for channel ``i``, augmented with an
|
|
85
|
+
``"infinite_l"`` entry (``r0``/``i0``/``p0``) for 2/3-pole fits.
|
|
86
|
+
"""
|
|
87
|
+
|
|
88
|
+
if traces.ndim not in (2, 3):
|
|
89
|
+
raise ValueError("traces must be a 2D or 3D array")
|
|
90
|
+
|
|
91
|
+
norm = _UNIT_NORM.get(unit, 1.0)
|
|
92
|
+
nb_channels = traces.shape[1] if traces.ndim == 3 else 1
|
|
93
|
+
|
|
94
|
+
params = {
|
|
95
|
+
"signal_gen_frequency": _broadcast(signal_gen_frequency, nb_channels, "signal_gen_frequency"),
|
|
96
|
+
"signal_gen_current": _broadcast(signal_gen_current, nb_channels, "signal_gen_current"),
|
|
97
|
+
"rshunt": _broadcast(rshunt, nb_channels, "rshunt"),
|
|
98
|
+
"rp": _broadcast(rp, nb_channels, "rp"),
|
|
99
|
+
"r0": _broadcast(r0, nb_channels, "r0"),
|
|
100
|
+
"dt": _broadcast(dt, nb_channels, "dt"),
|
|
101
|
+
"add_180phase": _broadcast(add_180phase, nb_channels, "add_180phase"),
|
|
102
|
+
"tes_bias": _broadcast(tes_bias, nb_channels, "tes_bias"),
|
|
103
|
+
"didv_1pole": _broadcast(didv_1pole, nb_channels, "didv_1pole"),
|
|
104
|
+
"didv_2pole": _broadcast(didv_2pole, nb_channels, "didv_2pole"),
|
|
105
|
+
"didv_3pole": _broadcast(didv_3pole, nb_channels, "didv_3pole"),
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
data_array_truncated = None
|
|
109
|
+
fit_array = None
|
|
110
|
+
result_list: list[dict] = []
|
|
111
|
+
|
|
112
|
+
for ichan in range(nb_channels):
|
|
113
|
+
chan_traces = traces[:, ichan, :] / norm if traces.ndim == 3 else traces / norm
|
|
114
|
+
|
|
115
|
+
if mask is not None:
|
|
116
|
+
chan_traces = chan_traces[mask[ichan, :], :]
|
|
117
|
+
|
|
118
|
+
if add_autocuts:
|
|
119
|
+
cut = qp.autocuts_didv(chan_traces, fs=sample_rate, niter=1)
|
|
120
|
+
chan_traces = chan_traces[cut]
|
|
121
|
+
|
|
122
|
+
didv_inst = qp.DIDV(
|
|
123
|
+
chan_traces,
|
|
124
|
+
sample_rate,
|
|
125
|
+
params["signal_gen_frequency"][ichan],
|
|
126
|
+
params["signal_gen_current"][ichan],
|
|
127
|
+
params["rshunt"][ichan],
|
|
128
|
+
r0=params["r0"][ichan],
|
|
129
|
+
rp=params["rp"][ichan],
|
|
130
|
+
dutycycle=0.5,
|
|
131
|
+
add180phase=params["add_180phase"][ichan],
|
|
132
|
+
dt0=params["dt"][ichan],
|
|
133
|
+
)
|
|
134
|
+
didv_inst.processtraces()
|
|
135
|
+
|
|
136
|
+
nb_samples = didv_inst._tmean.shape[0]
|
|
137
|
+
if data_array_truncated is None:
|
|
138
|
+
data_array_truncated = np.zeros((nb_channels, nb_samples), dtype=np.float64)
|
|
139
|
+
fit_array = np.zeros((nb_channels, nb_samples), dtype=np.float64)
|
|
140
|
+
|
|
141
|
+
offset = didv_inst._offset * norm
|
|
142
|
+
truncated = (didv_inst._tmean - didv_inst._offset) * norm
|
|
143
|
+
nyq = sample_rate / 2
|
|
144
|
+
b, a = signal.butter(2, _DISPLAY_LOWPASS_HZ / nyq)
|
|
145
|
+
# Add the baseline back after filtering (a linear filter's DC gain is
|
|
146
|
+
# 1, so this is equivalent to filtering the un-subtracted trace, but
|
|
147
|
+
# keeps the filter's even-padding extension working on the smaller
|
|
148
|
+
# AC-only signal): the result should sit at the same physical bias
|
|
149
|
+
# point as ordinary (non-fit) display frames, not be zero-centered.
|
|
150
|
+
data_array_truncated[ichan, :] = (
|
|
151
|
+
signal.filtfilt(b, a, truncated, axis=-1, padtype="even") + offset
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
poles = None
|
|
155
|
+
if params["didv_1pole"][ichan]:
|
|
156
|
+
didv_inst.dofit(1, fcutoff=100e3)
|
|
157
|
+
poles = 1
|
|
158
|
+
if params["didv_2pole"][ichan]:
|
|
159
|
+
didv_inst.dofit(2, fcutoff=100e3)
|
|
160
|
+
poles = 2
|
|
161
|
+
if params["didv_3pole"][ichan]:
|
|
162
|
+
didv_inst.dofit(3, fcutoff=100e3)
|
|
163
|
+
poles = 3
|
|
164
|
+
|
|
165
|
+
ilg_params = None
|
|
166
|
+
if poles in (2, 3):
|
|
167
|
+
fit_for_ilg = didv_inst.fitresult(poles)
|
|
168
|
+
ilg_params = qp.get_biasparams_ilg(
|
|
169
|
+
fit_for_ilg["params"],
|
|
170
|
+
fit_for_ilg["cov"],
|
|
171
|
+
params["tes_bias"][ichan],
|
|
172
|
+
params["tes_bias"][ichan] * 0.05,
|
|
173
|
+
params["rshunt"][ichan],
|
|
174
|
+
params["rp"][ichan],
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
didv_inst.calc_smallsignal_params(biasparams=ilg_params, poles=poles)
|
|
178
|
+
|
|
179
|
+
result = didv_inst.fitresult(poles) if poles is not None else None
|
|
180
|
+
if result is None:
|
|
181
|
+
raise ValueError(f"No dIdV pole count selected for channel {ichan}")
|
|
182
|
+
|
|
183
|
+
if poles in (2, 3):
|
|
184
|
+
result["infinite_l"] = {
|
|
185
|
+
"r0": ilg_params["r0"],
|
|
186
|
+
"i0": ilg_params["i0"],
|
|
187
|
+
"p0": ilg_params["p0"],
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
result_list.append(result)
|
|
191
|
+
|
|
192
|
+
dt_sample = 1 / sample_rate
|
|
193
|
+
time = np.arange(0, nb_samples) * dt_sample
|
|
194
|
+
fit_array[ichan, :] = (
|
|
195
|
+
norm
|
|
196
|
+
* qp.squarewaveresponse(
|
|
197
|
+
time,
|
|
198
|
+
params["signal_gen_current"][ichan],
|
|
199
|
+
params["signal_gen_frequency"][ichan],
|
|
200
|
+
result["params"],
|
|
201
|
+
dutycycle=0.5,
|
|
202
|
+
rsh=params["rshunt"][ichan],
|
|
203
|
+
)
|
|
204
|
+
+ offset
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
return data_array_truncated, {"fit_array": fit_array, "results": result_list}
|