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,455 @@
1
+ """PsychoPy stimulus device.
2
+
3
+ Wraps :class:`mesofield.subprocesses.psychopy.PsychoPyProcess` behind the
4
+ standard :class:`~mesofield.protocols.StimulusDevice` interface so the
5
+ :class:`~mesofield.base.Procedure` can drive it through the same
6
+ ``arm/start/stop/shutdown`` lifecycle as any other hardware device.
7
+
8
+ PsychoPy is *not* a :class:`~mesofield.protocols.DataProducer`: it never
9
+ emits on ``signals.data`` and does not implement ``save_data`` /
10
+ ``get_data``. Its ``signals.started`` fires when the subprocess prints
11
+ ``PSYCHOPY_READY``; ``signals.finished`` fires when ``QProcess`` exits.
12
+
13
+ The producer (`PsychoPyDevice`) does not write its own data file --
14
+ PsychoPy writes its own CSV directly. The matching ingest-side parser
15
+ (`Psychopy`, an IntervalSeriesSource) lives at the bottom of this
16
+ module so producer and parser sit in the same file.
17
+ `PsychoPyDevice.Parser` resolves to `Psychopy` for manifest-driven
18
+ dispatch.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import re
24
+ from datetime import datetime
25
+ from pathlib import Path
26
+ from typing import Any, ClassVar, Dict, Optional
27
+
28
+ import numpy as np
29
+ import pandas as pd
30
+
31
+ from mesofield import DeviceRegistry
32
+ from mesofield.signals import DeviceSignals
33
+ from mesofield.utils._logger import get_logger
34
+ from mesofield.datakit.sources.register import IntervalSeriesSource, SourceContext
35
+ from mesofield.datakit.timeline import DataqueueIndex
36
+
37
+
38
+ @DeviceRegistry.register("psychopy")
39
+ class PsychoPyDevice:
40
+ """Stimulus device that launches a PsychoPy script as a subprocess."""
41
+
42
+ device_type: ClassVar[str] = "stimulus"
43
+ file_type: str = ""
44
+ bids_type: Optional[str] = None
45
+
46
+ def __init__(self, cfg: Dict[str, Any]):
47
+ self.signals = DeviceSignals()
48
+ self.cfg = dict(cfg)
49
+ self.device_id: str = cfg.get("id", "psychopy")
50
+ self.is_primary: bool = bool(cfg.get("primary", False))
51
+ self.logger = get_logger(f"{__name__}.PsychoPyDevice[{self.device_id}]")
52
+ self._process = None # mesofield.subprocesses.psychopy.PsychoPyProcess
53
+ self._config = None
54
+ self._started: Optional[datetime] = None
55
+ self._stopped: Optional[datetime] = None
56
+
57
+ # -- lifecycle ------------------------------------------------------
58
+ def initialize(self) -> bool:
59
+ return True
60
+
61
+ def arm(self, config) -> None:
62
+ """Per-run prep: stash the experiment config so ``start`` can launch."""
63
+ self._config = config
64
+
65
+ def start(self) -> bool:
66
+ from mesofield.devices.subprocesses.psychopy import PsychoPyProcess
67
+
68
+ if self._config is None:
69
+ self.logger.error("PsychoPyDevice.start called before arm()")
70
+ return False
71
+
72
+ self._process = PsychoPyProcess(self._config)
73
+ self._process.ready.connect(self._on_ready)
74
+ self._process.finished.connect(self._on_finished)
75
+ self._started = datetime.now()
76
+ self._process.start()
77
+ return True
78
+
79
+ def stop(self) -> bool:
80
+ self._stopped = datetime.now()
81
+ if self._process is not None and self._process.process is not None:
82
+ try:
83
+ self._process.process.terminate()
84
+ if not self._process.process.waitForFinished(2000):
85
+ self._process.process.kill()
86
+ except Exception as exc:
87
+ self.logger.warning(f"PsychoPy stop failed: {exc}")
88
+ self.signals.finished.emit()
89
+ return True
90
+
91
+ def shutdown(self) -> None:
92
+ self.stop()
93
+ self._process = None
94
+
95
+ # -- callbacks ------------------------------------------------------
96
+ def _on_ready(self) -> None:
97
+ self.signals.started.emit()
98
+
99
+ def _on_finished(self, *_args) -> None:
100
+ self.signals.finished.emit()
101
+
102
+ # -- introspection --------------------------------------------------
103
+ def status(self) -> Dict[str, Any]:
104
+ """Snapshot of the PsychoPy subprocess state.
105
+
106
+ Returns:
107
+ Dict with ``device_id``, a ``running`` flag, and ``started``
108
+ / ``stopped`` timestamps (``None`` if the lifecycle stage
109
+ hasn't been reached).
110
+ """
111
+ return {
112
+ "device_id": self.device_id,
113
+ "running": self._process is not None,
114
+ "started": self._started,
115
+ "stopped": self._stopped,
116
+ }
117
+
118
+ @property
119
+ def metadata(self) -> Dict[str, Any]:
120
+ return {
121
+ "device_id": self.device_id,
122
+ "device_type": self.device_type,
123
+ }
124
+
125
+
126
+ # ---------------------------------------------------------------------------
127
+ # Parser (the ingest-side counterpart). Loads PsychoPy trial windows as an
128
+ # interval table, aligns to nidaq pulses via the dataqueue. Bound below
129
+ # as `PsychoPyDevice.Parser`.
130
+
131
+
132
+ _TASK_PATTERN = re.compile(r"task[-_]?([A-Za-z0-9]+)", re.IGNORECASE)
133
+ _TASK_ALIASES = {
134
+ "gratings": "task-gratings",
135
+ "movies": "task-movies",
136
+ }
137
+ _TIME_SUFFIXES = (".started", ".stopped", ".rt", ".t")
138
+
139
+
140
+ class Psychopy(IntervalSeriesSource):
141
+ """Load Psychopy trial windows as an interval table."""
142
+
143
+ tag = "psychopy"
144
+ patterns = ("**/*_psychopy.csv",)
145
+ task_pattern = _TASK_PATTERN
146
+ task_aliases = _TASK_ALIASES
147
+ time_suffixes = _TIME_SUFFIXES
148
+ keypress_rt_pattern = re.compile(r"key_resp.*\.rt$")
149
+ time_source_prefix = "display_"
150
+ time_source_suffix = ".started"
151
+ gratings_columns = (
152
+ "trials.thisN",
153
+ "stim_grayScreen.started",
154
+ "stim_grayScreen.stopped",
155
+ "stim_grating.started",
156
+ "stim_grating.stopped",
157
+ "display_gratings.stopped",
158
+ )
159
+ movies_columns = (
160
+ "trials_2.thisN",
161
+ "trials_2.thisIndex",
162
+ "trials2.thisN",
163
+ "trials.thisIndex",
164
+ "description",
165
+ "thisRow.t",
166
+ "display_mov.started",
167
+ "natural_mov.started",
168
+ "display_mov.stopped",
169
+ "grey.started",
170
+ "grey.stopped",
171
+ )
172
+ dataqueue_elapsed_column = "queue_elapsed"
173
+ dataqueue_device_column = "device_id"
174
+ dataqueue_device_match = "nidaq"
175
+ dataqueue_payload_column = "payload"
176
+ dataqueue_payload_value = 1
177
+
178
+ def build_intervals(
179
+ self,
180
+ path: Path,
181
+ *,
182
+ context: SourceContext | None = None,
183
+ ) -> tuple[pd.DataFrame, dict]:
184
+ context = self._require_context(context)
185
+ # 1) Read raw psychopy CSV.
186
+ raw = pd.read_csv(path, low_memory=False)
187
+
188
+ # 2) Infer task and choose trial table.
189
+ task = self._infer_task(path)
190
+ if task == "task-gratings":
191
+ trial_table = self._extract_gratings(raw)
192
+ task_branch = "gratings"
193
+ elif task == "task-movies":
194
+ trial_table = self._extract_movies(raw)
195
+ task_branch = "movies"
196
+ else:
197
+ trial_table = raw
198
+ task_branch = "raw"
199
+
200
+ # 3) Resolve timeline and keypress offset.
201
+ time_source = trial_table if any(
202
+ c.startswith(self.time_source_prefix) and c.endswith(self.time_source_suffix) for c in trial_table.columns
203
+ ) else raw
204
+ row_time, row_col, row_meta = self._resolve_row_time(time_source)
205
+ key_rt, key_col = self._resolve_keypress_rt(raw)
206
+
207
+ # 4) Parse experiment start time (optional).
208
+ exp_start = None
209
+ exp_start_raw = raw.get("expStart")
210
+ if exp_start_raw is not None and exp_start_raw.dropna().any():
211
+ exp_start = self._parse_exp_start(exp_start_raw.dropna().iloc[0])
212
+
213
+ # 5) Apply dataqueue offset and align times.
214
+ dq_start, dq_meta = self._dataqueue_offset(context)
215
+ exp_start_offset = None
216
+ exp_start_keypress_offset = None
217
+ exp_start_offset = self._calc_exp_start_offset(exp_start, dq_meta.get("abs_start_time"))
218
+ # Offset into psychopy-zero time (exp_start + keypress RT).
219
+ exp_start_keypress_offset = exp_start_offset - float(key_rt)
220
+ dq_start = float(exp_start_keypress_offset)
221
+ aligned_t = self._align_time(row_time, key_rt, dq_start)
222
+ aligned_table = self._align_table(trial_table, key_rt, dq_start)
223
+
224
+ # 6) For gratings, build windows once from the aligned table.
225
+ if task_branch == "gratings":
226
+ if "window_grating_start" in aligned_table.columns:
227
+ aligned_table["gratings_windows"] = [
228
+ self._build_window_list(aligned_table, "window_grating_start", "window_grating_stop")
229
+ ] * len(aligned_table)
230
+ if "window_gray_start" in aligned_table.columns:
231
+ aligned_table["gray_windows"] = [
232
+ self._build_window_list(aligned_table, "window_gray_start", "window_gray_stop")
233
+ ] * len(aligned_table)
234
+
235
+ # 7) Build interval table and metrics.
236
+ interval_start = None
237
+ interval_stop = None
238
+ if "window_grating_start" in aligned_table.columns and "window_grating_stop" in aligned_table.columns:
239
+ interval_start = aligned_table["window_grating_start"]
240
+ interval_stop = aligned_table["window_grating_stop"]
241
+ elif "window_gray_start" in aligned_table.columns and "window_gray_stop" in aligned_table.columns:
242
+ interval_start = aligned_table["window_gray_start"]
243
+ interval_stop = aligned_table["window_gray_stop"]
244
+ else:
245
+ interval_start = pd.Series(aligned_t, index=aligned_table.index)
246
+ interval_stop = pd.Series(aligned_t, index=aligned_table.index)
247
+
248
+ intervals = aligned_table.rename(
249
+ columns={c: f"{task_branch}_{c}" for c in aligned_table.columns}
250
+ ).copy()
251
+ intervals["start_s"] = pd.to_numeric(interval_start, errors="coerce")
252
+ intervals["stop_s"] = pd.to_numeric(interval_stop, errors="coerce")
253
+ intervals = self._apply_experiment_window(intervals, context, dq_start)
254
+ metrics = {
255
+ "source_file": str(path),
256
+ "n_rows": int(len(raw)),
257
+ "task_inferred": task,
258
+ "task_branch": task_branch,
259
+ "row_time_column": row_col,
260
+ "key_resp_rt": key_rt,
261
+ "key_resp_column": key_col,
262
+ "exp_start": exp_start.isoformat() if exp_start is not None else None,
263
+ "start_offset_s": dq_start,
264
+ }
265
+ if exp_start_offset is not None:
266
+ metrics["exp_start_offset_s"] = float(exp_start_offset)
267
+ if exp_start_keypress_offset is not None:
268
+ metrics["exp_start_keypress_offset_s"] = float(exp_start_keypress_offset)
269
+ metrics.update(row_meta)
270
+ metrics.update(dq_meta)
271
+ metrics["time_basis"] = "psychopy_zero_based" if dq_start is not None else "psychopy_native"
272
+ metrics["alignment_method"] = "keypress_relative"
273
+
274
+ return intervals, metrics
275
+
276
+ def _parse_exp_start(self, value: Any) -> Optional[pd.Timestamp]:
277
+ text = str(value).strip()
278
+ text = text.replace("h", ":", 1)
279
+ text = re.sub(r":(\d{2})\.(\d{2}\.\d+)", r":\1:\2", text)
280
+ parsed = pd.to_datetime(text, errors="coerce", utc=False)
281
+ return None if pd.isna(parsed) else parsed
282
+
283
+ def _strip_tz(self, value: pd.Timestamp) -> pd.Timestamp:
284
+ return value.tz_localize(None) if getattr(value, "tzinfo", None) else value
285
+
286
+ def _calc_exp_start_offset(self, exp_start: pd.Timestamp, abs_start_time: Any) -> Optional[float]:
287
+ if not abs_start_time:
288
+ return None
289
+ abs_start = pd.to_datetime(abs_start_time, errors="coerce", utc=False)
290
+ if pd.isna(abs_start):
291
+ return None
292
+ abs_start = self._strip_tz(abs_start)
293
+ exp_start = self._strip_tz(exp_start)
294
+ return (abs_start - exp_start).total_seconds()
295
+
296
+ def _infer_task(self, file_path: Path) -> str | None:
297
+ name = file_path.as_posix().lower()
298
+ match = self.task_pattern.search(name)
299
+ if match:
300
+ return self.task_aliases.get(match.group(1).lower())
301
+ for key, label in self.task_aliases.items():
302
+ if key in name:
303
+ return label
304
+ return None
305
+
306
+ def _select_columns(self, frame: pd.DataFrame, columns: list[str]) -> pd.DataFrame:
307
+ available = [col for col in columns if col in frame.columns]
308
+ if not available:
309
+ raise ValueError("Psychopy required columns are missing from the CSV.")
310
+ return frame.loc[:, available].copy()
311
+
312
+ def _coalesce_column(self, frame: pd.DataFrame, columns: list[str]) -> pd.Series:
313
+ available = [col for col in columns if col in frame.columns]
314
+ if not available:
315
+ raise ValueError(f"Psychopy required columns missing: {columns}")
316
+ return frame.loc[:, available].bfill(axis=1).iloc[:, 0]
317
+
318
+ def _resolve_keypress_rt(self, frame: pd.DataFrame) -> tuple[float, str]:
319
+ candidates = [col for col in frame.columns if self.keypress_rt_pattern.match(str(col))]
320
+ for col in candidates:
321
+ series = pd.to_numeric(frame[col], errors="coerce")
322
+ if series.notna().any():
323
+ return float(series.dropna().iloc[0]), str(col)
324
+ raise ValueError("Psychopy keypress RT column not found (key_resp*.rt).")
325
+
326
+ def _resolve_row_time(self, frame: pd.DataFrame) -> tuple[np.ndarray, str, dict]:
327
+ display_candidates = [
328
+ c for c in frame.columns if c.startswith(self.time_source_prefix) and c.endswith(self.time_source_suffix)
329
+ ]
330
+ for col in display_candidates:
331
+ series = pd.to_numeric(frame[col], errors="coerce")
332
+ if series.notna().sum() >= 2:
333
+ filled = series.interpolate(limit_direction="both")
334
+ time_cols = [c for c in frame.columns if any(c.endswith(suf) for suf in self.time_suffixes)]
335
+ max_times = [pd.to_numeric(frame[c], errors="coerce").max() for c in time_cols]
336
+ max_time = max([t for t in max_times if pd.notna(t)], default=filled.max())
337
+ if max_time > filled.max():
338
+ last_valid_idx = filled.last_valid_index()
339
+ if last_valid_idx is not None and last_valid_idx < len(filled) - 1:
340
+ filled.loc[last_valid_idx + 1:] = max_time
341
+ meta = {
342
+ "row_time_column": col,
343
+ "row_time_extended_to": float(max_time),
344
+ }
345
+ return filled.to_numpy(dtype=np.float64), col, meta
346
+ raise ValueError("Psychopy requires a display_*.started column with at least 2 numeric values.")
347
+
348
+ def _dataqueue_offset(self, context: SourceContext) -> tuple[Optional[float], dict]:
349
+ #TODO: If a dataqueue does not have nidaq payload==1 but may start at nidaq==2,
350
+ # treat the first pulse as the start and log a warning.
351
+ dq_path = context.path_for("dataqueue")
352
+ if context.dataqueue_frame is not None:
353
+ frame = context.dataqueue_frame
354
+ queue_all = pd.to_numeric(frame.get(self.dataqueue_elapsed_column), errors="coerce").dropna()
355
+ if queue_all.empty:
356
+ raise ValueError("Dataqueue frame contains no queue_elapsed samples")
357
+ queue_start = float(queue_all.iloc[0])
358
+ device_series = frame.get(self.dataqueue_device_column, pd.Series(dtype=str)).astype(str)
359
+ mask = device_series.str.contains(self.dataqueue_device_match, case=False, na=False, regex=False)
360
+ device_rows = frame.loc[mask]
361
+ pulses = pd.to_numeric(device_rows.get(self.dataqueue_elapsed_column), errors="coerce")
362
+ abs_start = pd.to_datetime(device_rows.get("device_ts"), errors="coerce", utc=True)
363
+ abs_start = abs_start.dropna().dt.strftime("%Y-%m-%dT%H:%M:%S.%fZ").to_numpy(dtype=str)
364
+ else:
365
+ if dq_path is None:
366
+ raise FileNotFoundError("Psychopy: dataqueue path not available")
367
+ timeline = DataqueueIndex.from_path(dq_path)
368
+ queue_all = timeline.queue_series()
369
+ queue_start = float(queue_all.iloc[0])
370
+ device_slice = timeline.slice(self.dataqueue_device_match)
371
+ abs_start = device_slice.packet_absolute().to_numpy(dtype=str)
372
+ pulses = pd.to_numeric(device_slice.rows.get(self.dataqueue_elapsed_column), errors="coerce")
373
+ pulses = pulses.dropna()
374
+ if pulses.empty:
375
+ raise ValueError(f"Dataqueue timeline exists {timeline.source_path} but contains no nidaq payload==1 pulses.")
376
+
377
+ start_rel = float(pulses.iloc[0]) - 0
378
+ meta = {
379
+ "dataqueue_file": str(dq_path) if dq_path is not None else None,
380
+ "dataqueue_pulses": int(pulses.size),
381
+ "dataqueue_queue_start": queue_start,
382
+ "abs_start_time": abs_start[0] if len(abs_start) else None,
383
+ }
384
+ return start_rel, meta
385
+
386
+ def _apply_experiment_window(
387
+ self,
388
+ intervals: pd.DataFrame,
389
+ context: SourceContext,
390
+ dq_start: float | None,
391
+ ) -> pd.DataFrame:
392
+ if context.experiment_window is None or dq_start is None:
393
+ return intervals
394
+ start, stop = context.experiment_window
395
+ window_start = float(start) - float(dq_start)
396
+ window_stop = float(stop) - float(dq_start)
397
+
398
+ intervals = intervals.copy()
399
+ intervals["start_s"] = intervals["start_s"].clip(lower=window_start, upper=window_stop)
400
+ intervals["stop_s"] = intervals["stop_s"].clip(lower=window_start, upper=window_stop)
401
+ mask = (intervals["stop_s"] >= window_start) & (intervals["start_s"] <= window_stop)
402
+ return intervals.loc[mask].reset_index(drop=True)
403
+
404
+ def _align_time(self, values: np.ndarray, key_rt: float, offset: Optional[float]) -> np.ndarray:
405
+ aligned = values.astype(np.float64, copy=True) - float(key_rt)
406
+ if offset is not None:
407
+ aligned = aligned - float(offset)
408
+ return aligned
409
+
410
+ def _align_table(self, frame: pd.DataFrame, key_rt: float, offset: Optional[float]) -> pd.DataFrame:
411
+ aligned = frame.copy()
412
+ for col in aligned.columns:
413
+ name = str(col)
414
+ if not (name.lower().endswith(self.time_suffixes) or name.startswith("window_")):
415
+ continue
416
+ series = pd.to_numeric(aligned[col], errors="coerce")
417
+ aligned[col] = self._align_time(series.to_numpy(dtype=np.float64), key_rt, offset)
418
+ return aligned.dropna(axis=1, how="all")
419
+
420
+ def _build_window_list(self, frame: pd.DataFrame, start_col: str, stop_col: str) -> list[tuple[float, float]]:
421
+ if start_col not in frame.columns or stop_col not in frame.columns:
422
+ return []
423
+ starts = pd.to_numeric(frame[start_col], errors="coerce").to_numpy(dtype=np.float64, copy=False)
424
+ stops = pd.to_numeric(frame[stop_col], errors="coerce").to_numpy(dtype=np.float64, copy=False)
425
+ return [
426
+ (float(start), float(stop))
427
+ for start, stop in zip(starts, stops)
428
+ if np.isfinite(start) and np.isfinite(stop) and stop > start
429
+ ]
430
+
431
+ def _extract_gratings(self, frame: pd.DataFrame) -> pd.DataFrame:
432
+ selected = self._select_columns(frame, list(self.gratings_columns))
433
+ result = selected.loc[selected.get("trials.thisN").notna()].copy()
434
+ if result.empty:
435
+ raise ValueError("Psychopy gratings trials are empty.")
436
+
437
+ gray_start = self._coalesce_column(result, ["stim_grayScreen.started"])
438
+ gray_stop = self._coalesce_column(result, ["stim_grating.started"])
439
+ grating_start = self._coalesce_column(result, ["stim_grating.started"])
440
+ grating_stop = self._coalesce_column(result, ["display_gratings.stopped"])
441
+
442
+ result["window_grating_start"] = pd.to_numeric(grating_start, errors="coerce")
443
+ result["window_grating_stop"] = pd.to_numeric(grating_stop, errors="coerce")
444
+ result["window_gray_start"] = pd.to_numeric(gray_start, errors="coerce")
445
+ result["window_gray_stop"] = pd.to_numeric(gray_stop, errors="coerce")
446
+
447
+ return result
448
+
449
+ def _extract_movies(self, frame: pd.DataFrame) -> pd.DataFrame:
450
+ return self._select_columns(frame, list(self.movies_columns))
451
+
452
+
453
+ # Manifest-driven dispatch: SOURCE_REGISTRY["psychopy"] resolves to
454
+ # PsychoPyDevice.Parser.
455
+ PsychoPyDevice.Parser = Psychopy
File without changes
@@ -0,0 +1,133 @@
1
+ import os
2
+ import winreg
3
+ import base64
4
+ from dataclasses import dataclass
5
+
6
+ import dill
7
+ from PyQt6.QtCore import QObject, pyqtSignal, QProcess, QTimer, QEventLoop, Qt
8
+ from PyQt6.QtWidgets import QMessageBox
9
+
10
+ from typing import TYPE_CHECKING
11
+ if TYPE_CHECKING:
12
+ from mesofield.config import ExperimentConfig
13
+
14
+ class PsychopyParameters:
15
+ def __init__(self, params: dict):
16
+ for key, value in params.items():
17
+ setattr(self, key, value)
18
+
19
+ def __repr__(self):
20
+ return f"<PsychopyParameters {self.__dict__}>"
21
+
22
+ def get_psychopy_python_exe():
23
+ try:
24
+ key = winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, r"SOFTWARE\PsychoPy", 0, winreg.KEY_READ)
25
+ install_path, _ = winreg.QueryValueEx(key, "InstallPath")
26
+ winreg.CloseKey(key)
27
+ python_exe = os.path.join(install_path, "python.exe")
28
+ if os.path.exists(python_exe):
29
+ return python_exe
30
+ except OSError:
31
+ pass
32
+ return r"C:\Program Files\PsychoPy\python.exe"
33
+
34
+ def launch(config: 'ExperimentConfig', parent=None):
35
+ """Launches a PsychoPy experiment as a subprocess encapsulated in PsychoPyProcess."""
36
+ proc = PsychoPyProcess(config, parent)
37
+ proc.start()
38
+ return proc
39
+
40
+ class PsychoPyProcess(QObject):
41
+ ready = pyqtSignal()
42
+ finished = pyqtSignal(int, QProcess.ExitStatus)
43
+ error = pyqtSignal(str)
44
+
45
+ def __init__(self, config, parent=None):
46
+ super().__init__(parent)
47
+ self.config = config
48
+ self._handshake_ok = False
49
+ self.process = QProcess(self)
50
+ self.process.readyReadStandardOutput.connect(self._on_stdout)
51
+ self.process.readyReadStandardError.connect(self._on_stderr)
52
+ self.process.finished.connect(self._on_finished)
53
+
54
+ def start(self):
55
+ # Serialize parameters
56
+ params = PsychopyParameters(self.config.psychopy_parameters)
57
+ serialized = dill.dumps(params, byref=True)
58
+ b64 = base64.b64encode(serialized).decode('ascii')
59
+ exe = get_psychopy_python_exe()
60
+ script = os.path.join(self.config._save_dir, self.config.psychopy_filename)
61
+
62
+ # Handshake timeout
63
+ self._timer = QTimer(self)
64
+ self._timer.setSingleShot(True)
65
+ self._timer.timeout.connect(self._on_timeout)
66
+ self._timer.start(29000)
67
+
68
+ # show blocking waiting dialog until ready or error
69
+ from PyQt6.QtWidgets import QApplication
70
+ parent_win = self.parent() if callable(getattr(self, 'parent', None)) else None
71
+ waiting = QMessageBox(parent_win)
72
+ waiting.setWindowTitle('Launching PsychoPy')
73
+ waiting.setText('Waiting for PsychoPy script to print(PSYCHOPY_READY, flush=true)...')
74
+ waiting.setStandardButtons(QMessageBox.NoButton)
75
+ waiting.setWindowModality(Qt.ApplicationModal)
76
+ waiting.show()
77
+ waiting.activateWindow()
78
+ waiting.raise_()
79
+ QApplication.processEvents()
80
+
81
+ # connect handshake signals to close waiting dialog
82
+ self.ready.connect(waiting.accept)
83
+ self.error.connect(waiting.reject)
84
+
85
+ # start process
86
+ self.process.start(exe, [script, b64])
87
+
88
+ # block until handshake result
89
+ result = waiting.exec()
90
+ # cleanup waiting dialog
91
+ waiting.close()
92
+ # show ready or error popup
93
+ if self._handshake_ok:
94
+ # show ready popup
95
+ ready_box = QMessageBox(parent_win)
96
+ ready_box.setWindowTitle('PsychoPy Ready')
97
+ ready_box.setText('PsychoPy is ready.\nPress spacebar to start recording.')
98
+ ready_box.setStandardButtons(QMessageBox.Ok)
99
+ ready_box.setWindowModality(Qt.ApplicationModal)
100
+ ready_box.show()
101
+ ready_box.activateWindow()
102
+ ready_box.raise_()
103
+ QApplication.processEvents()
104
+ ready_box.exec()
105
+ else:
106
+ # show timeout error
107
+ err = QMessageBox(parent_win)
108
+ err.setIcon(QMessageBox.Critical)
109
+ err.setWindowTitle('PsychoPy Error')
110
+ err.setText('PsychoPy handshake timed out')
111
+ err.exec()
112
+
113
+ def _on_stdout(self):
114
+ data = self.process.readAllStandardOutput().data().decode()
115
+ print(data, end="")
116
+ if "PSYCHOPY_READY" in data:
117
+ # handshake succeeded
118
+ self._handshake_ok = True
119
+ if self._timer.isActive():
120
+ self._timer.stop()
121
+ self.ready.emit()
122
+
123
+ def _on_stderr(self):
124
+ data = self.process.readAllStandardError().data().decode()
125
+ print(data, end="")
126
+
127
+ def _on_finished(self, exit_code, exit_status):
128
+ self.finished.emit(exit_code, exit_status)
129
+
130
+ def _on_timeout(self):
131
+ # handshake failed
132
+ self._handshake_ok = False
133
+ self.error.emit("PsychoPy handshake timed out")