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
mesofield/config.py
ADDED
|
@@ -0,0 +1,751 @@
|
|
|
1
|
+
"""Experiment configuration registry.
|
|
2
|
+
|
|
3
|
+
This module defines two classes:
|
|
4
|
+
|
|
5
|
+
:class:`ConfigRegister`
|
|
6
|
+
A generic key/value registry with optional type validation and
|
|
7
|
+
per-key change callbacks. Used as a building block by
|
|
8
|
+
:class:`ExperimentConfig`.
|
|
9
|
+
|
|
10
|
+
:class:`ExperimentConfig`
|
|
11
|
+
Experiment-aware extension of ``ConfigRegister`` with default
|
|
12
|
+
parameters (subject / session / task / LED pattern / duration), a
|
|
13
|
+
BIDS-style path layout (``experiment_dir`` → ``data_dir`` →
|
|
14
|
+
``bids_dir``), JSON load/save, and an attached
|
|
15
|
+
:class:`~mesofield.hardware.HardwareManager`.
|
|
16
|
+
|
|
17
|
+
Typical lifecycle:
|
|
18
|
+
|
|
19
|
+
.. code-block:: python
|
|
20
|
+
|
|
21
|
+
cfg = ExperimentConfig("path/to/hardware.yaml")
|
|
22
|
+
cfg.load_json("path/to/experiment.json")
|
|
23
|
+
cfg.set("subject", "001")
|
|
24
|
+
seq = cfg.build_sequence(cfg.hardware.primary)
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
import os
|
|
28
|
+
import json
|
|
29
|
+
import dataclasses
|
|
30
|
+
import datetime
|
|
31
|
+
from typing import Dict, Any, List, Optional, Type, TypeVar, Callable
|
|
32
|
+
|
|
33
|
+
import pandas as pd
|
|
34
|
+
import useq
|
|
35
|
+
from useq import TIntervalLoops
|
|
36
|
+
|
|
37
|
+
from mesofield.hardware import HardwareManager
|
|
38
|
+
from mesofield.protocols import DataProducer
|
|
39
|
+
from mesofield.utils._logger import get_logger, hyperlink
|
|
40
|
+
|
|
41
|
+
T = TypeVar('T')
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class ConfigRegister:
|
|
45
|
+
"""A registry that maintains configuration values with optional type validation."""
|
|
46
|
+
|
|
47
|
+
def __init__(self):
|
|
48
|
+
self._registry: Dict[str, Any] = {}
|
|
49
|
+
self._metadata: Dict[str, Dict[str, Any]] = {}
|
|
50
|
+
self._callbacks: Dict[str, List[Callable[[str, Any], None]]] = {}
|
|
51
|
+
self._choices: Dict[str, List[Any]] = {}
|
|
52
|
+
|
|
53
|
+
def register(self, key: str, default: Any = None,
|
|
54
|
+
type_hint: Optional[Type] = None,
|
|
55
|
+
description: str = "",
|
|
56
|
+
category: str = "general") -> None:
|
|
57
|
+
"""Register a configuration parameter with metadata."""
|
|
58
|
+
self._registry[key] = default
|
|
59
|
+
self._metadata[key] = {
|
|
60
|
+
"type": type_hint,
|
|
61
|
+
"description": description,
|
|
62
|
+
"category": category
|
|
63
|
+
}
|
|
64
|
+
self._callbacks[key] = []
|
|
65
|
+
|
|
66
|
+
def get(self, key: str, default: Any = None) -> Any:
|
|
67
|
+
"""Get a configuration value."""
|
|
68
|
+
return self._registry.get(key, default)
|
|
69
|
+
|
|
70
|
+
def set(self, key: str, value: Any) -> None:
|
|
71
|
+
"""Set a configuration value with type validation."""
|
|
72
|
+
# Register the key if it doesn't exist
|
|
73
|
+
if key not in self._registry:
|
|
74
|
+
self.register(key, value)
|
|
75
|
+
|
|
76
|
+
# Validate type if type hint exists
|
|
77
|
+
type_hint = self._metadata.get(key, {}).get("type")
|
|
78
|
+
if type_hint and not isinstance(value, type_hint):
|
|
79
|
+
try:
|
|
80
|
+
# Attempt type conversion
|
|
81
|
+
value = type_hint(value)
|
|
82
|
+
except (ValueError, TypeError):
|
|
83
|
+
raise TypeError(f"Invalid type for {key}. Expected {type_hint.__name__}, got {type(value).__name__}")
|
|
84
|
+
|
|
85
|
+
# Update value
|
|
86
|
+
self._registry[key] = value
|
|
87
|
+
|
|
88
|
+
# Trigger callbacks
|
|
89
|
+
for callback in self._callbacks.get(key, []):
|
|
90
|
+
callback(key, value)
|
|
91
|
+
|
|
92
|
+
def has(self, key: str) -> bool:
|
|
93
|
+
"""Check if a key exists in the registry."""
|
|
94
|
+
return key in self._registry
|
|
95
|
+
|
|
96
|
+
def keys(self) -> List[str]:
|
|
97
|
+
"""Get all registered keys."""
|
|
98
|
+
return list(self._registry.keys())
|
|
99
|
+
|
|
100
|
+
def items(self) -> Dict[str, Any]:
|
|
101
|
+
"""Get all key-value pairs."""
|
|
102
|
+
return self._registry.copy()
|
|
103
|
+
|
|
104
|
+
def get_metadata(self, key: str) -> Dict[str, Any]:
|
|
105
|
+
"""Get metadata for a key."""
|
|
106
|
+
return self._metadata.get(key, {})
|
|
107
|
+
|
|
108
|
+
def register_callback(self, key: str, callback: Callable[[str, Any], None]) -> None:
|
|
109
|
+
"""Register a callback for when a key's value changes."""
|
|
110
|
+
if key not in self._callbacks:
|
|
111
|
+
self._callbacks[key] = []
|
|
112
|
+
self._callbacks[key].append(callback)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def register_choices(self, key: str, choices: List[Any]) -> None:
|
|
116
|
+
"""Register a list of selectable choices for a configuration key."""
|
|
117
|
+
self._choices[key] = list(choices)
|
|
118
|
+
|
|
119
|
+
def get_choices(self, key: str) -> Optional[List[Any]]:
|
|
120
|
+
"""Return the list of choices for *key*, or ``None`` if none are registered."""
|
|
121
|
+
return self._choices.get(key)
|
|
122
|
+
|
|
123
|
+
def clear(self) -> None:
|
|
124
|
+
"""Clear all configurations."""
|
|
125
|
+
self._registry.clear()
|
|
126
|
+
self._choices.clear()
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class ExperimentConfig(ConfigRegister):
|
|
130
|
+
"""Generate and store experiment parameters using a configuration registry.
|
|
131
|
+
|
|
132
|
+
``ExperimentConfig`` extends :class:`ConfigRegister` with experiment-aware
|
|
133
|
+
defaults (subject / session / task, LED pattern, duration, etc.), a
|
|
134
|
+
BIDS-style path layout (``experiment_dir / data_dir / bids_dir``), and
|
|
135
|
+
integration with a :class:`~mesofield.hardware.HardwareManager`.
|
|
136
|
+
|
|
137
|
+
Example:
|
|
138
|
+
.. code-block:: python
|
|
139
|
+
|
|
140
|
+
from mesofield.config import ExperimentConfig
|
|
141
|
+
|
|
142
|
+
config = ExperimentConfig("path/to/hardware.yaml")
|
|
143
|
+
# Populate from a JSON config file:
|
|
144
|
+
config.load_json("path/to/experiment.json")
|
|
145
|
+
|
|
146
|
+
config.experiment_dir = "./output"
|
|
147
|
+
config.set("subject", "001")
|
|
148
|
+
config.set("task", "TestTask")
|
|
149
|
+
config.notes.append("This is a test note.")
|
|
150
|
+
|
|
151
|
+
# Persist parameters and notes back to JSON:
|
|
152
|
+
config.save_json("path/to/experiment.json")
|
|
153
|
+
"""
|
|
154
|
+
|
|
155
|
+
def __init__(self, path: Optional[str] = None):
|
|
156
|
+
super().__init__()
|
|
157
|
+
# Initialize logging first
|
|
158
|
+
self.logger = get_logger(__name__)
|
|
159
|
+
if path:
|
|
160
|
+
self.logger.info(
|
|
161
|
+
"Initializing ExperimentConfig with hardware path: "
|
|
162
|
+
f"{hyperlink(path, os.path.basename(os.path.normpath(path)))}"
|
|
163
|
+
)
|
|
164
|
+
else:
|
|
165
|
+
self.logger.info("Initializing ExperimentConfig with hardware path: None")
|
|
166
|
+
|
|
167
|
+
# Initialize the configuration registry
|
|
168
|
+
self._json_file_path = ''
|
|
169
|
+
self._save_dir = ''
|
|
170
|
+
self.subjects: Dict[str, Dict[str, Any]] = {}
|
|
171
|
+
self.selected_subject: str | None = None
|
|
172
|
+
self.display_keys: List[str] | None = None
|
|
173
|
+
|
|
174
|
+
# Register common configuration parameters with defaults and types
|
|
175
|
+
self._register_default_parameters()
|
|
176
|
+
self.logger.debug("Registered default parameters")
|
|
177
|
+
|
|
178
|
+
# Initialize hardware
|
|
179
|
+
self.hardware: HardwareManager
|
|
180
|
+
self._hardware_yaml_path: Optional[str] = None
|
|
181
|
+
self._hardware_path_locked: bool = False
|
|
182
|
+
try:
|
|
183
|
+
hardware_path, locked = self._resolve_hardware_path(path)
|
|
184
|
+
self._hardware_yaml_path = hardware_path
|
|
185
|
+
self._hardware_path_locked = locked
|
|
186
|
+
self.hardware = HardwareManager(hardware_path)
|
|
187
|
+
except Exception as e:
|
|
188
|
+
self.logger.warning(f"Hardware config not available: {e}. Starting in default state.")
|
|
189
|
+
self.hardware = HardwareManager()
|
|
190
|
+
|
|
191
|
+
self.notes: list = []
|
|
192
|
+
|
|
193
|
+
def _register_default_parameters(self):
|
|
194
|
+
"""Register default parameters in the registry."""
|
|
195
|
+
# Core experiment parameters
|
|
196
|
+
self.register("subject", "sub", str, "Subject identifier", "experiment")
|
|
197
|
+
self.register("session", "ses", str, "Session identifier", "experiment")
|
|
198
|
+
self.register("task", "task", str, "Task identifier", "experiment")
|
|
199
|
+
self.register("start_on_trigger", False, bool, "Whether to start acquisition on trigger", "hardware")
|
|
200
|
+
self.register("duration", 60, int, "Sequence duration in seconds", "experiment")
|
|
201
|
+
self.register("trial_duration", None, int, "Trial duration in seconds", "experiment")
|
|
202
|
+
self.register("led_pattern", ["4", "4"], list, "Arduino LED sequence pattern", "hardware")
|
|
203
|
+
self.register("psychopy_filename", "experiment.py", str, "PsychoPy experiment filename", "experiment")
|
|
204
|
+
|
|
205
|
+
def set(self, key: str, value: Any) -> None:
|
|
206
|
+
"""Set config values with field-specific normalization where needed."""
|
|
207
|
+
if key == "led_pattern":
|
|
208
|
+
value = self._normalize_led_pattern(value)
|
|
209
|
+
super().set(key, value)
|
|
210
|
+
|
|
211
|
+
def _resolve_hardware_path(self, path: Optional[str]) -> tuple[str, bool]:
|
|
212
|
+
"""Resolve the hardware YAML path.
|
|
213
|
+
|
|
214
|
+
Returns (hardware_path, locked) where locked indicates an explicit file was provided.
|
|
215
|
+
"""
|
|
216
|
+
if path:
|
|
217
|
+
abs_path = os.path.abspath(path)
|
|
218
|
+
if os.path.isdir(abs_path):
|
|
219
|
+
self.experiment_dir = abs_path
|
|
220
|
+
return os.path.join(abs_path, "hardware.yaml"), False
|
|
221
|
+
# treat as explicit file path
|
|
222
|
+
self.experiment_dir = os.path.dirname(abs_path)
|
|
223
|
+
return abs_path, True
|
|
224
|
+
|
|
225
|
+
# no path provided: resolve from experiment_dir (or cwd)
|
|
226
|
+
if not self.experiment_dir:
|
|
227
|
+
self.experiment_dir = os.getcwd()
|
|
228
|
+
return os.path.join(self.experiment_dir, "hardware.yaml"), False
|
|
229
|
+
|
|
230
|
+
def load_hardware(self, yaml_path: str) -> None:
|
|
231
|
+
"""Load (or reload) a hardware YAML configuration.
|
|
232
|
+
|
|
233
|
+
This replaces the current :class:`HardwareManager` with a new one
|
|
234
|
+
pointed at *yaml_path*. Devices are **not** initialised until
|
|
235
|
+
:meth:`HardwareManager.initialize` is called (which is normally done
|
|
236
|
+
by :class:`~mesofield.base.Procedure.initialize_hardware`).
|
|
237
|
+
"""
|
|
238
|
+
abs_path = os.path.abspath(yaml_path)
|
|
239
|
+
self._hardware_yaml_path = abs_path
|
|
240
|
+
self._hardware_path_locked = True
|
|
241
|
+
self.hardware = HardwareManager(abs_path)
|
|
242
|
+
self.logger.info(
|
|
243
|
+
"Loaded hardware config from: "
|
|
244
|
+
f"{hyperlink(abs_path, os.path.basename(abs_path))}"
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
@property
|
|
248
|
+
def _cores(self):# -> tuple[CMMCorePlus, ...]:
|
|
249
|
+
"""Return the tuple of CMMCorePlus instances from the hardware cameras."""
|
|
250
|
+
return tuple(cam.core for cam in self.hardware.cameras if hasattr(cam, 'core'))
|
|
251
|
+
|
|
252
|
+
@property
|
|
253
|
+
def experiment_dir(self) -> str:
|
|
254
|
+
"""Get the experiment directory (base directory)."""
|
|
255
|
+
return self._save_dir
|
|
256
|
+
|
|
257
|
+
@experiment_dir.setter
|
|
258
|
+
def experiment_dir(self, path: str):
|
|
259
|
+
"""Set the experiment directory (base directory)."""
|
|
260
|
+
if isinstance(path, str):
|
|
261
|
+
self._save_dir = os.path.abspath(path)
|
|
262
|
+
else:
|
|
263
|
+
print(f"ExperimentConfig: \n Invalid experiment directory path: {path}")
|
|
264
|
+
|
|
265
|
+
@property
|
|
266
|
+
def data_dir(self) -> str:
|
|
267
|
+
"""Get the data directory (experiment_dir/data)."""
|
|
268
|
+
return os.path.join(self._save_dir, 'data')
|
|
269
|
+
|
|
270
|
+
@property
|
|
271
|
+
def save_dir(self) -> str:
|
|
272
|
+
"""Get the save directory (legacy alias for data_dir)."""
|
|
273
|
+
return self.data_dir
|
|
274
|
+
|
|
275
|
+
@save_dir.setter
|
|
276
|
+
def save_dir(self, path: str):
|
|
277
|
+
"""Set the save directory (base experiment directory)."""
|
|
278
|
+
self.experiment_dir = path
|
|
279
|
+
|
|
280
|
+
@property
|
|
281
|
+
def subject(self) -> str:
|
|
282
|
+
"""Get the subject ID."""
|
|
283
|
+
return self.get("subject")
|
|
284
|
+
|
|
285
|
+
@property
|
|
286
|
+
def session(self) -> str:
|
|
287
|
+
"""Get the session ID as a zero-padded BIDS string (e.g. "01").
|
|
288
|
+
|
|
289
|
+
Formatting is enforced here so paths/filenames are always padded
|
|
290
|
+
regardless of how the raw value was entered (GUI, JSON, etc.).
|
|
291
|
+
"""
|
|
292
|
+
raw = self.get("session")
|
|
293
|
+
try:
|
|
294
|
+
return f"{int(raw):02d}"
|
|
295
|
+
except (TypeError, ValueError):
|
|
296
|
+
return "" if raw is None else str(raw)
|
|
297
|
+
|
|
298
|
+
@property
|
|
299
|
+
def task(self) -> str:
|
|
300
|
+
"""Get the task ID."""
|
|
301
|
+
return self.get("task")
|
|
302
|
+
|
|
303
|
+
@property
|
|
304
|
+
def start_on_trigger(self) -> bool:
|
|
305
|
+
"""Get whether to start on trigger."""
|
|
306
|
+
return self.get("start_on_trigger")
|
|
307
|
+
|
|
308
|
+
@property
|
|
309
|
+
def sequence_duration(self) -> int:
|
|
310
|
+
"""Get the sequence duration in seconds."""
|
|
311
|
+
return int(self.get("duration"))
|
|
312
|
+
|
|
313
|
+
@property
|
|
314
|
+
def trial_duration(self) -> int:
|
|
315
|
+
"""Get the trial duration in seconds."""
|
|
316
|
+
trial_dur = self.get("trial_duration")
|
|
317
|
+
return int(trial_dur) if trial_dur is not None else None
|
|
318
|
+
|
|
319
|
+
@property
|
|
320
|
+
def num_trials(self) -> int:
|
|
321
|
+
"""Calculate the number of trials."""
|
|
322
|
+
return int(self.get("num_trials", 20))
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def build_sequence(self, camera: DataProducer) -> useq.MDASequence:
|
|
326
|
+
"""Build a :class:`useq.MDASequence` sized to this experiment.
|
|
327
|
+
|
|
328
|
+
The loop count is derived from ``num_meso_frames`` when set, or
|
|
329
|
+
from ``camera.sampling_rate * sequence_duration`` otherwise. All
|
|
330
|
+
``HardwareManager`` fields are attached as sequence metadata so
|
|
331
|
+
downstream engines (e.g. :class:`~mesofield.engines.MesoEngine`)
|
|
332
|
+
can resolve the LED pattern and NI-DAQ at setup time.
|
|
333
|
+
|
|
334
|
+
Args:
|
|
335
|
+
camera: The primary :class:`DataProducer` whose
|
|
336
|
+
``sampling_rate`` drives the default loop count.
|
|
337
|
+
|
|
338
|
+
Returns:
|
|
339
|
+
A ready-to-run ``MDASequence`` with a zero-interval time plan.
|
|
340
|
+
"""
|
|
341
|
+
if self.has('num_meso_frames'):
|
|
342
|
+
loops = int(self.get('num_meso_frames'))
|
|
343
|
+
else:
|
|
344
|
+
try:
|
|
345
|
+
loops = int(camera.sampling_rate * self.sequence_duration)
|
|
346
|
+
except Exception:
|
|
347
|
+
loops = 5
|
|
348
|
+
|
|
349
|
+
metadata = dict(self.hardware.__dict__)
|
|
350
|
+
metadata["led_sequence"] = self.led_pattern
|
|
351
|
+
|
|
352
|
+
# convert to a datetime.timedelta and build the time_plan
|
|
353
|
+
time_plan = TIntervalLoops(
|
|
354
|
+
interval=0,
|
|
355
|
+
loops=loops,
|
|
356
|
+
prioritize_duration=False
|
|
357
|
+
)
|
|
358
|
+
return useq.MDASequence(metadata=metadata, time_plan=time_plan)
|
|
359
|
+
|
|
360
|
+
@property
|
|
361
|
+
def bids_dir(self) -> str:
|
|
362
|
+
""" Dynamic construct of BIDS directory path """
|
|
363
|
+
bids = os.path.join(
|
|
364
|
+
f"sub-{self.subject}",
|
|
365
|
+
f"ses-{self.session}",
|
|
366
|
+
)
|
|
367
|
+
return os.path.abspath(os.path.join(self.save_dir, bids))
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
@property
|
|
371
|
+
def dataframe(self):
|
|
372
|
+
"""Convert parameters to a pandas DataFrame."""
|
|
373
|
+
combined_params = self.items()
|
|
374
|
+
data = {'Parameter': list(combined_params.keys()),
|
|
375
|
+
'Value': list(combined_params.values())}
|
|
376
|
+
return pd.DataFrame(data)
|
|
377
|
+
|
|
378
|
+
@property
|
|
379
|
+
def psychopy_filename(self) -> str:
|
|
380
|
+
"""Get the PsychoPy experiment filename."""
|
|
381
|
+
return self.get("psychopy_filename")
|
|
382
|
+
|
|
383
|
+
@property
|
|
384
|
+
def psychopy_path(self) -> str:
|
|
385
|
+
"""Get the PsychoPy script path."""
|
|
386
|
+
return os.path.join(self._save_dir, self.psychopy_filename)
|
|
387
|
+
|
|
388
|
+
@property
|
|
389
|
+
def psychopy_save_path(self) -> str:
|
|
390
|
+
"""Get the PsychoPy save path."""
|
|
391
|
+
return os.path.join(self._save_dir, f"data/sub-{self.subject}/ses-{self.session}/beh/{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}_sub-{self.subject}_ses-{self.session}_task-{self.task}_psychopy")
|
|
392
|
+
|
|
393
|
+
@property
|
|
394
|
+
def psychopy_parameters(self) -> dict:
|
|
395
|
+
"""Get parameters for PsychoPy."""
|
|
396
|
+
return {
|
|
397
|
+
'subject': self.subject,
|
|
398
|
+
'session': self.session,
|
|
399
|
+
'save_dir': self.save_dir,
|
|
400
|
+
'num_trials': self.num_trials,
|
|
401
|
+
'save_path': self.psychopy_save_path
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
@property
|
|
405
|
+
def led_pattern(self) -> list[str]:
|
|
406
|
+
"""Get the LED pattern."""
|
|
407
|
+
value = self.get("led_pattern")
|
|
408
|
+
return self._normalize_led_pattern(value)
|
|
409
|
+
|
|
410
|
+
@led_pattern.setter
|
|
411
|
+
def led_pattern(self, value: Any) -> None:
|
|
412
|
+
"""Set the LED pattern, normalising strings / JSON lists to ``list[str]``."""
|
|
413
|
+
self.set("led_pattern", value)
|
|
414
|
+
|
|
415
|
+
@staticmethod
|
|
416
|
+
def _normalize_led_pattern(value: Any) -> list[str]:
|
|
417
|
+
if isinstance(value, str):
|
|
418
|
+
raw = value.strip()
|
|
419
|
+
if not raw:
|
|
420
|
+
raise ValueError("led_pattern must not be empty")
|
|
421
|
+
|
|
422
|
+
try:
|
|
423
|
+
parsed = json.loads(raw)
|
|
424
|
+
except json.JSONDecodeError:
|
|
425
|
+
parsed = raw
|
|
426
|
+
|
|
427
|
+
if isinstance(parsed, list):
|
|
428
|
+
value = parsed
|
|
429
|
+
elif isinstance(parsed, str):
|
|
430
|
+
value = list(parsed)
|
|
431
|
+
else:
|
|
432
|
+
# Numeric tokens like "422222442" parse as int in json.loads.
|
|
433
|
+
# Treat the original string as compact LED sequence shorthand.
|
|
434
|
+
value = list(raw)
|
|
435
|
+
|
|
436
|
+
if isinstance(value, list):
|
|
437
|
+
normalized = [str(item) for item in value]
|
|
438
|
+
if not normalized:
|
|
439
|
+
raise ValueError("led_pattern must not be empty")
|
|
440
|
+
return normalized
|
|
441
|
+
|
|
442
|
+
raise ValueError("led_pattern must be a list or a JSON string representing a list")
|
|
443
|
+
|
|
444
|
+
def make_path(self, suffix: str, extension: str, bids_type: Optional[str] = None, create_dir: bool = False):
|
|
445
|
+
"""Build a unique BIDS-style output file path.
|
|
446
|
+
|
|
447
|
+
The returned path follows the layout
|
|
448
|
+
``<bids_dir>/[<bids_type>/]<timestamp>_sub-<id>_ses-<id>_task-<id>_<suffix>.<ext>``.
|
|
449
|
+
If a file with that name already exists, ``_<n>`` is appended to keep
|
|
450
|
+
the path unique.
|
|
451
|
+
|
|
452
|
+
Args:
|
|
453
|
+
suffix: Trailing tag added to the filename, e.g. ``"images"``.
|
|
454
|
+
extension: File extension without the leading dot, e.g. ``"jpg"``.
|
|
455
|
+
bids_type: Optional BIDS modality subdirectory under ``bids_dir``
|
|
456
|
+
(e.g. ``"func"``). When ``None``, the file is placed directly
|
|
457
|
+
under ``bids_dir``.
|
|
458
|
+
create_dir: When ``True``, parent directories are created.
|
|
459
|
+
|
|
460
|
+
Returns:
|
|
461
|
+
Absolute path to the generated file.
|
|
462
|
+
|
|
463
|
+
Example:
|
|
464
|
+
.. code-block:: python
|
|
465
|
+
|
|
466
|
+
cfg.make_path("images", "jpg", "func")
|
|
467
|
+
# -> 'C:/save_dir/data/sub-001/ses-01/func/'
|
|
468
|
+
# '20250110_123456_sub-001_ses-01_task-example_images.jpg'
|
|
469
|
+
"""
|
|
470
|
+
file = f"{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}_sub-{self.subject}_ses-{self.session}_task-{self.task}_{suffix}.{extension}"
|
|
471
|
+
|
|
472
|
+
if bids_type is None:
|
|
473
|
+
bids_path = self.bids_dir
|
|
474
|
+
else:
|
|
475
|
+
bids_path = os.path.join(self.bids_dir, bids_type)
|
|
476
|
+
|
|
477
|
+
if create_dir:
|
|
478
|
+
os.makedirs(bids_path, exist_ok=True)
|
|
479
|
+
base, ext = os.path.splitext(file)
|
|
480
|
+
counter = 1
|
|
481
|
+
file_path = os.path.join(bids_path, file)
|
|
482
|
+
while os.path.exists(file_path):
|
|
483
|
+
file_path = os.path.join(bids_path, f"{base}_{counter}{ext}")
|
|
484
|
+
counter += 1
|
|
485
|
+
return file_path
|
|
486
|
+
|
|
487
|
+
def load_json(self, file_path) -> None:
|
|
488
|
+
""" Load parameters from a JSON configuration file into the config object.
|
|
489
|
+
"""
|
|
490
|
+
file_path_str = os.fspath(file_path)
|
|
491
|
+
file_link = hyperlink(
|
|
492
|
+
file_path_str,
|
|
493
|
+
os.path.basename(os.path.normpath(file_path_str)),
|
|
494
|
+
)
|
|
495
|
+
self.logger.info(f"Loading configuration from: {file_link}")
|
|
496
|
+
try:
|
|
497
|
+
with open(file_path_str, 'r') as f:
|
|
498
|
+
loaded_config = json.load(f)
|
|
499
|
+
self.logger.info("Successfully loaded configuration JSON")
|
|
500
|
+
except FileNotFoundError:
|
|
501
|
+
self.logger.error(f"Configuration file not found: {file_link}")
|
|
502
|
+
return
|
|
503
|
+
except json.JSONDecodeError as e:
|
|
504
|
+
self.logger.error(f"Error decoding JSON from {file_link}: {e}")
|
|
505
|
+
return
|
|
506
|
+
|
|
507
|
+
self._json_file_path = file_path_str #store the json filepath
|
|
508
|
+
json_dir = os.path.dirname(os.path.abspath(file_path_str))
|
|
509
|
+
if json_dir:
|
|
510
|
+
self.experiment_dir = json_dir
|
|
511
|
+
if not self._hardware_path_locked:
|
|
512
|
+
try:
|
|
513
|
+
hardware_path = os.path.join(self.experiment_dir, "hardware.yaml")
|
|
514
|
+
if hardware_path != self._hardware_yaml_path:
|
|
515
|
+
self._hardware_yaml_path = hardware_path
|
|
516
|
+
self.hardware = HardwareManager(hardware_path)
|
|
517
|
+
except Exception as e:
|
|
518
|
+
self.logger.error(f"Failed to reinitialize hardware: {e}")
|
|
519
|
+
raise
|
|
520
|
+
self._apply_config(loaded_config)
|
|
521
|
+
|
|
522
|
+
def load_dict(self, data: Any) -> None:
|
|
523
|
+
"""Load parameters from a dataclass instance or plain mapping.
|
|
524
|
+
|
|
525
|
+
This is the programmatic counterpart to :meth:`load_json` used by
|
|
526
|
+
scripted procedures (see :meth:`Procedure.define_config`). Unlike
|
|
527
|
+
:meth:`load_json` it does not touch the hardware YAML path -- scripted
|
|
528
|
+
hardware is supplied directly via :meth:`Procedure.define_hardware`.
|
|
529
|
+
|
|
530
|
+
*data* may be a ``@dataclass`` instance or any mapping. Both the flat
|
|
531
|
+
and the ``Configuration``/``Subjects`` shapes are accepted.
|
|
532
|
+
"""
|
|
533
|
+
if dataclasses.is_dataclass(data) and not isinstance(data, type):
|
|
534
|
+
loaded_config = dataclasses.asdict(data)
|
|
535
|
+
else:
|
|
536
|
+
loaded_config = dict(data)
|
|
537
|
+
self.logger.info("Loading configuration from in-memory mapping")
|
|
538
|
+
self._apply_config(loaded_config)
|
|
539
|
+
|
|
540
|
+
def _apply_config(self, loaded_config: dict) -> None:
|
|
541
|
+
"""Apply a parsed configuration mapping to the registry."""
|
|
542
|
+
self.display_keys = loaded_config.get("DisplayKeys")
|
|
543
|
+
# Detect new style JSON with 'Configuration' and 'Subjects'
|
|
544
|
+
self.subjects = {}
|
|
545
|
+
|
|
546
|
+
if "Configuration" in loaded_config and "Subjects" in loaded_config:
|
|
547
|
+
config_params = loaded_config.get("Configuration", {})
|
|
548
|
+
for key, value in config_params.items():
|
|
549
|
+
if isinstance(value, list) and key != "led_pattern":
|
|
550
|
+
# Lists in Configuration are treated as selectable choices.
|
|
551
|
+
# Store the full list as choices and default to the first item.
|
|
552
|
+
self.register_choices(key, value)
|
|
553
|
+
if value:
|
|
554
|
+
self.set(key, value[0])
|
|
555
|
+
else:
|
|
556
|
+
self.set(key, value)
|
|
557
|
+
if config_params.get("experiment_directory"):
|
|
558
|
+
self.experiment_dir = config_params.get("experiment_directory")
|
|
559
|
+
self.subjects = loaded_config.get("Subjects", {})
|
|
560
|
+
if self.subjects:
|
|
561
|
+
first = next(iter(self.subjects.keys()))
|
|
562
|
+
self.select_subject(first)
|
|
563
|
+
else:
|
|
564
|
+
# legacy flat structure
|
|
565
|
+
for key, value in loaded_config.items():
|
|
566
|
+
self.set(key, value)
|
|
567
|
+
|
|
568
|
+
if "Plugins" in loaded_config:
|
|
569
|
+
self.plugins: dict = loaded_config.get("Plugins", {})
|
|
570
|
+
for plugin in self.plugins:
|
|
571
|
+
if self.plugins.get(plugin, {}).get('enabled') is True:
|
|
572
|
+
self.register(plugin,
|
|
573
|
+
self.plugins.get(plugin, {}).get('config'),
|
|
574
|
+
dict,
|
|
575
|
+
f"{plugin} plugin configuration",
|
|
576
|
+
"plugins")
|
|
577
|
+
|
|
578
|
+
def _auto_increment_session(self) -> None:
|
|
579
|
+
"""Increment the session number in the config and persist it to the JSON file."""
|
|
580
|
+
# get current session number
|
|
581
|
+
curr = int(self.session)
|
|
582
|
+
next_num = curr + 1
|
|
583
|
+
session_str = f"{next_num:02d}"
|
|
584
|
+
|
|
585
|
+
# update in-memory config
|
|
586
|
+
self.set("session", session_str)
|
|
587
|
+
|
|
588
|
+
# persist back to the JSON file if available
|
|
589
|
+
path = getattr(self, "_json_file_path", "")
|
|
590
|
+
if path and os.path.isfile(path):
|
|
591
|
+
try:
|
|
592
|
+
with open(path, "r") as f:
|
|
593
|
+
data = json.load(f)
|
|
594
|
+
|
|
595
|
+
# new-style JSON
|
|
596
|
+
if "Subjects" in data and self.selected_subject in data["Subjects"]:
|
|
597
|
+
data["Subjects"][self.selected_subject]["session"] = session_str
|
|
598
|
+
# configuration block
|
|
599
|
+
elif "Configuration" in data:
|
|
600
|
+
data["Configuration"]["session"] = session_str
|
|
601
|
+
# legacy flat structure
|
|
602
|
+
else:
|
|
603
|
+
data["session"] = session_str
|
|
604
|
+
|
|
605
|
+
with open(path, "w") as f:
|
|
606
|
+
json.dump(data, f, indent=4)
|
|
607
|
+
except Exception as e:
|
|
608
|
+
self.logger.error(f"Failed to update session in JSON file: {e}")
|
|
609
|
+
else:
|
|
610
|
+
self.logger.warning("No JSON file to update; _json_file_path not set or file missing")
|
|
611
|
+
|
|
612
|
+
def save_json(self, path: Optional[str] = None) -> None:
|
|
613
|
+
"""Persist displayed configuration values back to the JSON file."""
|
|
614
|
+
path = path or getattr(self, "_json_file_path", "")
|
|
615
|
+
if not path or not os.path.isfile(path):
|
|
616
|
+
self.logger.warning("No JSON file to update; _json_file_path not set or file missing")
|
|
617
|
+
return
|
|
618
|
+
try:
|
|
619
|
+
with open(path, "r") as f:
|
|
620
|
+
data = json.load(f)
|
|
621
|
+
|
|
622
|
+
display = self.display_keys or []
|
|
623
|
+
subject_vals = data.get("Subjects", {}).get(self.selected_subject, {})
|
|
624
|
+
|
|
625
|
+
if "Configuration" in data:
|
|
626
|
+
cfg_block = data.get("Configuration", {})
|
|
627
|
+
for k in display:
|
|
628
|
+
if k in subject_vals:
|
|
629
|
+
continue # subject-specific key
|
|
630
|
+
if k in cfg_block:
|
|
631
|
+
cfg_block[k] = self.get(k)
|
|
632
|
+
data["Configuration"] = cfg_block
|
|
633
|
+
else:
|
|
634
|
+
for k in display:
|
|
635
|
+
if k in subject_vals:
|
|
636
|
+
continue
|
|
637
|
+
if k in data:
|
|
638
|
+
data[k] = self.get(k)
|
|
639
|
+
|
|
640
|
+
if subject_vals:
|
|
641
|
+
for k in display:
|
|
642
|
+
if k in subject_vals and self.has(k):
|
|
643
|
+
subject_vals[k] = self.get(k)
|
|
644
|
+
if "Subjects" not in data:
|
|
645
|
+
data["Subjects"] = {}
|
|
646
|
+
data["Subjects"][self.selected_subject] = subject_vals
|
|
647
|
+
|
|
648
|
+
with open(path, "w") as f:
|
|
649
|
+
json.dump(data, f, indent=4)
|
|
650
|
+
except Exception as e:
|
|
651
|
+
self.logger.error(f"Failed to update configuration JSON: {e}")
|
|
652
|
+
|
|
653
|
+
def select_subject(self, subject_id: str) -> None:
|
|
654
|
+
"""Apply subject-specific parameters from ``self.subjects``."""
|
|
655
|
+
subj = self.subjects.get(subject_id)
|
|
656
|
+
if not subj:
|
|
657
|
+
raise ValueError(f"Subject {subject_id} not found")
|
|
658
|
+
self.selected_subject = subject_id
|
|
659
|
+
self.set("subject", subject_id)
|
|
660
|
+
for key, val in subj.items():
|
|
661
|
+
try:
|
|
662
|
+
self.set(key, val)
|
|
663
|
+
except Exception as e:
|
|
664
|
+
self.logger.error(f"Failed to update session in JSON file: {e}")
|
|
665
|
+
|
|
666
|
+
def _read_json_file(self) -> Optional[dict]:
|
|
667
|
+
path = getattr(self, "_json_file_path", "")
|
|
668
|
+
if not path or not os.path.isfile(path):
|
|
669
|
+
self.logger.warning("No JSON file to update; _json_file_path not set or file missing")
|
|
670
|
+
return None
|
|
671
|
+
with open(path, "r") as f:
|
|
672
|
+
return json.load(f)
|
|
673
|
+
|
|
674
|
+
def _write_json_file(self, data: dict) -> None:
|
|
675
|
+
path = getattr(self, "_json_file_path", "")
|
|
676
|
+
with open(path, "w") as f:
|
|
677
|
+
json.dump(data, f, indent=4)
|
|
678
|
+
|
|
679
|
+
def add_subject(self, subject_id: str) -> None:
|
|
680
|
+
"""Add a new subject, seeding parameters from existing subjects.
|
|
681
|
+
|
|
682
|
+
The new subject's parameter dict is the union of keys from existing
|
|
683
|
+
subjects with blank string values, so all subjects share a consistent
|
|
684
|
+
parameter set and :meth:`select_subject` will accept the new entry.
|
|
685
|
+
Persists to ``experiment.json``.
|
|
686
|
+
"""
|
|
687
|
+
subject_id = (subject_id or "").strip()
|
|
688
|
+
if not subject_id:
|
|
689
|
+
raise ValueError("Subject ID must not be empty")
|
|
690
|
+
if subject_id in self.subjects:
|
|
691
|
+
raise ValueError(f"Subject '{subject_id}' already exists")
|
|
692
|
+
|
|
693
|
+
seed_keys: set = set()
|
|
694
|
+
for params in self.subjects.values():
|
|
695
|
+
seed_keys.update(params.keys())
|
|
696
|
+
if not seed_keys and self.display_keys:
|
|
697
|
+
seed_keys = {k for k in self.display_keys if k != "subject"}
|
|
698
|
+
if not seed_keys:
|
|
699
|
+
seed_keys = {"session"}
|
|
700
|
+
|
|
701
|
+
new_params = {k: "" for k in seed_keys}
|
|
702
|
+
self.subjects[subject_id] = new_params
|
|
703
|
+
|
|
704
|
+
data = self._read_json_file()
|
|
705
|
+
if data is not None:
|
|
706
|
+
data.setdefault("Subjects", {})
|
|
707
|
+
data["Subjects"][subject_id] = new_params
|
|
708
|
+
self._write_json_file(data)
|
|
709
|
+
|
|
710
|
+
def add_parameter(self, name: str, default: Any, type_hint: Type) -> None:
|
|
711
|
+
"""Add a subject-scoped parameter to every subject and DisplayKeys.
|
|
712
|
+
|
|
713
|
+
Registers the parameter in the config registry, fills it on every
|
|
714
|
+
subject in :attr:`subjects`, appends it to :attr:`display_keys`, and
|
|
715
|
+
persists the additions to ``experiment.json``.
|
|
716
|
+
"""
|
|
717
|
+
name = (name or "").strip()
|
|
718
|
+
if not name:
|
|
719
|
+
raise ValueError("Parameter name must not be empty")
|
|
720
|
+
if self.has(name):
|
|
721
|
+
raise ValueError(f"Parameter '{name}' already exists")
|
|
722
|
+
if type_hint not in (str, int, bool):
|
|
723
|
+
raise ValueError("type_hint must be str, int, or bool")
|
|
724
|
+
|
|
725
|
+
self.register(name, default, type_hint, "User-added subject parameter", "subject")
|
|
726
|
+
|
|
727
|
+
for params in self.subjects.values():
|
|
728
|
+
if name not in params:
|
|
729
|
+
params[name] = default
|
|
730
|
+
|
|
731
|
+
if self.selected_subject and self.selected_subject in self.subjects:
|
|
732
|
+
self.set(name, self.subjects[self.selected_subject].get(name, default))
|
|
733
|
+
else:
|
|
734
|
+
self.set(name, default)
|
|
735
|
+
|
|
736
|
+
if self.display_keys is None:
|
|
737
|
+
self.display_keys = []
|
|
738
|
+
if name not in self.display_keys:
|
|
739
|
+
self.display_keys.append(name)
|
|
740
|
+
|
|
741
|
+
data = self._read_json_file()
|
|
742
|
+
if data is not None:
|
|
743
|
+
subjects_block = data.setdefault("Subjects", {})
|
|
744
|
+
for subj_id in subjects_block:
|
|
745
|
+
subjects_block[subj_id].setdefault(name, default)
|
|
746
|
+
display = data.setdefault("DisplayKeys", [])
|
|
747
|
+
if name not in display:
|
|
748
|
+
display.append(name)
|
|
749
|
+
self._write_json_file(data)
|
|
750
|
+
|
|
751
|
+
|