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.
Files changed (49) hide show
  1. driftlessflip/__init__.py +5 -0
  2. driftlessflip/__main__.py +3 -0
  3. driftlessflip/acquisition/__init__.py +1 -0
  4. driftlessflip/acquisition/looping.py +162 -0
  5. driftlessflip/acquisition/preview.py +485 -0
  6. driftlessflip/acquisition/runner.py +931 -0
  7. driftlessflip/acquisition/tuning.py +226 -0
  8. driftlessflip/cli.py +264 -0
  9. driftlessflip/config.py +392 -0
  10. driftlessflip/gui_entry.py +13 -0
  11. driftlessflip/hardware/__init__.py +1 -0
  12. driftlessflip/hardware/base.py +62 -0
  13. driftlessflip/hardware/simulator.py +273 -0
  14. driftlessflip/hardware/timeharp.py +550 -0
  15. driftlessflip/metadata.py +343 -0
  16. driftlessflip/processing/__init__.py +1 -0
  17. driftlessflip/processing/aggregation.py +388 -0
  18. driftlessflip/processing/analysis.py +296 -0
  19. driftlessflip/processing/corrections.py +152 -0
  20. driftlessflip/processing/fitting.py +494 -0
  21. driftlessflip/processing/rates.py +166 -0
  22. driftlessflip/processing/review.py +622 -0
  23. driftlessflip/processing/t3.py +81 -0
  24. driftlessflip/schemas/__init__.py +1 -0
  25. driftlessflip/schemas/run_metadata.schema.json +118 -0
  26. driftlessflip/storage/__init__.py +1 -0
  27. driftlessflip/storage/background.py +233 -0
  28. driftlessflip/storage/common.py +52 -0
  29. driftlessflip/storage/csv_export.py +200 -0
  30. driftlessflip/storage/legacy_histogram.py +197 -0
  31. driftlessflip/storage/manifest.py +48 -0
  32. driftlessflip/storage/naming.py +193 -0
  33. driftlessflip/storage/nwb_export.py +309 -0
  34. driftlessflip/storage/profiles.py +111 -0
  35. driftlessflip/storage/raw_h5.py +371 -0
  36. driftlessflip/storage/recovery.py +467 -0
  37. driftlessflip/storage/settings_file.py +344 -0
  38. driftlessflip/storage/verify.py +202 -0
  39. driftlessflip/ui/__init__.py +1 -0
  40. driftlessflip/ui/app.py +4996 -0
  41. driftlessflip/ui/axes.py +201 -0
  42. driftlessflip/ui/plots.py +2002 -0
  43. driftlessflip-0.15.0.data/data/share/driftlessflip/driftlessflip-logo.png +0 -0
  44. driftlessflip-0.15.0.dist-info/METADATA +441 -0
  45. driftlessflip-0.15.0.dist-info/RECORD +49 -0
  46. driftlessflip-0.15.0.dist-info/WHEEL +5 -0
  47. driftlessflip-0.15.0.dist-info/entry_points.txt +2 -0
  48. driftlessflip-0.15.0.dist-info/licenses/LICENSE +21 -0
  49. driftlessflip-0.15.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,5 @@
1
+ """DriftlessFLIP acquisition and analysis software."""
2
+
3
+ __version__ = "0.15.0"
4
+
5
+ __all__ = ["__version__"]
@@ -0,0 +1,3 @@
1
+ from driftlessflip.cli import main
2
+
3
+ raise SystemExit(main())
@@ -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