nirspy 0.2.0__tar.gz → 0.3.0__tar.gz
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-0.2.0 → nirspy-0.3.0}/CHANGELOG.md +22 -0
- {nirspy-0.2.0 → nirspy-0.3.0}/PKG-INFO +1 -1
- {nirspy-0.2.0 → nirspy-0.3.0}/pyproject.toml +1 -1
- {nirspy-0.2.0 → nirspy-0.3.0}/src/nirspy/__init__.py +1 -1
- {nirspy-0.2.0 → nirspy-0.3.0}/src/nirspy/blocks/analysis.py +143 -13
- {nirspy-0.2.0 → nirspy-0.3.0}/src/nirspy/domain/__init__.py +7 -1
- nirspy-0.3.0/src/nirspy/domain/execution.py +297 -0
- {nirspy-0.2.0 → nirspy-0.3.0}/src/nirspy/engine/mne_adapter.py +187 -1
- {nirspy-0.2.0 → nirspy-0.3.0}/src/nirspy/gui/app.py +5 -1
- {nirspy-0.2.0 → nirspy-0.3.0}/src/nirspy/gui/callbacks/converter_callbacks.py +115 -0
- {nirspy-0.2.0 → nirspy-0.3.0}/src/nirspy/gui/callbacks/execution_callbacks.py +5 -0
- nirspy-0.3.0/src/nirspy/gui/callbacks/param_callbacks.py +684 -0
- {nirspy-0.2.0 → nirspy-0.3.0}/src/nirspy/gui/callbacks/pipeline_callbacks.py +11 -7
- nirspy-0.3.0/src/nirspy/gui/callbacks/runtime_callbacks.py +483 -0
- nirspy-0.3.0/src/nirspy/gui/components/condition_groups_editor.py +451 -0
- nirspy-0.3.0/src/nirspy/gui/components/condition_timeline.py +336 -0
- {nirspy-0.2.0 → nirspy-0.3.0}/src/nirspy/gui/components/converter_view.py +25 -0
- nirspy-0.3.0/src/nirspy/gui/components/hrf_runtime_dialog.py +859 -0
- {nirspy-0.2.0 → nirspy-0.3.0}/src/nirspy/gui/components/param_editor.py +102 -9
- {nirspy-0.2.0 → nirspy-0.3.0}/src/nirspy/gui/components/run_button.py +27 -2
- nirspy-0.3.0/src/nirspy/gui/components/runtime_dialog.py +119 -0
- {nirspy-0.2.0 → nirspy-0.3.0}/src/nirspy/gui/layouts.py +22 -1
- {nirspy-0.2.0 → nirspy-0.3.0}/src/nirspy/io/converters.py +50 -0
- {nirspy-0.2.0 → nirspy-0.3.0}/tests/blocks/test_analysis.py +10 -4
- nirspy-0.3.0/tests/blocks/test_condition_groups.py +327 -0
- nirspy-0.3.0/tests/domain/test_pipeline_runner.py +217 -0
- nirspy-0.3.0/tests/engine/test_mne_adapter_groups.py +236 -0
- {nirspy-0.2.0 → nirspy-0.3.0}/tests/gui/test_builder.py +4 -1
- nirspy-0.3.0/tests/gui/test_condition_groups_editor.py +523 -0
- nirspy-0.3.0/tests/gui/test_condition_timeline.py +474 -0
- nirspy-0.3.0/tests/gui/test_hrf_runtime_dialog.py +699 -0
- nirspy-0.3.0/tests/gui/test_runtime_dialog.py +473 -0
- nirspy-0.2.0/src/nirspy/domain/execution.py +0 -117
- nirspy-0.2.0/src/nirspy/gui/callbacks/param_callbacks.py +0 -174
- {nirspy-0.2.0 → nirspy-0.3.0}/.github/ISSUE_TEMPLATE/bug.yml +0 -0
- {nirspy-0.2.0 → nirspy-0.3.0}/.github/ISSUE_TEMPLATE/config.yml +0 -0
- {nirspy-0.2.0 → nirspy-0.3.0}/.github/ISSUE_TEMPLATE/feature.yml +0 -0
- {nirspy-0.2.0 → nirspy-0.3.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
- {nirspy-0.2.0 → nirspy-0.3.0}/.github/dependabot.yml +0 -0
- {nirspy-0.2.0 → nirspy-0.3.0}/.github/workflows/ci.yml +0 -0
- {nirspy-0.2.0 → nirspy-0.3.0}/.github/workflows/docs.yml +0 -0
- {nirspy-0.2.0 → nirspy-0.3.0}/.gitignore +0 -0
- {nirspy-0.2.0 → nirspy-0.3.0}/.python-version +0 -0
- {nirspy-0.2.0 → nirspy-0.3.0}/CLAUDE.md +0 -0
- {nirspy-0.2.0 → nirspy-0.3.0}/CODE_OF_CONDUCT.md +0 -0
- {nirspy-0.2.0 → nirspy-0.3.0}/CONTRIBUTING.md +0 -0
- {nirspy-0.2.0 → nirspy-0.3.0}/LICENSE +0 -0
- {nirspy-0.2.0 → nirspy-0.3.0}/README.md +0 -0
- {nirspy-0.2.0 → nirspy-0.3.0}/SECURITY.md +0 -0
- {nirspy-0.2.0 → nirspy-0.3.0}/docs/architecture.md +0 -0
- {nirspy-0.2.0 → nirspy-0.3.0}/docs/getting-started.md +0 -0
- {nirspy-0.2.0 → nirspy-0.3.0}/docs/index.md +0 -0
- {nirspy-0.2.0 → nirspy-0.3.0}/docs/reference/blocks.md +0 -0
- {nirspy-0.2.0 → nirspy-0.3.0}/docs/reference/cli.md +0 -0
- {nirspy-0.2.0 → nirspy-0.3.0}/docs/reference/domain.md +0 -0
- {nirspy-0.2.0 → nirspy-0.3.0}/docs/reference/engine.md +0 -0
- {nirspy-0.2.0 → nirspy-0.3.0}/docs/roadmap.md +0 -0
- {nirspy-0.2.0 → nirspy-0.3.0}/docs/tutorials/first-pipeline.md +0 -0
- {nirspy-0.2.0 → nirspy-0.3.0}/examples/data/.gitkeep +0 -0
- {nirspy-0.2.0 → nirspy-0.3.0}/examples/pipelines/basic-preproc.yml +0 -0
- {nirspy-0.2.0 → nirspy-0.3.0}/examples/pipelines/best-practices-block-design.yml +0 -0
- {nirspy-0.2.0 → nirspy-0.3.0}/examples/pipelines/motion-heavy-recording.yml +0 -0
- {nirspy-0.2.0 → nirspy-0.3.0}/examples/pipelines/resting-state-connectivity.yml +0 -0
- {nirspy-0.2.0 → nirspy-0.3.0}/mkdocs.yml +0 -0
- {nirspy-0.2.0 → nirspy-0.3.0}/requirements.txt +0 -0
- {nirspy-0.2.0 → nirspy-0.3.0}/scripts/smoke_e1.py +0 -0
- {nirspy-0.2.0 → nirspy-0.3.0}/src/nirspy/blocks/__init__.py +0 -0
- {nirspy-0.2.0 → nirspy-0.3.0}/src/nirspy/blocks/load.py +0 -0
- {nirspy-0.2.0 → nirspy-0.3.0}/src/nirspy/blocks/manual_exclude.py +0 -0
- {nirspy-0.2.0 → nirspy-0.3.0}/src/nirspy/blocks/motion.py +0 -0
- {nirspy-0.2.0 → nirspy-0.3.0}/src/nirspy/blocks/preprocessing.py +0 -0
- {nirspy-0.2.0 → nirspy-0.3.0}/src/nirspy/blocks/quality.py +0 -0
- {nirspy-0.2.0 → nirspy-0.3.0}/src/nirspy/blocks/registry.py +0 -0
- {nirspy-0.2.0 → nirspy-0.3.0}/src/nirspy/cli/__init__.py +0 -0
- {nirspy-0.2.0 → nirspy-0.3.0}/src/nirspy/cli/main.py +0 -0
- {nirspy-0.2.0 → nirspy-0.3.0}/src/nirspy/domain/block.py +0 -0
- {nirspy-0.2.0 → nirspy-0.3.0}/src/nirspy/domain/cache.py +0 -0
- {nirspy-0.2.0 → nirspy-0.3.0}/src/nirspy/domain/data_types.py +0 -0
- {nirspy-0.2.0 → nirspy-0.3.0}/src/nirspy/domain/exceptions.py +0 -0
- {nirspy-0.2.0 → nirspy-0.3.0}/src/nirspy/domain/pipeline.py +0 -0
- {nirspy-0.2.0 → nirspy-0.3.0}/src/nirspy/domain/validation.py +0 -0
- {nirspy-0.2.0 → nirspy-0.3.0}/src/nirspy/engine/__init__.py +0 -0
- {nirspy-0.2.0 → nirspy-0.3.0}/src/nirspy/engine/cache_adapter.py +0 -0
- {nirspy-0.2.0 → nirspy-0.3.0}/src/nirspy/engine/exceptions.py +0 -0
- {nirspy-0.2.0 → nirspy-0.3.0}/src/nirspy/gui/__init__.py +0 -0
- {nirspy-0.2.0 → nirspy-0.3.0}/src/nirspy/gui/assets/tutorial.css +0 -0
- {nirspy-0.2.0 → nirspy-0.3.0}/src/nirspy/gui/callbacks/__init__.py +0 -0
- {nirspy-0.2.0 → nirspy-0.3.0}/src/nirspy/gui/callbacks/io_callbacks.py +0 -0
- {nirspy-0.2.0 → nirspy-0.3.0}/src/nirspy/gui/callbacks/tutorial_callbacks.py +0 -0
- {nirspy-0.2.0 → nirspy-0.3.0}/src/nirspy/gui/callbacks/viz_callbacks.py +0 -0
- {nirspy-0.2.0 → nirspy-0.3.0}/src/nirspy/gui/components/__init__.py +0 -0
- {nirspy-0.2.0 → nirspy-0.3.0}/src/nirspy/gui/components/block_card.py +0 -0
- {nirspy-0.2.0 → nirspy-0.3.0}/src/nirspy/gui/components/block_catalog.py +0 -0
- {nirspy-0.2.0 → nirspy-0.3.0}/src/nirspy/gui/components/condition_selector.py +0 -0
- {nirspy-0.2.0 → nirspy-0.3.0}/src/nirspy/gui/components/condition_windows_editor.py +0 -0
- {nirspy-0.2.0 → nirspy-0.3.0}/src/nirspy/gui/components/error_display.py +0 -0
- {nirspy-0.2.0 → nirspy-0.3.0}/src/nirspy/gui/components/hrf_plot.py +0 -0
- {nirspy-0.2.0 → nirspy-0.3.0}/src/nirspy/gui/components/param_metadata.py +0 -0
- {nirspy-0.2.0 → nirspy-0.3.0}/src/nirspy/gui/components/pipeline_view.py +0 -0
- {nirspy-0.2.0 → nirspy-0.3.0}/src/nirspy/gui/components/probe_viewer.py +0 -0
- {nirspy-0.2.0 → nirspy-0.3.0}/src/nirspy/gui/components/qc_dashboard.py +0 -0
- {nirspy-0.2.0 → nirspy-0.3.0}/src/nirspy/gui/components/raw_data_plot.py +0 -0
- {nirspy-0.2.0 → nirspy-0.3.0}/src/nirspy/gui/components/tooltips.py +0 -0
- {nirspy-0.2.0 → nirspy-0.3.0}/src/nirspy/gui/components/tutorial.py +0 -0
- {nirspy-0.2.0 → nirspy-0.3.0}/src/nirspy/gui/pages/__init__.py +0 -0
- {nirspy-0.2.0 → nirspy-0.3.0}/src/nirspy/io/__init__.py +0 -0
- {nirspy-0.2.0 → nirspy-0.3.0}/src/nirspy/io/oxysoft_txt.py +0 -0
- {nirspy-0.2.0 → nirspy-0.3.0}/src/nirspy/io/pipeline_runner.py +0 -0
- {nirspy-0.2.0 → nirspy-0.3.0}/src/nirspy/io/pipeline_schema.json +0 -0
- {nirspy-0.2.0 → nirspy-0.3.0}/src/nirspy/io/yaml_serializer.py +0 -0
- {nirspy-0.2.0 → nirspy-0.3.0}/tests/__init__.py +0 -0
- {nirspy-0.2.0 → nirspy-0.3.0}/tests/blocks/__init__.py +0 -0
- {nirspy-0.2.0 → nirspy-0.3.0}/tests/blocks/conftest.py +0 -0
- {nirspy-0.2.0 → nirspy-0.3.0}/tests/blocks/test_manual_exclude.py +0 -0
- {nirspy-0.2.0 → nirspy-0.3.0}/tests/blocks/test_motion.py +0 -0
- {nirspy-0.2.0 → nirspy-0.3.0}/tests/blocks/test_preprocessing.py +0 -0
- {nirspy-0.2.0 → nirspy-0.3.0}/tests/blocks/test_prune_telemetry.py +0 -0
- {nirspy-0.2.0 → nirspy-0.3.0}/tests/blocks/test_quality.py +0 -0
- {nirspy-0.2.0 → nirspy-0.3.0}/tests/cli/__init__.py +0 -0
- {nirspy-0.2.0 → nirspy-0.3.0}/tests/cli/test_run.py +0 -0
- {nirspy-0.2.0 → nirspy-0.3.0}/tests/conftest.py +0 -0
- {nirspy-0.2.0 → nirspy-0.3.0}/tests/docs/__init__.py +0 -0
- {nirspy-0.2.0 → nirspy-0.3.0}/tests/docs/test_mkdocs_build.py +0 -0
- {nirspy-0.2.0 → nirspy-0.3.0}/tests/domain/__init__.py +0 -0
- {nirspy-0.2.0 → nirspy-0.3.0}/tests/domain/test_block.py +0 -0
- {nirspy-0.2.0 → nirspy-0.3.0}/tests/domain/test_cache.py +0 -0
- {nirspy-0.2.0 → nirspy-0.3.0}/tests/domain/test_data_types.py +0 -0
- {nirspy-0.2.0 → nirspy-0.3.0}/tests/domain/test_exceptions.py +0 -0
- {nirspy-0.2.0 → nirspy-0.3.0}/tests/domain/test_execution.py +0 -0
- {nirspy-0.2.0 → nirspy-0.3.0}/tests/domain/test_pipeline.py +0 -0
- {nirspy-0.2.0 → nirspy-0.3.0}/tests/domain/test_validation.py +0 -0
- {nirspy-0.2.0 → nirspy-0.3.0}/tests/engine/__init__.py +0 -0
- {nirspy-0.2.0 → nirspy-0.3.0}/tests/engine/test_average_epochs_empty.py +0 -0
- {nirspy-0.2.0 → nirspy-0.3.0}/tests/engine/test_cache_adapter.py +0 -0
- {nirspy-0.2.0 → nirspy-0.3.0}/tests/engine/test_filter_bads.py +0 -0
- {nirspy-0.2.0 → nirspy-0.3.0}/tests/engine/test_mne_adapter.py +0 -0
- {nirspy-0.2.0 → nirspy-0.3.0}/tests/engine/test_mne_adapter_motion.py +0 -0
- {nirspy-0.2.0 → nirspy-0.3.0}/tests/engine/test_ui_error_messages.py +0 -0
- {nirspy-0.2.0 → nirspy-0.3.0}/tests/gui/__init__.py +0 -0
- {nirspy-0.2.0 → nirspy-0.3.0}/tests/gui/test_app_factory.py +0 -0
- {nirspy-0.2.0 → nirspy-0.3.0}/tests/gui/test_condition_windows_editor.py +0 -0
- {nirspy-0.2.0 → nirspy-0.3.0}/tests/gui/test_converter_view.py +0 -0
- {nirspy-0.2.0 → nirspy-0.3.0}/tests/gui/test_error_messages.py +0 -0
- {nirspy-0.2.0 → nirspy-0.3.0}/tests/gui/test_execution.py +0 -0
- {nirspy-0.2.0 → nirspy-0.3.0}/tests/gui/test_hrf_plot.py +0 -0
- {nirspy-0.2.0 → nirspy-0.3.0}/tests/gui/test_param_editor_list.py +0 -0
- {nirspy-0.2.0 → nirspy-0.3.0}/tests/gui/test_param_editor_metadata.py +0 -0
- {nirspy-0.2.0 → nirspy-0.3.0}/tests/gui/test_security_5a.py +0 -0
- {nirspy-0.2.0 → nirspy-0.3.0}/tests/gui/test_smoke.py +0 -0
- {nirspy-0.2.0 → nirspy-0.3.0}/tests/gui/test_tutorial.py +0 -0
- {nirspy-0.2.0 → nirspy-0.3.0}/tests/integration/__init__.py +0 -0
- {nirspy-0.2.0 → nirspy-0.3.0}/tests/integration/test_pipeline_templates.py +0 -0
- {nirspy-0.2.0 → nirspy-0.3.0}/tests/io/__init__.py +0 -0
- {nirspy-0.2.0 → nirspy-0.3.0}/tests/io/test_converters_condnames.py +0 -0
- {nirspy-0.2.0 → nirspy-0.3.0}/tests/io/test_oxysoft_txt.py +0 -0
- {nirspy-0.2.0 → nirspy-0.3.0}/tests/io/test_pipeline_runner.py +0 -0
- {nirspy-0.2.0 → nirspy-0.3.0}/tests/io/test_yaml_serializer.py +0 -0
- {nirspy-0.2.0 → nirspy-0.3.0}/tests/test_smoke.py +0 -0
- {nirspy-0.2.0 → nirspy-0.3.0}/third_party/licenses/README.md +0 -0
|
@@ -7,6 +7,28 @@ versionamento por [SemVer](https://semver.org/).
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.3.0] - 2026-05-24
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- **T-023 — PipelineRunner stepable** (PR #36): refactor `execution.py` into stepable executor supporting block-by-block interactive runs.
|
|
14
|
+
- **T-024 — ConditionGroup domain + engine** (PR #37): `ConditionGroup` dataclass, `BlockAverageParams` extension with `event_indices`, `create_epochs_per_group` in `MNEAdapter`, YAML round-trip.
|
|
15
|
+
- **T-025 — Condition groups editor** (PR #39): radio toggle HRF mode + builder editor for condition groups in ParamEditor.
|
|
16
|
+
- **T-027 — Run Interactive button + generic dialog** (PR #40): toggle button, per-block runtime dialog with ParamEditor.
|
|
17
|
+
- **T-028 — HRF specialized 2-stage dialog** (PR #41): grouped conditions + time windows per group in dedicated HRF dialog.
|
|
18
|
+
- **T-030 — Condition timeline selection** (PR #43): individual event occurrence selection via Plotly scatter timeline.
|
|
19
|
+
- **Probe distance check on .nirs→.snirf conversion**: warns about inter-optode distances outside physiological range during format conversion.
|
|
20
|
+
|
|
21
|
+
### Fixed
|
|
22
|
+
- Preserve `0.0` values in group time fields (falsy fallback bug).
|
|
23
|
+
- Auto-select new group + timeline-first card layout.
|
|
24
|
+
- Wire `snirf_path` through to T-030 timeline in builder.
|
|
25
|
+
- Use correct per-instance key for active group lookup.
|
|
26
|
+
- Clear condition windows/groups on SNIRF path change.
|
|
27
|
+
- Merge probe click callbacks to fix anchor race condition.
|
|
28
|
+
|
|
29
|
+
### Removed
|
|
30
|
+
- Probe head silhouette, 10-20 grid, and channel interaction (T-026/T-029) — reverted for redesign in future milestone.
|
|
31
|
+
|
|
10
32
|
## [0.2.0] - 2026-05-22
|
|
11
33
|
|
|
12
34
|
### Added
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: nirspy
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: GUI fNIRS-first em Python — builder modular de pipeline sobre MNE-NIRS
|
|
5
5
|
Project-URL: Homepage, https://github.com/BrunoFurlanetto/nirspy
|
|
6
6
|
Project-URL: Repository, https://github.com/BrunoFurlanetto/nirspy
|
|
@@ -55,6 +55,70 @@ def _validate_condition_window(name: str, window: ConditionWindow) -> None:
|
|
|
55
55
|
)
|
|
56
56
|
|
|
57
57
|
|
|
58
|
+
@dataclass(frozen=True)
|
|
59
|
+
class ConditionGroup:
|
|
60
|
+
"""A named group of SNIRF conditions sharing temporal parameters.
|
|
61
|
+
|
|
62
|
+
Used by ``BlockAverageParams.per_condition_groups`` to let users
|
|
63
|
+
aggregate multiple SNIRF condition keys under a custom label with
|
|
64
|
+
shared tmin/tmax/baseline windows. The label becomes the key in
|
|
65
|
+
the resulting ``dict[str, Evoked]``.
|
|
66
|
+
|
|
67
|
+
Modes (D8)
|
|
68
|
+
----------
|
|
69
|
+
Exactly one of ``condition_names`` or ``event_indices`` must be
|
|
70
|
+
non-empty. Using both simultaneously is forbidden — ``__post_init__``
|
|
71
|
+
raises :class:`~nirspy.domain.exceptions.ValidationError`.
|
|
72
|
+
|
|
73
|
+
condition_names:
|
|
74
|
+
Classic mode (T-024): groups all occurrences of the listed
|
|
75
|
+
SNIRF condition keys together.
|
|
76
|
+
event_indices:
|
|
77
|
+
Timeline mode (T-030): groups specific occurrences identified by
|
|
78
|
+
their chronological index in ``raw.annotations`` (sorted by onset).
|
|
79
|
+
Index 0 = first occurrence across *all* stim annotations.
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
label: str
|
|
83
|
+
condition_names: list[str] = field(default_factory=list)
|
|
84
|
+
tmin: float = -2.0
|
|
85
|
+
tmax: float = 18.0
|
|
86
|
+
baseline_tmin: float = -2.0
|
|
87
|
+
baseline_tmax: float = 0.0
|
|
88
|
+
event_indices: list[int] = field(default_factory=list)
|
|
89
|
+
|
|
90
|
+
def __post_init__(self) -> None:
|
|
91
|
+
"""Enforce mutual exclusion between condition_names and event_indices."""
|
|
92
|
+
has_names = bool(self.condition_names)
|
|
93
|
+
has_indices = bool(self.event_indices)
|
|
94
|
+
if has_names and has_indices:
|
|
95
|
+
raise ValidationError(
|
|
96
|
+
f"ConditionGroup {self.label!r}: condition_names and "
|
|
97
|
+
"event_indices are mutually exclusive (D8). "
|
|
98
|
+
"Populate one or the other, not both."
|
|
99
|
+
)
|
|
100
|
+
if not has_names and not has_indices:
|
|
101
|
+
raise ValidationError(
|
|
102
|
+
f"ConditionGroup {self.label!r}: either condition_names or "
|
|
103
|
+
"event_indices must be non-empty."
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _validate_condition_group(name: str, group: ConditionGroup) -> None:
|
|
108
|
+
"""Raise :class:`ValidationError` if *group* has invalid ranges."""
|
|
109
|
+
if group.tmin >= group.tmax:
|
|
110
|
+
raise ValidationError(
|
|
111
|
+
f"ConditionGroup {name!r}: tmin ({group.tmin}) "
|
|
112
|
+
f"must be < tmax ({group.tmax})."
|
|
113
|
+
)
|
|
114
|
+
if group.baseline_tmin > group.baseline_tmax:
|
|
115
|
+
raise ValidationError(
|
|
116
|
+
f"ConditionGroup {name!r}: baseline_tmin "
|
|
117
|
+
f"({group.baseline_tmin}) must be <= baseline_tmax "
|
|
118
|
+
f"({group.baseline_tmax})."
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
|
|
58
122
|
@dataclass(frozen=True)
|
|
59
123
|
class BlockAverageParams:
|
|
60
124
|
"""Parameters for block averaging (HRF computation).
|
|
@@ -90,6 +154,9 @@ class BlockAverageParams:
|
|
|
90
154
|
per_condition_windows: dict[str, ConditionWindow] = field(
|
|
91
155
|
default_factory=dict,
|
|
92
156
|
)
|
|
157
|
+
per_condition_groups: dict[str, ConditionGroup] = field(
|
|
158
|
+
default_factory=dict,
|
|
159
|
+
)
|
|
93
160
|
|
|
94
161
|
def __post_init__(self) -> None:
|
|
95
162
|
"""Coerce raw dicts to ConditionWindow for YAML round-trip.
|
|
@@ -125,6 +192,26 @@ class BlockAverageParams:
|
|
|
125
192
|
coerced[key] = val
|
|
126
193
|
object.__setattr__(self, "per_condition_windows", coerced)
|
|
127
194
|
|
|
195
|
+
# Mutual exclusion: per_condition_windows OR per_condition_groups (D3)
|
|
196
|
+
if self.per_condition_windows and self.per_condition_groups:
|
|
197
|
+
raise ValidationError(
|
|
198
|
+
"BlockAverageParams: per_condition_windows and "
|
|
199
|
+
"per_condition_groups are mutually exclusive (D3). "
|
|
200
|
+
"Use one or the other, not both."
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
# Coerce raw dicts to ConditionGroup for YAML round-trip.
|
|
204
|
+
# event_indices defaults to [] when absent so legacy YAML (T-024,
|
|
205
|
+
# condition_names only) continues to deserialise without changes.
|
|
206
|
+
if self.per_condition_groups:
|
|
207
|
+
coerced_groups: dict[str, ConditionGroup] = {}
|
|
208
|
+
for grp_key, grp_val in self.per_condition_groups.items():
|
|
209
|
+
if isinstance(grp_val, dict):
|
|
210
|
+
coerced_groups[grp_key] = ConditionGroup(**grp_val)
|
|
211
|
+
else:
|
|
212
|
+
coerced_groups[grp_key] = grp_val
|
|
213
|
+
object.__setattr__(self, "per_condition_groups", coerced_groups)
|
|
214
|
+
|
|
128
215
|
|
|
129
216
|
_BA_SPEC = BlockSpec(
|
|
130
217
|
block_id="block_average",
|
|
@@ -244,17 +331,34 @@ class BlockAverageBlock:
|
|
|
244
331
|
else:
|
|
245
332
|
used_event_id = event_id
|
|
246
333
|
|
|
247
|
-
#
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
334
|
+
# Filter per_condition_windows to only keys present in event_id.
|
|
335
|
+
# Stale keys can appear when the user swaps the SNIRF file while
|
|
336
|
+
# per-condition windows are already configured. Raising would give
|
|
337
|
+
# a confusing error; silently discarding (with warning) is correct
|
|
338
|
+
# because the GUI sync callback (sync_conditions_on_path_change)
|
|
339
|
+
# already removed them in the GUI path -- this is defence-in-depth
|
|
340
|
+
# for YAML-loaded pipelines or any other path that bypasses the GUI.
|
|
341
|
+
# Restores T-012 hotfix (2d7b63d) regressed by T-024 (#37).
|
|
342
|
+
filtered_pcw: dict[str, ConditionWindow] = dict(
|
|
343
|
+
self.params.per_condition_windows
|
|
344
|
+
)
|
|
345
|
+
if filtered_pcw:
|
|
346
|
+
unknown = set(filtered_pcw) - set(used_event_id)
|
|
252
347
|
if unknown:
|
|
253
|
-
|
|
348
|
+
import warnings
|
|
349
|
+
|
|
350
|
+
warnings.warn(
|
|
254
351
|
f"BlockAverageBlock: per_condition_windows contains "
|
|
255
|
-
f"condition(s) {sorted(unknown)} not found in "
|
|
256
|
-
f"event_id {sorted(used_event_id.keys())}."
|
|
352
|
+
f"condition(s) {sorted(unknown)} not found in the current "
|
|
353
|
+
f"SNIRF event_id {sorted(used_event_id.keys())}. "
|
|
354
|
+
f"These entries will be skipped (stale keys from a "
|
|
355
|
+
f"previous SNIRF file).",
|
|
356
|
+
UserWarning,
|
|
357
|
+
stacklevel=2,
|
|
257
358
|
)
|
|
359
|
+
filtered_pcw = {
|
|
360
|
+
k: v for k, v in filtered_pcw.items() if k in used_event_id
|
|
361
|
+
}
|
|
258
362
|
|
|
259
363
|
# Build rejection dict
|
|
260
364
|
reject: dict[str, float] | None = None
|
|
@@ -264,8 +368,34 @@ class BlockAverageBlock:
|
|
|
264
368
|
"hbr": self.params.amplitude_threshold,
|
|
265
369
|
}
|
|
266
370
|
|
|
371
|
+
# ---- Per-condition-groups path (T-024) ----
|
|
372
|
+
if self.params.per_condition_groups:
|
|
373
|
+
# Validate each group
|
|
374
|
+
for grp_name, grp in self.params.per_condition_groups.items():
|
|
375
|
+
_validate_condition_group(grp_name, grp)
|
|
376
|
+
|
|
377
|
+
epochs_dict = self._adapter.create_epochs_per_group(
|
|
378
|
+
raw,
|
|
379
|
+
groups=self.params.per_condition_groups,
|
|
380
|
+
reject=reject,
|
|
381
|
+
)
|
|
382
|
+
evoked_dict = self._adapter.average_epochs(epochs_dict)
|
|
383
|
+
|
|
384
|
+
metadata: dict[str, Any] = {
|
|
385
|
+
"conditions": list(evoked_dict.keys()),
|
|
386
|
+
"n_conditions": len(evoked_dict),
|
|
387
|
+
"n_epochs_total": sum(
|
|
388
|
+
len(ep.events) for ep in epochs_dict.values()
|
|
389
|
+
),
|
|
390
|
+
"per_condition_groups_used": True,
|
|
391
|
+
"tmin": self.params.tmin,
|
|
392
|
+
"tmax": self.params.tmax,
|
|
393
|
+
}
|
|
394
|
+
for grp_label, ep in epochs_dict.items():
|
|
395
|
+
metadata[f"n_epochs_{grp_label}"] = len(ep.events)
|
|
396
|
+
|
|
267
397
|
# ---- Per-condition path vs legacy single-Epochs path ----
|
|
268
|
-
|
|
398
|
+
elif filtered_pcw:
|
|
269
399
|
default_window = (
|
|
270
400
|
self.params.tmin,
|
|
271
401
|
self.params.tmax,
|
|
@@ -276,7 +406,7 @@ class BlockAverageBlock:
|
|
|
276
406
|
raw,
|
|
277
407
|
used_event_id,
|
|
278
408
|
default_window=default_window,
|
|
279
|
-
per_condition_windows=
|
|
409
|
+
per_condition_windows=filtered_pcw,
|
|
280
410
|
reject=reject,
|
|
281
411
|
)
|
|
282
412
|
evoked_dict = self._adapter.average_epochs(epochs_dict)
|
|
@@ -285,11 +415,11 @@ class BlockAverageBlock:
|
|
|
285
415
|
windows_used: dict[str, dict[str, float]] = {}
|
|
286
416
|
n_epochs_total = 0
|
|
287
417
|
skipped_conditions: list[str] = []
|
|
288
|
-
metadata
|
|
418
|
+
metadata = {}
|
|
289
419
|
|
|
290
420
|
for cond in used_event_id:
|
|
291
|
-
if cond in
|
|
292
|
-
w =
|
|
421
|
+
if cond in filtered_pcw:
|
|
422
|
+
w = filtered_pcw[cond]
|
|
293
423
|
windows_used[cond] = {
|
|
294
424
|
"tmin": w.tmin,
|
|
295
425
|
"tmax": w.tmax,
|
|
@@ -4,7 +4,12 @@ from nirspy.domain.block import Block, BlockResult, BlockSpec
|
|
|
4
4
|
from nirspy.domain.cache import CacheProtocol
|
|
5
5
|
from nirspy.domain.data_types import DataType
|
|
6
6
|
from nirspy.domain.exceptions import DomainError, ExecutionError, NirspyError, ValidationError
|
|
7
|
-
from nirspy.domain.execution import
|
|
7
|
+
from nirspy.domain.execution import (
|
|
8
|
+
ExecutionContext,
|
|
9
|
+
PipelineRunner,
|
|
10
|
+
ProgressCallback,
|
|
11
|
+
run_pipeline_sync,
|
|
12
|
+
)
|
|
8
13
|
from nirspy.domain.pipeline import Pipeline, RegistryProtocol
|
|
9
14
|
from nirspy.domain.validation import validate_io_chain
|
|
10
15
|
|
|
@@ -17,6 +22,7 @@ __all__ = [
|
|
|
17
22
|
"DomainError",
|
|
18
23
|
"NirspyError",
|
|
19
24
|
"ExecutionContext",
|
|
25
|
+
"PipelineRunner",
|
|
20
26
|
"ExecutionError",
|
|
21
27
|
"Pipeline",
|
|
22
28
|
"ProgressCallback",
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
"""ExecutionContext, PipelineRunner and run_pipeline_sync."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import dataclasses
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from typing import TYPE_CHECKING, Any
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from nirspy.domain.cache import CacheProtocol
|
|
12
|
+
from nirspy.domain.pipeline import Pipeline
|
|
13
|
+
|
|
14
|
+
from nirspy.domain.block import Block, BlockResult, BlockSpec
|
|
15
|
+
from nirspy.domain.exceptions import ExecutionError, ValidationError
|
|
16
|
+
|
|
17
|
+
# Signature: (block_id, step_index, total_steps) -> None
|
|
18
|
+
ProgressCallback = Callable[[str, int, int], None]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _noop_progress(block_id: str, step: int, total: int) -> None: # noqa: ARG001
|
|
22
|
+
pass
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class ExecutionContext:
|
|
27
|
+
"""Carries runtime dependencies injected into each block's ``run`` call.
|
|
28
|
+
|
|
29
|
+
All fields are optional — blocks must handle ``None`` gracefully when
|
|
30
|
+
optional resources are absent.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
cache: CacheProtocol | None = None
|
|
34
|
+
"""Optional cache adapter; ``None`` means caching is disabled for this run."""
|
|
35
|
+
|
|
36
|
+
progress: ProgressCallback = field(default=_noop_progress)
|
|
37
|
+
"""Callback invoked after each block completes. Defaults to a no-op."""
|
|
38
|
+
|
|
39
|
+
extra: dict[str, Any] = field(default_factory=dict)
|
|
40
|
+
"""Extension point for future runners (e.g. distributed context IDs).
|
|
41
|
+
|
|
42
|
+
The linear runner stores the previous block's metadata here under
|
|
43
|
+
``"prev_metadata"`` so downstream blocks can access upstream diagnostics
|
|
44
|
+
(e.g. SCI values for channel pruning). Individual metadata keys are also
|
|
45
|
+
promoted to top-level extra keys when present (e.g. ``"sci_values"``).
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class PipelineRunner:
|
|
51
|
+
"""Step-by-step pipeline executor.
|
|
52
|
+
|
|
53
|
+
Allows the GUI to advance block-by-block, optionally injecting
|
|
54
|
+
``params_override`` dicts before each execution step. The override
|
|
55
|
+
is **transient** --- it does not mutate the original block's params.
|
|
56
|
+
|
|
57
|
+
Usage (interactive)::
|
|
58
|
+
|
|
59
|
+
runner = PipelineRunner(pipeline, context)
|
|
60
|
+
runner.start()
|
|
61
|
+
while not runner.is_complete:
|
|
62
|
+
spec = runner.next_block()
|
|
63
|
+
if spec is None:
|
|
64
|
+
break
|
|
65
|
+
result = runner.execute_current(params_override={"tmin": -5.0})
|
|
66
|
+
|
|
67
|
+
Usage (headless) --- equivalent to the old ``run_pipeline_sync``::
|
|
68
|
+
|
|
69
|
+
runner = PipelineRunner(pipeline, context)
|
|
70
|
+
runner.start()
|
|
71
|
+
while not runner.is_complete:
|
|
72
|
+
runner.next_block()
|
|
73
|
+
runner.execute_current()
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
def __init__(
|
|
77
|
+
self,
|
|
78
|
+
pipeline: Pipeline,
|
|
79
|
+
context: ExecutionContext | None = None,
|
|
80
|
+
) -> None:
|
|
81
|
+
self._pipeline = pipeline
|
|
82
|
+
self._context = context or ExecutionContext()
|
|
83
|
+
self._enabled_steps: list[Block] = []
|
|
84
|
+
self._current_idx: int = -1
|
|
85
|
+
self._results: list[BlockResult] = []
|
|
86
|
+
self._prev_result: BlockResult | None = None
|
|
87
|
+
self._started: bool = False
|
|
88
|
+
self._block_ready: bool = False
|
|
89
|
+
|
|
90
|
+
def start(self) -> None:
|
|
91
|
+
"""Initialize the runner. Must be called before ``next_block``."""
|
|
92
|
+
self._enabled_steps = [
|
|
93
|
+
step for step in self._pipeline.steps if step.spec.enabled
|
|
94
|
+
]
|
|
95
|
+
self._current_idx = -1
|
|
96
|
+
self._results = []
|
|
97
|
+
self._prev_result = None
|
|
98
|
+
self._started = True
|
|
99
|
+
self._block_ready = False
|
|
100
|
+
|
|
101
|
+
def next_block(self) -> BlockSpec | None:
|
|
102
|
+
"""Advance to the next block and return its spec.
|
|
103
|
+
|
|
104
|
+
Returns ``None`` when all blocks have been executed (pipeline
|
|
105
|
+
complete).
|
|
106
|
+
|
|
107
|
+
Raises
|
|
108
|
+
------
|
|
109
|
+
RuntimeError
|
|
110
|
+
If ``start()`` was not called first.
|
|
111
|
+
"""
|
|
112
|
+
if not self._started:
|
|
113
|
+
raise RuntimeError(
|
|
114
|
+
"PipelineRunner.start() must be called before next_block()."
|
|
115
|
+
)
|
|
116
|
+
next_idx = self._current_idx + 1
|
|
117
|
+
if next_idx >= len(self._enabled_steps):
|
|
118
|
+
self._block_ready = False
|
|
119
|
+
return None
|
|
120
|
+
self._current_idx = next_idx
|
|
121
|
+
self._block_ready = True
|
|
122
|
+
return self._enabled_steps[self._current_idx].spec
|
|
123
|
+
def execute_current(
|
|
124
|
+
self,
|
|
125
|
+
params_override: dict[str, Any] | None = None,
|
|
126
|
+
) -> BlockResult:
|
|
127
|
+
"""Execute the current block, optionally with transient param overrides.
|
|
128
|
+
|
|
129
|
+
Parameters
|
|
130
|
+
----------
|
|
131
|
+
params_override:
|
|
132
|
+
Dict of param field names to override values. Applied
|
|
133
|
+
transiently --- the original block's ``params`` attribute is
|
|
134
|
+
**not** mutated.
|
|
135
|
+
|
|
136
|
+
Raises
|
|
137
|
+
------
|
|
138
|
+
RuntimeError
|
|
139
|
+
If ``next_block()`` was not called or the pipeline is complete.
|
|
140
|
+
ValidationError
|
|
141
|
+
If ``params_override`` contains unknown field names.
|
|
142
|
+
"""
|
|
143
|
+
if not self._block_ready:
|
|
144
|
+
raise RuntimeError(
|
|
145
|
+
"PipelineRunner.execute_current() called without a "
|
|
146
|
+
"preceding next_block() call, or the pipeline is complete."
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
block = self._enabled_steps[self._current_idx]
|
|
150
|
+
total = len(self._enabled_steps)
|
|
151
|
+
|
|
152
|
+
# Build inputs dict (same logic as old run_pipeline_sync)
|
|
153
|
+
if self._prev_result is None:
|
|
154
|
+
inputs: dict[str, Any] = {}
|
|
155
|
+
else:
|
|
156
|
+
inputs = {self._prev_result.block_id: self._prev_result.data}
|
|
157
|
+
|
|
158
|
+
# Apply transient params override if provided
|
|
159
|
+
if params_override and hasattr(block, "params"):
|
|
160
|
+
block = self._apply_params_override(block, params_override)
|
|
161
|
+
|
|
162
|
+
try:
|
|
163
|
+
result = block.run(self._context, inputs)
|
|
164
|
+
except Exception as exc: # noqa: BLE001
|
|
165
|
+
raise ExecutionError(
|
|
166
|
+
f"Block '{block.spec.block_id}' failed at step "
|
|
167
|
+
f"{self._current_idx + 1}/{total}: {exc}"
|
|
168
|
+
) from exc
|
|
169
|
+
|
|
170
|
+
# Propagate metadata to context.extra for downstream blocks (ADR-014)
|
|
171
|
+
self._context.extra["prev_metadata"] = result.metadata
|
|
172
|
+
for key, value in result.metadata.items():
|
|
173
|
+
self._context.extra[key] = value
|
|
174
|
+
|
|
175
|
+
self._context.progress(
|
|
176
|
+
block.spec.block_id, self._current_idx + 1, total,
|
|
177
|
+
)
|
|
178
|
+
self._results.append(result)
|
|
179
|
+
self._prev_result = result
|
|
180
|
+
self._block_ready = False
|
|
181
|
+
|
|
182
|
+
return result
|
|
183
|
+
@property
|
|
184
|
+
def is_complete(self) -> bool:
|
|
185
|
+
"""Whether all enabled blocks have been executed."""
|
|
186
|
+
if not self._started:
|
|
187
|
+
return False
|
|
188
|
+
return (
|
|
189
|
+
self._current_idx >= len(self._enabled_steps) - 1
|
|
190
|
+
and not self._block_ready
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
@property
|
|
194
|
+
def current_idx(self) -> int:
|
|
195
|
+
"""Index of the current block (0-based among enabled steps)."""
|
|
196
|
+
return self._current_idx
|
|
197
|
+
|
|
198
|
+
@property
|
|
199
|
+
def results(self) -> list[BlockResult]:
|
|
200
|
+
"""List of results from executed blocks so far."""
|
|
201
|
+
return list(self._results)
|
|
202
|
+
|
|
203
|
+
@property
|
|
204
|
+
def total_steps(self) -> int:
|
|
205
|
+
"""Total number of enabled steps."""
|
|
206
|
+
return len(self._enabled_steps)
|
|
207
|
+
|
|
208
|
+
@property
|
|
209
|
+
def current_block(self) -> Block | None:
|
|
210
|
+
"""The current block instance, or None if not started/complete."""
|
|
211
|
+
if not self._started or self._current_idx < 0:
|
|
212
|
+
return None
|
|
213
|
+
if self._current_idx >= len(self._enabled_steps):
|
|
214
|
+
return None
|
|
215
|
+
return self._enabled_steps[self._current_idx]
|
|
216
|
+
|
|
217
|
+
@staticmethod
|
|
218
|
+
def _apply_params_override(
|
|
219
|
+
block: Block,
|
|
220
|
+
overrides: dict[str, Any],
|
|
221
|
+
) -> Block:
|
|
222
|
+
"""Create a copy of block with overridden params (transient).
|
|
223
|
+
|
|
224
|
+
The original block is never mutated. A new block instance is
|
|
225
|
+
created with the merged params dataclass.
|
|
226
|
+
|
|
227
|
+
Raises
|
|
228
|
+
------
|
|
229
|
+
ValidationError
|
|
230
|
+
If any key in overrides is not a valid field of the params
|
|
231
|
+
dataclass.
|
|
232
|
+
"""
|
|
233
|
+
params = getattr(block, "params", None)
|
|
234
|
+
if params is None or not dataclasses.is_dataclass(params):
|
|
235
|
+
raise ValidationError(
|
|
236
|
+
f"Block '{block.spec.block_id}' has no dataclass params "
|
|
237
|
+
f"--- cannot apply params_override."
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
# Validate override keys
|
|
241
|
+
valid_fields = {f.name for f in dataclasses.fields(params)}
|
|
242
|
+
unknown = set(overrides) - valid_fields
|
|
243
|
+
if unknown:
|
|
244
|
+
raise ValidationError(
|
|
245
|
+
f"params_override contains unknown field(s) for "
|
|
246
|
+
f"'{block.spec.block_id}': {sorted(unknown)}. "
|
|
247
|
+
f"Valid fields: {sorted(valid_fields)}."
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
# Merge: original params + overrides
|
|
251
|
+
# cast needed because is_dataclass() does not narrow for mypy
|
|
252
|
+
params_dict: dict[str, Any] = dataclasses.asdict(params) # type: ignore[arg-type]
|
|
253
|
+
merged = {**params_dict, **overrides}
|
|
254
|
+
params_cls: type[Any] = type(params)
|
|
255
|
+
new_params = params_cls(**merged)
|
|
256
|
+
|
|
257
|
+
# Create new block instance with merged params
|
|
258
|
+
block_cls = type(block)
|
|
259
|
+
return block_cls(params=new_params) # type: ignore[call-arg]
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def run_pipeline_sync(
|
|
263
|
+
pipeline: Pipeline,
|
|
264
|
+
context: ExecutionContext | None = None,
|
|
265
|
+
) -> list[BlockResult]:
|
|
266
|
+
"""Execute pipeline synchronously --- backward-compatible wrapper.
|
|
267
|
+
|
|
268
|
+
This is a thin wrapper around :class:`PipelineRunner` that calls
|
|
269
|
+
``next_block()`` + ``execute_current()`` in a loop until complete.
|
|
270
|
+
Behavior is identical to the pre-T-023 implementation.
|
|
271
|
+
|
|
272
|
+
Parameters
|
|
273
|
+
----------
|
|
274
|
+
pipeline:
|
|
275
|
+
The :class:`~nirspy.domain.pipeline.Pipeline` to execute.
|
|
276
|
+
context:
|
|
277
|
+
Optional :class:`ExecutionContext`. A default (no cache, no-op progress)
|
|
278
|
+
is created when omitted.
|
|
279
|
+
|
|
280
|
+
Returns
|
|
281
|
+
-------
|
|
282
|
+
list[BlockResult]
|
|
283
|
+
One result per enabled block, in execution order.
|
|
284
|
+
|
|
285
|
+
Raises
|
|
286
|
+
------
|
|
287
|
+
ExecutionError
|
|
288
|
+
Wraps any exception raised by a block's ``run`` method.
|
|
289
|
+
"""
|
|
290
|
+
runner = PipelineRunner(pipeline, context)
|
|
291
|
+
runner.start()
|
|
292
|
+
while not runner.is_complete:
|
|
293
|
+
spec = runner.next_block()
|
|
294
|
+
if spec is None:
|
|
295
|
+
break
|
|
296
|
+
runner.execute_current()
|
|
297
|
+
return runner.results
|