driftlessflip 0.15.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.
- driftlessflip/__init__.py +5 -0
- driftlessflip/__main__.py +3 -0
- driftlessflip/acquisition/__init__.py +1 -0
- driftlessflip/acquisition/looping.py +162 -0
- driftlessflip/acquisition/preview.py +485 -0
- driftlessflip/acquisition/runner.py +931 -0
- driftlessflip/acquisition/tuning.py +226 -0
- driftlessflip/cli.py +264 -0
- driftlessflip/config.py +392 -0
- driftlessflip/gui_entry.py +13 -0
- driftlessflip/hardware/__init__.py +1 -0
- driftlessflip/hardware/base.py +62 -0
- driftlessflip/hardware/simulator.py +273 -0
- driftlessflip/hardware/timeharp.py +550 -0
- driftlessflip/metadata.py +343 -0
- driftlessflip/processing/__init__.py +1 -0
- driftlessflip/processing/aggregation.py +388 -0
- driftlessflip/processing/analysis.py +296 -0
- driftlessflip/processing/corrections.py +152 -0
- driftlessflip/processing/fitting.py +494 -0
- driftlessflip/processing/rates.py +166 -0
- driftlessflip/processing/review.py +622 -0
- driftlessflip/processing/t3.py +81 -0
- driftlessflip/schemas/__init__.py +1 -0
- driftlessflip/schemas/run_metadata.schema.json +118 -0
- driftlessflip/storage/__init__.py +1 -0
- driftlessflip/storage/background.py +233 -0
- driftlessflip/storage/common.py +52 -0
- driftlessflip/storage/csv_export.py +200 -0
- driftlessflip/storage/legacy_histogram.py +197 -0
- driftlessflip/storage/manifest.py +48 -0
- driftlessflip/storage/naming.py +193 -0
- driftlessflip/storage/nwb_export.py +309 -0
- driftlessflip/storage/profiles.py +111 -0
- driftlessflip/storage/raw_h5.py +371 -0
- driftlessflip/storage/recovery.py +467 -0
- driftlessflip/storage/settings_file.py +344 -0
- driftlessflip/storage/verify.py +202 -0
- driftlessflip/ui/__init__.py +1 -0
- driftlessflip/ui/app.py +4996 -0
- driftlessflip/ui/axes.py +201 -0
- driftlessflip/ui/plots.py +2002 -0
- driftlessflip-0.15.0.data/data/share/driftlessflip/driftlessflip-logo.png +0 -0
- driftlessflip-0.15.0.dist-info/METADATA +441 -0
- driftlessflip-0.15.0.dist-info/RECORD +49 -0
- driftlessflip-0.15.0.dist-info/WHEEL +5 -0
- driftlessflip-0.15.0.dist-info/entry_points.txt +2 -0
- driftlessflip-0.15.0.dist-info/licenses/LICENSE +21 -0
- driftlessflip-0.15.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Recording orchestration and state management."""
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""Repeated acquisitions on a fixed start-to-start interval.
|
|
2
|
+
|
|
3
|
+
Each repeat is an ordinary, independently verifiable recording in its own timestamped
|
|
4
|
+
folder. They are linked only by a shared session identifier in metadata, so a failure in
|
|
5
|
+
one repeat cannot damage the others and a partially completed loop still leaves every
|
|
6
|
+
finished recording valid and verifiable on its own.
|
|
7
|
+
|
|
8
|
+
The interval is start-to-start, matching how stimulation protocols are specified. It must
|
|
9
|
+
leave room for the acquisition plus finalisation, otherwise repeats would overlap and the
|
|
10
|
+
board would still be running when the next one tried to arm.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import threading
|
|
16
|
+
import time
|
|
17
|
+
import uuid
|
|
18
|
+
from dataclasses import dataclass, field
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
from driftlessflip.acquisition.runner import ProgressCallback, RecordingRunner
|
|
23
|
+
from driftlessflip.config import AcquisitionConfig
|
|
24
|
+
|
|
25
|
+
#: Headroom beyond the acquisition itself for writing, verifying, and arming again.
|
|
26
|
+
FINALISATION_MARGIN_S = 0.5
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(slots=True)
|
|
30
|
+
class LoopOutcome:
|
|
31
|
+
"""What a looped session produced, including any repeat that failed."""
|
|
32
|
+
|
|
33
|
+
session_uuid: str
|
|
34
|
+
directories: list[Path] = field(default_factory=list)
|
|
35
|
+
failures: list[tuple[int, str]] = field(default_factory=list)
|
|
36
|
+
completed: int = 0
|
|
37
|
+
requested: int = 0
|
|
38
|
+
stopped_early: bool = False
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def all_succeeded(self) -> bool:
|
|
42
|
+
return not self.failures and self.completed == self.requested
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def validate_loop(config: AcquisitionConfig) -> None:
|
|
46
|
+
"""Reject a loop whose repeats would overlap, before anything is recorded."""
|
|
47
|
+
if config.loop_repeats < 1:
|
|
48
|
+
raise ValueError("loop_repeats must be at least 1")
|
|
49
|
+
if config.loop_repeats == 1:
|
|
50
|
+
return
|
|
51
|
+
minimum = config.duration_s + FINALISATION_MARGIN_S
|
|
52
|
+
if config.loop_interval_s < minimum:
|
|
53
|
+
raise ValueError(
|
|
54
|
+
f"loop interval {config.loop_interval_s:g} s is shorter than the "
|
|
55
|
+
f"{minimum:g} s needed for a {config.duration_s:g} s acquisition plus "
|
|
56
|
+
"finalisation; repeats would overlap"
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class LoopRunner:
|
|
61
|
+
"""Runs N recordings on a fixed start-to-start interval."""
|
|
62
|
+
|
|
63
|
+
def __init__(self, config: AcquisitionConfig) -> None:
|
|
64
|
+
self.config = config
|
|
65
|
+
self.stop_event = threading.Event()
|
|
66
|
+
self._active: RecordingRunner | None = None
|
|
67
|
+
self._lock = threading.Lock()
|
|
68
|
+
|
|
69
|
+
def request_stop(self) -> None:
|
|
70
|
+
"""Stop after the current repeat; the running acquisition is asked to stop too."""
|
|
71
|
+
self.stop_event.set()
|
|
72
|
+
with self._lock:
|
|
73
|
+
if self._active is not None:
|
|
74
|
+
self._active.request_stop()
|
|
75
|
+
|
|
76
|
+
def request_annotation(self, message: str, started_monotonic_ns: int | None = None) -> Any:
|
|
77
|
+
with self._lock:
|
|
78
|
+
if self._active is None:
|
|
79
|
+
raise RuntimeError("annotations are accepted only while a recording is running")
|
|
80
|
+
runner = self._active
|
|
81
|
+
return runner.request_annotation(message, started_monotonic_ns)
|
|
82
|
+
|
|
83
|
+
def update_notes(self, text: str) -> None:
|
|
84
|
+
"""Forward to whichever repeat is currently recording.
|
|
85
|
+
|
|
86
|
+
Silently does nothing between repeats -- there is no current recording for the
|
|
87
|
+
edit to belong to, and (unlike an annotation) there is no operator-facing error
|
|
88
|
+
to report for a field that just keeps accepting keystrokes either way.
|
|
89
|
+
"""
|
|
90
|
+
with self._lock:
|
|
91
|
+
runner = self._active
|
|
92
|
+
if runner is not None:
|
|
93
|
+
runner.update_notes(text)
|
|
94
|
+
|
|
95
|
+
def run(self, progress: ProgressCallback | None = None) -> LoopOutcome:
|
|
96
|
+
validate_loop(self.config)
|
|
97
|
+
callback = progress or (lambda update: None)
|
|
98
|
+
session_uuid = str(uuid.uuid4())
|
|
99
|
+
outcome = LoopOutcome(session_uuid=session_uuid, requested=self.config.loop_repeats)
|
|
100
|
+
|
|
101
|
+
for repeat in range(self.config.loop_repeats):
|
|
102
|
+
if self.stop_event.is_set():
|
|
103
|
+
outcome.stopped_early = True
|
|
104
|
+
break
|
|
105
|
+
started = time.monotonic()
|
|
106
|
+
runner = RecordingRunner(
|
|
107
|
+
self.config,
|
|
108
|
+
session_uuid=session_uuid,
|
|
109
|
+
session_repeat_index=repeat,
|
|
110
|
+
session_repeat_count=self.config.loop_repeats,
|
|
111
|
+
)
|
|
112
|
+
with self._lock:
|
|
113
|
+
self._active = runner
|
|
114
|
+
callback(
|
|
115
|
+
{
|
|
116
|
+
"state": "LOOP_REPEAT_STARTING",
|
|
117
|
+
"message": f"Repeat {repeat + 1} of {self.config.loop_repeats}",
|
|
118
|
+
"session_uuid": session_uuid,
|
|
119
|
+
"repeat_index": repeat,
|
|
120
|
+
}
|
|
121
|
+
)
|
|
122
|
+
try:
|
|
123
|
+
outcome.directories.append(runner.run(callback))
|
|
124
|
+
outcome.completed += 1
|
|
125
|
+
except Exception as error:
|
|
126
|
+
# One repeat failing must not abandon the rest of the session.
|
|
127
|
+
outcome.failures.append((repeat, str(error)))
|
|
128
|
+
callback(
|
|
129
|
+
{
|
|
130
|
+
"state": "LOOP_REPEAT_FAILED",
|
|
131
|
+
"message": f"Repeat {repeat + 1} failed: {error}",
|
|
132
|
+
"repeat_index": repeat,
|
|
133
|
+
}
|
|
134
|
+
)
|
|
135
|
+
finally:
|
|
136
|
+
with self._lock:
|
|
137
|
+
self._active = None
|
|
138
|
+
|
|
139
|
+
remaining = self.config.loop_repeats - repeat - 1
|
|
140
|
+
if remaining <= 0 or self.stop_event.is_set():
|
|
141
|
+
continue
|
|
142
|
+
# Start-to-start, so the wait is the interval minus however long this repeat took.
|
|
143
|
+
wait = self.config.loop_interval_s - (time.monotonic() - started)
|
|
144
|
+
deadline = time.monotonic() + max(0.0, wait)
|
|
145
|
+
while time.monotonic() < deadline and not self.stop_event.is_set():
|
|
146
|
+
time.sleep(min(0.05, deadline - time.monotonic()))
|
|
147
|
+
|
|
148
|
+
if self.stop_event.is_set():
|
|
149
|
+
outcome.stopped_early = True
|
|
150
|
+
callback(
|
|
151
|
+
{
|
|
152
|
+
"state": "LOOP_COMPLETE",
|
|
153
|
+
"message": (
|
|
154
|
+
f"{outcome.completed} of {outcome.requested} repeats completed"
|
|
155
|
+
+ (f", {len(outcome.failures)} failed" if outcome.failures else "")
|
|
156
|
+
),
|
|
157
|
+
"session_uuid": session_uuid,
|
|
158
|
+
"directories": [str(path) for path in outcome.directories],
|
|
159
|
+
"failures": outcome.failures,
|
|
160
|
+
}
|
|
161
|
+
)
|
|
162
|
+
return outcome
|
|
@@ -0,0 +1,485 @@
|
|
|
1
|
+
"""Non-recording live acquisition for setting up an experiment.
|
|
2
|
+
|
|
3
|
+
Preview exists so an operator can adjust discriminator levels, offsets, sync
|
|
4
|
+
divider, and the MPET window while watching real count rates and a real decay
|
|
5
|
+
curve, then commit to a recording once the signal looks right. Every preview
|
|
6
|
+
start re-applies the current settings through `configure`, so changing a value
|
|
7
|
+
and previewing again always reflects the new value.
|
|
8
|
+
|
|
9
|
+
This module deliberately imports nothing from `driftlessflip.storage`. Preview
|
|
10
|
+
cannot create a run directory, a raw TTTR file, or a manifest, so a preview can
|
|
11
|
+
never be mistaken for or promoted into a recording.
|
|
12
|
+
|
|
13
|
+
Afterpulse and dead-time corrections are applied, so a retuned afterpulse ratio has a
|
|
14
|
+
visible effect. A background *recording* is not, because loading one would mean reaching
|
|
15
|
+
into the storage layer and giving up the guarantee above; background correction belongs
|
|
16
|
+
to the recording, where its provenance can be captured.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import threading
|
|
22
|
+
import time
|
|
23
|
+
from dataclasses import dataclass, replace
|
|
24
|
+
from datetime import datetime, timezone
|
|
25
|
+
from typing import Any, Callable
|
|
26
|
+
|
|
27
|
+
import numpy as np
|
|
28
|
+
|
|
29
|
+
from driftlessflip.config import (
|
|
30
|
+
AcquisitionConfig,
|
|
31
|
+
AnalysisConfig,
|
|
32
|
+
validate_preview_window,
|
|
33
|
+
)
|
|
34
|
+
from driftlessflip.hardware.base import AcquisitionBackend, release_backend
|
|
35
|
+
from driftlessflip.hardware.simulator import SimulatorBackend
|
|
36
|
+
from driftlessflip.hardware.timeharp import TimeHarpBackend
|
|
37
|
+
from driftlessflip.processing.analysis import calculate_metrics, decode_marker_events
|
|
38
|
+
from driftlessflip.processing.corrections import Corrections
|
|
39
|
+
from driftlessflip.processing.t3 import DecodedT3Chunk, TimeHarpT3Decoder
|
|
40
|
+
|
|
41
|
+
PreviewCallback = Callable[[dict[str, Any]], None]
|
|
42
|
+
|
|
43
|
+
PREVIEW_BACKEND_DURATION_S = 359_999.0
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass(slots=True)
|
|
47
|
+
class PreviewAccumulator:
|
|
48
|
+
"""Rolling per-sample histograms over a bounded window of the newest samples.
|
|
49
|
+
|
|
50
|
+
Sample indices stay absolute so the time axis keeps advancing, but storage is a
|
|
51
|
+
ring of `window_samples` slots. Because T3 macro-sync is monotonic, a slot only
|
|
52
|
+
ever needs clearing when the window advances past it.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
window_samples: int
|
|
56
|
+
channel_count: int
|
|
57
|
+
lifetime_bins: int
|
|
58
|
+
sample_rate_hz: float
|
|
59
|
+
sync_period_s: float
|
|
60
|
+
hardware_resolution_ps: float
|
|
61
|
+
lifetime_bin_width_ps: float
|
|
62
|
+
histograms: np.ndarray = None # type: ignore[assignment]
|
|
63
|
+
marker_bits: np.ndarray = None # type: ignore[assignment]
|
|
64
|
+
newest_sample: int = -1
|
|
65
|
+
|
|
66
|
+
def __post_init__(self) -> None:
|
|
67
|
+
self.histograms = np.zeros(
|
|
68
|
+
(self.window_samples, self.channel_count, self.lifetime_bins), dtype=np.uint32
|
|
69
|
+
)
|
|
70
|
+
self.marker_bits = np.zeros(self.window_samples, dtype=np.uint8)
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def oldest_sample(self) -> int:
|
|
74
|
+
return max(0, self.newest_sample - self.window_samples + 1)
|
|
75
|
+
|
|
76
|
+
def _sample_index(self, macro_sync: np.ndarray) -> np.ndarray:
|
|
77
|
+
return np.floor(
|
|
78
|
+
macro_sync.astype(np.float64) * self.sync_period_s * self.sample_rate_hz
|
|
79
|
+
).astype(np.int64)
|
|
80
|
+
|
|
81
|
+
def add(self, decoded: DecodedT3Chunk) -> None:
|
|
82
|
+
photon_sample = (
|
|
83
|
+
self._sample_index(decoded.photon_macro_sync)
|
|
84
|
+
if len(decoded.photon_macro_sync)
|
|
85
|
+
else None
|
|
86
|
+
)
|
|
87
|
+
marker_sample = (
|
|
88
|
+
self._sample_index(decoded.marker_macro_sync)
|
|
89
|
+
if len(decoded.marker_macro_sync)
|
|
90
|
+
else None
|
|
91
|
+
)
|
|
92
|
+
present = [block for block in (photon_sample, marker_sample) if block is not None]
|
|
93
|
+
if not present:
|
|
94
|
+
return
|
|
95
|
+
self._advance_to(int(max(int(block.max()) for block in present)))
|
|
96
|
+
|
|
97
|
+
if photon_sample is not None:
|
|
98
|
+
lifetime_bin = np.floor(
|
|
99
|
+
decoded.photon_dtime.astype(np.float64)
|
|
100
|
+
* self.hardware_resolution_ps
|
|
101
|
+
/ self.lifetime_bin_width_ps
|
|
102
|
+
).astype(np.int64)
|
|
103
|
+
channel = decoded.photon_channels.astype(np.int64)
|
|
104
|
+
keep = (
|
|
105
|
+
(photon_sample > self.newest_sample - self.window_samples)
|
|
106
|
+
& (photon_sample >= 0)
|
|
107
|
+
& (channel >= 0)
|
|
108
|
+
& (channel < self.channel_count)
|
|
109
|
+
& (lifetime_bin >= 0)
|
|
110
|
+
& (lifetime_bin < self.lifetime_bins)
|
|
111
|
+
)
|
|
112
|
+
np.add.at(
|
|
113
|
+
self.histograms,
|
|
114
|
+
(
|
|
115
|
+
photon_sample[keep] % self.window_samples,
|
|
116
|
+
channel[keep],
|
|
117
|
+
lifetime_bin[keep],
|
|
118
|
+
),
|
|
119
|
+
1,
|
|
120
|
+
)
|
|
121
|
+
if marker_sample is not None:
|
|
122
|
+
keep = marker_sample > self.newest_sample - self.window_samples
|
|
123
|
+
np.bitwise_or.at(
|
|
124
|
+
self.marker_bits,
|
|
125
|
+
marker_sample[keep] % self.window_samples,
|
|
126
|
+
decoded.marker_bits[keep].astype(np.uint8),
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
def _advance_to(self, newest: int) -> None:
|
|
130
|
+
if newest <= self.newest_sample:
|
|
131
|
+
return
|
|
132
|
+
advanced = newest - self.newest_sample
|
|
133
|
+
if advanced >= self.window_samples:
|
|
134
|
+
self.histograms[:] = 0
|
|
135
|
+
self.marker_bits[:] = 0
|
|
136
|
+
else:
|
|
137
|
+
slots = np.arange(self.newest_sample + 1, newest + 1) % self.window_samples
|
|
138
|
+
self.histograms[slots] = 0
|
|
139
|
+
self.marker_bits[slots] = 0
|
|
140
|
+
self.newest_sample = newest
|
|
141
|
+
|
|
142
|
+
def ordered_window(self) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
|
|
143
|
+
"""Histograms, marker bits, and absolute sample indices in time order."""
|
|
144
|
+
if self.newest_sample < 0:
|
|
145
|
+
empty_hist = np.zeros((0, self.channel_count, self.lifetime_bins), dtype=np.uint32)
|
|
146
|
+
return empty_hist, np.zeros(0, dtype=np.uint8), np.zeros(0, dtype=np.int64)
|
|
147
|
+
indices = np.arange(self.oldest_sample, self.newest_sample + 1, dtype=np.int64)
|
|
148
|
+
slots = indices % self.window_samples
|
|
149
|
+
return self.histograms[slots], self.marker_bits[slots], indices
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
class PreviewRunner:
|
|
153
|
+
"""Runs acquisition for display only. Writes nothing, anywhere."""
|
|
154
|
+
|
|
155
|
+
#: Parameters applied when metrics are calculated from an already-filled histogram,
|
|
156
|
+
#: so they can be retuned while a preview runs. Everything else either reprograms the
|
|
157
|
+
#: board -- which TH260Lib forbids during a measurement -- or fixes a buffer shape.
|
|
158
|
+
LIVE_ADJUSTABLE = frozenset(
|
|
159
|
+
{"t0_ns", "mpet_window_start_ns", "mpet_window_end_ns", "afterpulse_ratio"}
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
#: Per-sample metrics the display needs, cached in a ring beside the histograms.
|
|
163
|
+
CACHED_METRICS = (
|
|
164
|
+
"intensity_photons_per_s",
|
|
165
|
+
"mpet_ns",
|
|
166
|
+
"corrected_photons",
|
|
167
|
+
"phasor_g",
|
|
168
|
+
"phasor_s",
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
def __init__(self, config: AcquisitionConfig) -> None:
|
|
172
|
+
self.config = config
|
|
173
|
+
self.stop_event = threading.Event()
|
|
174
|
+
self.backend: AcquisitionBackend | None = None
|
|
175
|
+
self._analysis_lock = threading.Lock()
|
|
176
|
+
# Metrics for samples that are already complete do not change, so they are kept
|
|
177
|
+
# in a ring parallel to the histograms and only the newly filled samples are
|
|
178
|
+
# recalculated. Recomputing the whole window every snapshot made the cost grow
|
|
179
|
+
# with elapsed time until the window was full.
|
|
180
|
+
self._metrics: dict[str, np.ndarray] = {}
|
|
181
|
+
self._metrics_through = -1
|
|
182
|
+
self._metrics_signature: tuple[Any, ...] | None = None
|
|
183
|
+
#: Populated when the device did not stop or close cleanly.
|
|
184
|
+
self.device_release_failures: list[str] = []
|
|
185
|
+
self._clear_decay_requested = threading.Event()
|
|
186
|
+
#: The decay curve sums samples newer than this. Left at -1 (everything in the
|
|
187
|
+
#: window qualifies) until "Clear" moves it forward -- a display-only reference
|
|
188
|
+
#: that never touches the accumulator itself, so the intensity/MPET trace history
|
|
189
|
+
#: (built from the same window) is not disturbed by clearing the decay curve.
|
|
190
|
+
self._decay_reference_sample = -1
|
|
191
|
+
|
|
192
|
+
def request_stop(self) -> None:
|
|
193
|
+
self.stop_event.set()
|
|
194
|
+
|
|
195
|
+
def request_clear_decay(self) -> None:
|
|
196
|
+
"""Make the decay curve start summing fresh from the next sample onward.
|
|
197
|
+
|
|
198
|
+
A long preview window makes a real change (CFD threshold, sync offset, even
|
|
199
|
+
excitation power adjusted externally) hard to see, since the decay curve is the
|
|
200
|
+
sum over the whole window and old photons dominate until the window naturally
|
|
201
|
+
rolls past them. This restarts that sum immediately without stopping preview or
|
|
202
|
+
touching the underlying rolling accumulator, so nothing already displayed on the
|
|
203
|
+
intensity/MPET trace is rewritten.
|
|
204
|
+
"""
|
|
205
|
+
self._clear_decay_requested.set()
|
|
206
|
+
|
|
207
|
+
def update_analysis(self, **changes: float) -> AnalysisConfig:
|
|
208
|
+
"""Retune a post-processing parameter without restarting the preview."""
|
|
209
|
+
rejected = set(changes) - self.LIVE_ADJUSTABLE
|
|
210
|
+
if rejected:
|
|
211
|
+
raise ValueError(
|
|
212
|
+
"these parameters cannot change while a measurement is running: "
|
|
213
|
+
+ ", ".join(sorted(rejected))
|
|
214
|
+
)
|
|
215
|
+
with self._analysis_lock:
|
|
216
|
+
updated = replace(self.config.analysis, **changes)
|
|
217
|
+
updated.validate()
|
|
218
|
+
self.config.analysis = updated
|
|
219
|
+
return updated
|
|
220
|
+
|
|
221
|
+
def _analysis_snapshot(self) -> AnalysisConfig:
|
|
222
|
+
with self._analysis_lock:
|
|
223
|
+
return self.config.analysis
|
|
224
|
+
|
|
225
|
+
def validate(self) -> None:
|
|
226
|
+
"""Check only what preview actually uses; no output location is required."""
|
|
227
|
+
self.config.hardware.validate()
|
|
228
|
+
self.config.analysis.validate()
|
|
229
|
+
validate_preview_window(self.config.preview_window_s)
|
|
230
|
+
budget = self.config.max_histogram_memory_mb * 1024 * 1024
|
|
231
|
+
if self.estimated_preview_bytes() > budget:
|
|
232
|
+
raise ValueError(
|
|
233
|
+
f"preview window requires {self.estimated_preview_bytes() / 1048576:,.1f} MiB, "
|
|
234
|
+
f"exceeding the {self.config.max_histogram_memory_mb:,} MiB budget"
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
def window_samples(self) -> int:
|
|
238
|
+
return max(1, int(np.ceil(self.config.preview_window_s * self.config.analysis.sample_rate_hz)))
|
|
239
|
+
|
|
240
|
+
def estimated_preview_bytes(self) -> int:
|
|
241
|
+
return (
|
|
242
|
+
self.window_samples()
|
|
243
|
+
* len(self.config.hardware.channels)
|
|
244
|
+
* self.config.analysis.lifetime_bins
|
|
245
|
+
* 4
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
def _make_backend(self) -> AcquisitionBackend:
|
|
249
|
+
if self.config.backend == "simulator":
|
|
250
|
+
return SimulatorBackend()
|
|
251
|
+
return TimeHarpBackend(self.config.timeharp_library_path, self.config.device_serial)
|
|
252
|
+
|
|
253
|
+
def run(self, progress: PreviewCallback | None = None) -> dict[str, Any]:
|
|
254
|
+
"""Preview until `request_stop`. Returns a summary; creates no files."""
|
|
255
|
+
self.validate()
|
|
256
|
+
callback = progress or (lambda update: None)
|
|
257
|
+
self.backend = self._make_backend()
|
|
258
|
+
started_monotonic = time.perf_counter()
|
|
259
|
+
chunk_count = 0
|
|
260
|
+
overrun_seen = False
|
|
261
|
+
try:
|
|
262
|
+
callback({"state": "PREVIEW_OPENING", "message": "Opening acquisition backend"})
|
|
263
|
+
device_info = self.backend.open()
|
|
264
|
+
# Settings are re-applied on every preview start, so an edited value takes
|
|
265
|
+
# effect the next time the operator previews.
|
|
266
|
+
effective = self.backend.configure(
|
|
267
|
+
replace(self.config, duration_s=PREVIEW_BACKEND_DURATION_S)
|
|
268
|
+
)
|
|
269
|
+
tick_rate = float(effective.get("t3_macrotime_tick_rate_hz") or 0)
|
|
270
|
+
if tick_rate <= 0:
|
|
271
|
+
raise RuntimeError("T3 macrotime tick rate is not available or not positive")
|
|
272
|
+
resolution_ps = float(
|
|
273
|
+
effective.get("hardware_resolution_ps")
|
|
274
|
+
or self.config.analysis.lifetime_bin_width_ps
|
|
275
|
+
)
|
|
276
|
+
analysis = self.config.analysis
|
|
277
|
+
accumulator = PreviewAccumulator(
|
|
278
|
+
window_samples=self.window_samples(),
|
|
279
|
+
channel_count=len(self.config.hardware.channels),
|
|
280
|
+
lifetime_bins=analysis.lifetime_bins,
|
|
281
|
+
sample_rate_hz=analysis.sample_rate_hz,
|
|
282
|
+
sync_period_s=1 / tick_rate,
|
|
283
|
+
hardware_resolution_ps=resolution_ps,
|
|
284
|
+
lifetime_bin_width_ps=analysis.lifetime_bin_width_ps,
|
|
285
|
+
)
|
|
286
|
+
decoder = TimeHarpT3Decoder()
|
|
287
|
+
callback(
|
|
288
|
+
{
|
|
289
|
+
"state": "PREVIEW_RUNNING",
|
|
290
|
+
"message": "Preview running; nothing is being saved",
|
|
291
|
+
"device": device_info,
|
|
292
|
+
"effective_hardware_configuration": effective,
|
|
293
|
+
"preview_window_s": self.config.preview_window_s,
|
|
294
|
+
}
|
|
295
|
+
)
|
|
296
|
+
preview_start_utc = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
|
297
|
+
self.backend.start(PREVIEW_BACKEND_DURATION_S)
|
|
298
|
+
marker_event_count = 0
|
|
299
|
+
marker_record_count = 0
|
|
300
|
+
last_update = 0.0
|
|
301
|
+
while not self.stop_event.is_set():
|
|
302
|
+
chunk = self.backend.read_fifo()
|
|
303
|
+
# Preview saves nothing, so a lost chunk costs a redraw, not data.
|
|
304
|
+
if chunk.integrity_lost:
|
|
305
|
+
overrun_seen = True
|
|
306
|
+
if len(chunk.records):
|
|
307
|
+
chunk_count += 1
|
|
308
|
+
decoded = decoder.decode(chunk.records)
|
|
309
|
+
accumulator.add(decoded)
|
|
310
|
+
marker_events = decode_marker_events(
|
|
311
|
+
decoded,
|
|
312
|
+
self.config.hardware.markers,
|
|
313
|
+
sync_period_s=1 / tick_rate,
|
|
314
|
+
sample_rate_hz=analysis.sample_rate_hz,
|
|
315
|
+
recording_start_utc=preview_start_utc,
|
|
316
|
+
first_sequence=marker_event_count,
|
|
317
|
+
first_marker_record_sequence=marker_record_count,
|
|
318
|
+
)
|
|
319
|
+
marker_event_count += len(marker_events)
|
|
320
|
+
marker_record_count += decoded.marker_record_count
|
|
321
|
+
for marker_event in marker_events:
|
|
322
|
+
callback(
|
|
323
|
+
{
|
|
324
|
+
"state": "MARKER_EVENT",
|
|
325
|
+
"message": "TTL marker event observed in preview",
|
|
326
|
+
"recording": False,
|
|
327
|
+
"marker_event": marker_event,
|
|
328
|
+
}
|
|
329
|
+
)
|
|
330
|
+
elif self.backend.is_complete():
|
|
331
|
+
break
|
|
332
|
+
else:
|
|
333
|
+
time.sleep(0.002)
|
|
334
|
+
if self._clear_decay_requested.is_set():
|
|
335
|
+
self._decay_reference_sample = accumulator.newest_sample
|
|
336
|
+
self._clear_decay_requested.clear()
|
|
337
|
+
now = time.monotonic()
|
|
338
|
+
if now - last_update >= 0.2:
|
|
339
|
+
callback(self._snapshot(accumulator, overrun_seen))
|
|
340
|
+
last_update = now
|
|
341
|
+
callback(self._snapshot(accumulator, overrun_seen, state="PREVIEW_STOPPED"))
|
|
342
|
+
return {
|
|
343
|
+
"state": "stopped",
|
|
344
|
+
"elapsed_s": time.perf_counter() - started_monotonic,
|
|
345
|
+
"chunks_read": chunk_count,
|
|
346
|
+
"fifo_overrun_observed": overrun_seen,
|
|
347
|
+
"effective_hardware_configuration": effective,
|
|
348
|
+
}
|
|
349
|
+
except Exception as error:
|
|
350
|
+
callback({"state": "PREVIEW_ERROR", "message": str(error)})
|
|
351
|
+
raise
|
|
352
|
+
finally:
|
|
353
|
+
self.device_release_failures = release_backend(
|
|
354
|
+
self.backend,
|
|
355
|
+
lambda message: callback({"state": "DEVICE_RELEASE_FAILED", "message": message}),
|
|
356
|
+
)
|
|
357
|
+
|
|
358
|
+
def _window_metrics(
|
|
359
|
+
self,
|
|
360
|
+
accumulator: PreviewAccumulator,
|
|
361
|
+
histograms: np.ndarray,
|
|
362
|
+
marker_bits: np.ndarray,
|
|
363
|
+
indices: np.ndarray,
|
|
364
|
+
analysis: AnalysisConfig,
|
|
365
|
+
) -> dict[str, np.ndarray]:
|
|
366
|
+
"""Per-sample metrics for the whole window, recalculating only what changed.
|
|
367
|
+
|
|
368
|
+
A completed sample's histogram never changes again, so its metrics do not
|
|
369
|
+
either. Only the samples filled since the last snapshot are recalculated, plus
|
|
370
|
+
the sample on the boundary, which was still accumulating when it was last seen.
|
|
371
|
+
Retuning a live parameter changes the signature and forces a full recalculation,
|
|
372
|
+
because it applies to every sample in the window.
|
|
373
|
+
"""
|
|
374
|
+
# Afterpulse and dead time are applied here too, so retuning the afterpulse ratio
|
|
375
|
+
# during a preview visibly changes MPET rather than doing nothing.
|
|
376
|
+
channels = self.config.hardware.channels
|
|
377
|
+
corrections = Corrections(
|
|
378
|
+
dead_time_s=np.asarray([channel.dead_time_ns * 1e-9 for channel in channels]),
|
|
379
|
+
afterpulse_ratio=np.asarray(
|
|
380
|
+
self.config.hardware.afterpulse_ratios(analysis.afterpulse_ratio)
|
|
381
|
+
),
|
|
382
|
+
sample_period_s=1.0 / analysis.sample_rate_hz,
|
|
383
|
+
)
|
|
384
|
+
signature = (
|
|
385
|
+
analysis.t0_ns,
|
|
386
|
+
analysis.mpet_window_start_ns,
|
|
387
|
+
analysis.mpet_window_end_ns,
|
|
388
|
+
analysis.sample_rate_hz,
|
|
389
|
+
analysis.lifetime_bins,
|
|
390
|
+
analysis.lifetime_bin_width_ps,
|
|
391
|
+
analysis.laser_repetition_rate_hz,
|
|
392
|
+
tuple(np.asarray(corrections.afterpulse_ratio).tolist()),
|
|
393
|
+
tuple(np.asarray(corrections.dead_time_s).tolist()),
|
|
394
|
+
accumulator.window_samples,
|
|
395
|
+
accumulator.channel_count,
|
|
396
|
+
)
|
|
397
|
+
window, newest = accumulator.window_samples, accumulator.newest_sample
|
|
398
|
+
if signature != self._metrics_signature:
|
|
399
|
+
self._metrics = {
|
|
400
|
+
name: np.zeros((window, accumulator.channel_count), dtype=np.float64)
|
|
401
|
+
for name in self.CACHED_METRICS
|
|
402
|
+
}
|
|
403
|
+
self._metrics_signature = signature
|
|
404
|
+
self._metrics_through = -1
|
|
405
|
+
|
|
406
|
+
# The boundary sample was still filling when it was cached, so recompute it.
|
|
407
|
+
first_stale = max(int(indices[0]), self._metrics_through)
|
|
408
|
+
stale = np.arange(first_stale, newest + 1, dtype=np.int64)
|
|
409
|
+
if len(stale):
|
|
410
|
+
offsets = stale - int(indices[0])
|
|
411
|
+
fresh = calculate_metrics(
|
|
412
|
+
histograms[offsets],
|
|
413
|
+
marker_bits[offsets],
|
|
414
|
+
analysis,
|
|
415
|
+
corrections=corrections,
|
|
416
|
+
)
|
|
417
|
+
slots = stale % window
|
|
418
|
+
for name in self.CACHED_METRICS:
|
|
419
|
+
self._metrics[name][slots] = fresh[name]
|
|
420
|
+
self._metrics_through = newest
|
|
421
|
+
|
|
422
|
+
slots = indices % window
|
|
423
|
+
return {name: self._metrics[name][slots] for name in self.CACHED_METRICS}
|
|
424
|
+
|
|
425
|
+
def _snapshot(
|
|
426
|
+
self,
|
|
427
|
+
accumulator: PreviewAccumulator,
|
|
428
|
+
overrun_seen: bool,
|
|
429
|
+
state: str = "PREVIEW_RUNNING",
|
|
430
|
+
) -> dict[str, Any]:
|
|
431
|
+
assert self.backend is not None
|
|
432
|
+
histograms, marker_bits, indices = accumulator.ordered_window()
|
|
433
|
+
update: dict[str, Any] = {
|
|
434
|
+
"state": state,
|
|
435
|
+
"message": "Preview running; nothing is being saved"
|
|
436
|
+
if state == "PREVIEW_RUNNING"
|
|
437
|
+
else "Preview stopped; nothing was saved",
|
|
438
|
+
"recording": False,
|
|
439
|
+
"fifo_overrun_observed": overrun_seen,
|
|
440
|
+
"preview_window_s": self.config.preview_window_s,
|
|
441
|
+
}
|
|
442
|
+
try:
|
|
443
|
+
update["status"] = self.backend.status()
|
|
444
|
+
except Exception:
|
|
445
|
+
update["status"] = None
|
|
446
|
+
if not len(indices):
|
|
447
|
+
return update
|
|
448
|
+
analysis = self._analysis_snapshot()
|
|
449
|
+
metrics = self._window_metrics(accumulator, histograms, marker_bits, indices, analysis)
|
|
450
|
+
if state == "PREVIEW_RUNNING" and len(indices) > 1:
|
|
451
|
+
# The newest sample was still accumulating at the moment this snapshot was
|
|
452
|
+
# taken -- more records belonging to it may arrive before the next one. Shown
|
|
453
|
+
# as if complete, it reads as a spurious low point at the live edge, since
|
|
454
|
+
# intensity/MPET are computed as if a full sample period had elapsed. Holding
|
|
455
|
+
# it back one snapshot means it only appears once genuinely finished; the
|
|
456
|
+
# final snapshot after Stop shows it regardless, since there is no "next" one
|
|
457
|
+
# to reveal it as complete.
|
|
458
|
+
histograms = histograms[:-1]
|
|
459
|
+
indices = indices[:-1]
|
|
460
|
+
metrics = {name: values[:-1] for name, values in metrics.items()}
|
|
461
|
+
update.update(
|
|
462
|
+
{
|
|
463
|
+
"sample_time_s": (indices.astype(np.float64) + 0.5) / analysis.sample_rate_hz,
|
|
464
|
+
"sample_indices": indices,
|
|
465
|
+
"intensity": metrics["intensity_photons_per_s"],
|
|
466
|
+
"mpet": metrics["mpet_ns"],
|
|
467
|
+
"corrected_photons": metrics["corrected_photons"],
|
|
468
|
+
# Already computed by calculate_metrics, so publishing them costs nothing
|
|
469
|
+
# and lets the lower panel offer the same choices it does while recording.
|
|
470
|
+
"phasor_g": metrics["phasor_g"],
|
|
471
|
+
"phasor_s": metrics["phasor_s"],
|
|
472
|
+
# The summed decay over the window is what CFD, offset, and t0 are set
|
|
473
|
+
# from -- restricted to samples newer than the last "Clear" (§
|
|
474
|
+
# request_clear_decay), if any.
|
|
475
|
+
"decay_counts": (
|
|
476
|
+
histograms[indices > self._decay_reference_sample].sum(axis=0)
|
|
477
|
+
if np.any(indices > self._decay_reference_sample)
|
|
478
|
+
else np.zeros_like(histograms[0])
|
|
479
|
+
),
|
|
480
|
+
"decay_time_ns": (np.arange(analysis.lifetime_bins, dtype=np.float64) + 0.5)
|
|
481
|
+
* analysis.lifetime_bin_width_ps
|
|
482
|
+
/ 1000.0,
|
|
483
|
+
}
|
|
484
|
+
)
|
|
485
|
+
return update
|