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
@@ -0,0 +1,547 @@
1
+ """Suite2p loader with nidaq-aligned timeline and table payloads."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterable
6
+ from pathlib import Path
7
+ import re
8
+ from typing import Any, Dict, Optional, Tuple
9
+
10
+ import numpy as np
11
+ import pandas as pd
12
+
13
+ from mesofield.datakit.sources.register import LoadContext, TimeseriesSource
14
+ from mesofield.datakit.datamodel import StreamPayload
15
+
16
+
17
+
18
+ class Suite2pV2(TimeseriesSource):
19
+ """Load Suite2p outputs using nidaq pulse alignment."""
20
+
21
+ tag = "suite2p"
22
+ patterns = ("**/*_suite2p/**/F.npy",)
23
+ requires = ("dataqueue",)
24
+
25
+ required_files: Tuple[str, ...] = ("Fneu.npy", "iscell.npy")
26
+ optional_files: Tuple[str, ...] = ("ops.npy", "stat.npy")
27
+
28
+ COLUMN_MAP: Dict[Tuple[str, str], str] = {
29
+ ("raw", "cell_identifier"): "cell_identifier",
30
+ ("raw", "stat"): "stat",
31
+ ("raw", "ops"): "ops",
32
+ ("raw", "plane_directory"): "plane_directory",
33
+ ("raw", "cell_mask"): "cell_mask",
34
+ ("processed", "roi_fluorescence"): "roi_fluorescence",
35
+ ("processed", "neuropil_fluorescence"): "neuropil_fluorescence",
36
+ ("processed", "deltaf_f"): "deltaf_f",
37
+ ("toolkit", "time_native_s"): "time_native_s",
38
+ ("toolkit", "time_elapsed_s"): "time_elapsed_s",
39
+ }
40
+
41
+ NEUROPIL_SCALE = 0.3
42
+ BASELINE_PERCENTILE = 3.0
43
+ PULSES_PER_FRAME: Optional[int] = 2
44
+
45
+ def build_timeseries(
46
+ self,
47
+ path: Path,
48
+ *,
49
+ context: LoadContext | None = None,
50
+ ) -> tuple[np.ndarray, StreamPayload, dict]:
51
+ """Load Suite2p outputs and return timeline, payload, and metadata."""
52
+ plane_dir = path.parent
53
+ session_dir, subject, session, task = self._find_session_dir(plane_dir)
54
+ dq_path = None
55
+ if context is not None:
56
+ dq_path = context.path_for("dataqueue")
57
+
58
+ f_traces = self._ensure_float32(self._load_array(path))
59
+ ancillary = self._load_companions(plane_dir)
60
+
61
+ n_cells, n_frames = f_traces.shape
62
+
63
+ timeline, timing_meta = self._nidaq_timeline(
64
+ session_dir,
65
+ n_frames,
66
+ dq_path=dq_path,
67
+ dq_frame=context.dataqueue_frame if context else None,
68
+ subject=subject,
69
+ session=session,
70
+ task=task,
71
+ )
72
+ timeline = timeline - timeline[0]
73
+
74
+ cell_mask = ancillary["iscell"][:, 0].astype(bool)
75
+ accepted_cells = int(cell_mask.sum())
76
+
77
+ sections, processing_meta = self._process_traces(
78
+ raw_traces=f_traces,
79
+ neuropil_traces=ancillary["Fneu"],
80
+ cell_mask=cell_mask,
81
+ timeline=timeline,
82
+ )
83
+
84
+ raw_section = {
85
+ "cell_identifier": ancillary["iscell"],
86
+ "stat": ancillary["stat"],
87
+ "ops": ancillary["ops"],
88
+ "plane_directory": str(plane_dir),
89
+ "cell_mask": cell_mask,
90
+ }
91
+
92
+ sections = {"raw": raw_section, **sections}
93
+
94
+ table = self._build_payload_table(sections)
95
+ payload = StreamPayload.table(table)
96
+
97
+ meta = {
98
+ "source_file": str(path),
99
+ "plane": plane_dir.name,
100
+ "session_dir": str(session_dir) if session_dir else None,
101
+ "n_rois": int(n_cells),
102
+ "n_cells": accepted_cells,
103
+ "n_frames": int(n_frames),
104
+ }
105
+ meta.update(timing_meta)
106
+ meta.update(processing_meta)
107
+
108
+ return timeline, payload, meta
109
+
110
+ # ------------------------------------------------------------------
111
+ # Processing helpers
112
+ # ------------------------------------------------------------------
113
+ def _process_traces(
114
+ self,
115
+ *,
116
+ raw_traces: np.ndarray,
117
+ neuropil_traces: np.ndarray,
118
+ cell_mask: np.ndarray,
119
+ timeline: np.ndarray,
120
+ ) -> Tuple[Dict[str, Dict[str, Any]], Dict[str, Any]]:
121
+ timeline = np.asarray(timeline, dtype=np.float64)
122
+
123
+ defaults = {
124
+ "neuropil_scale": self.NEUROPIL_SCALE,
125
+ "baseline_percentile": self.BASELINE_PERCENTILE,
126
+ }
127
+
128
+ if raw_traces.size == 0 or neuropil_traces.size == 0:
129
+ raise ValueError("Suite2pV2: empty fluorescence arrays")
130
+
131
+ if timeline.shape[0] != raw_traces.shape[1]:
132
+ raise ValueError(
133
+ "Suite2pV2: timeline length mismatch; expected one timestamp per frame"
134
+ )
135
+
136
+ roi_fluo = raw_traces[cell_mask].astype(np.float32, copy=False)
137
+ neuropil_fluo = neuropil_traces[cell_mask].astype(np.float32, copy=False)
138
+
139
+ filtered_meta = {
140
+ "total_rois": int(raw_traces.shape[0]),
141
+ "filtered_cells": int(roi_fluo.shape[0]),
142
+ }
143
+
144
+ if roi_fluo.size == 0:
145
+ raise ValueError("Suite2pV2: no accepted cells after masking")
146
+
147
+ # Step 1: baseline and native $\Delta F / F$
148
+ baseline = np.percentile(
149
+ roi_fluo,
150
+ self.BASELINE_PERCENTILE,
151
+ axis=1,
152
+ keepdims=True,
153
+ ).astype(np.float32, copy=False)
154
+ baseline_mask = np.abs(baseline) > np.finfo(np.float64).eps
155
+ deltaf_f = np.full_like(roi_fluo, np.nan, dtype=np.float32)
156
+ np.divide(
157
+ roi_fluo - baseline,
158
+ baseline,
159
+ out=deltaf_f,
160
+ where=baseline_mask,
161
+ )
162
+
163
+ sections = {
164
+ "processed": {
165
+ "roi_fluorescence": roi_fluo,
166
+ "neuropil_fluorescence": neuropil_fluo,
167
+ "deltaf_f": deltaf_f,
168
+ },
169
+ "toolkit": {
170
+ "time_native_s": self._ensure_float32(timeline),
171
+ "time_elapsed_s": self._ensure_float32(timeline),
172
+ },
173
+ }
174
+
175
+ meta = {
176
+ **filtered_meta,
177
+ "processing_status": "ok",
178
+ **defaults,
179
+ }
180
+
181
+ if baseline.size:
182
+ meta["baseline_percentile_mean"] = float(np.nanmean(baseline))
183
+ return sections, meta
184
+
185
+ def _build_payload_table(self, sections: Dict[str, Dict[str, Any]]) -> pd.DataFrame:
186
+ flat_data: Dict[str, list[Any]] = {}
187
+
188
+ for section, items in sections.items():
189
+ if not items:
190
+ continue
191
+
192
+ for field, value in items.items():
193
+ column_name = self.COLUMN_MAP.get((section, field))
194
+ if column_name is None:
195
+ raise KeyError(f"Suite2pV2 column mapping missing for section '{section}' field '{field}'")
196
+
197
+ row_labels: list[str] | None = None
198
+ data_value = value
199
+
200
+ if isinstance(value, tuple) and len(value) == 2:
201
+ candidate, labels = value
202
+ if isinstance(labels, Iterable) and not isinstance(labels, (str, bytes)):
203
+ row_labels = []
204
+ for idx, lbl in enumerate(labels):
205
+ label_text = str(lbl).strip().replace(" ", "_")
206
+ row_labels.append(label_text or f"component_{idx}")
207
+ data_value = candidate
208
+ else:
209
+ data_value = candidate
210
+
211
+ coerced = self._coerce_table_value(data_value)
212
+
213
+ if row_labels:
214
+ components = self._expand_components(coerced, len(row_labels))
215
+ for idx, label in enumerate(row_labels):
216
+ flat_data[f"{column_name}_{label}"] = [components[idx]]
217
+ else:
218
+ flat_data[column_name] = [coerced]
219
+
220
+ frame = pd.DataFrame(flat_data)
221
+ frame.index = pd.Index([0], name="record")
222
+ return frame
223
+
224
+ @staticmethod
225
+ def _coerce_table_value(value: Any) -> Any:
226
+ if isinstance(value, pd.Series):
227
+ return Suite2pV2._coerce_table_value(value.to_numpy(copy=False))
228
+ if isinstance(value, pd.Index):
229
+ return Suite2pV2._coerce_table_value(value.to_numpy(copy=False))
230
+ if isinstance(value, np.ndarray):
231
+ if value.dtype == object:
232
+ return [Suite2pV2._coerce_table_value(item) for item in value.tolist()]
233
+ return Suite2pV2._ensure_numeric_dtype(value)
234
+ if isinstance(value, (list, tuple)):
235
+ if not value:
236
+ return np.asarray(value)
237
+ try:
238
+ array = np.asarray(value)
239
+ except Exception:
240
+ array = None
241
+ if array is not None and array.dtype != object:
242
+ return Suite2pV2._ensure_numeric_dtype(array)
243
+ return [Suite2pV2._coerce_table_value(item) for item in value]
244
+ if isinstance(value, np.generic):
245
+ return value.item()
246
+ return value
247
+
248
+ @staticmethod
249
+ def _expand_components(value: Any, expected: int) -> list[Any]:
250
+ if isinstance(value, list) and len(value) >= expected:
251
+ return value
252
+ if isinstance(value, list) and value:
253
+ first = value[0]
254
+ return [first for _ in range(expected)]
255
+ if isinstance(value, np.ndarray):
256
+ if value.ndim >= 1 and value.shape[0] >= expected:
257
+ return [value[idx] for idx in range(expected)]
258
+ return [value for _ in range(expected)]
259
+ return [value for _ in range(expected)]
260
+
261
+ @staticmethod
262
+ def _ensure_numeric_dtype(array: np.ndarray) -> np.ndarray:
263
+ if array.dtype.kind in {"i", "u"}:
264
+ return array.astype(np.int32, copy=False)
265
+ if array.dtype.kind in {"f"} and array.dtype != np.float32:
266
+ return array.astype(np.float32, copy=False)
267
+ return array
268
+
269
+ @staticmethod
270
+ def _ensure_float32(array: np.ndarray | Iterable[float]) -> np.ndarray:
271
+ arr = np.asarray(array)
272
+ if arr.dtype == np.float32:
273
+ return arr
274
+ return arr.astype(np.float32, copy=False)
275
+
276
+ @staticmethod
277
+ def _cast_nested_numeric(value: Any) -> Any:
278
+ if isinstance(value, dict):
279
+ return {key: Suite2pV2._cast_nested_numeric(val) for key, val in value.items()}
280
+ if isinstance(value, list):
281
+ return [Suite2pV2._cast_nested_numeric(val) for val in value]
282
+ if isinstance(value, tuple):
283
+ return tuple(Suite2pV2._cast_nested_numeric(val) for val in value)
284
+ if isinstance(value, np.ndarray):
285
+ if value.dtype == object:
286
+ return [Suite2pV2._cast_nested_numeric(item) for item in value.tolist()]
287
+ return Suite2pV2._ensure_numeric_dtype(value)
288
+ if isinstance(value, np.generic):
289
+ kind = value.dtype.kind
290
+ if kind == "f" and value.dtype != np.float32:
291
+ return np.float32(value)
292
+ if kind in {"i", "u"} and value.itemsize > np.dtype(np.int32).itemsize:
293
+ return np.int32(value)
294
+ return value.item()
295
+ return value
296
+
297
+ # ------------------------------------------------------------------
298
+ # Loading helpers
299
+ # ------------------------------------------------------------------
300
+ @staticmethod
301
+ def _load_array(path: Path) -> np.ndarray:
302
+ arr = np.load(path, allow_pickle=False)
303
+ if not isinstance(arr, np.ndarray):
304
+ raise ValueError(f"Expected numpy array in {path}, got {type(arr)}")
305
+ return arr
306
+
307
+ def _load_companions(self, plane_dir: Path) -> Dict[str, Any]:
308
+ required = tuple(self.required_files)
309
+
310
+ missing = [name for name in required if not (plane_dir / name).exists()]
311
+ if missing:
312
+ raise FileNotFoundError(f"Suite2p plane missing companion files {missing} in {plane_dir}")
313
+
314
+ companions: Dict[str, Any] = {}
315
+ companions["Fneu"] = self._ensure_float32(self._load_array(plane_dir / "Fneu.npy"))
316
+ companions["iscell"] = self._ensure_numeric_dtype(
317
+ np.load(plane_dir / "iscell.npy", allow_pickle=False)
318
+ )
319
+
320
+ stat_file = "stat.npy"
321
+ stat_path = plane_dir / stat_file
322
+ if stat_path.exists():
323
+ stat_raw = np.load(stat_path, allow_pickle=True)
324
+ stat_payload = stat_raw.tolist() if hasattr(stat_raw, "tolist") else stat_raw
325
+ companions["stat"] = self._cast_nested_numeric(stat_payload)
326
+ else:
327
+ companions["stat"] = []
328
+
329
+ ops_file = "ops.npy"
330
+ ops_path = plane_dir / ops_file
331
+ if ops_path.exists():
332
+ ops_raw = np.load(ops_path, allow_pickle=True)
333
+ try:
334
+ ops_payload = ops_raw.item()
335
+ except (ValueError, AttributeError):
336
+ ops_payload = ops_raw
337
+ companions["ops"] = self._cast_nested_numeric(ops_payload)
338
+ else:
339
+ companions["ops"] = {}
340
+
341
+ return companions
342
+
343
+ def _nidaq_timeline(
344
+ self,
345
+ session_dir: Optional[Path],
346
+ n_frames: int,
347
+ *,
348
+ dq_path: Optional[Path] = None,
349
+ dq_frame: Optional[pd.DataFrame] = None,
350
+ subject: Optional[str] = None,
351
+ session: Optional[str] = None,
352
+ task: Optional[str] = None,
353
+ ) -> tuple[np.ndarray, dict]:
354
+ """Build timeline using nidaq pulse timestamps from dataqueue."""
355
+ if dq_frame is None and dq_path is None:
356
+ if session_dir is None:
357
+ raise FileNotFoundError("Suite2pV2: session directory not found for nidaq timeline")
358
+ dq_path = self._find_dataqueue(session_dir, subject=subject, session=session, task=task)
359
+ if dq_path is None:
360
+ raise FileNotFoundError("Suite2pV2: dataqueue file not found for nidaq timeline")
361
+ if dq_frame is None:
362
+ df = pd.read_csv(dq_path)
363
+ else:
364
+ df = dq_frame
365
+
366
+ if not {"device_id", "queue_elapsed", "payload"}.issubset(df.columns):
367
+ raise ValueError("Suite2pV2: dataqueue missing device_id/queue_elapsed/payload columns")
368
+
369
+ nidaq = df.loc[df["device_id"].astype(str).str.contains("nidaq", case=False, na=False)]
370
+ if nidaq.empty:
371
+ raise ValueError("Suite2pV2: no nidaq rows found in dataqueue")
372
+
373
+ nidaq_times = pd.to_numeric(nidaq["queue_elapsed"], errors="coerce")
374
+ nidaq_payload = pd.to_numeric(nidaq["payload"], errors="coerce")
375
+
376
+ valid_mask = nidaq_times.notna() & nidaq_payload.notna()
377
+ nidaq_times = nidaq_times[valid_mask].to_numpy(dtype=np.float64)
378
+ nidaq_payload = nidaq_payload[valid_mask].to_numpy(dtype=np.float64)
379
+
380
+ if nidaq_times.size == 0:
381
+ raise ValueError("Suite2pV2: nidaq timestamps empty after filtering")
382
+
383
+ nidaq_times_rel = nidaq_times - nidaq_times[0]
384
+
385
+ order = np.argsort(nidaq_payload)
386
+ pulse_counter = nidaq_payload[order]
387
+ pulse_times = nidaq_times_rel[order]
388
+
389
+ if np.any(np.diff(pulse_counter) <= 0):
390
+ raise ValueError("Suite2pV2: nidaq payload must be strictly increasing")
391
+
392
+ pulse_span = pulse_counter[-1] - pulse_counter[0] + 1
393
+ pulses_per_frame = self.PULSES_PER_FRAME
394
+ if pulses_per_frame is None:
395
+ inferred = pulse_span / n_frames
396
+ if not np.isclose(inferred, round(inferred)):
397
+ raise ValueError("Suite2pV2: nidaq payload does not match expected frame count")
398
+ pulses_per_frame = int(round(inferred))
399
+
400
+ if pulses_per_frame < 1:
401
+ raise ValueError("Suite2pV2: pulses_per_frame must be >= 1")
402
+
403
+ expected_span = int(pulses_per_frame * n_frames)
404
+ if pulse_counter.size < 2:
405
+ raise ValueError("Suite2pV2: nidaq payload requires at least two pulses for interpolation")
406
+
407
+ expected_pulses = np.arange(expected_span, dtype=np.float64) + pulse_counter[0]
408
+ expected_times = self._interpolate_pulse_times(pulse_counter, pulse_times, expected_pulses)
409
+ timeline = expected_times[::pulses_per_frame]
410
+
411
+ if timeline.size != n_frames:
412
+ raise ValueError("Suite2pV2: nidaq interpolation produced unexpected frame count")
413
+
414
+ derived_frames = int(n_frames)
415
+
416
+ meta = {
417
+ "time_basis": "nidaq_interpolated",
418
+ "dataqueue_file": str(dq_path),
419
+ "nidaq_pulses_received": int(len(nidaq_times)),
420
+ "pulses_per_frame": int(pulses_per_frame),
421
+ "pulses_per_frame_source": "configured" if self.PULSES_PER_FRAME is not None else "inferred",
422
+ "dq_nidaq_pulses": int(len(nidaq_times)),
423
+ "dq_nidaq_frames_derived": derived_frames,
424
+ "dq_pulse_span": int(pulse_span),
425
+ "dq_expected_pulse_span": int(expected_span),
426
+ "dq_pulse_count": int(pulse_counter.size),
427
+ "dq_pulse_span_match": bool(int(pulse_span) == expected_span),
428
+ "dq_pulses_per_frame": int(pulses_per_frame),
429
+ }
430
+
431
+ return timeline, meta
432
+
433
+ @staticmethod
434
+ def _interpolate_pulse_times(
435
+ pulse_counter: np.ndarray,
436
+ pulse_times: np.ndarray,
437
+ expected_pulses: np.ndarray,
438
+ ) -> np.ndarray:
439
+ if pulse_counter.size < 2:
440
+ raise ValueError("Suite2pV2: insufficient nidaq pulses for interpolation")
441
+
442
+ expected_times = np.interp(expected_pulses, pulse_counter, pulse_times)
443
+
444
+ start_slope = (pulse_times[1] - pulse_times[0]) / (pulse_counter[1] - pulse_counter[0])
445
+ end_slope = (pulse_times[-1] - pulse_times[-2]) / (pulse_counter[-1] - pulse_counter[-2])
446
+
447
+ left_mask = expected_pulses < pulse_counter[0]
448
+ right_mask = expected_pulses > pulse_counter[-1]
449
+
450
+ if left_mask.any():
451
+ expected_times[left_mask] = pulse_times[0] + start_slope * (
452
+ expected_pulses[left_mask] - pulse_counter[0]
453
+ )
454
+ if right_mask.any():
455
+ expected_times[right_mask] = pulse_times[-1] + end_slope * (
456
+ expected_pulses[right_mask] - pulse_counter[-1]
457
+ )
458
+
459
+ return expected_times
460
+
461
+ def _find_session_dir(self, plane_dir: Path) -> tuple[Optional[Path], Optional[str], Optional[str], Optional[str]]:
462
+ """Walk up from plane_dir to find session directory or use inventory hint."""
463
+ subject = None
464
+ session = None
465
+ task = None
466
+
467
+ suite2p_dir = plane_dir.parent if plane_dir.name.startswith("plane") else plane_dir
468
+ suite2p_name = suite2p_dir.name
469
+ if suite2p_name:
470
+ subject_match = re.search(r"sub-([A-Za-z0-9]+)", suite2p_name)
471
+ session_match = re.search(r"ses-([A-Za-z0-9]+)", suite2p_name)
472
+ task_match = re.search(r"task-([A-Za-z0-9]+)", suite2p_name)
473
+ if subject_match:
474
+ subject = subject_match.group(1)
475
+ if session_match:
476
+ session = session_match.group(1)
477
+ if task_match:
478
+ task = task_match.group(1)
479
+
480
+ # Try walking up from plane_dir
481
+ current = plane_dir
482
+ max_levels = 5
483
+ for _ in range(max_levels):
484
+ if current == current.parent:
485
+ break
486
+ if (current / "beh").is_dir() and (current / "func").is_dir():
487
+ return current, subject, session, task
488
+ current = current.parent
489
+
490
+ # If suite2p is in processed/, try to find the data/ sibling
491
+ if "processed" in plane_dir.parts:
492
+ # Navigate to experiment root and check data/
493
+ idx = plane_dir.parts.index("processed")
494
+ exp_root = Path(*plane_dir.parts[:idx])
495
+ data_dir = exp_root / "data"
496
+ if data_dir.is_dir():
497
+ subject_dir = None
498
+ session_dir = None
499
+
500
+ if subject:
501
+ subject_dir = data_dir / f"sub-{subject}"
502
+ if not subject_dir.is_dir():
503
+ subject_dir = None
504
+
505
+ if subject_dir is not None and session:
506
+ session_dir = subject_dir / f"ses-{session}"
507
+ if not session_dir.is_dir():
508
+ session_dir = None
509
+
510
+ if session_dir is not None and (session_dir / "beh").is_dir():
511
+ return session_dir, subject, session, task
512
+
513
+ # Look for subject/session structure
514
+ for subject_dir in data_dir.iterdir():
515
+ if subject_dir.is_dir() and subject_dir.name.startswith("sub-"):
516
+ for session_dir in subject_dir.iterdir():
517
+ if session_dir.is_dir() and session_dir.name.startswith("ses-"):
518
+ if (session_dir / "beh").is_dir():
519
+ return session_dir, subject, session, task
520
+ return None, subject, session, task
521
+
522
+ def _find_dataqueue(
523
+ self,
524
+ session_dir: Path,
525
+ *,
526
+ subject: Optional[str] = None,
527
+ session: Optional[str] = None,
528
+ task: Optional[str] = None,
529
+ ) -> Optional[Path]:
530
+ """Find dataqueue CSV in session/beh directory."""
531
+ candidates = sorted((session_dir / "beh").glob("*_dataqueue.csv"))
532
+ if not candidates:
533
+ return None
534
+
535
+ def matches(candidate: Path) -> bool:
536
+ name = candidate.name
537
+ if subject and f"sub-{subject}" not in name:
538
+ return False
539
+ if session and f"ses-{session}" not in name:
540
+ return False
541
+ if task and f"task-{task}" not in name:
542
+ return False
543
+ return True
544
+
545
+ filtered = [path for path in candidates if matches(path)]
546
+ return filtered[0] if filtered else candidates[0]
547
+