TwoPy 0.1.5__tar.gz → 0.1.7__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 (141) hide show
  1. {twopy-0.1.5 → twopy-0.1.7}/PKG-INFO +2 -2
  2. {twopy-0.1.5 → twopy-0.1.7}/README.md +1 -1
  3. {twopy-0.1.5 → twopy-0.1.7}/pyproject.toml +1 -1
  4. {twopy-0.1.5 → twopy-0.1.7}/src/twopy/analysis/persistence.py +15 -2
  5. {twopy-0.1.5 → twopy-0.1.7}/src/twopy/analysis/response_processing/__init__.py +8 -0
  6. {twopy-0.1.5 → twopy-0.1.7}/src/twopy/analysis/response_processing/apply.py +131 -2
  7. {twopy-0.1.5 → twopy-0.1.7}/src/twopy/analysis/response_processing/options.py +38 -1
  8. {twopy-0.1.5 → twopy-0.1.7}/src/twopy/analysis/response_processing/persistence.py +100 -2
  9. {twopy-0.1.5 → twopy-0.1.7}/src/twopy/analysis/responses.py +95 -0
  10. {twopy-0.1.5 → twopy-0.1.7}/src/twopy/analysis/workflow.py +12 -0
  11. twopy-0.1.7/src/twopy/analysis_cache.py +263 -0
  12. {twopy-0.1.5 → twopy-0.1.7}/src/twopy/config.py +138 -2
  13. {twopy-0.1.5 → twopy-0.1.7}/src/twopy/conversion/api.py +5 -4
  14. {twopy-0.1.5 → twopy-0.1.7}/src/twopy/database/__init__.py +16 -0
  15. {twopy-0.1.5 → twopy-0.1.7}/src/twopy/database/modeled.py +7 -0
  16. twopy-0.1.7/src/twopy/database/search.py +433 -0
  17. {twopy-0.1.5 → twopy-0.1.7}/src/twopy/napari/controls.py +231 -45
  18. twopy-0.1.7/src/twopy/napari/database_search.py +491 -0
  19. twopy-0.1.7/src/twopy/napari/errors.py +24 -0
  20. {twopy-0.1.5 → twopy-0.1.7}/src/twopy/napari/interactive.py +2 -2
  21. {twopy-0.1.5 → twopy-0.1.7}/src/twopy/napari/loading.py +198 -3
  22. {twopy-0.1.5 → twopy-0.1.7}/src/twopy/napari/plotting/data.py +52 -47
  23. {twopy-0.1.5 → twopy-0.1.7}/src/twopy/napari/plotting/docks/dynamic_options.py +7 -1
  24. {twopy-0.1.5 → twopy-0.1.7}/src/twopy/napari/plotting/docks/options_panel.py +15 -1
  25. {twopy-0.1.5 → twopy-0.1.7}/src/twopy/napari/plotting/docks/response_plot_widget.py +181 -23
  26. {twopy-0.1.5 → twopy-0.1.7}/src/twopy/napari/plotting/docks/save_actions.py +16 -0
  27. {twopy-0.1.5 → twopy-0.1.7}/src/twopy/napari/plotting/docks/visibility_state.py +1 -1
  28. {twopy-0.1.5 → twopy-0.1.7}/src/twopy/napari/plotting/export_controls.py +44 -2
  29. {twopy-0.1.5 → twopy-0.1.7}/src/twopy/napari/plotting/label_visibility.py +1 -37
  30. twopy-0.1.7/src/twopy/napari/plotting/normalization_options.py +216 -0
  31. {twopy-0.1.5 → twopy-0.1.7}/src/twopy/napari/plotting/panels.py +2 -4
  32. {twopy-0.1.5 → twopy-0.1.7}/src/twopy/napari/plotting/processing_options.py +16 -0
  33. {twopy-0.1.5 → twopy-0.1.7}/src/twopy/napari/plotting/widgets.py +23 -7
  34. {twopy-0.1.5 → twopy-0.1.7}/src/twopy/napari/roi.py +41 -0
  35. {twopy-0.1.5 → twopy-0.1.7}/src/twopy/napari/session.py +34 -2
  36. {twopy-0.1.5 → twopy-0.1.7}/src/twopy/roi.py +74 -3
  37. {twopy-0.1.5 → twopy-0.1.7}/src/twopy.egg-info/PKG-INFO +2 -2
  38. {twopy-0.1.5 → twopy-0.1.7}/src/twopy.egg-info/SOURCES.txt +5 -0
  39. {twopy-0.1.5 → twopy-0.1.7}/tests/test_config.py +113 -1
  40. {twopy-0.1.5 → twopy-0.1.7}/tests/test_conversion.py +38 -0
  41. {twopy-0.1.5 → twopy-0.1.7}/tests/test_database.py +204 -0
  42. {twopy-0.1.5 → twopy-0.1.7}/tests/test_napari.py +1024 -13
  43. {twopy-0.1.5 → twopy-0.1.7}/tests/test_persistence.py +32 -0
  44. {twopy-0.1.5 → twopy-0.1.7}/tests/test_response_processing.py +98 -0
  45. {twopy-0.1.5 → twopy-0.1.7}/tests/test_roi.py +65 -3
  46. {twopy-0.1.5 → twopy-0.1.7}/setup.cfg +0 -0
  47. {twopy-0.1.5 → twopy-0.1.7}/src/twopy/__init__.py +0 -0
  48. {twopy-0.1.5 → twopy-0.1.7}/src/twopy/_segments.py +0 -0
  49. {twopy-0.1.5 → twopy-0.1.7}/src/twopy/_version.py +0 -0
  50. {twopy-0.1.5 → twopy-0.1.7}/src/twopy/analysis/__init__.py +0 -0
  51. {twopy-0.1.5 → twopy-0.1.7}/src/twopy/analysis/background_subtraction.py +0 -0
  52. {twopy-0.1.5 → twopy-0.1.7}/src/twopy/analysis/dff.py +0 -0
  53. {twopy-0.1.5 → twopy-0.1.7}/src/twopy/analysis/dff_options.py +0 -0
  54. {twopy-0.1.5 → twopy-0.1.7}/src/twopy/analysis/epoch_mapping.py +0 -0
  55. {twopy-0.1.5 → twopy-0.1.7}/src/twopy/analysis/motion.py +0 -0
  56. {twopy-0.1.5 → twopy-0.1.7}/src/twopy/analysis/response_processing/qc.py +0 -0
  57. {twopy-0.1.5 → twopy-0.1.7}/src/twopy/analysis/response_processing/signals.py +0 -0
  58. {twopy-0.1.5 → twopy-0.1.7}/src/twopy/analysis/response_window_options.py +0 -0
  59. {twopy-0.1.5 → twopy-0.1.7}/src/twopy/analysis/trials.py +0 -0
  60. {twopy-0.1.5 → twopy-0.1.7}/src/twopy/api.py +0 -0
  61. {twopy-0.1.5 → twopy-0.1.7}/src/twopy/conversion/__init__.py +0 -0
  62. {twopy-0.1.5 → twopy-0.1.7}/src/twopy/conversion/alignment_crop.py +0 -0
  63. {twopy-0.1.5 → twopy-0.1.7}/src/twopy/conversion/hdf5_writing.py +0 -0
  64. {twopy-0.1.5 → twopy-0.1.7}/src/twopy/conversion/matlab_values.py +0 -0
  65. {twopy-0.1.5 → twopy-0.1.7}/src/twopy/conversion/source_loading.py +0 -0
  66. {twopy-0.1.5 → twopy-0.1.7}/src/twopy/conversion/stimulus_code.py +0 -0
  67. {twopy-0.1.5 → twopy-0.1.7}/src/twopy/conversion/types.py +0 -0
  68. {twopy-0.1.5 → twopy-0.1.7}/src/twopy/converted.py +0 -0
  69. {twopy-0.1.5 → twopy-0.1.7}/src/twopy/database/cache.py +0 -0
  70. {twopy-0.1.5 → twopy-0.1.7}/src/twopy/database/catalog.py +0 -0
  71. {twopy-0.1.5 → twopy-0.1.7}/src/twopy/database/connection.py +0 -0
  72. {twopy-0.1.5 → twopy-0.1.7}/src/twopy/database/generic.py +0 -0
  73. {twopy-0.1.5 → twopy-0.1.7}/src/twopy/database/sql.py +0 -0
  74. {twopy-0.1.5 → twopy-0.1.7}/src/twopy/database/types.py +0 -0
  75. {twopy-0.1.5 → twopy-0.1.7}/src/twopy/frame_ranges.py +0 -0
  76. {twopy-0.1.5 → twopy-0.1.7}/src/twopy/inspection.py +0 -0
  77. {twopy-0.1.5 → twopy-0.1.7}/src/twopy/matlab.py +0 -0
  78. {twopy-0.1.5 → twopy-0.1.7}/src/twopy/napari/__init__.py +0 -0
  79. {twopy-0.1.5 → twopy-0.1.7}/src/twopy/napari/__main__.py +0 -0
  80. {twopy-0.1.5 → twopy-0.1.7}/src/twopy/napari/constants.py +0 -0
  81. {twopy-0.1.5 → twopy-0.1.7}/src/twopy/napari/display.py +0 -0
  82. {twopy-0.1.5 → twopy-0.1.7}/src/twopy/napari/display_paths.py +0 -0
  83. {twopy-0.1.5 → twopy-0.1.7}/src/twopy/napari/launcher.py +0 -0
  84. {twopy-0.1.5 → twopy-0.1.7}/src/twopy/napari/movie.py +0 -0
  85. {twopy-0.1.5 → twopy-0.1.7}/src/twopy/napari/paths.py +0 -0
  86. {twopy-0.1.5 → twopy-0.1.7}/src/twopy/napari/plotting/__init__.py +0 -0
  87. {twopy-0.1.5 → twopy-0.1.7}/src/twopy/napari/plotting/dff_options.py +0 -0
  88. {twopy-0.1.5 → twopy-0.1.7}/src/twopy/napari/plotting/docks/__init__.py +0 -0
  89. {twopy-0.1.5 → twopy-0.1.7}/src/twopy/napari/plotting/docks/api.py +0 -0
  90. {twopy-0.1.5 → twopy-0.1.7}/src/twopy/napari/plotting/docks/paths.py +0 -0
  91. {twopy-0.1.5 → twopy-0.1.7}/src/twopy/napari/plotting/docks/plot_area.py +0 -0
  92. {twopy-0.1.5 → twopy-0.1.7}/src/twopy/napari/plotting/export.py +0 -0
  93. {twopy-0.1.5 → twopy-0.1.7}/src/twopy/napari/plotting/form_controls.py +0 -0
  94. {twopy-0.1.5 → twopy-0.1.7}/src/twopy/napari/plotting/options.py +0 -0
  95. {twopy-0.1.5 → twopy-0.1.7}/src/twopy/napari/plotting/response_window_options.py +0 -0
  96. {twopy-0.1.5 → twopy-0.1.7}/src/twopy/napari/protocols.py +0 -0
  97. {twopy-0.1.5 → twopy-0.1.7}/src/twopy/napari/responses.py +0 -0
  98. {twopy-0.1.5 → twopy-0.1.7}/src/twopy/napari/sidebar.py +0 -0
  99. {twopy-0.1.5 → twopy-0.1.7}/src/twopy/napari/state.py +0 -0
  100. {twopy-0.1.5 → twopy-0.1.7}/src/twopy/napari/text.py +0 -0
  101. {twopy-0.1.5 → twopy-0.1.7}/src/twopy/napari/types.py +0 -0
  102. {twopy-0.1.5 → twopy-0.1.7}/src/twopy/napari/viewer.py +0 -0
  103. {twopy-0.1.5 → twopy-0.1.7}/src/twopy/parity/__init__.py +0 -0
  104. {twopy-0.1.5 → twopy-0.1.7}/src/twopy/parity/dff.py +0 -0
  105. {twopy-0.1.5 → twopy-0.1.7}/src/twopy/parity/psycho5.py +0 -0
  106. {twopy-0.1.5 → twopy-0.1.7}/src/twopy/parity/roi.py +0 -0
  107. {twopy-0.1.5 → twopy-0.1.7}/src/twopy/parity/saved_analysis.py +0 -0
  108. {twopy-0.1.5 → twopy-0.1.7}/src/twopy/photodiode.py +0 -0
  109. {twopy-0.1.5 → twopy-0.1.7}/src/twopy/photodiode_classification.py +0 -0
  110. {twopy-0.1.5 → twopy-0.1.7}/src/twopy/session.py +0 -0
  111. {twopy-0.1.5 → twopy-0.1.7}/src/twopy/spatial.py +0 -0
  112. {twopy-0.1.5 → twopy-0.1.7}/src/twopy/stimulus.py +0 -0
  113. {twopy-0.1.5 → twopy-0.1.7}/src/twopy/synchronization.py +0 -0
  114. {twopy-0.1.5 → twopy-0.1.7}/src/twopy/typing_guards.py +0 -0
  115. {twopy-0.1.5 → twopy-0.1.7}/src/twopy.egg-info/dependency_links.txt +0 -0
  116. {twopy-0.1.5 → twopy-0.1.7}/src/twopy.egg-info/entry_points.txt +0 -0
  117. {twopy-0.1.5 → twopy-0.1.7}/src/twopy.egg-info/requires.txt +0 -0
  118. {twopy-0.1.5 → twopy-0.1.7}/src/twopy.egg-info/top_level.txt +0 -0
  119. {twopy-0.1.5 → twopy-0.1.7}/tests/test_api.py +0 -0
  120. {twopy-0.1.5 → twopy-0.1.7}/tests/test_background_subtraction.py +0 -0
  121. {twopy-0.1.5 → twopy-0.1.7}/tests/test_converted.py +0 -0
  122. {twopy-0.1.5 → twopy-0.1.7}/tests/test_dff.py +0 -0
  123. {twopy-0.1.5 → twopy-0.1.7}/tests/test_epoch_mapping.py +0 -0
  124. {twopy-0.1.5 → twopy-0.1.7}/tests/test_frame_ranges.py +0 -0
  125. {twopy-0.1.5 → twopy-0.1.7}/tests/test_import.py +0 -0
  126. {twopy-0.1.5 → twopy-0.1.7}/tests/test_inspection.py +0 -0
  127. {twopy-0.1.5 → twopy-0.1.7}/tests/test_matlab.py +0 -0
  128. {twopy-0.1.5 → twopy-0.1.7}/tests/test_motion.py +0 -0
  129. {twopy-0.1.5 → twopy-0.1.7}/tests/test_packaging.py +0 -0
  130. {twopy-0.1.5 → twopy-0.1.7}/tests/test_parity.py +0 -0
  131. {twopy-0.1.5 → twopy-0.1.7}/tests/test_parity_psycho5.py +0 -0
  132. {twopy-0.1.5 → twopy-0.1.7}/tests/test_photodiode_classification.py +0 -0
  133. {twopy-0.1.5 → twopy-0.1.7}/tests/test_real_fixture.py +0 -0
  134. {twopy-0.1.5 → twopy-0.1.7}/tests/test_response_window_options.py +0 -0
  135. {twopy-0.1.5 → twopy-0.1.7}/tests/test_responses.py +0 -0
  136. {twopy-0.1.5 → twopy-0.1.7}/tests/test_saved_analysis.py +0 -0
  137. {twopy-0.1.5 → twopy-0.1.7}/tests/test_session.py +0 -0
  138. {twopy-0.1.5 → twopy-0.1.7}/tests/test_stimulus.py +0 -0
  139. {twopy-0.1.5 → twopy-0.1.7}/tests/test_synchronization.py +0 -0
  140. {twopy-0.1.5 → twopy-0.1.7}/tests/test_trials.py +0 -0
  141. {twopy-0.1.5 → twopy-0.1.7}/tests/test_workflow.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: twopy
