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.
- nirspy/__init__.py +3 -0
- nirspy/blocks/__init__.py +70 -0
- nirspy/blocks/analysis.py +339 -0
- nirspy/blocks/load.py +232 -0
- nirspy/blocks/manual_exclude.py +100 -0
- nirspy/blocks/preprocessing.py +277 -0
- nirspy/blocks/quality.py +234 -0
- nirspy/blocks/registry.py +127 -0
- nirspy/cli/__init__.py +0 -0
- nirspy/cli/main.py +83 -0
- nirspy/domain/__init__.py +27 -0
- nirspy/domain/block.py +118 -0
- nirspy/domain/cache.py +40 -0
- nirspy/domain/data_types.py +42 -0
- nirspy/domain/exceptions.py +74 -0
- nirspy/domain/execution.py +117 -0
- nirspy/domain/pipeline.py +162 -0
- nirspy/domain/validation.py +59 -0
- nirspy/engine/__init__.py +24 -0
- nirspy/engine/cache_adapter.py +236 -0
- nirspy/engine/exceptions.py +47 -0
- nirspy/engine/mne_adapter.py +371 -0
- nirspy/gui/__init__.py +5 -0
- nirspy/gui/app.py +49 -0
- nirspy/gui/callbacks/__init__.py +0 -0
- nirspy/gui/callbacks/converter_callbacks.py +188 -0
- nirspy/gui/callbacks/execution_callbacks.py +326 -0
- nirspy/gui/callbacks/io_callbacks.py +116 -0
- nirspy/gui/callbacks/param_callbacks.py +174 -0
- nirspy/gui/callbacks/pipeline_callbacks.py +252 -0
- nirspy/gui/callbacks/viz_callbacks.py +174 -0
- nirspy/gui/components/__init__.py +1 -0
- nirspy/gui/components/block_card.py +190 -0
- nirspy/gui/components/block_catalog.py +82 -0
- nirspy/gui/components/condition_selector.py +57 -0
- nirspy/gui/components/condition_windows_editor.py +238 -0
- nirspy/gui/components/converter_view.py +119 -0
- nirspy/gui/components/error_display.py +39 -0
- nirspy/gui/components/hrf_plot.py +152 -0
- nirspy/gui/components/param_editor.py +409 -0
- nirspy/gui/components/param_metadata.py +169 -0
- nirspy/gui/components/pipeline_view.py +80 -0
- nirspy/gui/components/probe_viewer.py +169 -0
- nirspy/gui/components/qc_dashboard.py +98 -0
- nirspy/gui/components/raw_data_plot.py +96 -0
- nirspy/gui/components/run_button.py +80 -0
- nirspy/gui/components/tooltips.py +127 -0
- nirspy/gui/layouts.py +225 -0
- nirspy/gui/pages/__init__.py +0 -0
- nirspy/io/__init__.py +44 -0
- nirspy/io/converters.py +826 -0
- nirspy/io/oxysoft_txt.py +358 -0
- nirspy/io/pipeline_runner.py +195 -0
- nirspy/io/pipeline_schema.json +80 -0
- nirspy/io/yaml_serializer.py +247 -0
- nirspy-0.0.1.dist-info/METADATA +171 -0
- nirspy-0.0.1.dist-info/RECORD +60 -0
- nirspy-0.0.1.dist-info/WHEEL +4 -0
- nirspy-0.0.1.dist-info/entry_points.txt +2 -0
- nirspy-0.0.1.dist-info/licenses/LICENSE +28 -0
nirspy/__init__.py
ADDED
|
@@ -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
|
+
)
|