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.
- docs/_static/custom.css +40 -0
- docs/_static/favicon.png +0 -0
- docs/_static/logo.png +0 -0
- docs/api/index.md +70 -0
- docs/conf.py +200 -0
- docs/developer_guide.md +303 -0
- docs/index.md +25 -0
- docs/tutorial.md +4 -0
- docs/user_guide.md +172 -0
- examples/teensy_pulse_generator.py +320 -0
- experiments/pipeline_demo/experiment.json +24 -0
- experiments/pipeline_demo/hardware.yaml +23 -0
- experiments/pipeline_demo/procedure.py +50 -0
- experiments/two_cam_demo/experiment.json +24 -0
- experiments/two_cam_demo/hardware.yaml +58 -0
- experiments/two_cam_demo/load_dataset.py +213 -0
- experiments/two_cam_demo/procedure.py +87 -0
- external/video-codecs/openh264-1.8.0-win64.dll +0 -0
- mesofield/__init__.py +45 -0
- mesofield/__main__.py +11 -0
- mesofield/_version.py +24 -0
- mesofield/base.py +750 -0
- mesofield/cli/__init__.py +57 -0
- mesofield/cli/_richhelp.py +100 -0
- mesofield/cli/acquire.py +254 -0
- mesofield/cli/datakit.py +165 -0
- mesofield/cli/process.py +376 -0
- mesofield/cli/rig.py +108 -0
- mesofield/cli/tools.py +347 -0
- mesofield/config.py +751 -0
- mesofield/data/__init__.py +23 -0
- mesofield/data/batch.py +633 -0
- mesofield/data/manager.py +388 -0
- mesofield/data/writer.py +289 -0
- mesofield/datakit/__init__.py +44 -0
- mesofield/datakit/__main__.py +35 -0
- mesofield/datakit/_utils/_logger.py +5 -0
- mesofield/datakit/_version.py +141 -0
- mesofield/datakit/config.py +50 -0
- mesofield/datakit/core.py +783 -0
- mesofield/datakit/datamodel.py +200 -0
- mesofield/datakit/discover.py +124 -0
- mesofield/datakit/explore.py +651 -0
- mesofield/datakit/notebooks/pupil_dlc.ipynb +2445 -0
- mesofield/datakit/profile.py +535 -0
- mesofield/datakit/shell.py +83 -0
- mesofield/datakit/sources/__init__.py +65 -0
- mesofield/datakit/sources/analysis/mesomap.py +194 -0
- mesofield/datakit/sources/analysis/mesoscope.py +77 -0
- mesofield/datakit/sources/analysis/pupil.py +246 -0
- mesofield/datakit/sources/behavior/__init__.py +0 -0
- mesofield/datakit/sources/behavior/dataqueue.py +281 -0
- mesofield/datakit/sources/behavior/psychopy.py +364 -0
- mesofield/datakit/sources/behavior/treadmill.py +323 -0
- mesofield/datakit/sources/behavior/wheel.py +277 -0
- mesofield/datakit/sources/camera/mesoscope.py +32 -0
- mesofield/datakit/sources/camera/metadata_json.py +130 -0
- mesofield/datakit/sources/camera/pupil.py +28 -0
- mesofield/datakit/sources/camera/suite2p.py +547 -0
- mesofield/datakit/sources/register.py +204 -0
- mesofield/datakit/sources/session/config.py +130 -0
- mesofield/datakit/sources/session/notes.py +63 -0
- mesofield/datakit/sources/session/timestamps.py +58 -0
- mesofield/datakit/timeline.py +306 -0
- mesofield/devices/__init__.py +42 -0
- mesofield/devices/base.py +498 -0
- mesofield/devices/base_camera.py +295 -0
- mesofield/devices/cameras.py +740 -0
- mesofield/devices/daq.py +151 -0
- mesofield/devices/encoder.py +384 -0
- mesofield/devices/mocks.py +275 -0
- mesofield/devices/psychopy_device.py +455 -0
- mesofield/devices/subprocesses/__init__.py +0 -0
- mesofield/devices/subprocesses/psychopy.py +133 -0
- mesofield/devices/treadmill.py +318 -0
- mesofield/engines.py +380 -0
- mesofield/gui/Mesofield_icon.png +0 -0
- mesofield/gui/__init__.py +76 -0
- mesofield/gui/config_wizard.py +724 -0
- mesofield/gui/controller.py +535 -0
- mesofield/gui/dynamic_controller.py +78 -0
- mesofield/gui/maingui.py +427 -0
- mesofield/gui/mdagui.py +285 -0
- mesofield/gui/qt_device_adapter.py +109 -0
- mesofield/gui/speedplotter.py +152 -0
- mesofield/gui/theme.py +445 -0
- mesofield/gui/tiff_viewer.py +1050 -0
- mesofield/gui/viewer.py +691 -0
- mesofield/hardware.py +549 -0
- mesofield/playback.py +1298 -0
- mesofield/processing/__init__.py +12 -0
- mesofield/processing/runner.py +237 -0
- mesofield/processors/__init__.py +13 -0
- mesofield/processors/base.py +287 -0
- mesofield/processors/frame_mean.py +19 -0
- mesofield/protocols.py +378 -0
- mesofield/scaffold/__init__.py +34 -0
- mesofield/scaffold/experiment.py +400 -0
- mesofield/scaffold/rigs.py +121 -0
- mesofield/signals.py +85 -0
- mesofield/utils/__init__.py +0 -0
- mesofield/utils/_logger.py +156 -0
- mesofield/utils/retrofit.py +309 -0
- mesofield/utils/utils.py +217 -0
- mesofield-0.3.2b0.dist-info/METADATA +178 -0
- mesofield-0.3.2b0.dist-info/RECORD +111 -0
- mesofield-0.3.2b0.dist-info/WHEEL +5 -0
- mesofield-0.3.2b0.dist-info/entry_points.txt +2 -0
- mesofield-0.3.2b0.dist-info/licenses/LICENSE +21 -0
- mesofield-0.3.2b0.dist-info/top_level.txt +6 -0
- 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")
|