nirspy 0.0.1__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 (60) hide show
  1. nirspy/__init__.py +3 -0
  2. nirspy/blocks/__init__.py +70 -0
  3. nirspy/blocks/analysis.py +339 -0
  4. nirspy/blocks/load.py +232 -0
  5. nirspy/blocks/manual_exclude.py +100 -0
  6. nirspy/blocks/preprocessing.py +277 -0
  7. nirspy/blocks/quality.py +234 -0
  8. nirspy/blocks/registry.py +127 -0
  9. nirspy/cli/__init__.py +0 -0
  10. nirspy/cli/main.py +83 -0
  11. nirspy/domain/__init__.py +27 -0
  12. nirspy/domain/block.py +118 -0
  13. nirspy/domain/cache.py +40 -0
  14. nirspy/domain/data_types.py +42 -0
  15. nirspy/domain/exceptions.py +74 -0
  16. nirspy/domain/execution.py +117 -0
  17. nirspy/domain/pipeline.py +162 -0
  18. nirspy/domain/validation.py +59 -0
  19. nirspy/engine/__init__.py +24 -0
  20. nirspy/engine/cache_adapter.py +236 -0
  21. nirspy/engine/exceptions.py +47 -0
  22. nirspy/engine/mne_adapter.py +371 -0
  23. nirspy/gui/__init__.py +5 -0
  24. nirspy/gui/app.py +49 -0
  25. nirspy/gui/callbacks/__init__.py +0 -0
  26. nirspy/gui/callbacks/converter_callbacks.py +188 -0
  27. nirspy/gui/callbacks/execution_callbacks.py +326 -0
  28. nirspy/gui/callbacks/io_callbacks.py +116 -0
  29. nirspy/gui/callbacks/param_callbacks.py +174 -0
  30. nirspy/gui/callbacks/pipeline_callbacks.py +252 -0
  31. nirspy/gui/callbacks/viz_callbacks.py +174 -0
  32. nirspy/gui/components/__init__.py +1 -0
  33. nirspy/gui/components/block_card.py +190 -0
  34. nirspy/gui/components/block_catalog.py +82 -0
  35. nirspy/gui/components/condition_selector.py +57 -0
  36. nirspy/gui/components/condition_windows_editor.py +238 -0
  37. nirspy/gui/components/converter_view.py +119 -0
  38. nirspy/gui/components/error_display.py +39 -0
  39. nirspy/gui/components/hrf_plot.py +152 -0
  40. nirspy/gui/components/param_editor.py +409 -0
  41. nirspy/gui/components/param_metadata.py +169 -0
  42. nirspy/gui/components/pipeline_view.py +80 -0
  43. nirspy/gui/components/probe_viewer.py +169 -0
  44. nirspy/gui/components/qc_dashboard.py +98 -0
  45. nirspy/gui/components/raw_data_plot.py +96 -0
  46. nirspy/gui/components/run_button.py +80 -0
  47. nirspy/gui/components/tooltips.py +127 -0
  48. nirspy/gui/layouts.py +225 -0
  49. nirspy/gui/pages/__init__.py +0 -0
  50. nirspy/io/__init__.py +44 -0
  51. nirspy/io/converters.py +826 -0
  52. nirspy/io/oxysoft_txt.py +358 -0
  53. nirspy/io/pipeline_runner.py +195 -0
  54. nirspy/io/pipeline_schema.json +80 -0
  55. nirspy/io/yaml_serializer.py +247 -0
  56. nirspy-0.0.1.dist-info/METADATA +171 -0
  57. nirspy-0.0.1.dist-info/RECORD +60 -0
  58. nirspy-0.0.1.dist-info/WHEEL +4 -0
  59. nirspy-0.0.1.dist-info/entry_points.txt +2 -0
  60. nirspy-0.0.1.dist-info/licenses/LICENSE +28 -0