3
- Version: 0.1.5
3
+ Version: 0.1.7
4
4
  Summary: Two-photon imaging analysis tool with a napari interface.
5
5
  Author-email: Gustavo Madeira Santana <fromweb1@gumadeiras.com>
6
6
  License-Expression: MIT
@@ -28,7 +28,7 @@ Two-photon imaging analysis tool for the Clark Lab output format.
28
28
 
29
29
  twopy lets you open two-photon recordings, draw ROIs, plot responses in real time, process and analyze them, and save them.
30
30
 
31
- When you first load a recording, twopy converts it to a standardized HDF5 format. The converted format includes the aligned movie, mean image, stimulus tables, photodiode signals, and recording metadata. Analysis and the GUI both work from the converted files, so the original source files remain separate from twopy's outputs.
31
+ When you first load a recording, twopy converts it to a standardized HDF5 format. The converted format includes the aligned movie, mean image, stimulus tables, photodiode signals, and recording metadata. Analysis and the GUI both work from the converted files, so the original source files remain separate from twopy's outputs. By default, converted recordings and interactive analysis outputs are cached locally under `~/.cache/twopy/recordings` for speed; saving ROIs and analysis publishes the changed output files back to the configured `analysis_output` location in the background.
32
32
 
33
33
  ## Install
