mesofield 0.3.2b0__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 (111) hide show
  1. docs/_static/custom.css +40 -0
  2. docs/_static/favicon.png +0 -0
  3. docs/_static/logo.png +0 -0
  4. docs/api/index.md +70 -0
  5. docs/conf.py +200 -0
  6. docs/developer_guide.md +303 -0
  7. docs/index.md +25 -0
  8. docs/tutorial.md +4 -0
  9. docs/user_guide.md +172 -0
  10. examples/teensy_pulse_generator.py +320 -0
  11. experiments/pipeline_demo/experiment.json +24 -0
  12. experiments/pipeline_demo/hardware.yaml +23 -0
  13. experiments/pipeline_demo/procedure.py +50 -0
  14. experiments/two_cam_demo/experiment.json +24 -0
  15. experiments/two_cam_demo/hardware.yaml +58 -0
  16. experiments/two_cam_demo/load_dataset.py +213 -0
  17. experiments/two_cam_demo/procedure.py +87 -0
  18. external/video-codecs/openh264-1.8.0-win64.dll +0 -0
  19. mesofield/__init__.py +45 -0
  20. mesofield/__main__.py +11 -0
  21. mesofield/_version.py +24 -0
  22. mesofield/base.py +750 -0
  23. mesofield/cli/__init__.py +57 -0
  24. mesofield/cli/_richhelp.py +100 -0
  25. mesofield/cli/acquire.py +254 -0
  26. mesofield/cli/datakit.py +165 -0
  27. mesofield/cli/process.py +376 -0
  28. mesofield/cli/rig.py +108 -0
  29. mesofield/cli/tools.py +347 -0
  30. mesofield/config.py +751 -0
  31. mesofield/data/__init__.py +23 -0
  32. mesofield/data/batch.py +633 -0
  33. mesofield/data/manager.py +388 -0
  34. mesofield/data/writer.py +289 -0
  35. mesofield/datakit/__init__.py +44 -0
  36. mesofield/datakit/__main__.py +35 -0
  37. mesofield/datakit/_utils/_logger.py +5 -0
  38. mesofield/datakit/_version.py +141 -0
  39. mesofield/datakit/config.py +50 -0
  40. mesofield/datakit/core.py +783 -0
  41. mesofield/datakit/datamodel.py +200 -0
  42. mesofield/datakit/discover.py +124 -0
  43. mesofield/datakit/explore.py +651 -0
  44. mesofield/datakit/notebooks/pupil_dlc.ipynb +2445 -0
  45. mesofield/datakit/profile.py +535 -0
  46. mesofield/datakit/shell.py +83 -0
  47. mesofield/datakit/sources/__init__.py +65 -0
  48. mesofield/datakit/sources/analysis/mesomap.py +194 -0
  49. mesofield/datakit/sources/analysis/mesoscope.py +77 -0
  50. mesofield/datakit/sources/analysis/pupil.py +246 -0
  51. mesofield/datakit/sources/behavior/__init__.py +0 -0
  52. mesofield/datakit/sources/behavior/dataqueue.py +281 -0
  53. mesofield/datakit/sources/behavior/psychopy.py +364 -0
  54. mesofield/datakit/sources/behavior/treadmill.py +323 -0
  55. mesofield/datakit/sources/behavior/wheel.py +277 -0
  56. mesofield/datakit/sources/camera/mesoscope.py +32 -0
  57. mesofield/datakit/sources/camera/metadata_json.py +130 -0
  58. mesofield/datakit/sources/camera/pupil.py +28 -0
  59. mesofield/datakit/sources/camera/suite2p.py +547 -0
  60. mesofield/datakit/sources/register.py +204 -0
  61. mesofield/datakit/sources/session/config.py +130 -0
  62. mesofield/datakit/sources/session/notes.py +63 -0
  63. mesofield/datakit/sources/session/timestamps.py +58 -0
  64. mesofield/datakit/timeline.py +306 -0
  65. mesofield/devices/__init__.py +42 -0
  66. mesofield/devices/base.py +498 -0
  67. mesofield/devices/base_camera.py +295 -0
  68. mesofield/devices/cameras.py +740 -0
  69. mesofield/devices/daq.py +151 -0
  70. mesofield/devices/encoder.py +384 -0
  71. mesofield/devices/mocks.py +275 -0
  72. mesofield/devices/psychopy_device.py +455 -0
  73. mesofield/devices/subprocesses/__init__.py +0 -0
  74. mesofield/devices/subprocesses/psychopy.py +133 -0
  75. mesofield/devices/treadmill.py +318 -0
  76. mesofield/engines.py +380 -0
  77. mesofield/gui/Mesofield_icon.png +0 -0
  78. mesofield/gui/__init__.py +76 -0
  79. mesofield/gui/config_wizard.py +724 -0
  80. mesofield/gui/controller.py +535 -0
  81. mesofield/gui/dynamic_controller.py +78 -0
  82. mesofield/gui/maingui.py +427 -0
  83. mesofield/gui/mdagui.py +285 -0
  84. mesofield/gui/qt_device_adapter.py +109 -0
  85. mesofield/gui/speedplotter.py +152 -0
  86. mesofield/gui/theme.py +445 -0
  87. mesofield/gui/tiff_viewer.py +1050 -0
  88. mesofield/gui/viewer.py +691 -0
  89. mesofield/hardware.py +549 -0
  90. mesofield/playback.py +1298 -0
  91. mesofield/processing/__init__.py +12 -0
  92. mesofield/processing/runner.py +237 -0
  93. mesofield/processors/__init__.py +13 -0
  94. mesofield/processors/base.py +287 -0
  95. mesofield/processors/frame_mean.py +19 -0
  96. mesofield/protocols.py +378 -0
  97. mesofield/scaffold/__init__.py +34 -0
  98. mesofield/scaffold/experiment.py +400 -0
  99. mesofield/scaffold/rigs.py +121 -0
  100. mesofield/signals.py +85 -0
  101. mesofield/utils/__init__.py +0 -0
  102. mesofield/utils/_logger.py +156 -0
  103. mesofield/utils/retrofit.py +309 -0
  104. mesofield/utils/utils.py +217 -0
  105. mesofield-0.3.2b0.dist-info/METADATA +178 -0
  106. mesofield-0.3.2b0.dist-info/RECORD +111 -0
  107. mesofield-0.3.2b0.dist-info/WHEEL +5 -0
  108. mesofield-0.3.2b0.dist-info/entry_points.txt +2 -0
  109. mesofield-0.3.2b0.dist-info/licenses/LICENSE +21 -0
  110. mesofield-0.3.2b0.dist-info/top_level.txt +6 -0
  111. scripts/bench_frame_processor.py +103 -0