nirspy/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """NIRSPY — NIRS Processing in Python."""
2
+
3
+ __version__ = "0.0.1"
@@ -0,0 +1,70 @@
1
+ """nirspy.blocks -- concrete pipeline blocks.
2
+
3
+ The module-level :data:`registry` is pre-populated with all built-in block
4
+ **classes** so callers only need to import this package to gain access to the
5
+ full set. Instantiation happens at pipeline-assembly time (ADR-009).
6
+
7
+ >>> from nirspy.blocks import registry
8
+ >>> sorted(registry.list_blocks())
9
+ ['bandpass_filter', 'beer_lambert', 'block_average', 'load_snirf',
10
+ 'optical_density', 'prune_channels', 'scalp_coupling_index']
11
+ """
12
+
13
+ from nirspy.blocks.analysis import (
14
+ BlockAverageBlock,
15
+ BlockAverageParams,
16
+ ConditionWindow,
17
+ )
18
+ from nirspy.blocks.load import LoadSnirfBlock, LoadSnirfParams
19
+ from nirspy.blocks.manual_exclude import (
20
+ ManualChannelExcludeBlock,
21
+ ManualChannelExcludeParams,
22
+ )
23
+ from nirspy.blocks.preprocessing import (
24
+ BandpassFilterBlock,
25
+ BandpassFilterParams,
26
+ BeerLambertBlock,
27
+ BeerLambertParams,
28
+ OpticalDensityBlock,
29
+ OpticalDensityParams,
30
+ )
31
+ from nirspy.blocks.quality import (
32
+ PruneChannelsBlock,
33
+ PruneChannelsParams,
34
+ ScalpCouplingIndexBlock,
35
+ ScalpCouplingIndexParams,
36
+ )
37
+ from nirspy.blocks.registry import BlockRegistry, register, registry
38
+
39
+ # Register built-in block classes (not instances -- ADR-009)
40
+ registry.register("load_snirf", LoadSnirfBlock)
41
+ registry.register("optical_density", OpticalDensityBlock)
42
+ registry.register("beer_lambert", BeerLambertBlock)
43
+ registry.register("bandpass_filter", BandpassFilterBlock)
44
+ registry.register("scalp_coupling_index", ScalpCouplingIndexBlock)
45
+ registry.register("prune_channels", PruneChannelsBlock)
46
+ registry.register("block_average", BlockAverageBlock)
47
+ registry.register("manual_channel_exclude", ManualChannelExcludeBlock)
48
+
49
+ __all__ = [
50
+ "BandpassFilterBlock",
51
+ "BandpassFilterParams",
52
+ "BeerLambertBlock",
53
+ "BeerLambertParams",
54
+ "BlockAverageBlock",
55
+ "BlockAverageParams",
56
+ "ConditionWindow",
57
+ "BlockRegistry",
58
+ "ManualChannelExcludeBlock",
59
+ "ManualChannelExcludeParams",
60
+ "LoadSnirfBlock",
61
+ "LoadSnirfParams",
62
+ "OpticalDensityBlock",
63
+ "OpticalDensityParams",
64
+ "PruneChannelsBlock",
65
+ "PruneChannelsParams",
66
+ "ScalpCouplingIndexBlock",
67
+ "ScalpCouplingIndexParams",
68
+ "register",
69
+ "registry",
70
+ ]
@@ -0,0 +1,339 @@
1
+ """Analysis blocks -- Etapa 3 (T-004).
2
+
3
+ BlockAverageBlock: computes epoch-averaged HRF per stimulus condition.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from dataclasses import dataclass, field
9
+ from typing import Any, ClassVar
10
+
11
+ import mne
12
+ import mne.io
13
+
14
+ from nirspy.domain.block import BlockResult, BlockSpec
15
+ from nirspy.domain.data_types import DataType
16
+ from nirspy.domain.exceptions import ValidationError
17
+ from nirspy.engine.mne_adapter import MNEAdapter
18
+
19
+ # ---------------------------------------------------------------------------
20
+ # BlockAverage
21
+ # ---------------------------------------------------------------------------
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class ConditionWindow:
26
+ """Temporal window override for a single condition.
27
+
28
+ Each field mirrors the corresponding global parameter in
29
+ :class:`BlockAverageParams`. When a condition has an entry in
30
+ ``per_condition_windows`` these values take precedence over the
31
+ globals for that condition only.
32
+ """
33
+
34
+ tmin: float
35
+ tmax: float
36
+ baseline_tmin: float
37
+ baseline_tmax: float
38
+
39
+
40
+ def _validate_condition_window(name: str, window: ConditionWindow) -> None:
41
+ """Raise :class:`ValidationError` if *window* has invalid ranges."""
42
+ if window.tmin >= window.tmax:
43
+ raise ValidationError(
44
+ f"ConditionWindow {name!r}: tmin ({window.tmin}) "
45
+ f"must be < tmax ({window.tmax})."
46
+ )
47
+ if window.baseline_tmin > window.baseline_tmax:
48
+ raise ValidationError(
49
+ f"ConditionWindow {name!r}: baseline_tmin "
50
+ f"({window.baseline_tmin}) must be <= baseline_tmax "
51
+ f"({window.baseline_tmax})."
52
+ )
53
+
54
+
55
+ @dataclass(frozen=True)
56
+ class BlockAverageParams:
57
+ """Parameters for block averaging (HRF computation).
58
+
59
+ Attributes
60
+ ----------
61
+ tmin:
62
+ Epoch start relative to event onset (seconds). Default -2.0.
63
+ tmax:
64
+ Epoch end relative to event onset (seconds). Default 18.0.
65
+ baseline_tmin:
66
+ Baseline correction start. Default -2.0.
67
+ baseline_tmax:
68
+ Baseline correction end. Default 0.0.
69
+ reject_by_amplitude:
70
+ Whether to reject epochs exceeding amplitude threshold.
71
+ amplitude_threshold:
72
+ Rejection threshold in mol/L (default 80e-6 = 80 uM).
73
+ pick_conditions:
74
+ List of condition names to include. None = all conditions.
75
+ per_condition_windows:
76
+ Per-condition temporal window overrides. Empty dict (default)
77
+ uses the global window for every condition.
78
+ """
79
+
80
+ tmin: float = -2.0
81
+ tmax: float = 18.0
82
+ baseline_tmin: float = -2.0
83
+ baseline_tmax: float = 0.0
84
+ reject_by_amplitude: bool = True
85
+ amplitude_threshold: float = 80e-6
86
+ pick_conditions: list[str] | None = field(default=None)
87
+ per_condition_windows: dict[str, ConditionWindow] = field(
88
+ default_factory=dict,
89
+ )
90
+
91
+ def __post_init__(self) -> None:
92
+ """Coerce raw dicts to ConditionWindow for YAML round-trip."""
93
+ if self.per_condition_windows:
94
+ coerced: dict[str, ConditionWindow] = {}
95
+ for key, val in self.per_condition_windows.items():
96
+ if isinstance(val, dict):
97
+ coerced[key] = ConditionWindow(**val)
98
+ else:
99
+ coerced[key] = val
100
+ object.__setattr__(self, "per_condition_windows", coerced)
101
+
102
+
103
+ _BA_SPEC = BlockSpec(
104
+ block_id="block_average",
105
+ display_name="Block Average (HRF)",
106
+ input_type=DataType.RAW_HAEMO,
107
+ output_type=DataType.EVOKED,
108
+ params_class=BlockAverageParams,
109
+ description="Computes epoch-averaged HRF per stimulus condition.",
110
+ )
111
+
112
+
113
+ class BlockAverageBlock:
114
+ """Compute epoch-averaged HRF per stimulus condition.
115
+
116
+ Pipeline:
117
+ 1. Extract events from raw annotations
118
+ 2. Create epochs (tmin/tmax window, baseline correction)
119
+ 3. Optionally reject epochs by amplitude threshold
120
+ 4. Average per condition -> dict[str, Evoked]
121
+
122
+ When ``per_condition_windows`` is non-empty the block creates one
123
+ :class:`mne.Epochs` per condition (each with its own window) via
124
+ :meth:`MNEAdapter.create_epochs_per_condition`.
125
+
126
+ Invariant: input Raw must have hbo/hbr channels (RAW_HAEMO).
127
+ Raw must have annotations/events; raises ValidationError if none found.
128
+ """
129
+
130
+ SPEC: ClassVar[BlockSpec] = _BA_SPEC
131
+
132
+ def __init__(
133
+ self,
134
+ params: BlockAverageParams | None = None,
135
+ adapter: MNEAdapter | None = None,
136
+ ) -> None:
137
+ self.params: BlockAverageParams = params or BlockAverageParams()
138
+ self._adapter: MNEAdapter = adapter or MNEAdapter()
139
+
140
+ @property
141
+ def spec(self) -> BlockSpec:
142
+ """Return the static block descriptor."""
143
+ return _BA_SPEC
144
+
145
+ def run(self, context: Any, inputs: dict[str, Any]) -> BlockResult:
146
+ """Execute block averaging."""
147
+ if not inputs:
148
+ raise ValidationError(
149
+ "BlockAverageBlock requires input data. "
150
+ "It cannot be the first block in a pipeline."
151
+ )
152
+
153
+ # Validate params
154
+ required = {
155
+ "tmin": self.params.tmin,
156
+ "tmax": self.params.tmax,
157
+ "baseline_tmin": self.params.baseline_tmin,
158
+ "baseline_tmax": self.params.baseline_tmax,
159
+ }
160
+ missing = [name for name, val in required.items() if val is None]
161
+ if missing:
162
+ raise ValidationError(
163
+ f"BlockAverageBlock: required parameter(s) "
164
+ f"{missing} must not be empty."
165
+ )
166
+
167
+ if self.params.baseline_tmin > self.params.baseline_tmax:
168
+ raise ValidationError(
169
+ f"BlockAverageBlock: baseline_tmin ({self.params.baseline_tmin}) "
170
+ f"must be <= baseline_tmax ({self.params.baseline_tmax})."
171
+ )
172
+
173
+ if self.params.tmin >= self.params.tmax:
174
+ raise ValidationError(
175
+ f"BlockAverageBlock: tmin ({self.params.tmin}) "
176
+ f"must be < tmax ({self.params.tmax})."
177
+ )
178
+
179
+
180
+ # Validate each per-condition window
181
+ for cond_name, window in self.params.per_condition_windows.items():
182
+ _validate_condition_window(cond_name, window)
183
+
184
+ raw: mne.io.BaseRaw = next(iter(inputs.values()))
185
+
186
+ # Validate channel type
187
+ ch_types = set(raw.get_channel_types())
188
+ if "hbo" not in ch_types and "hbr" not in ch_types:
189
+ raise ValidationError(
190
+ f"BlockAverageBlock expects hbo/hbr channels (RAW_HAEMO), "
191
+ f"got: {sorted(ch_types)}. "
192
+ f"Ensure a BeerLambert block precedes this one."
193
+ )
194
+
195
+ # Check for annotations/events
196
+ events_from_annot, event_id = mne.events_from_annotations(
197
+ raw, verbose=False
198
+ )
199
+ if len(events_from_annot) == 0:
200
+ raise ValidationError(
201
+ "BlockAverageBlock: no events found in raw annotations. "
202
+ "Ensure the SNIRF file contains stimulus annotations."
203
+ )
204
+
205
+ # Filter conditions if pick_conditions is set
206
+ used_event_id: dict[str, int] | None = None
207
+ if self.params.pick_conditions is not None:
208
+ used_event_id = {
209
+ k: v for k, v in event_id.items()
210
+ if k in self.params.pick_conditions
211
+ }
212
+ if not used_event_id:
213
+ raise ValidationError(
214
+ f"BlockAverageBlock: none of pick_conditions "
215
+ f"{self.params.pick_conditions} found in event_id "
216
+ f"{list(event_id.keys())}."
217
+ )
218
+ else:
219
+ used_event_id = event_id
220
+
221
+ # Validate per_condition_windows keys exist in event_id
222
+ if self.params.per_condition_windows:
223
+ unknown = (
224
+ set(self.params.per_condition_windows) - set(used_event_id)
225
+ )
226
+ if unknown:
227
+ raise ValidationError(
228
+ f"BlockAverageBlock: per_condition_windows contains "
229
+ f"condition(s) {sorted(unknown)} not found in "
230
+ f"event_id {sorted(used_event_id.keys())}."
231
+ )
232
+
233
+ # Build rejection dict
234
+ reject: dict[str, float] | None = None
235
+ if self.params.reject_by_amplitude:
236
+ reject = {
237
+ "hbo": self.params.amplitude_threshold,
238
+ "hbr": self.params.amplitude_threshold,
239
+ }
240
+
241
+ # ---- Per-condition path vs legacy single-Epochs path ----
242
+ if self.params.per_condition_windows:
243
+ default_window = (
244
+ self.params.tmin,
245
+ self.params.tmax,
246
+ self.params.baseline_tmin,
247
+ self.params.baseline_tmax,
248
+ )
249
+ epochs_dict = self._adapter.create_epochs_per_condition(
250
+ raw,
251
+ used_event_id,
252
+ default_window=default_window,
253
+ per_condition_windows=self.params.per_condition_windows,
254
+ reject=reject,
255
+ )
256
+ evoked_dict = self._adapter.average_epochs(epochs_dict)
257
+
258
+ # Metadata for per-condition path
259
+ windows_used: dict[str, dict[str, float]] = {}
260
+ n_epochs_total = 0
261
+ skipped_conditions: list[str] = []
262
+ metadata: dict[str, Any] = {}
263
+
264
+ for cond in used_event_id:
265
+ if cond in self.params.per_condition_windows:
266
+ w = self.params.per_condition_windows[cond]
267
+ windows_used[cond] = {
268
+ "tmin": w.tmin,
269
+ "tmax": w.tmax,
270
+ "baseline_tmin": w.baseline_tmin,
271
+ "baseline_tmax": w.baseline_tmax,
272
+ }
273
+ else:
274
+ windows_used[cond] = {
275
+ "tmin": self.params.tmin,
276
+ "tmax": self.params.tmax,
277
+ "baseline_tmin": self.params.baseline_tmin,
278
+ "baseline_tmax": self.params.baseline_tmax,
279
+ }
280
+ if cond in epochs_dict:
281
+ n_ep = len(epochs_dict[cond].events)
282
+ n_epochs_total += n_ep
283
+ metadata[f"n_epochs_{cond}"] = n_ep
284
+ if cond not in evoked_dict:
285
+ skipped_conditions.append(cond)
286
+
287
+ metadata.update({
288
+ "conditions": list(evoked_dict.keys()),
289
+ "n_conditions": len(evoked_dict),
290
+ "n_epochs_total": n_epochs_total,
291
+ "skipped_conditions": skipped_conditions,
292
+ "tmin": self.params.tmin,
293
+ "tmax": self.params.tmax,
294
+ "per_condition_used": True,
295
+ "windows_used": windows_used,
296
+ })
297
+
298
+ else:
299
+ # Legacy single-Epochs path
300
+ epochs = self._adapter.create_epochs(
301
+ raw,
302
+ tmin=self.params.tmin,
303
+ tmax=self.params.tmax,
304
+ baseline_tmin=self.params.baseline_tmin,
305
+ baseline_tmax=self.params.baseline_tmax,
306
+ reject=reject,
307
+ event_id=used_event_id,
308
+ )
309
+ evoked_dict = self._adapter.average_epochs(epochs)
310
+
311
+ skipped_conditions = [
312
+ cond for cond in epochs.event_id
313
+ if cond not in evoked_dict
314
+ ]
315
+ metadata = {
316
+ "conditions": list(evoked_dict.keys()),
317
+ "n_conditions": len(evoked_dict),
318
+ "n_epochs_total": len(epochs.events),
319
+ "skipped_conditions": skipped_conditions,
320
+ "tmin": self.params.tmin,
321
+ "tmax": self.params.tmax,
322
+ }
323
+
324
+ for condition in evoked_dict:
325
+ metadata[f"n_epochs_{condition}"] = len(
326
+ epochs[condition]
327
+ )
328
+
329
+ if epochs.drop_log is not None:
330
+ n_dropped = sum(
331
+ 1 for log in epochs.drop_log if len(log) > 0
332
+ )
333
+ metadata["n_epochs_dropped"] = n_dropped
334
+
335
+ return BlockResult(
336
+ data=evoked_dict,
337
+ block_id=_BA_SPEC.block_id,
338
+ metadata=metadata,
339
+ )
nirspy/blocks/load.py ADDED
@@ -0,0 +1,232 @@
1
+ """LoadSnirfBlock — loads a SNIRF file and returns a Raw MNE object.
2
+
3
+ Entry block for any fNIRS pipeline. Wraps :class:`~nirspy.engine.mne_adapter.MNEAdapter`
4
+ and produces a :class:`~nirspy.domain.data_types.DataType.RAW` result.
5
+
6
+ Security (S-02): Path is canonicalized via ``resolve()`` and validated against
7
+ an allowlist of directories. Paths containing ``..`` components are rejected.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import os
13
+ from dataclasses import dataclass
14
+ from pathlib import Path
15
+ from typing import Any, ClassVar
16
+
17
+ import mne.io
18
+
19
+ from nirspy.domain.block import BlockResult, BlockSpec
20
+ from nirspy.domain.data_types import DataType
21
+ from nirspy.domain.exceptions import ExecutionError, ValidationError
22
+ from nirspy.engine.exceptions import MNEOperationError, SnirfLoadError
23
+ from nirspy.engine.mne_adapter import MNEAdapter
24
+
25
+ # ---------------------------------------------------------------------------
26
+ # Security: default allowed directories for file loading (S-02)
27
+ # ---------------------------------------------------------------------------
28
+
29
+ _DEFAULT_ALLOWED_DIRS: list[Path] = [
30
+ Path.home(),
31
+ Path.cwd(),
32
+ ]
33
+
34
+
35
+ def get_allowed_dirs() -> list[Path]:
36
+ """Return the list of allowed base directories for file loading.
37
+
38
+ Directories are resolved (absolute, no symlinks). Defaults to user home
39
+ and current working directory. Can be extended via ``NIRSPY_ALLOWED_DIRS``
40
+ environment variable (``os.pathsep``-separated paths).
41
+ """
42
+ dirs = [p.resolve() for p in _DEFAULT_ALLOWED_DIRS]
43
+ env_dirs = os.environ.get("NIRSPY_ALLOWED_DIRS", "")
44
+ if env_dirs:
45
+ for d in env_dirs.split(os.pathsep):
46
+ p = Path(d).resolve()
47
+ if p.is_dir():
48
+ dirs.append(p)
49
+ return dirs
50
+
51
+
52
+ def validate_snirf_path(path: Path, allowed_dirs: list[Path] | None = None) -> Path:
53
+ """Validate and canonicalize a SNIRF file path (S-02).
54
+
55
+ Parameters
56
+ ----------
57
+ path:
58
+ User-provided path to validate.
59
+ allowed_dirs:
60
+ Optional override for testing. Defaults to :func:`get_allowed_dirs`.
61
+
62
+ Returns
63
+ -------
64
+ Path
65
+ Resolved (canonical) path guaranteed to be within allowed directories.
66
+
67
+ Raises
68
+ ------
69
+ ValidationError
70
+ When the path contains ``..`` traversal, is outside allowed directories,
71
+ or does not have a ``.snirf`` extension.
72
+ """
73
+ # Reject raw path containing ".." before resolution
74
+ path_str = str(path)
75
+ if ".." in path_str.replace("\\", "/").split("/"):
76
+ raise ValidationError(
77
+ f"Path traversal detected: '{path}'. "
78
+ "Paths containing '..' components are not allowed."
79
+ )
80
+
81
+ resolved = path.resolve()
82
+
83
+ # Validate extension
84
+ if resolved.suffix.lower() != ".snirf":
85
+ raise ValidationError(
86
+ f"Expected a .snirf file, got '{resolved.suffix}': {resolved}"
87
+ )
88
+
89
+ # Validate against allowlist
90
+ dirs = allowed_dirs if allowed_dirs is not None else get_allowed_dirs()
91
+ for allowed in dirs:
92
+ try:
93
+ resolved.relative_to(allowed)
94
+ return resolved
95
+ except ValueError:
96
+ continue
97
+
98
+ raise ValidationError(
99
+ f"Path '{resolved}' is outside allowed directories. "
100
+ f"Allowed: {[str(d) for d in dirs]}"
101
+ )
102
+
103
+
104
+ # ---------------------------------------------------------------------------
105
+ # Block definition
106
+ # ---------------------------------------------------------------------------
107
+
108
+
109
+ @dataclass(frozen=True)
110
+ class LoadSnirfParams:
111
+ """Parameters for :class:`LoadSnirfBlock`.
112
+
113
+ Attributes
114
+ ----------
115
+ path:
116
+ Absolute or relative path to the ``.snirf`` file to load.
117
+ """
118
+
119
+ path: str
120
+
121
+
122
+ _SPEC = BlockSpec(
123
+ block_id="load_snirf",
124
+ display_name="Load SNIRF",
125
+ description="Load a SNIRF file and emit a MNE Raw object.",
126
+ input_type=DataType.NONE,
127
+ output_type=DataType.RAW,
128
+ params_class=LoadSnirfParams,
129
+ )
130
+
131
+
132
+ class LoadSnirfBlock:
133
+ """Block that loads a SNIRF file via MNE-NIRS and emits ``DataType.RAW``.
134
+
135
+ Parameters
136
+ ----------
137
+ params:
138
+ :class:`LoadSnirfParams` instance holding the file path. Stored as
139
+ ``self.params`` and accessed in :meth:`run` (ADR-009).
140
+ adapter:
141
+ Optional :class:`~nirspy.engine.mne_adapter.MNEAdapter` override.
142
+ Defaults to a freshly constructed :class:`MNEAdapter` so that tests
143
+ can inject a fake adapter without modifying production code.
144
+ allowed_dirs:
145
+ Optional override for path validation allowlist. When *None*
146
+ (default), uses :func:`get_allowed_dirs`.
147
+
148
+ Class attributes
149
+ ----------------
150
+ SPEC:
151
+ Class-level reference to the static :class:`~nirspy.domain.block.BlockSpec`
152
+ descriptor. Exposed here so the serialiser can read ``params_class``
153
+ from the class without instantiating it.
154
+ """
155
+
156
+ SPEC: ClassVar[BlockSpec] = _SPEC
157
+
158
+ def __init__(
159
+ self,
160
+ params: LoadSnirfParams,
161
+ adapter: MNEAdapter | None = None,
162
+ allowed_dirs: list[Path] | None = None,
163
+ ) -> None:
164
+ self.params: LoadSnirfParams = params
165
+ self._adapter: MNEAdapter = adapter or MNEAdapter()
166
+ self._allowed_dirs = allowed_dirs
167
+
168
+ @property
169
+ def spec(self) -> BlockSpec:
170
+ """Return the static block descriptor."""
171
+ return _SPEC
172
+
173
+ def run(self, context: Any, inputs: dict[str, Any]) -> BlockResult:
174
+ """Load the SNIRF file referenced in ``self.params`` and return a :class:`BlockResult`.
175
+
176
+ Security (S-02): The path is validated and canonicalized before loading.
177
+
178
+ Parameters
179
+ ----------
180
+ context:
181
+ :class:`~nirspy.domain.execution.ExecutionContext` injected by the
182
+ runner — unused here but required by the :class:`~nirspy.domain.block.Block`
183
+ Protocol.
184
+ inputs:
185
+ Must be an empty dict (``{}``). The load block is always the first
186
+ step in a linear pipeline and has no upstream dependency.
187
+
188
+ Returns
189
+ -------
190
+ BlockResult
191
+ ``data`` is the :class:`mne.io.BaseRaw` object.
192
+ ``metadata`` contains ``n_channels`` and ``sfreq``.
193
+
194
+ Raises
195
+ ------
196
+ ExecutionError
197
+ When *inputs* is non-empty (contract violation: load block must be first).
198
+ ValidationError
199
+ When the path fails security validation (traversal, outside allowlist).
200
+ ~nirspy.engine.exceptions.SnirfLoadError
201
+ When the file is missing or cannot be parsed (propagated from adapter).
202
+ ~nirspy.engine.exceptions.MNEOperationError
203
+ When MNE raises an unexpected error during loading.
204
+ """
205
+ if inputs:
206
+ raise ExecutionError(
207
+ "LoadSnirfBlock received non-empty inputs. "
208
+ "The load block must be the first block in the pipeline (inputs must be {})."
209
+ )
210
+
211
+ # S-02: Validate path before loading
212
+ validated_path = validate_snirf_path(
213
+ Path(self.params.path), self._allowed_dirs
214
+ )
215
+
216
+ try:
217
+ raw: mne.io.BaseRaw = self._adapter.load_snirf(validated_path)
218
+ except (SnirfLoadError, MNEOperationError):
219
+ raise
220
+ except Exception as exc: # noqa: BLE001
221
+ raise ExecutionError(
222
+ f"LoadSnirfBlock failed to load '{self.params.path}': {exc}"
223
+ ) from exc
224
+
225
+ return BlockResult(
226
+ data=raw,
227
+ block_id=_SPEC.block_id,
228
+ metadata={
229
+ "n_channels": len(raw.ch_names),
230
+ "sfreq": raw.info["sfreq"],
231
+ },
232
+ )