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,281 @@
1
+ """Dataqueue synchronization data source.
2
+
3
+ This loader ingests the central ``*_dataqueue.csv`` log produced by the rig and
4
+ returns a stream describing every message with absolute and relative timing.
5
+ It is commonly used to align per-device clocks (treadmill, cameras, nidaq) by
6
+ extracting anchor pairs for downstream timeline fitting.
7
+ """
8
+
9
+ from typing import Optional
10
+ import pandas as pd
11
+ import numpy as np
12
+ from pathlib import Path
13
+
14
+ from mesofield.datakit.sources.register import LoadContext, TimeseriesSource
15
+
16
+
17
+ class DataqueueSource(TimeseriesSource):
18
+ """Load the dataqueue CSV as a time-indexed table."""
19
+ tag = "dataqueue"
20
+ patterns = ("**/*_dataqueue.csv",)
21
+ camera_tag = None
22
+ time_column = "queue_elapsed"
23
+ device_id_column = "device_id"
24
+ device_timestamp_column = "device_ts"
25
+ payload_column = "payload"
26
+ master_device_priority: tuple[str, ...] = ("dhyana", "mesoscope")
27
+ device_alias_patterns: tuple[tuple[str, str], ...] = (
28
+ ("dhyana", "meso"),
29
+ ("mesoscope", "meso"),
30
+ ("thorcam", "pupil"),
31
+ )
32
+
33
+ def build_timeseries(
34
+ self,
35
+ path: Path,
36
+ *,
37
+ context: LoadContext | None = None,
38
+ ) -> tuple[np.ndarray, pd.DataFrame, dict]:
39
+ """Read ``*_dataqueue.csv`` and return a time-indexed table."""
40
+ raw = pd.read_csv(path, low_memory=False)
41
+
42
+ if self.time_column not in raw.columns:
43
+ raise ValueError(f"Dataqueue file missing required time column '{self.time_column}': {path}")
44
+
45
+ queue_numeric = pd.to_numeric(raw[self.time_column], errors="coerce")
46
+ queue_elapsed = queue_numeric.to_numpy(dtype=np.float64)
47
+ if queue_elapsed.size == 0:
48
+ raise ValueError("Dataqueue has no timing samples")
49
+
50
+ queue_start = float(queue_elapsed[0])
51
+ t = queue_elapsed - queue_start
52
+
53
+ master_id = self._select_master_device(raw)
54
+ master_elapsed = self._master_elapsed(raw, master_id, queue_start)
55
+ if master_elapsed.size == 0:
56
+ master_elapsed = t
57
+
58
+ device_elapsed = self._build_device_elapsed(raw, queue_start)
59
+ device_ts = self._build_device_ts(raw)
60
+ device_aliases = self._build_device_aliases(raw)
61
+ affine = self._fit_master_affine(raw, master_id, queue_start)
62
+ device_aligned = self._apply_affine(device_elapsed, affine)
63
+
64
+ frame = raw.copy()
65
+ frame["time_elapsed_s"] = t.astype(np.float64)
66
+ per_device_start_map: dict[str, float] = {}
67
+ if self.device_id_column in frame.columns:
68
+ per_device_start = (
69
+ frame.groupby(self.device_id_column)[self.time_column]
70
+ .transform("min")
71
+ .astype(np.float64)
72
+ )
73
+ per_device_start_map = {
74
+ str(device_id): float(value)
75
+ for device_id, value in (
76
+ frame.groupby(self.device_id_column)[self.time_column]
77
+ .min()
78
+ .items()
79
+ )
80
+ }
81
+ # `device_elapsed_s` is `queue_elapsed - per_device_start`; keep the
82
+ # offsets in meta and drop the redundant full-length column.
83
+
84
+ # Memory optimizations: parse timestamp strings to datetime64, low-cardinality
85
+ # string columns to categoricals. These columns are stored as full-length
86
+ # ndarrays per row in the materialized output, so per-value bytes matter.
87
+ #
88
+ # ``payload`` is mostly numeric (TTL levels, counts) with a sparse mix of
89
+ # semantic strings (e.g. ``EncoderData(...)``). The dataqueue itself
90
+ # treats payloads as opaque — sources that need the semantic content
91
+ # (such as ``TreadmillSource``) re-read the raw CSV via
92
+ # ``context.path_for("dataqueue")`` and parse it themselves. Here we
93
+ # just downcast the whole column to ``float32`` (non-numeric entries
94
+ # become NaN) to keep the materialized output small.
95
+ if self.device_timestamp_column in frame.columns:
96
+ parsed = pd.to_datetime(frame[self.device_timestamp_column], errors="coerce", utc=True)
97
+ if not parsed.isna().all():
98
+ frame[self.device_timestamp_column] = parsed
99
+ if "packet_ts" in frame.columns:
100
+ parsed = pd.to_datetime(frame["packet_ts"], errors="coerce", utc=True)
101
+ if not parsed.isna().all():
102
+ frame["packet_ts"] = parsed
103
+ if self.device_id_column in frame.columns:
104
+ frame[self.device_id_column] = frame[self.device_id_column].astype("category")
105
+ if self.payload_column in frame.columns:
106
+ frame[self.payload_column] = (
107
+ pd.to_numeric(frame[self.payload_column], errors="coerce")
108
+ .astype(np.float32)
109
+ )
110
+
111
+ meta = {
112
+ "source_file": str(path),
113
+ "n_entries": int(len(raw)),
114
+ "master_device_id": master_id,
115
+ "master_queue_start": float(queue_start),
116
+ "per_device_queue_start": per_device_start_map,
117
+ "device_sample_rate_hz": self._estimate_device_rates(device_elapsed),
118
+ "device_aliases": device_aliases,
119
+ "affine": affine,
120
+ "time_basis": self.time_column,
121
+ }
122
+
123
+ return t.astype(np.float64), frame, meta
124
+
125
+ def _select_master_device(self, df: pd.DataFrame) -> str:
126
+ """Pick the device used as the master time basis."""
127
+
128
+ if self.device_id_column not in df.columns:
129
+ return "master"
130
+
131
+ ids = df[self.device_id_column].astype(str).fillna("")
132
+ for pattern in self.master_device_priority:
133
+ mask = ids.str.contains(pattern, case=False, regex=False)
134
+ if mask.any():
135
+ return str(ids[mask].iloc[0])
136
+
137
+ non_empty = ids[ids != ""]
138
+ if not non_empty.empty:
139
+ return str(non_empty.iloc[0])
140
+ return "master"
141
+
142
+ def _master_elapsed(self, df: pd.DataFrame, master_id: str, queue_start: float) -> np.ndarray:
143
+ """Return elapsed seconds for the master device."""
144
+
145
+ device_series = df.get(self.device_id_column)
146
+ if device_series is None:
147
+ return np.array([], dtype=np.float64)
148
+
149
+ mask = device_series.astype(str) == master_id
150
+ master_rows = df.loc[mask]
151
+ if master_rows.empty:
152
+ return np.array([], dtype=np.float64)
153
+
154
+ master_queue = pd.to_numeric(master_rows[self.time_column], errors="coerce").dropna()
155
+ if master_queue.empty:
156
+ return np.array([], dtype=np.float64)
157
+
158
+ master_queue = master_queue - float(master_queue.iloc[0])
159
+ return master_queue.to_numpy(dtype=np.float64)
160
+
161
+ def _estimate_rate(self, timeline: np.ndarray) -> float:
162
+ if timeline.size < 2:
163
+ return 0.0
164
+ diffs = np.diff(timeline)
165
+ diffs = diffs[np.isfinite(diffs) & (diffs > 0)]
166
+ if diffs.size == 0:
167
+ return 0.0
168
+ return float(1.0 / np.median(diffs))
169
+
170
+ def _build_device_elapsed(self, df: pd.DataFrame, queue_start: float) -> dict[str, np.ndarray]:
171
+ """Return per-device elapsed seconds from queue timestamps."""
172
+
173
+ device_elapsed: dict[str, np.ndarray] = {}
174
+ if self.device_id_column not in df.columns:
175
+ return device_elapsed
176
+
177
+ for device_id, group in df.groupby(self.device_id_column):
178
+ device_key = str(device_id)
179
+ queue_rel = pd.to_numeric(group[self.time_column], errors="coerce").to_numpy(dtype=np.float64)
180
+ queue_rel = queue_rel - float(queue_start)
181
+ order = np.argsort(queue_rel)
182
+ device_elapsed[device_key] = queue_rel[order]
183
+ return device_elapsed
184
+
185
+ def _build_device_ts(self, df: pd.DataFrame) -> dict[str, np.ndarray]:
186
+ """Return per-device raw timestamps as ISO strings when possible."""
187
+
188
+ device_ts: dict[str, np.ndarray] = {}
189
+ if self.device_id_column not in df.columns or self.device_timestamp_column not in df.columns:
190
+ return device_ts
191
+
192
+ for device_id, group in df.groupby(self.device_id_column):
193
+ device_key = str(device_id)
194
+ parsed = pd.to_datetime(group[self.device_timestamp_column], errors="coerce", utc=True)
195
+ if parsed.isna().all():
196
+ device_ts[device_key] = group[self.device_timestamp_column].to_numpy()
197
+ else:
198
+ device_ts[device_key] = parsed.dt.strftime("%Y-%m-%dT%H:%M:%S.%fZ").to_numpy()
199
+ return device_ts
200
+
201
+ def _build_device_aliases(self, df: pd.DataFrame) -> dict[str, str]:
202
+ """Map device identifiers to configured aliases (e.g., Dhyana → meso)."""
203
+
204
+ aliases: dict[str, str] = {}
205
+ if self.device_id_column not in df.columns:
206
+ return aliases
207
+
208
+ ids = df[self.device_id_column].dropna().astype(str).unique().tolist()
209
+ for device_id in ids:
210
+ alias = self._alias_for_device(device_id)
211
+ if alias is not None:
212
+ aliases[device_id] = alias
213
+ return aliases
214
+
215
+ def _alias_for_device(self, device_id: str) -> Optional[str]:
216
+ lowered = device_id.lower()
217
+ for pattern, alias in self.device_alias_patterns:
218
+ if pattern.lower() in lowered:
219
+ return alias
220
+ return None
221
+
222
+ def _fit_master_affine(
223
+ self,
224
+ df: pd.DataFrame,
225
+ master_id: str,
226
+ queue_start: float,
227
+ ) -> dict[str, float] | None:
228
+ """Fit affine map from elapsed seconds to absolute time using master device."""
229
+
230
+ if self.device_id_column not in df.columns or self.device_timestamp_column not in df.columns:
231
+ return None
232
+
233
+ master_rows = df.loc[df[self.device_id_column].astype(str) == master_id]
234
+ if master_rows.empty:
235
+ return None
236
+
237
+ elapsed = pd.to_numeric(master_rows[self.time_column], errors="coerce").to_numpy(dtype=np.float64)
238
+ elapsed = elapsed - float(queue_start)
239
+ absolute = pd.to_datetime(master_rows[self.device_timestamp_column], errors="coerce", utc=True)
240
+ if elapsed.size < 2 or absolute.isna().all():
241
+ return None
242
+
243
+ valid_mask = np.isfinite(elapsed) & ~absolute.isna()
244
+ if valid_mask.sum() < 2:
245
+ return None
246
+
247
+ elapsed = elapsed[valid_mask]
248
+ absolute = absolute[valid_mask]
249
+
250
+ e0 = float(elapsed[0])
251
+ e1 = float(elapsed[-1])
252
+ a0 = float(absolute.iloc[0].value) / 1e9
253
+ a1 = float(absolute.iloc[-1].value) / 1e9
254
+ if e1 == e0:
255
+ return None
256
+
257
+ a = (a1 - a0) / (e1 - e0)
258
+ b = a0 - a * e0
259
+ return {"a": float(a), "b": float(b)}
260
+
261
+ def _apply_affine(
262
+ self,
263
+ device_elapsed: dict[str, np.ndarray],
264
+ affine: dict[str, float] | None,
265
+ ) -> dict[str, np.ndarray]:
266
+ """Project elapsed times into aligned absolute seconds (UTC)."""
267
+
268
+ if affine is None:
269
+ return {}
270
+ a = affine.get("a")
271
+ b = affine.get("b")
272
+ if a is None or b is None:
273
+ return {}
274
+
275
+ aligned: dict[str, np.ndarray] = {}
276
+ for device_id, elapsed in device_elapsed.items():
277
+ aligned[device_id] = (a * elapsed + b).astype(np.float64)
278
+ return aligned
279
+
280
+ def _estimate_device_rates(self, device_elapsed: dict[str, np.ndarray]) -> dict[str, float]:
281
+ return {device_id: self._estimate_rate(values) for device_id, values in device_elapsed.items()}
@@ -0,0 +1,364 @@
1
+ """Psychopy behavioral event data source.
2
+
3
+ Explicit assumptions:
4
+ - Psychopy CSV contains at least one display_*.started column with 2+ numeric rows.
5
+ - A single keypress RT column exists (key_resp*.rt) and is used to align time zero.
6
+ - If a dataqueue timeline exists, it must include nidaq payload==1 pulses.
7
+
8
+ psychopy files have an expStart column that indicates start of the experiment in absolute clock datetime.
9
+ Psychopy files have a Custom_Trigger routine; a spacebar press then triggers a sync pulse to the DAQ system.
10
+ Thus, the true start is the `expStart` + the `key_resp_2.rt` (the key_resp_2.rt is column in the psychopy file, with 1 value, but that value may not be on the first row)
11
+
12
+ true end can be calculated using the `expStart` + the elapsed value from the last row of the column that precedes `Subject ID` and
13
+
14
+ The dataqueue file collects nidaq TTL pulses. The first pulse (payload=1) has a clock time and relative time (queue_elapsed). We can use this to align the psychopy timeline to the dataqueue timeline.
15
+ The experimental window, for sample_experiment3, is between the first nidaq and last nidaq.
16
+
17
+ The psychopy experiment likely runs longer than the nidaq pulse window, so we can trim the psychopy timeline to be within the nidaq pulse window.
18
+
19
+ Additional details:
20
+ - The loader derives its primary timeline from event start columns (e.g. display_*.started).
21
+ - All time-like columns are aligned by subtracting the keypress RT so time zero is the trigger.
22
+ - For task-gratings, rows are taken from trial metadata and extended to include the final stopped event.
23
+ - When dataqueue is available, the timeline is trimmed to the nidaq window but remains zero-based.
24
+ """
25
+
26
+
27
+ from __future__ import annotations
28
+
29
+ from pathlib import Path
30
+ from typing import Any, Optional
31
+ import re
32
+
33
+ import numpy as np
34
+ import pandas as pd
35
+
36
+ from mesofield.datakit.sources.register import IntervalSeriesSource, LoadContext
37
+ from mesofield.datakit.timeline import DataqueueIndex
38
+
39
+
40
+
41
+ _TASK_PATTERN = re.compile(r"task[-_]?([A-Za-z0-9]+)", re.IGNORECASE)
42
+ _TASK_ALIASES = {
43
+ "gratings": "task-gratings",
44
+ "movies": "task-movies",
45
+ }
46
+ _TIME_SUFFIXES = (".started", ".stopped", ".rt", ".t")
47
+
48
+
49
+ class Psychopy(IntervalSeriesSource):
50
+ """Load Psychopy trial windows as an interval table."""
51
+
52
+ tag = "psychopy"
53
+ patterns = ("**/*_psychopy.csv",)
54
+ requires = ("dataqueue",)
55
+ task_pattern = _TASK_PATTERN
56
+ task_aliases = _TASK_ALIASES
57
+ time_suffixes = _TIME_SUFFIXES
58
+ keypress_rt_pattern = re.compile(r"key_resp.*\.rt$")
59
+ time_source_prefix = "display_"
60
+ time_source_suffix = ".started"
61
+ gratings_columns = (
62
+ "trials.thisN",
63
+ "stim_grayScreen.started",
64
+ "stim_grayScreen.stopped",
65
+ "stim_grating.started",
66
+ "stim_grating.stopped",
67
+ "display_gratings.stopped",
68
+ )
69
+ movies_columns = (
70
+ "trials_2.thisN",
71
+ "trials_2.thisIndex",
72
+ "trials2.thisN",
73
+ "trials.thisIndex",
74
+ "description",
75
+ "thisRow.t",
76
+ "display_mov.started",
77
+ "natural_mov.started",
78
+ "display_mov.stopped",
79
+ "grey.started",
80
+ "grey.stopped",
81
+ )
82
+ dataqueue_elapsed_column = "queue_elapsed"
83
+ dataqueue_device_column = "device_id"
84
+ dataqueue_device_match = "nidaq"
85
+ dataqueue_payload_column = "payload"
86
+ dataqueue_payload_value = 1
87
+ def build_intervals(
88
+ self,
89
+ path: Path,
90
+ *,
91
+ context: LoadContext | None = None,
92
+ ) -> tuple[pd.DataFrame, dict]:
93
+ context = self._require_context(context)
94
+ # 1) Read raw psychopy CSV.
95
+ raw = pd.read_csv(path, low_memory=False)
96
+
97
+ # 2) Infer task and choose trial table.
98
+ task = self._infer_task(path)
99
+ if task == "task-gratings":
100
+ trial_table = self._extract_gratings(raw)
101
+ task_branch = "gratings"
102
+ elif task == "task-movies":
103
+ trial_table = self._extract_movies(raw)
104
+ task_branch = "movies"
105
+ else:
106
+ trial_table = raw
107
+ task_branch = "raw"
108
+
109
+ # 3) Resolve timeline and keypress offset.
110
+ time_source = trial_table if any(
111
+ c.startswith(self.time_source_prefix) and c.endswith(self.time_source_suffix) for c in trial_table.columns
112
+ ) else raw
113
+ row_time, row_col, row_meta = self._resolve_row_time(time_source)
114
+ key_rt, key_col = self._resolve_keypress_rt(raw)
115
+
116
+ # 4) Parse experiment start time (optional).
117
+ exp_start = None
118
+ exp_start_raw = raw.get("expStart")
119
+ if exp_start_raw is not None and exp_start_raw.dropna().any():
120
+ exp_start = self._parse_exp_start(exp_start_raw.dropna().iloc[0])
121
+
122
+ # 5) Apply dataqueue offset and align times.
123
+ dq_start, dq_meta = self._dataqueue_offset(context)
124
+ exp_start_offset = None
125
+ exp_start_keypress_offset = None
126
+ exp_start_offset = self._calc_exp_start_offset(exp_start, dq_meta.get("abs_start_time"))
127
+ # Offset into psychopy-zero time (exp_start + keypress RT).
128
+ exp_start_keypress_offset = exp_start_offset - float(key_rt)
129
+ dq_start = float(exp_start_keypress_offset)
130
+ aligned_t = self._align_time(row_time, key_rt, dq_start)
131
+ aligned_table = self._align_table(trial_table, key_rt, dq_start)
132
+
133
+ # 6) For gratings, build windows once from the aligned table.
134
+ if task_branch == "gratings":
135
+ if "window_grating_start" in aligned_table.columns:
136
+ aligned_table["gratings_windows"] = [
137
+ self._build_window_list(aligned_table, "window_grating_start", "window_grating_stop")
138
+ ] * len(aligned_table)
139
+ if "window_gray_start" in aligned_table.columns:
140
+ aligned_table["gray_windows"] = [
141
+ self._build_window_list(aligned_table, "window_gray_start", "window_gray_stop")
142
+ ] * len(aligned_table)
143
+
144
+ # 7) Build interval table and metrics.
145
+ interval_start = None
146
+ interval_stop = None
147
+ if "window_grating_start" in aligned_table.columns and "window_grating_stop" in aligned_table.columns:
148
+ interval_start = aligned_table["window_grating_start"]
149
+ interval_stop = aligned_table["window_grating_stop"]
150
+ elif "window_gray_start" in aligned_table.columns and "window_gray_stop" in aligned_table.columns:
151
+ interval_start = aligned_table["window_gray_start"]
152
+ interval_stop = aligned_table["window_gray_stop"]
153
+ else:
154
+ interval_start = pd.Series(aligned_t, index=aligned_table.index)
155
+ interval_stop = pd.Series(aligned_t, index=aligned_table.index)
156
+
157
+ intervals = aligned_table.rename(
158
+ columns={c: f"{task_branch}_{c}" for c in aligned_table.columns}
159
+ ).copy()
160
+ intervals["start_s"] = pd.to_numeric(interval_start, errors="coerce")
161
+ intervals["stop_s"] = pd.to_numeric(interval_stop, errors="coerce")
162
+ intervals = self._apply_experiment_window(intervals, context, dq_start)
163
+ metrics = {
164
+ "source_file": str(path),
165
+ "n_rows": int(len(raw)),
166
+ "task_inferred": task,
167
+ "task_branch": task_branch,
168
+ "row_time_column": row_col,
169
+ "key_resp_rt": key_rt,
170
+ "key_resp_column": key_col,
171
+ "exp_start": exp_start.isoformat() if exp_start is not None else None,
172
+ "start_offset_s": dq_start,
173
+ }
174
+ if exp_start_offset is not None:
175
+ metrics["exp_start_offset_s"] = float(exp_start_offset)
176
+ if exp_start_keypress_offset is not None:
177
+ metrics["exp_start_keypress_offset_s"] = float(exp_start_keypress_offset)
178
+ metrics.update(row_meta)
179
+ metrics.update(dq_meta)
180
+ metrics["time_basis"] = "psychopy_zero_based" if dq_start is not None else "psychopy_native"
181
+ metrics["alignment_method"] = "keypress_relative"
182
+
183
+ return intervals, metrics
184
+
185
+ def _parse_exp_start(self, value: Any) -> Optional[pd.Timestamp]:
186
+ text = str(value).strip()
187
+ text = text.replace("h", ":", 1)
188
+ text = re.sub(r":(\d{2})\.(\d{2}\.\d+)", r":\1:\2", text)
189
+ parsed = pd.to_datetime(text, errors="coerce", utc=False)
190
+ return None if pd.isna(parsed) else parsed
191
+
192
+ def _strip_tz(self, value: pd.Timestamp) -> pd.Timestamp:
193
+ return value.tz_localize(None) if getattr(value, "tzinfo", None) else value
194
+
195
+ def _calc_exp_start_offset(self, exp_start: pd.Timestamp, abs_start_time: Any) -> Optional[float]:
196
+ if not abs_start_time:
197
+ return None
198
+ abs_start = pd.to_datetime(abs_start_time, errors="coerce", utc=False)
199
+ if pd.isna(abs_start):
200
+ return None
201
+ abs_start = self._strip_tz(abs_start)
202
+ exp_start = self._strip_tz(exp_start)
203
+ return (abs_start - exp_start).total_seconds()
204
+
205
+ def _infer_task(self, file_path: Path) -> str | None:
206
+ name = file_path.as_posix().lower()
207
+ match = self.task_pattern.search(name)
208
+ if match:
209
+ return self.task_aliases.get(match.group(1).lower())
210
+ for key, label in self.task_aliases.items():
211
+ if key in name:
212
+ return label
213
+ return None
214
+
215
+ def _select_columns(self, frame: pd.DataFrame, columns: list[str]) -> pd.DataFrame:
216
+ available = [col for col in columns if col in frame.columns]
217
+ if not available:
218
+ raise ValueError("Psychopy required columns are missing from the CSV.")
219
+ return frame.loc[:, available].copy()
220
+
221
+ def _coalesce_column(self, frame: pd.DataFrame, columns: list[str]) -> pd.Series:
222
+ available = [col for col in columns if col in frame.columns]
223
+ if not available:
224
+ raise ValueError(f"Psychopy required columns missing: {columns}")
225
+ return frame.loc[:, available].bfill(axis=1).iloc[:, 0]
226
+
227
+ def _resolve_keypress_rt(self, frame: pd.DataFrame) -> tuple[float, str]:
228
+ candidates = [col for col in frame.columns if self.keypress_rt_pattern.match(str(col))]
229
+ for col in candidates:
230
+ series = pd.to_numeric(frame[col], errors="coerce")
231
+ if series.notna().any():
232
+ return float(series.dropna().iloc[0]), str(col)
233
+ raise ValueError("Psychopy keypress RT column not found (key_resp*.rt).")
234
+
235
+ def _resolve_row_time(self, frame: pd.DataFrame) -> tuple[np.ndarray, str, dict]:
236
+ display_candidates = [
237
+ c for c in frame.columns if c.startswith(self.time_source_prefix) and c.endswith(self.time_source_suffix)
238
+ ]
239
+ for col in display_candidates:
240
+ series = pd.to_numeric(frame[col], errors="coerce")
241
+ if series.notna().sum() >= 2:
242
+ filled = series.interpolate(limit_direction="both")
243
+ time_cols = [c for c in frame.columns if any(c.endswith(suf) for suf in self.time_suffixes)]
244
+ max_times = [pd.to_numeric(frame[c], errors="coerce").max() for c in time_cols]
245
+ max_time = max([t for t in max_times if pd.notna(t)], default=filled.max())
246
+ if max_time > filled.max():
247
+ last_valid_idx = filled.last_valid_index()
248
+ if last_valid_idx is not None and last_valid_idx < len(filled) - 1:
249
+ filled.loc[last_valid_idx + 1:] = max_time
250
+ meta = {
251
+ "row_time_column": col,
252
+ "row_time_extended_to": float(max_time),
253
+ }
254
+ return filled.to_numpy(dtype=np.float64), col, meta
255
+ raise ValueError("Psychopy requires a display_*.started column with at least 2 numeric values.")
256
+
257
+ def _dataqueue_offset(self, context: LoadContext) -> tuple[Optional[float], dict]:
258
+ #TODO: If a dataqueue does not have nidaq payload==1 but may start at nidaq==2,
259
+ # treat the first pulse as the start and log a warning.
260
+ dq_path = context.path_for("dataqueue")
261
+ if context.dataqueue_frame is not None:
262
+ frame = context.dataqueue_frame
263
+ queue_all = pd.to_numeric(frame.get(self.dataqueue_elapsed_column), errors="coerce").dropna()
264
+ if queue_all.empty:
265
+ raise ValueError("Dataqueue frame contains no queue_elapsed samples")
266
+ queue_start = float(queue_all.iloc[0])
267
+ device_series = frame.get(self.dataqueue_device_column, pd.Series(dtype=str)).astype(str)
268
+ mask = device_series.str.contains(self.dataqueue_device_match, case=False, na=False, regex=False)
269
+ device_rows = frame.loc[mask]
270
+ pulses = pd.to_numeric(device_rows.get(self.dataqueue_elapsed_column), errors="coerce")
271
+ abs_start = pd.to_datetime(device_rows.get("device_ts"), errors="coerce", utc=True)
272
+ abs_start = abs_start.dropna().dt.strftime("%Y-%m-%dT%H:%M:%S.%fZ").to_numpy(dtype=str)
273
+ else:
274
+ if dq_path is None:
275
+ raise FileNotFoundError("Psychopy: dataqueue path not available")
276
+ timeline = DataqueueIndex.from_path(dq_path)
277
+ queue_all = timeline.queue_series()
278
+ queue_start = float(queue_all.iloc[0])
279
+ device_slice = timeline.slice(self.dataqueue_device_match)
280
+ abs_start = device_slice.packet_absolute().to_numpy(dtype=str)
281
+ pulses = pd.to_numeric(device_slice.rows.get(self.dataqueue_elapsed_column), errors="coerce")
282
+ # if self.dataqueue_payload_column in device_slice.rows.columns:
283
+ # payload = pd.to_numeric(device_slice.rows[self.dataqueue_payload_column], errors="coerce")
284
+ # pulses = pulses.loc[payload == self.dataqueue_payload_value]
285
+ pulses = pulses.dropna()
286
+ if pulses.empty:
287
+ raise ValueError(f"Dataqueue timeline exists {timeline.source_path} but contains no nidaq payload==1 pulses.")
288
+
289
+ start_rel = float(pulses.iloc[0]) - 0
290
+ meta = {
291
+ "dataqueue_file": str(dq_path) if dq_path is not None else None,
292
+ "dataqueue_pulses": int(pulses.size),
293
+ "dataqueue_queue_start": queue_start,
294
+ "abs_start_time": abs_start[0] if len(abs_start) else None,
295
+ }
296
+ return start_rel, meta
297
+
298
+ def _apply_experiment_window(
299
+ self,
300
+ intervals: pd.DataFrame,
301
+ context: LoadContext,
302
+ dq_start: float | None,
303
+ ) -> pd.DataFrame:
304
+ if context.experiment_window is None or dq_start is None:
305
+ return intervals
306
+ start, stop = context.experiment_window
307
+ window_start = float(start) - float(dq_start)
308
+ window_stop = float(stop) - float(dq_start)
309
+
310
+ intervals = intervals.copy()
311
+ intervals["start_s"] = intervals["start_s"].clip(lower=window_start, upper=window_stop)
312
+ intervals["stop_s"] = intervals["stop_s"].clip(lower=window_start, upper=window_stop)
313
+ mask = (intervals["stop_s"] >= window_start) & (intervals["start_s"] <= window_stop)
314
+ return intervals.loc[mask].reset_index(drop=True)
315
+
316
+ def _align_time(self, values: np.ndarray, key_rt: float, offset: Optional[float]) -> np.ndarray:
317
+ aligned = values.astype(np.float64, copy=True) - float(key_rt)
318
+ if offset is not None:
319
+ aligned = aligned - float(offset)
320
+ return aligned
321
+
322
+ def _align_table(self, frame: pd.DataFrame, key_rt: float, offset: Optional[float]) -> pd.DataFrame:
323
+ aligned = frame.copy()
324
+ for col in aligned.columns:
325
+ name = str(col)
326
+ if not (name.lower().endswith(self.time_suffixes) or name.startswith("window_")):
327
+ continue
328
+ series = pd.to_numeric(aligned[col], errors="coerce")
329
+ aligned[col] = self._align_time(series.to_numpy(dtype=np.float64), key_rt, offset)
330
+ return aligned.dropna(axis=1, how="all")
331
+
332
+ def _build_window_list(self, frame: pd.DataFrame, start_col: str, stop_col: str) -> list[tuple[float, float]]:
333
+ if start_col not in frame.columns or stop_col not in frame.columns:
334
+ return []
335
+ starts = pd.to_numeric(frame[start_col], errors="coerce").to_numpy(dtype=np.float64, copy=False)
336
+ stops = pd.to_numeric(frame[stop_col], errors="coerce").to_numpy(dtype=np.float64, copy=False)
337
+ return [
338
+ (float(start), float(stop))
339
+ for start, stop in zip(starts, stops)
340
+ if np.isfinite(start) and np.isfinite(stop) and stop > start
341
+ ]
342
+
343
+ def _extract_gratings(self, frame: pd.DataFrame) -> pd.DataFrame:
344
+ selected = self._select_columns(frame, list(self.gratings_columns))
345
+ result = selected.loc[selected.get("trials.thisN").notna()].copy()
346
+ if result.empty:
347
+ raise ValueError("Psychopy gratings trials are empty.")
348
+
349
+ gray_start = self._coalesce_column(result, ["stim_grayScreen.started"])
350
+ gray_stop = self._coalesce_column(result, ["stim_grating.started"])
351
+ grating_start = self._coalesce_column(result, ["stim_grating.started"])
352
+ grating_stop = self._coalesce_column(result, ["display_gratings.stopped"])
353
+
354
+ result["window_grating_start"] = pd.to_numeric(grating_start, errors="coerce")
355
+ result["window_grating_stop"] = pd.to_numeric(grating_stop, errors="coerce")
356
+ result["window_gray_start"] = pd.to_numeric(gray_start, errors="coerce")
357
+ result["window_gray_stop"] = pd.to_numeric(gray_stop, errors="coerce")
358
+
359
+ return result
360
+
361
+ def _extract_movies(self, frame: pd.DataFrame) -> pd.DataFrame:
362
+ return self._select_columns(frame, list(self.movies_columns))
363
+
364
+