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.
Files changed (159) hide show
  1. {nirspy-0.2.0 → nirspy-0.3.0}/CHANGELOG.md +22 -0
  2. {nirspy-0.2.0 → nirspy-0.3.0}/PKG-INFO +1 -1
  3. {nirspy-0.2.0 → nirspy-0.3.0}/pyproject.toml +1 -1
  4. {nirspy-0.2.0 → nirspy-0.3.0}/src/nirspy/__init__.py +1 -1
  5. {nirspy-0.2.0 → nirspy-0.3.0}/src/nirspy/blocks/analysis.py +143 -13
  6. {nirspy-0.2.0 → nirspy-0.3.0}/src/nirspy/domain/__init__.py +7 -1
  7. nirspy-0.3.0/src/nirspy/domain/execution.py +297 -0
  8. {nirspy-0.2.0 → nirspy-0.3.0}/src/nirspy/engine/mne_adapter.py +187 -1
  9. {nirspy-0.2.0 → nirspy-0.3.0}/src/nirspy/gui/app.py +5 -1
  10. {nirspy-0.2.0 → nirspy-0.3.0}/src/nirspy/gui/callbacks/converter_callbacks.py +115 -0
  11. {nirspy-0.2.0 → nirspy-0.3.0}/src/nirspy/gui/callbacks/execution_callbacks.py +5 -0
  12. nirspy-0.3.0/src/nirspy/gui/callbacks/param_callbacks.py +684 -0
  13. {nirspy-0.2.0 → nirspy-0.3.0}/src/nirspy/gui/callbacks/pipeline_callbacks.py +11 -7
  14. nirspy-0.3.0/src/nirspy/gui/callbacks/runtime_callbacks.py +483 -0
  15. nirspy-0.3.0/src/nirspy/gui/components/condition_groups_editor.py +451 -0
  16. nirspy-0.3.0/src/nirspy/gui/components/condition_timeline.py +336 -0
  17. {nirspy-0.2.0 → nirspy-0.3.0}/src/nirspy/gui/components/converter_view.py +25 -0
  18. nirspy-0.3.0/src/nirspy/gui/components/hrf_runtime_dialog.py +859 -0
  19. {nirspy-0.2.0 → nirspy-0.3.0}/src/nirspy/gui/components/param_editor.py +102 -9
  20. {nirspy-0.2.0 → nirspy-0.3.0}/src/nirspy/gui/components/run_button.py +27 -2
  21. nirspy-0.3.0/src/nirspy/gui/components/runtime_dialog.py +119 -0
  22. {nirspy-0.2.0 → nirspy-0.3.0}/src/nirspy/gui/layouts.py +22 -1
  23. {nirspy-0.2.0 → nirspy-0.3.0}/src/nirspy/io/converters.py +50 -0
  24. {nirspy-0.2.0 → nirspy-0.3.0}/tests/blocks/test_analysis.py +10 -4
  25. nirspy-0.3.0/tests/blocks/test_condition_groups.py +327 -0
  26. nirspy-0.3.0/tests/domain/test_pipeline_runner.py +217 -0
  27. nirspy-0.3.0/tests/engine/test_mne_adapter_groups.py +236 -0
  28. {nirspy-0.2.0 → nirspy-0.3.0}/tests/gui/test_builder.py +4 -1
  29. nirspy-0.3.0/tests/gui/test_condition_groups_editor.py +523 -0
  30. nirspy-0.3.0/tests/gui/test_condition_timeline.py +474 -0
  31. nirspy-0.3.0/tests/gui/test_hrf_runtime_dialog.py +699 -0
  32. nirspy-0.3.0/tests/gui/test_runtime_dialog.py +473 -0
  33. nirspy-0.2.0/src/nirspy/domain/execution.py +0 -117
  34. nirspy-0.2.0/src/nirspy/gui/callbacks/param_callbacks.py +0 -174
  35. {nirspy-0.2.0 → nirspy-0.3.0}/.github/ISSUE_TEMPLATE/bug.yml +0 -0
  36. {nirspy-0.2.0 → nirspy-0.3.0}/.github/ISSUE_TEMPLATE/config.yml +0 -0
  37. {nirspy-0.2.0 → nirspy-0.3.0}/.github/ISSUE_TEMPLATE/feature.yml +0 -0
  38. {nirspy-0.2.0 → nirspy-0.3.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  39. {nirspy-0.2.0 → nirspy-0.3.0}/.github/dependabot.yml +0 -0
  40. {nirspy-0.2.0 → nirspy-0.3.0}/.github/workflows/ci.yml +0 -0
  41. {nirspy-0.2.0 → nirspy-0.3.0}/.github/workflows/docs.yml +0 -0
  42. {nirspy-0.2.0 → nirspy-0.3.0}/.gitignore +0 -0
  43. {nirspy-0.2.0 → nirspy-0.3.0}/.python-version +0 -0
  44. {nirspy-0.2.0 → nirspy-0.3.0}/CLAUDE.md +0 -0
  45. {nirspy-0.2.0 → nirspy-0.3.0}/CODE_OF_CONDUCT.md +0 -0
  46. {nirspy-0.2.0 → nirspy-0.3.0}/CONTRIBUTING.md +0 -0
  47. {nirspy-0.2.0 → nirspy-0.3.0}/LICENSE +0 -0
  48. {nirspy-0.2.0 → nirspy-0.3.0}/README.md +0 -0
  49. {nirspy-0.2.0 → nirspy-0.3.0}/SECURITY.md +0 -0
  50. {nirspy-0.2.0 → nirspy-0.3.0}/docs/architecture.md +0 -0
  51. {nirspy-0.2.0 → nirspy-0.3.0}/docs/getting-started.md +0 -0
  52. {nirspy-0.2.0 → nirspy-0.3.0}/docs/index.md +0 -0
  53. {nirspy-0.2.0 → nirspy-0.3.0}/docs/reference/blocks.md +0 -0
  54. {nirspy-0.2.0 → nirspy-0.3.0}/docs/reference/cli.md +0 -0
  55. {nirspy-0.2.0 → nirspy-0.3.0}/docs/reference/domain.md +0 -0
  56. {nirspy-0.2.0 → nirspy-0.3.0}/docs/reference/engine.md +0 -0
  57. {nirspy-0.2.0 → nirspy-0.3.0}/docs/roadmap.md +0 -0
  58. {nirspy-0.2.0 → nirspy-0.3.0}/docs/tutorials/first-pipeline.md +0 -0
  59. {nirspy-0.2.0 → nirspy-0.3.0}/examples/data/.gitkeep +0 -0
  60. {nirspy-0.2.0 → nirspy-0.3.0}/examples/pipelines/basic-preproc.yml +0 -0
  61. {nirspy-0.2.0 → nirspy-0.3.0}/examples/pipelines/best-practices-block-design.yml +0 -0
  62. {nirspy-0.2.0 → nirspy-0.3.0}/examples/pipelines/motion-heavy-recording.yml +0 -0
  63. {nirspy-0.2.0 → nirspy-0.3.0}/examples/pipelines/resting-state-connectivity.yml +0 -0
  64. {nirspy-0.2.0 → nirspy-0.3.0}/mkdocs.yml +0 -0
  65. {nirspy-0.2.0 → nirspy-0.3.0}/requirements.txt +0 -0
  66. {nirspy-0.2.0 → nirspy-0.3.0}/scripts/smoke_e1.py +0 -0
  67. {nirspy-0.2.0 → nirspy-0.3.0}/src/nirspy/blocks/__init__.py +0 -0
  68. {nirspy-0.2.0 → nirspy-0.3.0}/src/nirspy/blocks/load.py +0 -0
  69. {nirspy-0.2.0 → nirspy-0.3.0}/src/nirspy/blocks/manual_exclude.py +0 -0
  70. {nirspy-0.2.0 → nirspy-0.3.0}/src/nirspy/blocks/motion.py +0 -0
  71. {nirspy-0.2.0 → nirspy-0.3.0}/src/nirspy/blocks/preprocessing.py +0 -0
  72. {nirspy-0.2.0 → nirspy-0.3.0}/src/nirspy/blocks/quality.py +0 -0
  73. {nirspy-0.2.0 → nirspy-0.3.0}/src/nirspy/blocks/registry.py +0 -0
  74. {nirspy-0.2.0 → nirspy-0.3.0}/src/nirspy/cli/__init__.py +0 -0
  75. {nirspy-0.2.0 → nirspy-0.3.0}/src/nirspy/cli/main.py +0 -0
  76. {nirspy-0.2.0 → nirspy-0.3.0}/src/nirspy/domain/block.py +0 -0
  77. {nirspy-0.2.0 → nirspy-0.3.0}/src/nirspy/domain/cache.py +0 -0
  78. {nirspy-0.2.0 → nirspy-0.3.0}/src/nirspy/domain/data_types.py +0 -0
  79. {nirspy-0.2.0 → nirspy-0.3.0}/src/nirspy/domain/exceptions.py +0 -0
  80. {nirspy-0.2.0 → nirspy-0.3.0}/src/nirspy/domain/pipeline.py +0 -0
  81. {nirspy-0.2.0 → nirspy-0.3.0}/src/nirspy/domain/validation.py +0 -0
  82. {nirspy-0.2.0 → nirspy-0.3.0}/src/nirspy/engine/__init__.py +0 -0
  83. {nirspy-0.2.0 → nirspy-0.3.0}/src/nirspy/engine/cache_adapter.py +0 -0
  84. {nirspy-0.2.0 → nirspy-0.3.0}/src/nirspy/engine/exceptions.py +0 -0
  85. {nirspy-0.2.0 → nirspy-0.3.0}/src/nirspy/gui/__init__.py +0 -0
  86. {nirspy-0.2.0 → nirspy-0.3.0}/src/nirspy/gui/assets/tutorial.css +0 -0
  87. {nirspy-0.2.0 → nirspy-0.3.0}/src/nirspy/gui/callbacks/__init__.py +0 -0
  88. {nirspy-0.2.0 → nirspy-0.3.0}/src/nirspy/gui/callbacks/io_callbacks.py +0 -0
  89. {nirspy-0.2.0 → nirspy-0.3.0}/src/nirspy/gui/callbacks/tutorial_callbacks.py +0 -0
  90. {nirspy-0.2.0 → nirspy-0.3.0}/src/nirspy/gui/callbacks/viz_callbacks.py +0 -0
  91. {nirspy-0.2.0 → nirspy-0.3.0}/src/nirspy/gui/components/__init__.py +0 -0
  92. {nirspy-0.2.0 → nirspy-0.3.0}/src/nirspy/gui/components/block_card.py +0 -0
  93. {nirspy-0.2.0 → nirspy-0.3.0}/src/nirspy/gui/components/block_catalog.py +0 -0
  94. {nirspy-0.2.0 → nirspy-0.3.0}/src/nirspy/gui/components/condition_selector.py +0 -0
  95. {nirspy-0.2.0 → nirspy-0.3.0}/src/nirspy/gui/components/condition_windows_editor.py +0 -0
  96. {nirspy-0.2.0 → nirspy-0.3.0}/src/nirspy/gui/components/error_display.py +0 -0
  97. {nirspy-0.2.0 → nirspy-0.3.0}/src/nirspy/gui/components/hrf_plot.py +0 -0
  98. {nirspy-0.2.0 → nirspy-0.3.0}/src/nirspy/gui/components/param_metadata.py +0 -0
  99. {nirspy-0.2.0 → nirspy-0.3.0}/src/nirspy/gui/components/pipeline_view.py +0 -0
  100. {nirspy-0.2.0 → nirspy-0.3.0}/src/nirspy/gui/components/probe_viewer.py +0 -0
  101. {nirspy-0.2.0 → nirspy-0.3.0}/src/nirspy/gui/components/qc_dashboard.py +0 -0
  102. {nirspy-0.2.0 → nirspy-0.3.0}/src/nirspy/gui/components/raw_data_plot.py +0 -0
  103. {nirspy-0.2.0 → nirspy-0.3.0}/src/nirspy/gui/components/tooltips.py +0 -0
  104. {nirspy-0.2.0 → nirspy-0.3.0}/src/nirspy/gui/components/tutorial.py +0 -0
  105. {nirspy-0.2.0 → nirspy-0.3.0}/src/nirspy/gui/pages/__init__.py +0 -0
  106. {nirspy-0.2.0 → nirspy-0.3.0}/src/nirspy/io/__init__.py +0 -0
  107. {nirspy-0.2.0 → nirspy-0.3.0}/src/nirspy/io/oxysoft_txt.py +0 -0
  108. {nirspy-0.2.0 → nirspy-0.3.0}/src/nirspy/io/pipeline_runner.py +0 -0
  109. {nirspy-0.2.0 → nirspy-0.3.0}/src/nirspy/io/pipeline_schema.json +0 -0
  110. {nirspy-0.2.0 → nirspy-0.3.0}/src/nirspy/io/yaml_serializer.py +0 -0
  111. {nirspy-0.2.0 → nirspy-0.3.0}/tests/__init__.py +0 -0
  112. {nirspy-0.2.0 → nirspy-0.3.0}/tests/blocks/__init__.py +0 -0
  113. {nirspy-0.2.0 → nirspy-0.3.0}/tests/blocks/conftest.py +0 -0
  114. {nirspy-0.2.0 → nirspy-0.3.0}/tests/blocks/test_manual_exclude.py +0 -0
  115. {nirspy-0.2.0 → nirspy-0.3.0}/tests/blocks/test_motion.py +0 -0
  116. {nirspy-0.2.0 → nirspy-0.3.0}/tests/blocks/test_preprocessing.py +0 -0
  117. {nirspy-0.2.0 → nirspy-0.3.0}/tests/blocks/test_prune_telemetry.py +0 -0
  118. {nirspy-0.2.0 → nirspy-0.3.0}/tests/blocks/test_quality.py +0 -0
  119. {nirspy-0.2.0 → nirspy-0.3.0}/tests/cli/__init__.py +0 -0
  120. {nirspy-0.2.0 → nirspy-0.3.0}/tests/cli/test_run.py +0 -0
  121. {nirspy-0.2.0 → nirspy-0.3.0}/tests/conftest.py +0 -0
  122. {nirspy-0.2.0 → nirspy-0.3.0}/tests/docs/__init__.py +0 -0
  123. {nirspy-0.2.0 → nirspy-0.3.0}/tests/docs/test_mkdocs_build.py +0 -0
  124. {nirspy-0.2.0 → nirspy-0.3.0}/tests/domain/__init__.py +0 -0
  125. {nirspy-0.2.0 → nirspy-0.3.0}/tests/domain/test_block.py +0 -0
  126. {nirspy-0.2.0 → nirspy-0.3.0}/tests/domain/test_cache.py +0 -0
  127. {nirspy-0.2.0 → nirspy-0.3.0}/tests/domain/test_data_types.py +0 -0
  128. {nirspy-0.2.0 → nirspy-0.3.0}/tests/domain/test_exceptions.py +0 -0
  129. {nirspy-0.2.0 → nirspy-0.3.0}/tests/domain/test_execution.py +0 -0
  130. {nirspy-0.2.0 → nirspy-0.3.0}/tests/domain/test_pipeline.py +0 -0
  131. {nirspy-0.2.0 → nirspy-0.3.0}/tests/domain/test_validation.py +0 -0
  132. {nirspy-0.2.0 → nirspy-0.3.0}/tests/engine/__init__.py +0 -0
  133. {nirspy-0.2.0 → nirspy-0.3.0}/tests/engine/test_average_epochs_empty.py +0 -0
  134. {nirspy-0.2.0 → nirspy-0.3.0}/tests/engine/test_cache_adapter.py +0 -0
  135. {nirspy-0.2.0 → nirspy-0.3.0}/tests/engine/test_filter_bads.py +0 -0
  136. {nirspy-0.2.0 → nirspy-0.3.0}/tests/engine/test_mne_adapter.py +0 -0
  137. {nirspy-0.2.0 → nirspy-0.3.0}/tests/engine/test_mne_adapter_motion.py +0 -0
  138. {nirspy-0.2.0 → nirspy-0.3.0}/tests/engine/test_ui_error_messages.py +0 -0
  139. {nirspy-0.2.0 → nirspy-0.3.0}/tests/gui/__init__.py +0 -0
  140. {nirspy-0.2.0 → nirspy-0.3.0}/tests/gui/test_app_factory.py +0 -0
  141. {nirspy-0.2.0 → nirspy-0.3.0}/tests/gui/test_condition_windows_editor.py +0 -0
  142. {nirspy-0.2.0 → nirspy-0.3.0}/tests/gui/test_converter_view.py +0 -0
  143. {nirspy-0.2.0 → nirspy-0.3.0}/tests/gui/test_error_messages.py +0 -0
  144. {nirspy-0.2.0 → nirspy-0.3.0}/tests/gui/test_execution.py +0 -0
  145. {nirspy-0.2.0 → nirspy-0.3.0}/tests/gui/test_hrf_plot.py +0 -0
  146. {nirspy-0.2.0 → nirspy-0.3.0}/tests/gui/test_param_editor_list.py +0 -0
  147. {nirspy-0.2.0 → nirspy-0.3.0}/tests/gui/test_param_editor_metadata.py +0 -0
  148. {nirspy-0.2.0 → nirspy-0.3.0}/tests/gui/test_security_5a.py +0 -0
  149. {nirspy-0.2.0 → nirspy-0.3.0}/tests/gui/test_smoke.py +0 -0
  150. {nirspy-0.2.0 → nirspy-0.3.0}/tests/gui/test_tutorial.py +0 -0
  151. {nirspy-0.2.0 → nirspy-0.3.0}/tests/integration/__init__.py +0 -0
  152. {nirspy-0.2.0 → nirspy-0.3.0}/tests/integration/test_pipeline_templates.py +0 -0
  153. {nirspy-0.2.0 → nirspy-0.3.0}/tests/io/__init__.py +0 -0
  154. {nirspy-0.2.0 → nirspy-0.3.0}/tests/io/test_converters_condnames.py +0 -0
  155. {nirspy-0.2.0 → nirspy-0.3.0}/tests/io/test_oxysoft_txt.py +0 -0
  156. {nirspy-0.2.0 → nirspy-0.3.0}/tests/io/test_pipeline_runner.py +0 -0
  157. {nirspy-0.2.0 → nirspy-0.3.0}/tests/io/test_yaml_serializer.py +0 -0
  158. {nirspy-0.2.0 → nirspy-0.3.0}/tests/test_smoke.py +0 -0
  159. {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.2.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
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "nirspy"
7
- version = "0.2.0"
7
+ version = "0.3.0"
8
8
  description = "GUI fNIRS-first em Python — builder modular de pipeline sobre MNE-NIRS"
9
9
  readme = "README.md"
10
10
  license = { file = "LICENSE" }
@@ -1,3 +1,3 @@
1
1
  """NIRSPY — NIRS Processing in Python."""
2
2
 
3
- __version__ = "0.2.0"
3
+ __version__ = "0.3.0"
@@ -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
- # Validate per_condition_windows keys exist in event_id
248
- if self.params.per_condition_windows:
249
- unknown = (
250
- set(self.params.per_condition_windows) - set(used_event_id)
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
- raise ValidationError(
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
- if self.params.per_condition_windows:
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=self.params.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: dict[str, Any] = {}
418
+ metadata = {}
289
419
 
290
420
  for cond in used_event_id:
291
- if cond in self.params.per_condition_windows:
292
- w = self.params.per_condition_windows[cond]
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 ExecutionContext, ProgressCallback, run_pipeline_sync
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