mesofield/playback.py ADDED
@@ -0,0 +1,1298 @@
1
+ """Replay a saved mesofield session as if it were a live acquisition.
2
+
3
+ A playback run instantiates :class:`PlaybackCamera` and :class:`PlaybackEncoder`
4
+ devices that read recorded OME-TIFF frames and encoder CSVs off disk and re-emit
5
+ them through the standard :class:`~mesofield.signals.DeviceSignals` bundle. The
6
+ :class:`~mesofield.data.manager.DataManager` registers them like any other
7
+ producer, so the in-memory :class:`~mesofield.data.manager.DataQueue`, every
8
+ subscribed processor and the GUI viewer see the data move through the same
9
+ pipeline they would during a live capture.
10
+
11
+ Public surface used by ``mesofield playback <experiment_dir>``:
12
+
13
+ - :func:`discover_playback_context` -- locate ``manifest.json``, build a
14
+ :class:`PlaybackProcedure` with one playback device per producer.
15
+ - :func:`launch_playback_app` -- run that procedure inside the standard
16
+ :class:`~mesofield.gui.maingui.MainWindow`.
17
+
18
+ Playback is read-only: devices do not allocate writers, and the procedure
19
+ skips the dataqueue logger, the per-device ``save_data`` step, and the
20
+ ``manifest.json`` re-write so the original session folder is left untouched.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import csv
26
+ import json
27
+ import re
28
+ import threading
29
+ import time
30
+ from dataclasses import dataclass, field
31
+ from datetime import datetime
32
+ from pathlib import Path
33
+ from typing import TYPE_CHECKING, Any, ClassVar, Dict, List, Optional, Tuple
34
+
35
+ import numpy as np
36
+
37
+ import importlib.util
38
+ import sys
39
+ import uuid
40
+
41
+ from mesofield import DeviceRegistry
42
+ from mesofield.base import Procedure
43
+ from mesofield.devices.base import BaseDataProducer
44
+ from mesofield.devices.base_camera import BaseCamera
45
+ from mesofield.utils._logger import get_logger
46
+
47
+ if TYPE_CHECKING:
48
+ from mesofield.config import ExperimentConfig
49
+
50
+
51
+ __all__ = [
52
+ "PlaybackClock",
53
+ "PlaybackCamera",
54
+ "PlaybackEncoder",
55
+ "PlaybackContext",
56
+ "PlaybackSession",
57
+ "discover_playback_context",
58
+ "discover_playback_sessions",
59
+ "PlaybackBrowser",
60
+ "launch_playback_app",
61
+ ]
62
+
63
+
64
+ # ---------------------------------------------------------------------------
65
+ # Shared clock
66
+ # ---------------------------------------------------------------------------
67
+
68
+
69
+ @dataclass
70
+ class PlaybackClock:
71
+ """Wall-clock anchor shared by every playback device in a session.
72
+
73
+ ``t0_original`` is the earliest device timestamp across all producers in
74
+ the manifest; ``t0_wall`` is captured at ``arm()`` time. Each replay thread
75
+ sleeps until ``t0_wall + (original_ts - t0_original) / speed``, which keeps
76
+ the two cameras and the encoder in their original relative order.
77
+ """
78
+
79
+ t0_original: float = 0.0
80
+ speed: float = 1.0
81
+ t0_wall: float = 0.0
82
+
83
+ def reset(self, wall_now: Optional[float] = None) -> None:
84
+ self.t0_wall = time.time() if wall_now is None else wall_now
85
+
86
+ def sleep_until(self, original_ts: float, stop_event: threading.Event) -> bool:
87
+ """Block until the wall-clock moment matching ``original_ts``.
88
+
89
+ Returns ``True`` when interrupted via ``stop_event``.
90
+ """
91
+ if self.speed <= 0:
92
+ return stop_event.wait(0)
93
+ delay = (original_ts - self.t0_original) / self.speed
94
+ target = self.t0_wall + delay
95
+ wait = target - time.time()
96
+ if wait <= 0:
97
+ return stop_event.is_set()
98
+ return stop_event.wait(wait)
99
+
100
+
101
+ # ---------------------------------------------------------------------------
102
+ # Frame metadata / CSV readers
103
+ # ---------------------------------------------------------------------------
104
+
105
+
106
+ def _parse_iso_to_unix(value: str) -> float:
107
+ if value.endswith("Z"):
108
+ value = value[:-1] + "+00:00"
109
+ return datetime.fromisoformat(value).timestamp()
110
+
111
+
112
+ def _load_frame_timestamps(meta_path: Path) -> List[float]:
113
+ """Return UNIX-second timestamps from a ``*_frame_metadata.json`` sidecar.
114
+
115
+ Handles three on-disk shapes mesofield writers produce:
116
+
117
+ - ``CustomWriter`` (OME-TIFF) and ``CV2Writer`` MDA-mode emit
118
+ ``{"p0": [...]}`` keyed by position.
119
+ - ``CV2Writer.finish`` (direct capture-loop mode used by ``OpenCVCamera``)
120
+ wraps the same payload under ``{"frame_metadatas": {"p0": [...]}}``.
121
+ - A bare ``[{...}, ...]`` list is also tolerated.
122
+ """
123
+ with meta_path.open("r", encoding="utf-8") as fh:
124
+ records = json.load(fh)
125
+
126
+ # Unwrap CV2Writer.finish's outer envelope.
127
+ if isinstance(records, dict) and "frame_metadatas" in records:
128
+ records = records["frame_metadatas"]
129
+
130
+ timestamps: List[float] = []
131
+ if isinstance(records, dict):
132
+ for _pos_key, entries in records.items():
133
+ for entry in entries or []:
134
+ ts_raw = entry.get("TimeReceivedByCore")
135
+ if ts_raw is None:
136
+ continue
137
+ timestamps.append(_parse_iso_to_unix(ts_raw))
138
+ elif isinstance(records, list):
139
+ for entry in records:
140
+ ts_raw = entry.get("TimeReceivedByCore")
141
+ if ts_raw is None:
142
+ continue
143
+ timestamps.append(_parse_iso_to_unix(ts_raw))
144
+ return timestamps
145
+
146
+
147
+ # ---------------------------------------------------------------------------
148
+ # Frame readers (OME-TIFF and video)
149
+ # ---------------------------------------------------------------------------
150
+
151
+
152
+ _VIDEO_SUFFIXES = {".mp4", ".avi", ".mov", ".mkv"}
153
+
154
+
155
+ class _FrameReader:
156
+ """Uniform iterator over a recorded camera output.
157
+
158
+ Hides the difference between ``tifffile``-readable stacks and
159
+ ``cv2.VideoCapture``-readable videos so :meth:`PlaybackCamera._run_loop`
160
+ only writes the timing logic once. Used as a context manager; supports
161
+ ``rewind()`` for the ``loop=True`` mode.
162
+ """
163
+
164
+ def __init__(self, path: Path) -> None:
165
+ self.path = path
166
+ self.kind = "video" if path.suffix.lower() in _VIDEO_SUFFIXES else "tiff"
167
+ self.n_frames: int = 0
168
+ self._tf: Any = None
169
+ self._pages: Optional[List[Any]] = None
170
+ self._cap: Any = None
171
+
172
+ def __enter__(self) -> "_FrameReader":
173
+ if self.kind == "tiff":
174
+ import tifffile
175
+
176
+ self._tf = tifffile.TiffFile(str(self.path))
177
+ self._pages = list(self._tf.pages)
178
+ self.n_frames = len(self._pages)
179
+ else:
180
+ import cv2
181
+
182
+ self._cap = cv2.VideoCapture(str(self.path))
183
+ if not self._cap.isOpened():
184
+ raise RuntimeError(f"Could not open video {self.path}")
185
+ count = int(self._cap.get(cv2.CAP_PROP_FRAME_COUNT))
186
+ self.n_frames = max(count, 0)
187
+ return self
188
+
189
+ def __exit__(self, *exc: Any) -> None:
190
+ if self._tf is not None:
191
+ try:
192
+ self._tf.close()
193
+ except Exception:
194
+ pass
195
+ self._tf = None
196
+ if self._cap is not None:
197
+ try:
198
+ self._cap.release()
199
+ except Exception:
200
+ pass
201
+ self._cap = None
202
+
203
+ def read(self, idx: int) -> Optional[np.ndarray]:
204
+ """Return frame ``idx`` (0-based) as an ndarray, or ``None`` if absent."""
205
+ if self.kind == "tiff":
206
+ if self._pages is None or idx >= len(self._pages):
207
+ return None
208
+ return np.asarray(self._pages[idx].asarray())
209
+ # Video: cv2 advances sequentially; idx is informational.
210
+ if self._cap is None:
211
+ return None
212
+ ret, frame = self._cap.read()
213
+ return frame if ret else None
214
+
215
+ def rewind(self) -> None:
216
+ if self.kind == "video" and self._cap is not None:
217
+ import cv2
218
+
219
+ self._cap.set(cv2.CAP_PROP_POS_FRAMES, 0)
220
+ # tiff is indexed, no state to reset.
221
+
222
+
223
+ def _load_encoder_rows(csv_path: Path) -> Tuple[List[str], List[Tuple[float, Any]]]:
224
+ """Read a saved producer CSV.
225
+
226
+ Returns ``(payload_columns, rows)`` where each row is ``(ts, payload)``.
227
+ ``payload`` is the scalar from the single ``payload`` column, or a dict
228
+ keyed by ``payload_columns`` when the CSV fans multiple columns out (the
229
+ treadmill case).
230
+ """
231
+ rows: List[Tuple[float, Any]] = []
232
+ with csv_path.open("r", newline="", encoding="utf-8") as fh:
233
+ reader = csv.reader(fh)
234
+ try:
235
+ header = next(reader)
236
+ except StopIteration:
237
+ return [], []
238
+ if not header or header[0] != "timestamp":
239
+ raise ValueError(
240
+ f"{csv_path}: expected first column 'timestamp', got {header!r}"
241
+ )
242
+ data_cols = header[1:]
243
+ scalar = len(data_cols) == 1 and data_cols[0] == "payload"
244
+
245
+ for raw in reader:
246
+ if not raw:
247
+ continue
248
+ try:
249
+ ts = float(raw[0])
250
+ except ValueError:
251
+ continue
252
+ if scalar:
253
+ payload: Any = _coerce_scalar(raw[1] if len(raw) > 1 else "")
254
+ else:
255
+ payload = {
256
+ col: _coerce_scalar(raw[i + 1]) if i + 1 < len(raw) else None
257
+ for i, col in enumerate(data_cols)
258
+ }
259
+ rows.append((ts, payload))
260
+ return data_cols, rows
261
+
262
+
263
+ def _coerce_scalar(text: str) -> Any:
264
+ if text == "" or text is None:
265
+ return None
266
+ try:
267
+ return int(text)
268
+ except ValueError:
269
+ pass
270
+ try:
271
+ return float(text)
272
+ except ValueError:
273
+ return text
274
+
275
+
276
+ # ---------------------------------------------------------------------------
277
+ # Playback camera
278
+ # ---------------------------------------------------------------------------
279
+
280
+
281
+ @DeviceRegistry.register("playback_camera")
282
+ class PlaybackCamera(BaseCamera, BaseDataProducer):
283
+ """Replays an OME-TIFF + sidecar JSON pair through the standard camera surface."""
284
+
285
+ device_type: ClassVar[str] = "camera"
286
+ file_type: ClassVar[str] = "ome.tiff"
287
+ bids_type: ClassVar[Optional[str]] = "func"
288
+ data_type: ClassVar[str] = "frames"
289
+ clock_source: ClassVar[str] = "wall_unix_s"
290
+
291
+ def __init__(self, cfg: Optional[Dict[str, Any]] = None, **kwargs: Any) -> None:
292
+ BaseDataProducer.__init__(self, cfg, **kwargs)
293
+ self._init_camera_surface(self.cfg, backend="playback")
294
+
295
+ frames_path = self.cfg.get("frames_path")
296
+ meta_path = self.cfg.get("metadata_path")
297
+ if not frames_path:
298
+ raise ValueError(
299
+ f"PlaybackCamera({self.device_id}): 'frames_path' is required"
300
+ )
301
+ self._frames_path = Path(frames_path)
302
+ self._meta_path = Path(meta_path) if meta_path else None
303
+ self._timestamps: List[float] = []
304
+ # Reflect the on-disk format in status()/metadata even though playback
305
+ # never allocates a writer.
306
+ ft = self.cfg.get("file_type")
307
+ if ft:
308
+ self.file_type = str(ft).lower()
309
+
310
+ self._clock: PlaybackClock = self.cfg.get("clock") or PlaybackClock()
311
+ self._loop: bool = bool(self.cfg.get("loop", False))
312
+
313
+ self.sampling_rate = float(
314
+ self.cfg.get("sampling_rate") or self.cfg.get("fps") or 0.0
315
+ )
316
+
317
+ self._stop_event = threading.Event()
318
+ self._thread: Optional[threading.Thread] = None
319
+ self._frame_count: Optional[int] = None
320
+
321
+ self._qt_image_adapter = None
322
+ self.image_ready = None
323
+ try:
324
+ from mesofield.gui.qt_device_adapter import QtImageAdapter
325
+
326
+ self._qt_image_adapter = QtImageAdapter()
327
+ self.image_ready = self._qt_image_adapter.image_ready
328
+ except Exception:
329
+ self.logger.debug("QtImageAdapter unavailable; running headless.")
330
+
331
+ # ---- lifecycle -----------------------------------------------------
332
+ def initialize(self) -> bool:
333
+ if not self._frames_path.is_file():
334
+ raise FileNotFoundError(
335
+ f"PlaybackCamera({self.device_id}): frames file missing: "
336
+ f"{self._frames_path}"
337
+ )
338
+ if self._meta_path and self._meta_path.is_file():
339
+ self._timestamps = _load_frame_timestamps(self._meta_path)
340
+ else:
341
+ self.logger.warning(
342
+ f"No frame metadata sidecar for {self.device_id}; "
343
+ f"synthesising timestamps from sampling_rate={self.sampling_rate}"
344
+ )
345
+ self._timestamps = []
346
+ return True
347
+
348
+ def arm(self, config: "ExperimentConfig") -> None:
349
+ # Read-only: clear the buffer but skip writer / output_path setup.
350
+ self.clear_buffer()
351
+
352
+ def set_writer(self, make_path: Any) -> None: # noqa: D401
353
+ """No-op: playback never allocates a writer."""
354
+ return None
355
+
356
+ def start(self) -> bool:
357
+ if self._thread is not None and self._thread.is_alive():
358
+ return False
359
+ self._stop_event.clear()
360
+ self._thread = threading.Thread(
361
+ target=self._run_loop,
362
+ name=f"PlaybackCamera-{self.device_id}",
363
+ daemon=True,
364
+ )
365
+ self._thread.start()
366
+ return BaseDataProducer.start(self)
367
+
368
+ def stop(self) -> bool:
369
+ self._stop_event.set()
370
+ thread = self._thread
371
+ if thread is not None and thread is not threading.current_thread():
372
+ thread.join(timeout=2.0)
373
+ self._thread = None
374
+ return BaseDataProducer.stop(self)
375
+
376
+ # ---- live preview contract (abstract on BaseCamera) ----------------
377
+ def snap(self) -> Optional[np.ndarray]:
378
+ try:
379
+ with _FrameReader(self._frames_path) as reader:
380
+ if reader.n_frames == 0:
381
+ return None
382
+ frame = reader.read(0)
383
+ except Exception as exc:
384
+ self.logger.error(f"snap failed for {self._frames_path}: {exc}")
385
+ return None
386
+ if frame is not None and self._qt_image_adapter is not None:
387
+ self._qt_image_adapter.emit_frame(frame)
388
+ return frame
389
+
390
+ def start_live(self) -> None:
391
+ self.start()
392
+
393
+ def stop_live(self) -> None:
394
+ self.stop()
395
+
396
+ # ---- run loop ------------------------------------------------------
397
+ def _run_loop(self) -> None:
398
+ try:
399
+ with _FrameReader(self._frames_path) as reader:
400
+ self._frame_count = reader.n_frames
401
+ if reader.n_frames == 0:
402
+ self.logger.warning(f"No frames in {self._frames_path}")
403
+ return
404
+
405
+ timestamps = self._effective_timestamps(reader.n_frames)
406
+
407
+ while not self._stop_event.is_set():
408
+ for idx in range(reader.n_frames):
409
+ if self._stop_event.is_set():
410
+ return
411
+ ts = timestamps[idx]
412
+ if self._clock.sleep_until(ts, self._stop_event):
413
+ return
414
+ try:
415
+ frame = reader.read(idx)
416
+ except Exception as exc:
417
+ self.logger.exception(f"frame read failed: {exc}")
418
+ continue
419
+ if frame is None:
420
+ continue
421
+ if self._qt_image_adapter is not None:
422
+ try:
423
+ self._qt_image_adapter.emit_frame(frame)
424
+ except Exception:
425
+ pass
426
+ try:
427
+ self.signals.frame.emit(frame, idx, ts)
428
+ except Exception:
429
+ pass
430
+ self.record({"frame_index": idx}, ts=ts)
431
+
432
+ if not self._loop:
433
+ return
434
+ # Re-anchor the clock so the next pass replays at speed.
435
+ reader.rewind()
436
+ self._clock.reset()
437
+ except Exception as exc:
438
+ self.logger.exception(f"playback loop crashed: {exc}")
439
+ finally:
440
+ self._emit_finished_if_natural()
441
+ self.logger.debug(f"playback camera {self.device_id} exited")
442
+
443
+ def _emit_finished_if_natural(self) -> None:
444
+ """Emit ``signals.finished`` when the loop exits without a stop call.
445
+
446
+ :meth:`stop` already emits via :class:`BaseDataProducer`; we only
447
+ emit here when the thread ran out of frames on its own, so the
448
+ :class:`~mesofield.base.Procedure`'s primary-finished hook fires
449
+ even though nothing explicitly called ``stop()``.
450
+ """
451
+ if self._stop_event.is_set():
452
+ return
453
+ self.is_active = False
454
+ self.is_running = False
455
+ try:
456
+ self.signals.finished.emit()
457
+ except Exception:
458
+ pass
459
+
460
+ def _effective_timestamps(self, n_pages: int) -> List[float]:
461
+ if self._timestamps and len(self._timestamps) >= n_pages:
462
+ return self._timestamps[:n_pages]
463
+ if self._timestamps:
464
+ # Pad with cadence of the last delta to match page count.
465
+ ts = list(self._timestamps)
466
+ if len(ts) >= 2:
467
+ step = ts[-1] - ts[-2]
468
+ elif self.sampling_rate:
469
+ step = 1.0 / self.sampling_rate
470
+ else:
471
+ step = 0.05
472
+ while len(ts) < n_pages:
473
+ ts.append(ts[-1] + step)
474
+ return ts
475
+ # No metadata at all: synthesise from sampling_rate, anchored at t0.
476
+ step = 1.0 / self.sampling_rate if self.sampling_rate else 0.05
477
+ t0 = self._clock.t0_original
478
+ return [t0 + i * step for i in range(n_pages)]
479
+
480
+ @property
481
+ def first_timestamp(self) -> Optional[float]:
482
+ if self._timestamps:
483
+ return self._timestamps[0]
484
+ return None
485
+
486
+ @property
487
+ def calibration(self) -> Dict[str, Any]:
488
+ return {
489
+ "source": str(self._frames_path),
490
+ "frame_count": self._frame_count,
491
+ "sampling_rate_hz": self.sampling_rate,
492
+ }
493
+
494
+
495
+ # ---------------------------------------------------------------------------
496
+ # Playback encoder
497
+ # ---------------------------------------------------------------------------
498
+
499
+
500
+ @DeviceRegistry.register("playback_encoder")
501
+ class PlaybackEncoder(BaseDataProducer):
502
+ """Replays a saved encoder/treadmill CSV through ``signals.data``."""
503
+
504
+ device_type: ClassVar[str] = "encoder"
505
+ file_type: ClassVar[str] = "csv"
506
+ bids_type: ClassVar[Optional[str]] = "beh"
507
+
508
+ def __init__(self, cfg: Optional[Dict[str, Any]] = None, **kwargs: Any) -> None:
509
+ super().__init__(cfg, **kwargs)
510
+ csv_path = self.cfg.get("csv_path")
511
+ if not csv_path:
512
+ raise ValueError(
513
+ f"PlaybackEncoder({self.device_id}): 'csv_path' is required"
514
+ )
515
+ self._csv_path = Path(csv_path)
516
+ self._rows: List[Tuple[float, Any]] = []
517
+ self._payload_columns: List[str] = []
518
+ self._clock: PlaybackClock = self.cfg.get("clock") or PlaybackClock()
519
+ self._loop: bool = bool(self.cfg.get("loop", False))
520
+ self._stop_event = threading.Event()
521
+ self._thread: Optional[threading.Thread] = None
522
+
523
+ # ---- lifecycle -----------------------------------------------------
524
+ def initialize(self) -> bool:
525
+ if not self._csv_path.is_file():
526
+ raise FileNotFoundError(
527
+ f"PlaybackEncoder({self.device_id}): csv missing: {self._csv_path}"
528
+ )
529
+ self._payload_columns, self._rows = _load_encoder_rows(self._csv_path)
530
+ return True
531
+
532
+ def arm(self, config: "ExperimentConfig") -> None:
533
+ # Read-only: clear buffer, skip make_path.
534
+ self.clear_buffer()
535
+
536
+ def start(self) -> bool:
537
+ if self._thread is not None and self._thread.is_alive():
538
+ return False
539
+ self._stop_event.clear()
540
+ self._thread = threading.Thread(
541
+ target=self._run_loop,
542
+ name=f"PlaybackEncoder-{self.device_id}",
543
+ daemon=True,
544
+ )
545
+ self._thread.start()
546
+ return super().start()
547
+
548
+ def stop(self) -> bool:
549
+ self._stop_event.set()
550
+ thread = self._thread
551
+ if thread is not None and thread is not threading.current_thread():
552
+ thread.join(timeout=2.0)
553
+ self._thread = None
554
+ return super().stop()
555
+
556
+ def save_data(self, path: Optional[str] = None) -> Optional[str]:
557
+ # Read-only.
558
+ return None
559
+
560
+ # ---- run loop ------------------------------------------------------
561
+ def _run_loop(self) -> None:
562
+ try:
563
+ if not self._rows:
564
+ self.logger.warning(f"No rows in {self._csv_path}")
565
+ return
566
+ while not self._stop_event.is_set():
567
+ for ts, payload in self._rows:
568
+ if self._stop_event.is_set():
569
+ return
570
+ if self._clock.sleep_until(ts, self._stop_event):
571
+ return
572
+ self.record(payload, ts=ts)
573
+ if not self._loop:
574
+ return
575
+ self._clock.reset()
576
+ finally:
577
+ if not self._stop_event.is_set():
578
+ self.is_active = False
579
+ self.is_running = False
580
+ try:
581
+ self.signals.finished.emit()
582
+ except Exception:
583
+ pass
584
+
585
+ @property
586
+ def first_timestamp(self) -> Optional[float]:
587
+ return self._rows[0][0] if self._rows else None
588
+
589
+ @property
590
+ def calibration(self) -> Dict[str, Any]:
591
+ return {
592
+ "source": str(self._csv_path),
593
+ "row_count": len(self._rows),
594
+ "payload_columns": list(self._payload_columns),
595
+ }
596
+
597
+
598
+ # ---------------------------------------------------------------------------
599
+ # Context + discovery
600
+ # ---------------------------------------------------------------------------
601
+
602
+
603
+ @dataclass
604
+ class PlaybackContext:
605
+ """Bundle returned by :func:`discover_playback_context`."""
606
+
607
+ procedure: Procedure
608
+ session_dir: Path
609
+ speed: float
610
+ loop: bool
611
+ clock: PlaybackClock
612
+ producers: List[str] = field(default_factory=list)
613
+
614
+
615
+ def _find_manifest(experiment_dir: Path) -> Path:
616
+ """Locate a ``manifest.json`` for *experiment_dir*.
617
+
618
+ Accepts either:
619
+
620
+ - a session directory containing ``manifest.json`` directly, or
621
+ - an experiment root with ``data/sub-*/ses-*/manifest.json`` below it
622
+ (the first match in lexicographic order is used).
623
+ """
624
+ direct = experiment_dir / "manifest.json"
625
+ if direct.is_file():
626
+ return direct
627
+ candidates = sorted(experiment_dir.glob("data/sub-*/ses-*/manifest.json"))
628
+ if candidates:
629
+ return candidates[-1] # most recent session lexicographically
630
+ # As a last resort, recursive search (capped by glob).
631
+ deep = sorted(experiment_dir.rglob("manifest.json"))
632
+ if deep:
633
+ return deep[-1]
634
+ raise FileNotFoundError(
635
+ f"No manifest.json found under {experiment_dir}. Pass either a session "
636
+ "directory or an experiment root containing data/sub-*/ses-*/manifest.json."
637
+ )
638
+
639
+
640
+ def _find_experiment_root(session_dir: Path) -> Optional[Path]:
641
+ """Walk up from *session_dir* looking for an experiment.json/hardware.yaml."""
642
+ for parent in [session_dir, *session_dir.parents]:
643
+ if (parent / "experiment.json").is_file() or (parent / "hardware.yaml").is_file():
644
+ return parent
645
+ return None
646
+
647
+
648
+ def _resolve_user_procedure_class(experiment_json: Path) -> Optional[type]:
649
+ """Return the :class:`Procedure` subclass declared in *experiment_json*.
650
+
651
+ Mirrors :func:`mesofield.base.load_procedure_from_config` but stops before
652
+ instantiation -- we only want the class so we can subclass it and inject
653
+ playback devices via :meth:`define_hardware`. Avoiding the instantiation
654
+ side-steps the live ``initialize_hardware`` step that would otherwise
655
+ try to open real cameras and serial ports during ``mesofield playback``.
656
+ """
657
+ try:
658
+ with experiment_json.open("r", encoding="utf-8") as fh:
659
+ cfg = json.load(fh)
660
+ except Exception:
661
+ return None
662
+ proc_file = cfg.get("procedure_file")
663
+ proc_class = cfg.get("procedure_class")
664
+ if not proc_file or not proc_class:
665
+ return None
666
+
667
+ if not Path(proc_file).is_absolute():
668
+ proc_file = str(experiment_json.parent / proc_file)
669
+ if not Path(proc_file).is_file():
670
+ return None
671
+
672
+ mod_name = f"mesofield_playback_procedure_{uuid.uuid4().hex}"
673
+ spec = importlib.util.spec_from_file_location(mod_name, proc_file)
674
+ if spec is None or spec.loader is None:
675
+ return None
676
+ module = importlib.util.module_from_spec(spec)
677
+ sys.modules[mod_name] = module
678
+ try:
679
+ spec.loader.exec_module(module)
680
+ except Exception:
681
+ return None
682
+ cls = getattr(module, proc_class, None)
683
+ if isinstance(cls, type) and issubclass(cls, Procedure):
684
+ return cls
685
+ return None
686
+
687
+
688
+ def _read_primary_device_id(hardware_yaml: Path) -> Optional[str]:
689
+ """Return the device_id flagged ``primary: true`` in a hardware.yaml.
690
+
691
+ Matches the convention :class:`HardwareManager` enforces: each top-level
692
+ stanza is a device, and the one with ``primary: true`` is the timing
693
+ anchor. Returns ``None`` if the file is unreadable or has no primary.
694
+ """
695
+ try:
696
+ import yaml
697
+
698
+ with hardware_yaml.open("r", encoding="utf-8") as fh:
699
+ data = yaml.safe_load(fh) or {}
700
+ except Exception:
701
+ return None
702
+ for key, stanza in data.items():
703
+ if isinstance(stanza, dict) and stanza.get("primary") is True:
704
+ return str(stanza.get("id", key))
705
+ return None
706
+
707
+
708
+ def _resolve_playback_camera_path(
709
+ entry: Dict[str, Any],
710
+ session_dir: Path,
711
+ logger: Any,
712
+ ) -> Optional[Path]:
713
+ """Resolve a camera producer's frames path with a metadata-sidecar fallback.
714
+
715
+ Some writers (e.g. ``OpenCVCamera`` -> ``CV2Writer``) name the on-disk file
716
+ by the device's display name while :class:`DataSaver` populates the
717
+ manifest's ``output_path`` from the YAML ``suffix:`` -- the two diverge
718
+ and the recorded ``output_path`` ends up pointing at a file that was
719
+ never written. Fall back to ``metadata_path`` minus the
720
+ ``_frame_metadata.json`` tail, which matches the writer's actual filename.
721
+ """
722
+ output_path = entry.get("output_path")
723
+ if output_path:
724
+ abs_output = (session_dir / output_path).resolve()
725
+ if abs_output.is_file():
726
+ return abs_output
727
+
728
+ meta_path = entry.get("metadata_path")
729
+ if meta_path and meta_path.endswith("_frame_metadata.json"):
730
+ derived = (session_dir / meta_path[: -len("_frame_metadata.json")]).resolve()
731
+ if derived.is_file():
732
+ logger.warning(
733
+ f"{entry.get('device_id')}: manifest output_path "
734
+ f"{output_path!r} not on disk; using {derived.name} derived "
735
+ "from metadata_path"
736
+ )
737
+ return derived
738
+
739
+ return None
740
+
741
+
742
+ def _build_devices(
743
+ manifest: Dict[str, Any],
744
+ session_dir: Path,
745
+ clock: PlaybackClock,
746
+ loop: bool,
747
+ logger: Any,
748
+ primary_device_id: Optional[str] = None,
749
+ ) -> Tuple[List[Any], List[str], List[float]]:
750
+ """Construct one playback device per replayable producer in *manifest*."""
751
+ devices: List[Any] = []
752
+ producer_ids: List[str] = []
753
+ first_timestamps: List[float] = []
754
+ producers = manifest.get("producers", []) or []
755
+
756
+ for entry in producers:
757
+ device_id = entry.get("device_id") or "device"
758
+ device_type = entry.get("device_type")
759
+
760
+ if device_type == "camera":
761
+ file_type = (entry.get("file_type") or "").lower()
762
+ if file_type not in {
763
+ "ome.tiff", "ome.tif", "tiff", "tif",
764
+ "mp4", "avi", "mov", "mkv",
765
+ }:
766
+ logger.warning(
767
+ f"Skipping camera {device_id}: file_type "
768
+ f"{file_type!r} not supported for playback"
769
+ )
770
+ continue
771
+ frames_path = _resolve_playback_camera_path(entry, session_dir, logger)
772
+ if frames_path is None:
773
+ logger.error(
774
+ f"Skipping camera {device_id}: cannot find frames file on "
775
+ f"disk (manifest output_path={entry.get('output_path')!r})"
776
+ )
777
+ continue
778
+ meta_path = entry.get("metadata_path")
779
+ abs_meta = (session_dir / meta_path).resolve() if meta_path else None
780
+ cfg = {
781
+ "id": device_id,
782
+ "frames_path": str(frames_path),
783
+ "metadata_path": str(abs_meta) if abs_meta else None,
784
+ "file_type": file_type,
785
+ "sampling_rate": entry.get("sampling_rate_hz") or 0.0,
786
+ "clock": clock,
787
+ "loop": loop,
788
+ }
789
+ device = PlaybackCamera(cfg)
790
+ elif device_type == "encoder":
791
+ output_path = entry.get("output_path")
792
+ if not output_path:
793
+ logger.warning(f"Skipping {device_id}: no output_path in manifest")
794
+ continue
795
+ abs_output = (session_dir / output_path).resolve()
796
+ cfg = {
797
+ "id": device_id,
798
+ "csv_path": str(abs_output),
799
+ "clock": clock,
800
+ "loop": loop,
801
+ }
802
+ device = PlaybackEncoder(cfg)
803
+ else:
804
+ logger.warning(
805
+ f"Skipping {device_id}: device_type "
806
+ f"{device_type!r} not supported in playback v1"
807
+ )
808
+ continue
809
+
810
+ try:
811
+ device.initialize()
812
+ except Exception as exc:
813
+ logger.error(f"initialize() failed for {device_id}: {exc}")
814
+ continue
815
+
816
+ ts0 = getattr(device, "first_timestamp", None)
817
+ if ts0 is not None:
818
+ first_timestamps.append(float(ts0))
819
+
820
+ devices.append(device)
821
+ producer_ids.append(device_id)
822
+
823
+ # Promote the device matching the recorded primary (preferred) or the
824
+ # first camera (fallback) to is_primary=True. HardwareManager enforces
825
+ # exactly-one-primary; Procedure.run wires cleanup off its `finished`.
826
+ primary_set = False
827
+ if primary_device_id:
828
+ for dev in devices:
829
+ if dev.device_id == primary_device_id:
830
+ dev.is_primary = True
831
+ primary_set = True
832
+ break
833
+ if not primary_set:
834
+ logger.warning(
835
+ f"hardware.yaml's primary device {primary_device_id!r} is not "
836
+ "in the replayable set; falling back to first camera"
837
+ )
838
+ if not primary_set:
839
+ for dev in devices:
840
+ if isinstance(dev, PlaybackCamera):
841
+ dev.is_primary = True
842
+ primary_set = True
843
+ break
844
+ if not primary_set and devices:
845
+ devices[0].is_primary = True
846
+
847
+ return devices, producer_ids, first_timestamps
848
+
849
+
850
+ def _make_playback_procedure(
851
+ base_cls: type,
852
+ *,
853
+ devices: List[Any],
854
+ session_dir: Path,
855
+ session_info: Dict[str, Any],
856
+ speed: float,
857
+ loop: bool,
858
+ ) -> Procedure:
859
+ """Dynamically subclass *base_cls* to inject playback devices.
860
+
861
+ The wrapper sets ``playback = True`` (so the base ``Procedure`` skips its
862
+ disk-writing side-effects), overrides ``define_hardware`` to return the
863
+ pre-built playback devices, and -- only when the base class has no real
864
+ ``experiment.json`` to drive it -- overrides ``define_config`` from the
865
+ manifest's session block. When an experiment.json is available we let the
866
+ user's class load it normally so duration/notes/processors line up with
867
+ the original run.
868
+ """
869
+
870
+ class _PlaybackWrapper(base_cls): # type: ignore[valid-type, misc]
871
+ playback = True
872
+
873
+ def define_hardware(self): # noqa: D401
874
+ return list(devices)
875
+
876
+ _PlaybackWrapper.__name__ = f"Playback[{base_cls.__name__}]"
877
+ _PlaybackWrapper.__qualname__ = _PlaybackWrapper.__name__
878
+
879
+ if base_cls is Procedure:
880
+ # No user procedure -> manufacture a config from the manifest so
881
+ # subject/session/task/protocol are populated for logging/paths.
882
+ def _define_config(self): # type: ignore[no-redef]
883
+ info = session_info
884
+ return {
885
+ "subject": str(info.get("subject", "playback")),
886
+ "session": str(info.get("session", "00")),
887
+ "task": str(info.get("task", "playback")),
888
+ "protocol": str(info.get("protocol", "playback")),
889
+ "experimenter": str(info.get("experimenter", "playback")),
890
+ "duration": int(info.get("duration", 0) or 0),
891
+ }
892
+
893
+ _PlaybackWrapper.define_config = _define_config # type: ignore[assignment]
894
+ proc = _PlaybackWrapper(config_path=None)
895
+ try:
896
+ proc.config.experiment_dir = str(session_dir.parent.parent.parent)
897
+ except Exception:
898
+ pass
899
+ return proc
900
+
901
+ # The user's procedure class drives __init__ from its own experiment.json.
902
+ # _PlaybackWrapper.define_hardware short-circuits the YAML path so devices
903
+ # come from our pre-built list.
904
+ experiment_root = _find_experiment_root(session_dir)
905
+ config_path = None
906
+ if experiment_root is not None:
907
+ candidate = experiment_root / "experiment.json"
908
+ if candidate.is_file():
909
+ config_path = str(candidate)
910
+ proc = _PlaybackWrapper(config_path)
911
+ return proc
912
+
913
+
914
+ def discover_playback_context(
915
+ experiment_dir: Path,
916
+ *,
917
+ speed: float = 1.0,
918
+ loop: bool = False,
919
+ ) -> PlaybackContext:
920
+ """Build a :class:`PlaybackContext` from a recorded session directory."""
921
+ logger = get_logger(__name__)
922
+ experiment_dir = Path(experiment_dir).expanduser().resolve()
923
+ manifest_path = _find_manifest(experiment_dir)
924
+ session_dir = manifest_path.parent
925
+
926
+ with manifest_path.open("r", encoding="utf-8") as fh:
927
+ manifest = json.load(fh)
928
+
929
+ experiment_root = _find_experiment_root(session_dir)
930
+ primary_id: Optional[str] = None
931
+ base_cls: type = Procedure
932
+ if experiment_root is not None:
933
+ hw_yaml = experiment_root / "hardware.yaml"
934
+ if hw_yaml.is_file():
935
+ primary_id = _read_primary_device_id(hw_yaml)
936
+ exp_json = experiment_root / "experiment.json"
937
+ if exp_json.is_file():
938
+ resolved = _resolve_user_procedure_class(exp_json)
939
+ if resolved is not None:
940
+ base_cls = resolved
941
+ logger.info(
942
+ f"Using user procedure class {base_cls.__name__} from "
943
+ f"{exp_json}"
944
+ )
945
+ else:
946
+ logger.debug(
947
+ f"No procedure_file/procedure_class in {exp_json}; "
948
+ "using base Procedure."
949
+ )
950
+
951
+ clock = PlaybackClock(speed=float(speed))
952
+ devices, producer_ids, first_timestamps = _build_devices(
953
+ manifest, session_dir, clock, loop, logger,
954
+ primary_device_id=primary_id,
955
+ )
956
+ if not devices:
957
+ raise RuntimeError(
958
+ f"No replayable producers found in {manifest_path}. "
959
+ "Playback supports camera (OME-TIFF / mp4) and encoder (CSV) producers."
960
+ )
961
+ clock.t0_original = min(first_timestamps) if first_timestamps else 0.0
962
+ clock.reset() # wall-clock anchor; reset again at procedure start.
963
+
964
+ session_info = dict(manifest.get("session") or {})
965
+ procedure = _make_playback_procedure(
966
+ base_cls,
967
+ devices=devices,
968
+ session_dir=session_dir,
969
+ session_info=session_info,
970
+ speed=float(speed),
971
+ loop=bool(loop),
972
+ )
973
+ logger.info(
974
+ f"Playback context ready: {len(producer_ids)} producers from "
975
+ f"{manifest_path} (speed={speed:.2f}x, loop={loop})"
976
+ )
977
+ return PlaybackContext(
978
+ procedure=procedure,
979
+ session_dir=session_dir,
980
+ speed=float(speed),
981
+ loop=bool(loop),
982
+ clock=clock,
983
+ producers=producer_ids,
984
+ )
985
+
986
+
987
+ # ---------------------------------------------------------------------------
988
+ # Session-list discovery (drives the ConfigController dropdowns)
989
+ # ---------------------------------------------------------------------------
990
+
991
+
992
+ @dataclass
993
+ class PlaybackSession:
994
+ """One ``(subject, session)`` directory discovered on disk.
995
+
996
+ Holds just enough metadata for the ConfigController dropdowns to present
997
+ a choice: the parsed subject/session ids, the absolute session directory,
998
+ the path to its manifest, and the task/protocol read from the manifest's
999
+ session block (used as display hints and to seed ``ExperimentConfig``).
1000
+ """
1001
+
1002
+ subject: str
1003
+ session: str
1004
+ session_dir: Path
1005
+ manifest_path: Path
1006
+ task: Optional[str] = None
1007
+ protocol: Optional[str] = None
1008
+
1009
+
1010
+ _SUB_RE = re.compile(r"^sub-(.+)$")
1011
+ _SES_RE = re.compile(r"^ses-(.+)$")
1012
+
1013
+
1014
+ def discover_playback_sessions(experiment_dir: Path) -> List[PlaybackSession]:
1015
+ """Enumerate every ``data/sub-*/ses-*/manifest.json`` under *experiment_dir*.
1016
+
1017
+ Falls back to scanning *experiment_dir* itself when it points directly at
1018
+ a session directory. Sessions are returned sorted by (subject, session) so
1019
+ the dropdowns have a predictable order.
1020
+ """
1021
+ experiment_dir = Path(experiment_dir).expanduser().resolve()
1022
+ candidates = sorted(experiment_dir.glob("data/sub-*/ses-*/manifest.json"))
1023
+ if not candidates and (experiment_dir / "manifest.json").is_file():
1024
+ candidates = [experiment_dir / "manifest.json"]
1025
+ sessions: List[PlaybackSession] = []
1026
+ for manifest_path in candidates:
1027
+ session_dir = manifest_path.parent
1028
+ sub_match = _SUB_RE.match(session_dir.parent.name)
1029
+ ses_match = _SES_RE.match(session_dir.name)
1030
+ if not (sub_match and ses_match):
1031
+ continue
1032
+ info: Dict[str, Any] = {}
1033
+ try:
1034
+ with manifest_path.open("r", encoding="utf-8") as fh:
1035
+ info = (json.load(fh) or {}).get("session") or {}
1036
+ except Exception:
1037
+ pass
1038
+ sessions.append(
1039
+ PlaybackSession(
1040
+ subject=sub_match.group(1),
1041
+ session=ses_match.group(1),
1042
+ session_dir=session_dir,
1043
+ manifest_path=manifest_path,
1044
+ task=info.get("task"),
1045
+ protocol=info.get("protocol"),
1046
+ )
1047
+ )
1048
+ sessions.sort(key=lambda s: (s.subject, s.session))
1049
+ return sessions
1050
+
1051
+
1052
+ # ---------------------------------------------------------------------------
1053
+ # Browser: ties subject/session dropdowns to procedure swaps
1054
+ # ---------------------------------------------------------------------------
1055
+
1056
+
1057
+ class PlaybackBrowser:
1058
+ """Drives :class:`~mesofield.gui.controller.ConfigController` for playback.
1059
+
1060
+ Two responsibilities:
1061
+
1062
+ 1. Re-seed ``ExperimentConfig.subjects`` from the set of recorded sessions
1063
+ on disk so the subject dropdown lists what is actually playable, and
1064
+ register the per-subject session ids as ``register_choices`` on the
1065
+ ``session`` key so it renders as a dropdown.
1066
+ 2. When the user picks a different subject or session, tear down the
1067
+ current playback procedure and swap a freshly-built one into
1068
+ :class:`~mesofield.gui.maingui.MainWindow`, re-using the existing
1069
+ widget-rebuild path (``_build_acquisition_ui`` + ``_on_config_applied``).
1070
+
1071
+ Implementation note: the actual swap is deferred via ``QTimer.singleShot``
1072
+ because the change callback fires inside a QComboBox signal handler --
1073
+ rebuilding the parent ConfigController during that callback would delete
1074
+ the QObject that emitted the signal.
1075
+ """
1076
+
1077
+ def __init__(
1078
+ self,
1079
+ main_window: Any,
1080
+ sessions: List[PlaybackSession],
1081
+ *,
1082
+ speed: float = 1.0,
1083
+ loop: bool = False,
1084
+ ) -> None:
1085
+ self.main_window = main_window
1086
+ self.sessions = list(sessions)
1087
+ self.speed = float(speed)
1088
+ self.loop = bool(loop)
1089
+ self.logger = get_logger(f"{__name__}.PlaybackBrowser")
1090
+
1091
+ self._by_subject: Dict[str, List[PlaybackSession]] = {}
1092
+ for ses in self.sessions:
1093
+ self._by_subject.setdefault(ses.subject, []).append(ses)
1094
+
1095
+ self._swapping: bool = False
1096
+ self._current: Optional[PlaybackSession] = None
1097
+ self._install()
1098
+
1099
+ # -- public API -----------------------------------------------------
1100
+ @property
1101
+ def current(self) -> Optional[PlaybackSession]:
1102
+ return self._current
1103
+
1104
+ # -- install ---------------------------------------------------------
1105
+ def _install(self) -> None:
1106
+ """Apply browser-aware behaviour to the live ConfigController.
1107
+
1108
+ Called once at startup and again after every procedure swap (because
1109
+ the new procedure has a fresh ``ExperimentConfig`` object and the
1110
+ ConfigController is rebuilt by ``MainWindow._on_config_applied``).
1111
+ """
1112
+ proc = self.main_window.procedure
1113
+ cfg = proc.config
1114
+
1115
+ # Mirror the disk inventory onto cfg.subjects so the existing subject
1116
+ # dropdown wiring (built in ConfigController._populate_subjects from
1117
+ # cfg.subjects.keys()) reflects what's available for replay.
1118
+ cfg.subjects = {
1119
+ sub: {
1120
+ "session": ses_list[0].session,
1121
+ "task": ses_list[0].task or cfg.get("task", "playback") or "playback",
1122
+ }
1123
+ for sub, ses_list in self._by_subject.items()
1124
+ }
1125
+
1126
+ current_sub = cfg.get("subject")
1127
+ if current_sub not in cfg.subjects and cfg.subjects:
1128
+ current_sub = next(iter(cfg.subjects))
1129
+
1130
+ # Build (and lock in) session choices for the current subject so the
1131
+ # ConfigController's form renders a session dropdown rather than a
1132
+ # free-text editor.
1133
+ self._set_session_choices(current_sub)
1134
+
1135
+ # When _change_subject was last called by the ConfigController, it ran
1136
+ # select_subject(initial) which set "session" to whatever cfg.subjects
1137
+ # said. If we have a more precise current_session (from the active
1138
+ # PlaybackContext), enforce it here so the form picks the right entry.
1139
+ if self._current is not None and current_sub == self._current.subject:
1140
+ cfg.set("session", self._current.session)
1141
+
1142
+ # Listen for subject/session changes; subject changes also update
1143
+ # session choices (the dropdown gets repopulated for the new subject).
1144
+ cfg.register_callback("subject", self._on_subject_changed)
1145
+ cfg.register_callback("session", self._on_session_changed)
1146
+
1147
+ # Push a refreshed ConfigController tab (the form widget reads
1148
+ # choices at construction time, so a rebuild is required for the new
1149
+ # session list to render).
1150
+ try:
1151
+ self.main_window._on_config_applied()
1152
+ except Exception as exc:
1153
+ self.logger.debug(f"_on_config_applied() reinstall failed: {exc}")
1154
+
1155
+ # Relabel the Record button now that the controller has been rebuilt.
1156
+ cc = getattr(self.main_window, "_config_controller", None)
1157
+ if cc is not None and hasattr(cc, "record_button"):
1158
+ cc.record_button.setText("Play")
1159
+ cc.record_button.setToolTip("Play back the selected session")
1160
+
1161
+ def _set_session_choices(self, subject: str) -> None:
1162
+ cfg = self.main_window.procedure.config
1163
+ sess_list = [s.session for s in self._by_subject.get(subject, [])]
1164
+ if sess_list:
1165
+ cfg.register_choices("session", sess_list)
1166
+
1167
+ # -- callbacks -------------------------------------------------------
1168
+ def _on_subject_changed(self, _key: str, value: Any) -> None:
1169
+ if self._swapping:
1170
+ return
1171
+ # Update choices for the new subject so the form, when ConfigController
1172
+ # rebuilds it after select_subject returns, renders the right options.
1173
+ self._set_session_choices(str(value))
1174
+ # The session change that select_subject() fires next will dispatch
1175
+ # the actual procedure swap.
1176
+
1177
+ def _on_session_changed(self, _key: str, value: Any) -> None:
1178
+ if self._swapping:
1179
+ return
1180
+ cfg = self.main_window.procedure.config
1181
+ subject = cfg.get("subject")
1182
+ # Defer to the event loop so the QComboBox that emitted this change
1183
+ # is not destroyed in the middle of its own signal dispatch.
1184
+ try:
1185
+ from PyQt6.QtCore import QTimer
1186
+
1187
+ QTimer.singleShot(0, lambda: self._swap_to(str(subject), str(value)))
1188
+ except Exception:
1189
+ # Headless / no Qt loop -- swap synchronously (e.g. tests).
1190
+ self._swap_to(str(subject), str(value))
1191
+
1192
+ # -- swap ------------------------------------------------------------
1193
+ def _swap_to(self, subject: str, session: str) -> None:
1194
+ target = next(
1195
+ (s for s in self.sessions if s.subject == subject and s.session == session),
1196
+ None,
1197
+ )
1198
+ if target is None:
1199
+ self.logger.warning(
1200
+ f"No session found on disk for sub-{subject}/ses-{session}; "
1201
+ "ignoring change"
1202
+ )
1203
+ return
1204
+ if self._current is not None and target.session_dir == self._current.session_dir:
1205
+ return # no-op
1206
+ self.logger.info(
1207
+ f"Swapping playback target -> sub-{subject}/ses-{session} "
1208
+ f"({target.session_dir})"
1209
+ )
1210
+ self._swapping = True
1211
+ try:
1212
+ self._teardown_current()
1213
+ new_ctx = discover_playback_context(
1214
+ target.session_dir, speed=self.speed, loop=self.loop
1215
+ )
1216
+ self.main_window.procedure = new_ctx.procedure
1217
+ try:
1218
+ self.main_window._build_acquisition_ui()
1219
+ except Exception as exc:
1220
+ self.logger.exception(f"_build_acquisition_ui failed: {exc}")
1221
+ self._current = target
1222
+ # Re-install on the new procedure's fresh config object.
1223
+ self._install()
1224
+ finally:
1225
+ self._swapping = False
1226
+
1227
+ def _teardown_current(self) -> None:
1228
+ proc = self.main_window.procedure
1229
+ try:
1230
+ proc.cleanup()
1231
+ except Exception:
1232
+ pass
1233
+ try:
1234
+ proc.config.hardware.shutdown()
1235
+ except Exception:
1236
+ pass
1237
+
1238
+
1239
+ # ---------------------------------------------------------------------------
1240
+ # GUI launcher
1241
+ # ---------------------------------------------------------------------------
1242
+
1243
+
1244
+ def launch_playback_app(
1245
+ context: PlaybackContext,
1246
+ *,
1247
+ browser_sessions: Optional[List[PlaybackSession]] = None,
1248
+ ) -> int:
1249
+ """Open the standard mesofield GUI against a :class:`PlaybackContext`.
1250
+
1251
+ When ``browser_sessions`` is supplied, a :class:`PlaybackBrowser` is wired
1252
+ into the MainWindow's ConfigController so the subject/session dropdowns
1253
+ swap playback targets on change.
1254
+ """
1255
+ from PyQt6.QtWidgets import QApplication
1256
+
1257
+ from mesofield.gui.maingui import MainWindow
1258
+ from mesofield.gui import theme
1259
+
1260
+ app = QApplication.instance() or QApplication([])
1261
+ theme.apply_theme(app)
1262
+
1263
+ window = MainWindow(context.procedure)
1264
+ title = (
1265
+ f"Mesofield Playback (speed={context.speed:g}x"
1266
+ f"{', loop' if context.loop else ''})"
1267
+ )
1268
+ window.setWindowTitle(title)
1269
+ window.show()
1270
+
1271
+ browser: Optional[PlaybackBrowser] = None
1272
+ if browser_sessions:
1273
+ # Find the session entry that matches the loaded context so the
1274
+ # browser's _current pointer is correct before any change events.
1275
+ match = next(
1276
+ (s for s in browser_sessions if s.session_dir == context.session_dir),
1277
+ None,
1278
+ )
1279
+ browser = PlaybackBrowser(
1280
+ window,
1281
+ browser_sessions,
1282
+ speed=context.speed,
1283
+ loop=context.loop,
1284
+ )
1285
+ browser._current = match
1286
+ # Hold a reference on the window so the browser isn't GC'd while the
1287
+ # GUI is alive.
1288
+ window._playback_browser = browser # type: ignore[attr-defined]
1289
+
1290
+ # Re-anchor the wall clock at the moment we kick off, so any GUI startup
1291
+ # delay does not eat the head of the recording, then start the procedure.
1292
+ context.clock.reset()
1293
+ try:
1294
+ context.procedure.run()
1295
+ except Exception:
1296
+ get_logger(__name__).exception("playback procedure.run failed")
1297
+
1298
+ return app.exec()