34
34
 
@@ -8,7 +8,7 @@ Two-photon imaging analysis tool for the Clark Lab output format.
8
8
 
9
9
  twopy lets you open two-photon recordings, draw ROIs, plot responses in real time, process and analyze them, and save them.
10
10
 
11
- When you first load a recording, twopy converts it to a standardized HDF5 format. The converted format includes the aligned movie, mean image, stimulus tables, photodiode signals, and recording metadata. Analysis and the GUI both work from the converted files, so the original source files remain separate from twopy's outputs.
11
+ When you first load a recording, twopy converts it to a standardized HDF5 format. The converted format includes the aligned movie, mean image, stimulus tables, photodiode signals, and recording metadata. Analysis and the GUI both work from the converted files, so the original source files remain separate from twopy's outputs. By default, converted recordings and interactive analysis outputs are cached locally under `~/.cache/twopy/recordings` for speed; saving ROIs and analysis publishes the changed output files back to the configured `analysis_output` location in the background.
12
12
 
13
13
  ## Install
14
14
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "twopy"
7
- version = "0.1.5"
7
+ version = "0.1.7"
8
8
  description = "Two-photon imaging analysis tool with a napari interface."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.13,<3.14"
@@ -26,6 +26,7 @@ from twopy.analysis.dff import RoiDeltaFOverF
26
26
  from twopy.analysis.response_processing import (
27
27
  ResponseProcessingOptions,
28
28
  RoiCorrelationScores,
29
+ RoiNormalizationFactors,
29
30
  )
