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
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
+