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.
Files changed (35) hide show
  1. pytesdaqx_scope/__init__.py +1 -0
  2. pytesdaqx_scope/acquisition/__init__.py +5 -0
  3. pytesdaqx_scope/acquisition/base.py +56 -0
  4. pytesdaqx_scope/acquisition/file_source.py +170 -0
  5. pytesdaqx_scope/acquisition/live_source.py +149 -0
  6. pytesdaqx_scope/analysis/__init__.py +4 -0
  7. pytesdaqx_scope/analysis/config.py +82 -0
  8. pytesdaqx_scope/analysis/didv_fit.py +207 -0
  9. pytesdaqx_scope/analysis/normalize.py +90 -0
  10. pytesdaqx_scope/analysis/pileup.py +135 -0
  11. pytesdaqx_scope/analysis/pipeline.py +135 -0
  12. pytesdaqx_scope/analysis/psd.py +38 -0
  13. pytesdaqx_scope/analysis/running_average.py +104 -0
  14. pytesdaqx_scope/board_reader.py +147 -0
  15. pytesdaqx_scope/controller.py +208 -0
  16. pytesdaqx_scope/file_board_reader.py +94 -0
  17. pytesdaqx_scope/gui/__init__.py +0 -0
  18. pytesdaqx_scope/gui/assets/checkmark.png +0 -0
  19. pytesdaqx_scope/gui/didv_results_formatter.py +94 -0
  20. pytesdaqx_scope/gui/main_window.py +280 -0
  21. pytesdaqx_scope/gui/theme.py +153 -0
  22. pytesdaqx_scope/gui/widgets/__init__.py +0 -0
  23. pytesdaqx_scope/gui/widgets/channel_panel.py +199 -0
  24. pytesdaqx_scope/gui/widgets/control_panel.py +116 -0
  25. pytesdaqx_scope/gui/widgets/didv_fit_panel.py +138 -0
  26. pytesdaqx_scope/gui/widgets/display_panel.py +206 -0
  27. pytesdaqx_scope/gui/widgets/pileup_cuts_panel.py +57 -0
  28. pytesdaqx_scope/gui/widgets/tools_panel.py +78 -0
  29. pytesdaqx_scope/gui/widgets/tools_window.py +56 -0
  30. pytesdaqx_scope/launcher.py +123 -0
  31. pytesdaqx_scope-0.1.0.dist-info/METADATA +197 -0
  32. pytesdaqx_scope-0.1.0.dist-info/RECORD +35 -0
  33. pytesdaqx_scope-0.1.0.dist-info/WHEEL +5 -0
  34. pytesdaqx_scope-0.1.0.dist-info/entry_points.txt +2 -0
  35. pytesdaqx_scope-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -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,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,4 @@
1
+ from .config import AnalysisConfig
2
+ from .pipeline import AnalysisPipeline, AnalysisResult
3
+
4
+ __all__ = ["AnalysisConfig", "AnalysisPipeline", "AnalysisResult"]
@@ -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}