30
31
  from twopy.analysis.response_processing.persistence import (
31
32
  read_response_processing_group,
@@ -99,6 +100,7 @@ class LoadedAnalysisOutputs:
99
100
  baseline_windows: tuple[FrameWindow, ...]
100
101
  grouped_responses: GroupedRoiResponses | None
101
102
  response_processing_options: ResponseProcessingOptions | None
103
+ normalization_factors: RoiNormalizationFactors | None
102
104
  correlation_scores: RoiCorrelationScores | None
103
105
 
104
106
 
@@ -112,6 +114,7 @@ def save_analysis_outputs(
112
114
  baseline_windows: Sequence[FrameWindow] = (),
113
115
  grouped_responses: GroupedRoiResponses | None = None,
114
116
  response_processing_options: ResponseProcessingOptions | None = None,
117
+ normalization_factors: RoiNormalizationFactors | None = None,
115
118
  correlation_scores: RoiCorrelationScores | None = None,
116
119
  response_summary_trials_csv: Path | None = None,
117
120
  response_summary_grouped_csv: Path | None = None,
@@ -128,6 +131,8 @@ def save_analysis_outputs(
128
131
  grouped_responses: Optional grouped trial responses.
129
132
  response_processing_options: Optional smoothing, low-pass, and
130
133
  correlation-QC settings used for these outputs.
134
+ normalization_factors: Optional per-ROI factors used for epoch-peak
135
+ normalization.
131
136
  correlation_scores: Optional ROI-level correlation QC scores.
132
137
  response_summary_trials_csv: Optional CSV path for one row per trial
133
138
  and ROI, with one column per relative response timepoint.
@@ -149,8 +154,10 @@ def save_analysis_outputs(
149
154
  if any(path is not None for path in summary_outputs) and grouped_responses is None:
150
155
  msg = "response summary CSV outputs require grouped_responses"
151
156
  raise ValueError(msg)
152
- if correlation_scores is not None and response_processing_options is None:
153
- msg = "correlation_scores require response_processing_options"
157
+ if (
158
+ correlation_scores is not None or normalization_factors is not None
159
+ ) and response_processing_options is None:
160
+ msg = "response-processing audit outputs require response_processing_options"
154
161
  raise ValueError(msg)
155
162
  if grouped_responses is not None:
156
163
  validate_grouped_roi_responses(grouped_responses)
@@ -181,6 +188,7 @@ def save_analysis_outputs(
181
188
  write_response_processing_group(
182
189
  h5_file.create_group("response_processing"),
183
190
  options=response_processing_options,
191
+ normalization_factors=normalization_factors,
184
192
  correlation_scores=correlation_scores,
185
193
  )
186
194
 
@@ -249,6 +257,11 @@ def load_analysis_outputs(path: Path) -> LoadedAnalysisOutputs:
249
257
  response_processing_options=(
250
258
  response_processing.options if response_processing is not None else None
251
259
  ),
260
+ normalization_factors=(
261
+ response_processing.normalization_factors
262
+ if response_processing is not None
263
+ else None
264
+ ),
252
265
  correlation_scores=(
253
266
  response_processing.correlation_scores
254
267
  if response_processing is not None
@@ -12,8 +12,10 @@ how to save the resulting audit trail.
12
12
 
13
13
  from twopy.analysis.response_processing.apply import (
14
14
  ResponseProcessingResult,
15
+ RoiNormalizationFactors,
15
16
  apply_correlation_filter_to_grouped_roi_responses,
16
17
  mask_grouped_roi_responses_by_included_rois,
18
+ normalize_grouped_roi_responses_by_epoch_peak,
17
19
  process_grouped_roi_responses,
18
20
  process_roi_delta_f_over_f,
19
21
  )
@@ -23,6 +25,8 @@ from twopy.analysis.response_processing.options import (
23
25
  CorrelationWindowSeconds,
24
26
  LowPassFilterMethod,
25
27
  LowPassFilterOptions,
28
+ NormalizationMethod,
29
+ NormalizationOptions,
26
30
  ResponseProcessingOptions,
27
31
  SmoothingMethod,
28
32
  SmoothingOptions,
@@ -45,15 +49,19 @@ __all__ = [
45
49
  "CorrelationWindowSeconds",
46
50
  "LowPassFilterMethod",
47
51
  "LowPassFilterOptions",
52
+ "NormalizationMethod",
53
+ "NormalizationOptions",
48
54
  "apply_correlation_filter_to_grouped_roi_responses",
49
55
  "mask_grouped_roi_responses_by_included_rois",
50
56
  "nan_aware_moving_average",
51
57
  "nan_aware_savgol_filter",
58
+ "normalize_grouped_roi_responses_by_epoch_peak",
52
59
  "process_grouped_roi_responses",
53
60
  "process_roi_delta_f_over_f",
54
61
  "ResponseProcessingOptions",
55
62
  "ResponseProcessingResult",
56
63
  "RoiCorrelationScores",
64
+ "RoiNormalizationFactors",
57
65
  "score_roi_correlations",
58
66
  "SmoothingMethod",
59
67
  "SmoothingOptions",
@@ -15,6 +15,7 @@ import numpy.typing as npt
15
15
 
16
16
  from twopy.analysis.dff import RoiDeltaFOverF
17
17
  from twopy.analysis.response_processing.options import (
18
+ NormalizationOptions,
18
19
  ResponseProcessingOptions,
19
20
  validate_response_processing_options,
20
21
  )
@@ -30,17 +31,36 @@ from twopy.analysis.response_processing.signals import (
30
31
  from twopy.analysis.responses import (
31
32
  GroupedRoiResponses,
32
33
  RoiResponseTrial,
34
+ summarize_roi_response_trials,
33
35
  validate_grouped_roi_responses,
34
36
  )
35
37
 
36
38
  __all__ = [
37
39
  "apply_correlation_filter_to_grouped_roi_responses",
40
+ "normalize_grouped_roi_responses_by_epoch_peak",
38
41
  "mask_grouped_roi_responses_by_included_rois",
42
+ "RoiNormalizationFactors",
39
43
  "ResponseProcessingResult",
40
44
  "process_grouped_roi_responses",
41
45
  "process_roi_delta_f_over_f",
42
46
  ]
43
47
 
48
+ _NORMALIZATION_MIN_PEAK = 1e-12
49
+
50
+
51
+ @dataclass(frozen=True)
52
+ class RoiNormalizationFactors:
53
+ """Per-ROI factors used for epoch-peak response normalization.
54
+
55
+ Inputs: grouped response trials and one enabled normalization option.
56
+ Outputs: the positive peak factor for each ROI in the selected epoch.
57
+ """
58
+
59
+ roi_labels: tuple[str, ...]
60
+ factors: npt.NDArray[np.float64]
61
+ epoch_number: int
62
+ epoch_name: str | None
63
+
44
64
 
45
65
  @dataclass(frozen=True)
46
66
  class ResponseProcessingResult:
@@ -53,6 +73,7 @@ class ResponseProcessingResult:
53
73
 
54
74
  grouped_responses: GroupedRoiResponses
55
75
  options: ResponseProcessingOptions
76
+ normalization_factors: RoiNormalizationFactors | None
56
77
  correlation_scores: RoiCorrelationScores | None
57
78
 
58
79
 
@@ -66,8 +87,8 @@ def process_roi_delta_f_over_f(
66
87
 
67
88
  Args:
68
89
  dff: Continuous ROI dF/F result.
69
- options: Processing options. Correlation filtering is ignored here
70
- because it needs grouped repeated-trial responses.
90
+ options: Processing options. Normalization and correlation filtering
91
+ are ignored here because they need grouped repeated-trial responses.
71
92
  data_rate_hz: Sampling rate in hertz.
72
93
 
73
94
  Returns:
@@ -129,6 +150,10 @@ def process_grouped_roi_responses(
129
150
  validate_response_processing_options(options, data_rate_hz=grouped.data_rate_hz)
130
151
  validate_grouped_roi_responses(grouped)
131
152
  processed = _copy_grouped_with_processed_values(grouped, options=options)
153
+ processed, normalization_factors = normalize_grouped_roi_responses_by_epoch_peak(
154
+ processed,
155
+ options=options.normalization,
156
+ )
132
157
  processed, correlation_scores = apply_correlation_filter_to_grouped_roi_responses(
133
158
  processed,
134
159
  options=options,
@@ -136,10 +161,57 @@ def process_grouped_roi_responses(
136
161
  return ResponseProcessingResult(
137
162
  grouped_responses=processed,
138
163
  options=options,
164
+ normalization_factors=normalization_factors,
139
165
  correlation_scores=correlation_scores,
140
166
  )
141
167
 
142
168
 
169
+ def normalize_grouped_roi_responses_by_epoch_peak(
170
+ grouped: GroupedRoiResponses,
171
+ *,
172
+ options: NormalizationOptions,
173
+ ) -> tuple[GroupedRoiResponses, RoiNormalizationFactors | None]:
174
+ """Normalize grouped responses by each ROI's selected-epoch peak.
175
+
176
+ Args:
177
+ grouped: Grouped ROI responses to normalize.
178
+ options: Normalization settings.
179
+
180
+ Returns:
181
+ ``(grouped_responses, factors)``. ``factors`` is ``None`` when
182
+ normalization is disabled.
183
+
184
+ Raises:
185
+ ValueError: If the selected epoch is absent or any ROI lacks a positive
186
+ finite peak in that epoch.
187
+ """
188
+ validate_grouped_roi_responses(grouped)
189
+ if options.method not in {"none", "epoch_peak"}:
190
+ msg = f"Unknown normalization method {options.method!r}"
191
+ raise ValueError(msg)
192
+ if options.method == "none":
193
+ return grouped, None
194
+ if options.epoch_number is None:
195
+ msg = "epoch-peak normalization requires an epoch_number"
196
+ raise ValueError(msg)
197
+ factors = _normalization_factors(grouped, options=options)
198
+ trials = tuple(
199
+ _copy_trial_with_values(trial, trial.values / factors.factors)
200
+ for trial in grouped.trials
201
+ )
202
+ return (
203
+ GroupedRoiResponses(
204
+ roi_labels=grouped.roi_labels,
205
+ data_rate_hz=grouped.data_rate_hz,
206
+ trials=trials,
207
+ pre_window_seconds=grouped.pre_window_seconds,
208
+ post_window_seconds=grouped.post_window_seconds,
209
+ response_window_auto=grouped.response_window_auto,
210
+ ),
211
+ factors,
212
+ )
213
+
214
+
143
215
  def apply_correlation_filter_to_grouped_roi_responses(
144
216
  grouped: GroupedRoiResponses,
145
217
  *,
@@ -259,6 +331,52 @@ def _copy_grouped_with_processed_values(
259
331
  )
260
332
 
261
333
 
334
+ def _normalization_factors(
335
+ grouped: GroupedRoiResponses,
336
+ *,
337
+ options: NormalizationOptions,
338
+ ) -> RoiNormalizationFactors:
339
+ """Return per-ROI selected-epoch peak factors."""
340
+ selected_trials = tuple(
341
+ trial
342
+ for trial in grouped.trials
343
+ if trial.epoch_number == options.epoch_number
344
+ and (options.epoch_name is None or trial.epoch_name == options.epoch_name)
345
+ )
346
+ if len(selected_trials) == 0:
347
+ label = (
348
+ f"{options.epoch_number}: {options.epoch_name}"
349
+ if options.epoch_name is not None
350
+ else str(options.epoch_number)
351
+ )
352
+ msg = f"No trials matched normalization epoch {label}"
353
+ raise ValueError(msg)
354
+ summary = summarize_roi_response_trials(
355
+ selected_trials,
356
+ roi_labels=grouped.roi_labels,
357
+ data_rate_hz=grouped.data_rate_hz,
358
+ )
359
+ factors = np.nanmax(summary.mean_values, axis=1)
360
+ bad_indices = tuple(
361
+ index
362
+ for index, factor in enumerate(factors)
363
+ if not np.isfinite(factor) or factor <= _NORMALIZATION_MIN_PEAK
364
+ )
365
+ if len(bad_indices) > 0:
366
+ bad_labels = ", ".join(grouped.roi_labels[index] for index in bad_indices)
367
+ msg = (
368
+ "Normalization epoch peak must be positive for every ROI; "
369
+ f"invalid ROI labels: {bad_labels}"
370
+ )
371
+ raise ValueError(msg)
372
+ return RoiNormalizationFactors(
373
+ roi_labels=grouped.roi_labels,
374
+ factors=factors.astype(np.float64, copy=False),
375
+ epoch_number=int(options.epoch_number or 0),
376
+ epoch_name=options.epoch_name,
377
+ )
378
+
379
+
262
380
  def _mask_excluded_rois(
263
381
  grouped: GroupedRoiResponses,
264
382
  *,
@@ -334,6 +452,17 @@ def _processing_metadata(
334
452
  else float(options.low_pass.cutoff_hz)
335
453
  ),
336
454
  "response_processing_low_pass_order": options.low_pass.order,
455
+ "response_processing_normalization_method": options.normalization.method,
456
+ "response_processing_normalization_epoch_number": (
457
+ "none"
458
+ if options.normalization.epoch_number is None
459
+ else int(options.normalization.epoch_number)
460
+ ),
461
+ "response_processing_normalization_epoch_name": (
462
+ "none"
463
+ if options.normalization.epoch_name is None
464
+ else options.normalization.epoch_name
465
+ ),
337
466
  "response_processing_correlation_reference": (
338
467
  options.correlation_filter.reference
339
468
  ),
@@ -19,6 +19,8 @@ __all__ = [
19
19
  "CorrelationWindowSeconds",
20
20
  "LowPassFilterMethod",
21
21
  "LowPassFilterOptions",
22
+ "NormalizationMethod",
23
+ "NormalizationOptions",
22
24
  "ResponseProcessingOptions",
23
25
  "SmoothingMethod",
24
26
  "SmoothingOptions",
@@ -27,6 +29,7 @@ __all__ = [
27
29
 
28
30
  SmoothingMethod = Literal["none", "moving_average", "savgol"]
29
31
  LowPassFilterMethod = Literal["none", "butterworth"]
32
+ NormalizationMethod = Literal["none", "epoch_peak"]
30
33
  CorrelationFilterReference = Literal["none", "epoch_mean", "epoch_peak"]
31
34
  CorrelationWindowSeconds = tuple[float | None, float | None]
32
35
 
@@ -66,6 +69,24 @@ class LowPassFilterOptions:
66
69
  order: int = 2
67
70
 
68
71
 
72
+ @dataclass(frozen=True)
73
+ class NormalizationOptions:
74
+ """Epoch-peak normalization settings for grouped ROI responses.
75
+
76
+ Inputs: a normalization method and the stimulus epoch used as the scale
77
+ reference.
78
+ Outputs: a typed contract consumed after trial grouping.
79
+
80
+ ``method="none"`` leaves grouped responses unchanged. ``method="epoch_peak"``
81
+ divides every response for each ROI by that ROI's mean-response peak in the
82
+ selected epoch, so the selected epoch's plotted peak becomes one.
83
+ """
84
+
85
+ method: NormalizationMethod = "none"
86
+ epoch_number: int | None = None
87
+ epoch_name: str | None = None
88
+
89
+
69
90
  @dataclass(frozen=True)
70
91
  class CorrelationFilterOptions:
71
92
  """Correlation-based response quality-control settings.
@@ -91,13 +112,15 @@ class CorrelationFilterOptions:
91
112
  class ResponseProcessingOptions:
92
113
  """Complete response post-processing configuration.
93
114
 
94
- Inputs: optional smoothing, low-pass filtering, and correlation QC settings.
115
+ Inputs: optional smoothing, low-pass filtering, epoch-peak normalization,
116
+ and correlation QC settings.
95
117
  Outputs: one immutable object passed through workflow, plotting, and future
96
118
  persistence boundaries.
97
119
  """
98
120
 
99
121
  smoothing: SmoothingOptions = field(default_factory=SmoothingOptions)
100
122
  low_pass: LowPassFilterOptions = field(default_factory=LowPassFilterOptions)
123
+ normalization: NormalizationOptions = field(default_factory=NormalizationOptions)
101
124
  correlation_filter: CorrelationFilterOptions = field(
102
125
  default_factory=CorrelationFilterOptions
103
126
  )
@@ -123,6 +146,7 @@ def validate_response_processing_options(
123
146
  """
124
147
  _validate_smoothing_options(options.smoothing)
125
148
  _validate_low_pass_options(options.low_pass, data_rate_hz=data_rate_hz)
149
+ _validate_normalization_options(options.normalization)
126
150
  _validate_correlation_options(options.correlation_filter)
127
151
 
128
152
 
@@ -186,6 +210,19 @@ def _validate_low_pass_options(
186
210
  raise ValueError(msg)
187
211
 
188
212
 
213
+ def _validate_normalization_options(options: NormalizationOptions) -> None:
214
+ """Validate epoch-peak normalization options."""
215
+ if options.method not in {"none", "epoch_peak"}:
216
+ msg = f"Unknown normalization method {options.method!r}"
217
+ raise ValueError(msg)
218
+ if options.epoch_number is not None and options.epoch_number < 1:
219
+ msg = f"normalization epoch_number must be positive; got {options.epoch_number}"
220
+ raise ValueError(msg)
221
+ if options.method == "epoch_peak" and options.epoch_number is None:
222
+ msg = "epoch-peak normalization requires an epoch_number"
223
+ raise ValueError(msg)
224
+
225
+
189
226
  def _validate_correlation_options(options: CorrelationFilterOptions) -> None:
190
227
  """Validate correlation-filter options."""
191
228
  if options.reference not in {"none", "epoch_mean", "epoch_peak"}:
@@ -1,6 +1,7 @@
1
1
  """Persist response-processing settings and QC audit data.
2
2
 
3
- Inputs: typed response-processing options and optional ROI correlation scores.
3
+ Inputs: typed response-processing options plus optional normalization and ROI
4
+ correlation audit data.
4
5
  Outputs: one inspectable HDF5 group that analysis persistence can embed.
5
6
 
6
7
  This module keeps the response-processing audit contract near the processing
@@ -12,12 +13,15 @@ from dataclasses import dataclass
12
13
  import h5py
13
14
  import numpy as np
14
15
 
16
+ from twopy.analysis.response_processing.apply import RoiNormalizationFactors
15
17
  from twopy.analysis.response_processing.options import (
16
18
  CorrelationFilterOptions,
17
19
  CorrelationFilterReference,
18
20
  CorrelationWindowSeconds,
19
21
  LowPassFilterMethod,
20
22
  LowPassFilterOptions,
23
+ NormalizationMethod,
24
+ NormalizationOptions,
21
25
  ResponseProcessingOptions,
22
26
  SmoothingMethod,
23
27
  SmoothingOptions,
@@ -41,6 +45,7 @@ _SMOOTHING_METHODS: tuple[SmoothingMethod, ...] = (
41
45
  "savgol",
42
46
  )
43
47
  _LOW_PASS_METHODS: tuple[LowPassFilterMethod, ...] = ("none", "butterworth")
48
+ _NORMALIZATION_METHODS: tuple[NormalizationMethod, ...] = ("none", "epoch_peak")
44
49
  _CORRELATION_REFERENCES: tuple[CorrelationFilterReference, ...] = (
45
50
  "none",
46
51
  "epoch_mean",
@@ -53,10 +58,12 @@ class PersistedResponseProcessing:
53
58
  """Response-processing audit data loaded from HDF5.
54
59
 
55
60
  Inputs: one ``response_processing`` HDF5 group.
56
- Outputs: typed options plus optional ROI correlation scores.
61
+ Outputs: typed options plus optional normalization factors and ROI
62
+ correlation scores.
57
63
  """
58
64
 
59
65
  options: ResponseProcessingOptions
66
+ normalization_factors: RoiNormalizationFactors | None
60
67
  correlation_scores: RoiCorrelationScores | None
61
68
 
62
69
 
@@ -64,6 +71,7 @@ def write_response_processing_group(
64
71
  group: h5py.Group,
65
72
  *,
66
73
  options: ResponseProcessingOptions,
74
+ normalization_factors: RoiNormalizationFactors | None = None,
67
75
  correlation_scores: RoiCorrelationScores | None = None,
68
76
  ) -> None:
69
77
  """Write response-processing settings and optional QC scores.
@@ -71,6 +79,8 @@ def write_response_processing_group(
71
79
  Args:
72
80
  group: Destination ``response_processing`` HDF5 group.
73
81
  options: Processing options used for the saved analysis output.
82
+ normalization_factors: Optional per-ROI normalization factors used for
83
+ epoch-peak normalization.
74
84
  correlation_scores: Optional ROI-level correlation QC result.
75
85
 
76
86
  Returns:
@@ -86,6 +96,24 @@ def write_response_processing_group(
86
96
  low_pass.attrs["cutoff_hz"] = _optional_float_attr(options.low_pass.cutoff_hz)
87
97
  low_pass.attrs["order"] = options.low_pass.order
88
98
 
99
+ normalization = group.create_group("normalization")
100
+ normalization.attrs["method"] = options.normalization.method
101
+ normalization.attrs["epoch_number"] = _optional_int_attr(
102
+ options.normalization.epoch_number,
103
+ )
104
+ normalization.attrs["epoch_name"] = _optional_text_attr(
105
+ options.normalization.epoch_name,
106
+ )
107
+ if normalization_factors is not None:
108
+ normalization.create_dataset(
109
+ "roi_labels",
110
+ data=np.asarray(
111
+ normalization_factors.roi_labels,
112
+ dtype=h5py.string_dtype("utf-8"),
113
+ ),
114
+ )
115
+ normalization.create_dataset("factors", data=normalization_factors.factors)
116
+
89
117
  correlation = group.create_group("correlation_filter")
90
118
  correlation.attrs["reference"] = options.correlation_filter.reference
91
119
  correlation.attrs["minimum_correlation"] = (
@@ -136,12 +164,25 @@ def read_response_processing_group(group: h5py.Group) -> PersistedResponseProces
136
164
  options = ResponseProcessingOptions(
137
165
  smoothing=_read_smoothing_options(group["smoothing"]),
138
166
  low_pass=_read_low_pass_options(group["low_pass"]),
167
+ normalization=(
168
+ _read_normalization_options(group["normalization"])
169
+ if "normalization" in group
170
+ else NormalizationOptions()
171
+ ),
139
172
  correlation_filter=_read_correlation_filter_options(
140
173
  group["correlation_filter"],
141
174
  ),
142
175
  )
143
176
  return PersistedResponseProcessing(
144
177
  options=options,
178
+ normalization_factors=(
179
+ _read_normalization_factors(
180
+ group["normalization"],
181
+ options=options.normalization,
182
+ )
183
+ if "normalization" in group and "factors" in group["normalization"]
184
+ else None
185
+ ),
145
186
  correlation_scores=(
146
187
  _read_correlation_scores(group["correlation_scores"])
147
188
  if "correlation_scores" in group
@@ -176,6 +217,19 @@ def _read_low_pass_options(group: h5py.Group) -> LowPassFilterOptions:
176
217
  )
177
218
 
178
219
 
220
+ def _read_normalization_options(group: h5py.Group) -> NormalizationOptions:
221
+ """Read normalization settings from one HDF5 group."""
222
+ return NormalizationOptions(
223
+ method=require_string_choice(
224
+ _plain_attr_value(group.attrs["method"]),
225
+ name="response_processing normalization method",
226
+ allowed=_NORMALIZATION_METHODS,
227
+ ),
228
+ epoch_number=_optional_int_from_attr(group.attrs["epoch_number"]),
229
+ epoch_name=_optional_text_from_attr(group.attrs["epoch_name"]),
230
+ )
231
+
232
+
179
233
  def _read_correlation_filter_options(group: h5py.Group) -> CorrelationFilterOptions:
180
234
  """Read correlation-filter settings from one HDF5 group."""
181
235
  return CorrelationFilterOptions(
@@ -189,6 +243,24 @@ def _read_correlation_filter_options(group: h5py.Group) -> CorrelationFilterOpti
189
243
  )
190
244
 
191
245
 
246
+ def _read_normalization_factors(
247
+ group: h5py.Group,
248
+ *,
249
+ options: NormalizationOptions,
250
+ ) -> RoiNormalizationFactors:
251
+ """Read persisted epoch-peak normalization factors."""
252
+ return RoiNormalizationFactors(
253
+ roi_labels=_read_string_dataset(group, "roi_labels"),
254
+ factors=require_float64_array(
255
+ group["factors"][()],
256
+ name="response_processing/normalization/factors",
257
+ ndim=1,
258
+ ),
259
+ epoch_number=int(options.epoch_number or 0),
260
+ epoch_name=options.epoch_name,
261
+ )
262
+
263
+
192
264
  def _read_correlation_scores(group: h5py.Group) -> RoiCorrelationScores:
193
265
  """Read persisted ROI correlation QC scores."""
194
266
  return RoiCorrelationScores(
@@ -226,6 +298,16 @@ def _optional_float_attr(value: float | None) -> str | float:
226
298
  return "none" if value is None else float(value)
227
299
 
228
300
 
301
+ def _optional_int_attr(value: int | None) -> str | int:
302
+ """Return an HDF5-friendly optional integer attribute value."""
303
+ return "none" if value is None else int(value)
304
+
305
+
306
+ def _optional_text_attr(value: str | None) -> str:
307
+ """Return an HDF5-friendly optional text attribute value."""
308
+ return "none" if value is None else value
309
+
310
+
229
311
  def _optional_float_from_attr(value: object) -> float | None:
230
312
  """Read an optional float value written by ``_optional_float_attr``."""
231
313
  plain_value = _plain_attr_value(value)
@@ -234,6 +316,22 @@ def _optional_float_from_attr(value: object) -> float | None:
234
316
  return float(plain_value)
235
317
 
236
318
 
319
+ def _optional_int_from_attr(value: object) -> int | None:
320
+ """Read an optional integer value written by ``_optional_int_attr``."""
321
+ plain_value = _plain_attr_value(value)
322
+ if plain_value == "none":
323
+ return None
324
+ return int(plain_value)
325
+
326
+
327
+ def _optional_text_from_attr(value: object) -> str | None:
328
+ """Read an optional text value written by ``_optional_text_attr``."""
329
+ plain_value = _plain_attr_value(value)
330
+ if plain_value == "none":
331
+ return None
332
+ return str(plain_value)
333
+
334
+
237
335
  def _read_string_dataset(group: h5py.Group, name: str) -> tuple[str, ...]:
238
336
  """Read a UTF-8 string dataset from HDF5."""
239
337
  return tuple(_decode_string(value) for value in group[name][()])