TwoPy 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. twopy/__init__.py +168 -0
  2. twopy/_segments.py +53 -0
  3. twopy/analysis/__init__.py +101 -0
  4. twopy/analysis/background_subtraction.py +672 -0
  5. twopy/analysis/dff.py +524 -0
  6. twopy/analysis/epoch_mapping.py +223 -0
  7. twopy/analysis/motion.py +66 -0
  8. twopy/analysis/persistence.py +975 -0
  9. twopy/analysis/response_processing/__init__.py +55 -0
  10. twopy/analysis/response_processing/apply.py +298 -0
  11. twopy/analysis/response_processing/options.py +192 -0
  12. twopy/analysis/response_processing/qc.py +270 -0
  13. twopy/analysis/response_processing/signals.py +200 -0
  14. twopy/analysis/responses.py +345 -0
  15. twopy/analysis/trials.py +341 -0
  16. twopy/analysis/workflow.py +457 -0
  17. twopy/api.py +78 -0
  18. twopy/config.py +200 -0
  19. twopy/conversion/__init__.py +40 -0
  20. twopy/conversion/alignment_crop.py +329 -0
  21. twopy/conversion/api.py +113 -0
  22. twopy/conversion/frame_ranges.py +35 -0
  23. twopy/conversion/hdf5_writing.py +362 -0
  24. twopy/conversion/matlab_values.py +123 -0
  25. twopy/conversion/source_loading.py +403 -0
  26. twopy/conversion/stimulus_code.py +248 -0
  27. twopy/conversion/types.py +295 -0
  28. twopy/converted.py +681 -0
  29. twopy/database/__init__.py +33 -0
  30. twopy/database/cache.py +166 -0
  31. twopy/database/catalog.py +90 -0
  32. twopy/database/connection.py +27 -0
  33. twopy/database/generic.py +254 -0
  34. twopy/database/modeled.py +419 -0
  35. twopy/database/sql.py +46 -0
  36. twopy/database/types.py +116 -0
  37. twopy/inspection.py +492 -0
  38. twopy/matlab.py +398 -0
  39. twopy/napari/__init__.py +27 -0
  40. twopy/napari/__main__.py +10 -0
  41. twopy/napari/constants.py +13 -0
  42. twopy/napari/controls.py +739 -0
  43. twopy/napari/display.py +181 -0
  44. twopy/napari/display_paths.py +197 -0
  45. twopy/napari/interactive.py +310 -0
  46. twopy/napari/launcher.py +175 -0
  47. twopy/napari/layout.py +147 -0
  48. twopy/napari/loading.py +207 -0
  49. twopy/napari/movie.py +71 -0
  50. twopy/napari/paths.py +181 -0
  51. twopy/napari/plotting/__init__.py +21 -0
  52. twopy/napari/plotting/data.py +266 -0
  53. twopy/napari/plotting/docks.py +1052 -0
  54. twopy/napari/plotting/export.py +803 -0
  55. twopy/napari/plotting/export_controls.py +209 -0
  56. twopy/napari/plotting/label_visibility.py +260 -0
  57. twopy/napari/plotting/options.py +269 -0
  58. twopy/napari/plotting/panels.py +119 -0
  59. twopy/napari/plotting/widgets.py +767 -0
  60. twopy/napari/protocols.py +90 -0
  61. twopy/napari/responses.py +91 -0
  62. twopy/napari/roi.py +238 -0
  63. twopy/napari/session.py +381 -0
  64. twopy/napari/state.py +106 -0
  65. twopy/napari/text.py +26 -0
  66. twopy/napari/types.py +39 -0
  67. twopy/napari/viewer.py +405 -0
  68. twopy/parity/__init__.py +30 -0
  69. twopy/parity/dff.py +399 -0
  70. twopy/parity/roi.py +145 -0
  71. twopy/parity/saved_analysis.py +174 -0
  72. twopy/photodiode.py +375 -0
  73. twopy/photodiode_classification.py +484 -0
  74. twopy/roi.py +462 -0
  75. twopy/session.py +167 -0
  76. twopy/spatial.py +154 -0
  77. twopy/stimulus.py +233 -0
  78. twopy/synchronization.py +235 -0
  79. twopy/typing_guards.py +255 -0
  80. twopy-0.1.0.dist-info/METADATA +311 -0
  81. twopy-0.1.0.dist-info/RECORD +84 -0
  82. twopy-0.1.0.dist-info/WHEEL +5 -0
  83. twopy-0.1.0.dist-info/entry_points.txt +2 -0
  84. twopy-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,181 @@
1
+ """Display-coordinate helpers for the twopy napari adapter.
2
+
3
+ Inputs: movie-coordinate images, label images, and spatial crops.
4
+ Outputs: napari-display arrays and full-frame movie-coordinate ROI labels.
5
+
6
+ twopy stores converted movie data as ``(frame, axis0, axis1)`` because those are
7
+ the HDF5 array axes. napari displays two-dimensional images as
8
+ ``(row, column)``. These helpers keep that transpose explicit so ROI pixels
9
+ drawn in napari can be converted back to analysis coordinates without guessing.
10
+ """
11
+
12
+ from typing import Any, Protocol, cast
13
+
14
+ import numpy as np
15
+ import numpy.typing as npt
16
+
17
+ from twopy.spatial import SpatialCrop
18
+ from twopy.typing_guards import (
19
+ int_or_none,
20
+ int_tuple_or_none,
21
+ string_key_mapping_or_none,
22
+ )
23
+
24
+ DISPLAY_CROP_METADATA_KEY = "twopy_display_spatial_crop"
25
+
26
+ __all__ = [
27
+ "display_image_from_movie_image",
28
+ "display_labels_from_movie_labels",
29
+ "display_metadata_for_spatial_crop",
30
+ "movie_labels_from_display_layer",
31
+ ]
32
+
33
+
34
+ class _LayerWithData(Protocol):
35
+ """Small protocol for napari-like layers with array data."""
36
+
37
+ data: object
38
+
39
+
40
+ class _LayerWithMetadata(Protocol):
41
+ """Small protocol for napari-like layers with metadata."""
42
+
43
+ metadata: object
44
+
45
+
46
+ class _LayerWithOptions(Protocol):
47
+ """Small protocol for fake test layers that store napari kwargs."""
48
+
49
+ options: dict[str, object]
50
+
51
+
52
+ def display_image_from_movie_image(
53
+ image: npt.NDArray[Any],
54
+ ) -> npt.NDArray[Any]:
55
+ """Convert a movie-coordinate image or movie block for napari display.
56
+
57
+ Args:
58
+ image: Two-dimensional ``(axis0, axis1)`` image or three-dimensional
59
+ ``(frame, axis0, axis1)`` movie block.
60
+
61
+ Returns:
62
+ View with the two spatial axes swapped for napari display.
63
+ """
64
+ values = np.asarray(image)
65
+ if values.ndim not in (2, 3):
66
+ msg = f"Expected a 2-D image or 3-D movie block; got {values.shape}"
67
+ raise ValueError(msg)
68
+ return np.swapaxes(values, -1, -2)
69
+
70
+
71
+ def display_labels_from_movie_labels(
72
+ label_image: npt.NDArray[np.int64],
73
+ spatial_crop: SpatialCrop,
74
+ ) -> npt.NDArray[np.int64]:
75
+ """Convert full-frame movie-coordinate labels into napari display labels.
76
+
77
+ Args:
78
+ label_image: Full-frame integer labels in movie coordinates.
79
+ spatial_crop: Spatial crop displayed in napari.
80
+
81
+ Returns:
82
+ Display-coordinate label image for the crop.
83
+
84
+ The viewer shows the same alignment-valid crop used by default response
85
+ analysis. Cropping here prevents users from drawing ROIs in pixels that the
86
+ default analysis path will reject later.
87
+ """
88
+ cropped_labels = spatial_crop.crop_image(label_image)
89
+ return display_image_from_movie_image(cropped_labels)
90
+
91
+
92
+ def movie_labels_from_display_layer(layer: object) -> npt.NDArray[np.int64]:
93
+ """Read napari display labels and return full-frame movie labels.
94
+
95
+ Args:
96
+ layer: napari Labels layer, or a test object with ``data`` and optional
97
+ crop metadata.
98
+
99
+ Returns:
100
+ Full-frame integer label image in movie coordinates.
101
+ """
102
+ display_labels = np.asarray(cast(_LayerWithData, layer).data, dtype=np.int64)
103
+ crop = _spatial_crop_from_layer_metadata(layer)
104
+ if crop is None:
105
+ return cast(npt.NDArray[np.int64], display_labels)
106
+
107
+ movie_crop_labels = display_image_from_movie_image(display_labels)
108
+ full_frame = np.zeros(crop.original_shape, dtype=np.int64)
109
+ full_frame[
110
+ crop.axis0_start : crop.axis0_stop,
111
+ crop.axis1_start : crop.axis1_stop,
112
+ ] = movie_crop_labels
113
+ return full_frame
114
+
115
+
116
+ def display_metadata_for_spatial_crop(spatial_crop: SpatialCrop) -> dict[str, object]:
117
+ """Return napari layer metadata that records the displayed movie crop.
118
+
119
+ Args:
120
+ spatial_crop: Crop shown in napari display coordinates.
121
+
122
+ Returns:
123
+ Plain dictionary that can be stored in ``layer.metadata``.
124
+ """
125
+ return {
126
+ DISPLAY_CROP_METADATA_KEY: {
127
+ "axis0_start": spatial_crop.axis0_start,
128
+ "axis0_stop": spatial_crop.axis0_stop,
129
+ "axis1_start": spatial_crop.axis1_start,
130
+ "axis1_stop": spatial_crop.axis1_stop,
131
+ "original_shape": spatial_crop.original_shape,
132
+ "source": spatial_crop.source,
133
+ },
134
+ }
135
+
136
+
137
+ def _spatial_crop_from_layer_metadata(layer: object) -> SpatialCrop | None:
138
+ """Return the crop recorded on a napari layer, if present.
139
+
140
+ Args:
141
+ layer: napari layer or test layer.
142
+
143
+ Returns:
144
+ ``SpatialCrop`` or ``None`` when the layer has no twopy crop metadata.
145
+ """
146
+ metadata = (
147
+ cast(_LayerWithMetadata, layer).metadata if hasattr(layer, "metadata") else None
148
+ )
149
+ if metadata is None and hasattr(layer, "options"):
150
+ metadata = cast(_LayerWithOptions, layer).options.get("metadata")
151
+ metadata_dict = string_key_mapping_or_none(metadata)
152
+ if metadata_dict is None:
153
+ return None
154
+ crop_data = metadata_dict.get(DISPLAY_CROP_METADATA_KEY)
155
+ crop_dict = string_key_mapping_or_none(crop_data)
156
+ if crop_dict is None:
157
+ return None
158
+ raw_shape = int_tuple_or_none(crop_dict.get("original_shape"), length=2)
159
+ if raw_shape is None:
160
+ return None
161
+ axis0_start = int_or_none(crop_dict.get("axis0_start"))
162
+ axis0_stop = int_or_none(crop_dict.get("axis0_stop"))
163
+ axis1_start = int_or_none(crop_dict.get("axis1_start"))
164
+ axis1_stop = int_or_none(crop_dict.get("axis1_stop"))
165
+ source = crop_dict.get("source")
166
+ if (
167
+ axis0_start is None
168
+ or axis0_stop is None
169
+ or axis1_start is None
170
+ or axis1_stop is None
171
+ or not isinstance(source, str)
172
+ ):
173
+ return None
174
+ return SpatialCrop(
175
+ axis0_start=axis0_start,
176
+ axis0_stop=axis0_stop,
177
+ axis1_start=axis1_start,
178
+ axis1_stop=axis1_stop,
179
+ original_shape=(raw_shape[0], raw_shape[1]),
180
+ source=source,
181
+ )
@@ -0,0 +1,197 @@
1
+ """Human-readable recording and output path labels for napari.
2
+
3
+ Inputs: loaded converted recordings and output paths.
4
+ Outputs: short strings for GUI labels and status messages.
5
+
6
+ Full recording paths are too long for the napari sidebars. These helpers keep
7
+ path presentation in one place while file-reading and file-writing code keeps
8
+ using full ``Path`` objects.
9
+ """
10
+
11
+ import re
12
+ from dataclasses import dataclass
13
+ from pathlib import Path
14
+
15
+ from twopy.config import DEFAULT_CONFIG_PATH, load_config
16
+ from twopy.converted import RecordingData
17
+
18
+ __all__ = [
19
+ "RecordingDisplaySummary",
20
+ "format_output_folder",
21
+ "format_twopy_h5_output",
22
+ "recording_display_summary",
23
+ ]
24
+
25
+ _YEAR_PATTERN = re.compile(r"^\d{4}$")
26
+ _DATE_PATTERN = re.compile(r"^\d{2}_\d{2}$")
27
+ _TIME_PATTERN = re.compile(r"^\d{2}_\d{2}_\d{2}$")
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class RecordingDisplaySummary:
32
+ """Short recording identity shown in the response Update tab.
33
+
34
+ Inputs: one converted recording path.
35
+ Outputs: root data path, genotype folder, stimulus folder, and recording
36
+ time.
37
+ """
38
+
39
+ root: Path
40
+ genotype: str
41
+ stimulus: str
42
+ recording: str
43
+
44
+ def lines(self) -> tuple[str, str, str, str]:
45
+ """Return display lines for a compact Qt label.
46
+
47
+ Args:
48
+ None.
49
+
50
+ Returns:
51
+ Four user-facing lines.
52
+ """
53
+ return (
54
+ f"Root: {self.root}",
55
+ f"Genotype: {self.genotype}",
56
+ f"Stimulus: {self.stimulus}",
57
+ f"Recording: {self.recording}",
58
+ )
59
+
60
+
61
+ def recording_display_summary(recording: RecordingData) -> RecordingDisplaySummary:
62
+ """Parse one recording into stable, readable identity fields.
63
+
64
+ Args:
65
+ recording: Loaded converted recording.
66
+
67
+ Returns:
68
+ Root/genotype/recording-time summary.
69
+
70
+ ``config.yml`` gives the cleanest root and genotype split when present.
71
+ The fallback still reads the final ``YYYY/MM_DD/HH_MM_SS`` path contract so
72
+ napari remains usable from copied example folders.
73
+ """
74
+ recording_dir = recording.source_session_dir.expanduser()
75
+ date_index = _recording_date_index(recording_dir)
76
+ recording_label = _recording_label(recording_dir, date_index)
77
+
78
+ try:
79
+ data_root = load_config(DEFAULT_CONFIG_PATH).data_path.expanduser()
80
+ relative = recording_dir.relative_to(data_root)
81
+ except (FileNotFoundError, ValueError):
82
+ data_root, genotype, stimulus = _fallback_path_parts(recording_dir, date_index)
83
+ else:
84
+ genotype = relative.parts[0] if len(relative.parts) > 0 else "unknown"
85
+ stimulus = relative.parts[1] if len(relative.parts) > 1 else "unknown"
86
+
87
+ return RecordingDisplaySummary(
88
+ root=data_root,
89
+ genotype=genotype,
90
+ stimulus=stimulus,
91
+ recording=recording_label,
92
+ )
93
+
94
+
95
+ def format_twopy_h5_output(path: Path) -> str:
96
+ """Return the compact display form for twopy HDF5 outputs.
97
+
98
+ Args:
99
+ path: Full output path.
100
+
101
+ Returns:
102
+ ``./twopy/<filename>.h5`` style string.
103
+ """
104
+ return f"./twopy/{path.name}"
105
+
106
+
107
+ def format_output_folder(path: Path, recording: RecordingData | None) -> str:
108
+ """Return a compact output-folder label for status messages.
109
+
110
+ Args:
111
+ path: File or folder path that was written.
112
+ recording: Optional selected recording used to compute a twopy-relative
113
+ folder.
114
+
115
+ Returns:
116
+ Short folder string such as ``./twopy`` or
117
+ ``./twopy/exports/recording_view``.
118
+ """
119
+ folder = path if path.suffix == "" else path.parent
120
+ if recording is None:
121
+ return str(folder)
122
+
123
+ twopy_dir = recording.path.parent
124
+ try:
125
+ relative = folder.relative_to(twopy_dir)
126
+ except ValueError:
127
+ return "./twopy"
128
+
129
+ if str(relative) == ".":
130
+ return "./twopy"
131
+ return f"./twopy/{relative}"
132
+
133
+
134
+ def _recording_date_index(recording_dir: Path) -> int | None:
135
+ """Return the index of the ``YYYY/MM_DD/HH_MM_SS`` path segment.
136
+
137
+ Args:
138
+ recording_dir: Source recording folder.
139
+
140
+ Returns:
141
+ Index of the year segment, or ``None`` when the path is not recognized.
142
+ """
143
+ parts = recording_dir.parts
144
+ for index in range(len(parts) - 2):
145
+ if (
146
+ _YEAR_PATTERN.match(parts[index]) is not None
147
+ and _DATE_PATTERN.match(parts[index + 1]) is not None
148
+ and _TIME_PATTERN.match(parts[index + 2]) is not None
149
+ ):
150
+ return index
151
+ return None
152
+
153
+
154
+ def _recording_label(recording_dir: Path, date_index: int | None) -> str:
155
+ """Return the recording date label from the source recording path.
156
+
157
+ Args:
158
+ recording_dir: Source recording folder.
159
+ date_index: Index of the year segment, if found.
160
+
161
+ Returns:
162
+ ``YYYY-MM-DD HH:MM:SS`` or the folder name as a fallback.
163
+ """
164
+ if date_index is None:
165
+ return recording_dir.name
166
+
167
+ parts = recording_dir.parts
168
+ year = parts[date_index]
169
+ month_day = parts[date_index + 1].replace("_", "-")
170
+ time = parts[date_index + 2].replace("_", ":")
171
+ return f"{year}-{month_day} {time}"
172
+
173
+
174
+ def _fallback_path_parts(
175
+ recording_dir: Path,
176
+ date_index: int | None,
177
+ ) -> tuple[Path, str, str]:
178
+ """Infer root, genotype, and stimulus when config is unavailable.
179
+
180
+ Args:
181
+ recording_dir: Source recording folder.
182
+ date_index: Index of the year segment, if found.
183
+
184
+ Returns:
185
+ ``(root, genotype, stimulus)`` best effort.
186
+
187
+ Stable lab recording paths end as
188
+ ``<genotype>/<stimulus>/<YYYY>/<MM_DD>/<HH_MM_SS>``. The fallback keeps that
189
+ contract explicit instead of guessing from folder names.
190
+ """
191
+ if date_index is None or date_index < 2:
192
+ return recording_dir.parent, recording_dir.name, "unknown"
193
+
194
+ parts = recording_dir.parts
195
+ genotype_index = date_index - 2
196
+ stimulus_index = date_index - 1
197
+ return Path(*parts[:genotype_index]), parts[genotype_index], parts[stimulus_index]
@@ -0,0 +1,310 @@
1
+ """Live response updates for napari ROI editing.
2
+
3
+ Inputs: the selected converted recording, its editable Labels layer, and the
4
+ response plot widget.
5
+ Outputs: debounced plot refreshes as ROI pixels change.
6
+
7
+ The controller owns GUI orchestration only. It copies Labels pixels on the GUI
8
+ thread, then runs the heavier response calculation through the same in-memory
9
+ analysis helper used by the manual update button.
10
+ """
11
+
12
+ from collections.abc import Callable
13
+ from concurrent.futures import Future, ThreadPoolExecutor
14
+ from contextlib import suppress
15
+ from dataclasses import dataclass
16
+ from typing import Protocol, cast
17
+
18
+ import numpy as np
19
+ from qtpy.QtCore import QTimer
20
+
21
+ from twopy.converted import RecordingData
22
+ from twopy.napari.plotting.data import ResponsePlotData
23
+ from twopy.napari.responses import compute_response_plot_data_from_roi_set
24
+ from twopy.napari.roi import roi_label_image_from_layer_for_recording
25
+ from twopy.roi import make_roi_set_from_label_image
26
+
27
+ __all__ = ["LiveResponseController"]
28
+
29
+
30
+ class ResponsePlotReceiver(Protocol):
31
+ """Small protocol for the plot widget methods used by live updates."""
32
+
33
+ def set_response_plot_data(
34
+ self,
35
+ plot_data: ResponsePlotData,
36
+ *,
37
+ reset_axes: bool,
38
+ ) -> None:
39
+ """Show newly computed plot data.
40
+
41
+ Args:
42
+ plot_data: Plot-ready response data.
43
+ reset_axes: Whether plot bounds should return to data-derived
44
+ defaults.
45
+
46
+ Returns:
47
+ None.
48
+ """
49
+ ...
50
+
51
+ def show_response_status(self, text: str) -> None:
52
+ """Show a user-facing response status message.
53
+
54
+ Args:
55
+ text: Status text.
56
+
57
+ Returns:
58
+ None.
59
+ """
60
+ ...
61
+
62
+
63
+ class _EventEmitter(Protocol):
64
+ """Small protocol for psygnal event emitters exposed by napari layers."""
65
+
66
+ def connect(self, callback: Callable[..., None]) -> object:
67
+ """Connect one callback to the event emitter."""
68
+ ...
69
+
70
+ def disconnect(self, callback: Callable[..., None]) -> object:
71
+ """Disconnect one callback from the event emitter."""
72
+ ...
73
+
74
+
75
+ @dataclass(frozen=True)
76
+ class _ConnectedEmitter:
77
+ """One napari Labels event connection owned by the controller."""
78
+
79
+ emitter: _EventEmitter
80
+
81
+
82
+ class LiveResponseController:
83
+ """Debounced response updater for one active napari Labels layer.
84
+
85
+ Inputs: a plot widget plus the selected recording/layer context.
86
+ Outputs: live response plot refreshes after ROI edits.
87
+
88
+ The controller keeps only one worker job active. If ROI pixels change while
89
+ a job is running, the running result is discarded when stale and one newer
90
+ job is scheduled. This keeps the UI responsive without building a complex
91
+ queue that would compute obsolete ROIs.
92
+ """
93
+
94
+ def __init__(
95
+ self,
96
+ plot_widget: ResponsePlotReceiver,
97
+ *,
98
+ debounce_ms: int = 400,
99
+ run_async: bool = True,
100
+ ) -> None:
101
+ """Create a live updater for one response plot widget.
102
+
103
+ Args:
104
+ plot_widget: Widget that receives plot data or status messages.
105
+ debounce_ms: Delay after the latest ROI edit before recomputing.
106
+ run_async: Whether heavy analysis should run in a worker thread.
107
+ Tests can turn this off to verify behavior synchronously.
108
+ """
109
+ self._plot_widget = plot_widget
110
+ self._recording: RecordingData | None = None
111
+ self._roi_labels_layer: object | None = None
112
+ self._connections: list[_ConnectedEmitter] = []
113
+ self._version = 0
114
+ self._active_version: int | None = None
115
+ self._pending_after_active_job = False
116
+ self._future: Future[ResponsePlotData] | None = None
117
+ self._executor = (
118
+ ThreadPoolExecutor(max_workers=1, thread_name_prefix="twopy-roi-live")
119
+ if run_async
120
+ else None
121
+ )
122
+ self._debounce_timer = QTimer()
123
+ self._debounce_timer.setSingleShot(True)
124
+ self._debounce_timer.timeout.connect(self._start_latest_job)
125
+ self._poll_timer = QTimer()
126
+ self._poll_timer.setInterval(75)
127
+ self._poll_timer.timeout.connect(self._collect_finished_job)
128
+ self._debounce_ms = debounce_ms
129
+
130
+ def set_context(
131
+ self,
132
+ recording: RecordingData | None,
133
+ roi_labels_layer: object | None,
134
+ ) -> None:
135
+ """Set the recording and Labels layer watched by live updates.
136
+
137
+ Args:
138
+ recording: Selected converted recording, or ``None``.
139
+ roi_labels_layer: Editable Labels layer for that recording, or
140
+ ``None``.
141
+
142
+ Returns:
143
+ None.
144
+ """
145
+ self._disconnect_layer_events()
146
+ self._recording = recording
147
+ self._roi_labels_layer = roi_labels_layer
148
+ self._version += 1
149
+ self._pending_after_active_job = False
150
+ self._connect_layer_events(roi_labels_layer)
151
+
152
+ def request_update(self, *_event_args: object) -> None:
153
+ """Schedule a live response update after an ROI edit.
154
+
155
+ Args:
156
+ _event_args: Optional napari event arguments. They are ignored
157
+ because the Labels layer itself is the source of truth.
158
+
159
+ Returns:
160
+ None.
161
+ """
162
+ self._version += 1
163
+ if self._debounce_ms <= 0:
164
+ self._start_latest_job()
165
+ return
166
+ self._debounce_timer.start(self._debounce_ms)
167
+
168
+ def update_now(self) -> None:
169
+ """Compute responses immediately on the GUI thread.
170
+
171
+ Args:
172
+ None.
173
+
174
+ Returns:
175
+ None.
176
+
177
+ The manual update button uses this synchronous path because a direct
178
+ button click should report validation errors immediately. Continuous ROI
179
+ edits use ``request_update`` so heavier work can run in the background.
180
+ """
181
+ self._version += 1
182
+ version = self._version
183
+ try:
184
+ plot_data = self._compute_current_plot_data()
185
+ except ValueError as error:
186
+ if version == self._version:
187
+ self._plot_widget.show_response_status(str(error))
188
+ return
189
+ if version == self._version:
190
+ self._plot_widget.set_response_plot_data(plot_data, reset_axes=True)
191
+
192
+ def shutdown(self) -> None:
193
+ """Disconnect layer events and stop background work.
194
+
195
+ Args:
196
+ None.
197
+
198
+ Returns:
199
+ None.
200
+ """
201
+ self._disconnect_layer_events()
202
+ self._debounce_timer.stop()
203
+ self._poll_timer.stop()
204
+ if self._executor is not None:
205
+ self._executor.shutdown(wait=False, cancel_futures=True)
206
+
207
+ def _start_latest_job(self) -> None:
208
+ """Start the newest queued response computation."""
209
+ if self._executor is None:
210
+ self.update_now()
211
+ return
212
+ if self._future is not None:
213
+ self._pending_after_active_job = True
214
+ return
215
+
216
+ version = self._version
217
+ try:
218
+ label_image = self._current_label_image()
219
+ except ValueError as error:
220
+ if version == self._version:
221
+ self._plot_widget.show_response_status(str(error))
222
+ return
223
+ if not np.any(label_image > 0):
224
+ if version == self._version:
225
+ self._plot_widget.show_response_status("No ROI labels to analyze.")
226
+ return
227
+
228
+ recording = self._require_recording()
229
+ roi_set = make_roi_set_from_label_image(label_image)
230
+ self._active_version = version
231
+ self._future = self._executor.submit(
232
+ compute_response_plot_data_from_roi_set,
233
+ recording,
234
+ roi_set,
235
+ source_path=None,
236
+ )
237
+ self._poll_timer.start()
238
+
239
+ def _collect_finished_job(self) -> None:
240
+ """Apply a finished worker result when it is still current."""
241
+ future = self._future
242
+ if future is None or not future.done():
243
+ return
244
+
245
+ version = self._active_version
246
+ self._future = None
247
+ self._active_version = None
248
+ self._poll_timer.stop()
249
+ try:
250
+ plot_data = future.result()
251
+ except ValueError as error:
252
+ if version == self._version:
253
+ self._plot_widget.show_response_status(str(error))
254
+ else:
255
+ if version == self._version:
256
+ self._plot_widget.set_response_plot_data(plot_data, reset_axes=True)
257
+
258
+ if self._pending_after_active_job or version != self._version:
259
+ self._pending_after_active_job = False
260
+ self._start_latest_job()
261
+
262
+ def _compute_current_plot_data(self) -> ResponsePlotData:
263
+ """Return plot data for the current recording and Labels layer."""
264
+ label_image = self._current_label_image()
265
+ if not np.any(label_image > 0):
266
+ msg = "No ROI labels to analyze."
267
+ raise ValueError(msg)
268
+ return compute_response_plot_data_from_roi_set(
269
+ self._require_recording(),
270
+ make_roi_set_from_label_image(label_image),
271
+ source_path=None,
272
+ )
273
+
274
+ def _current_label_image(self) -> np.ndarray:
275
+ """Return full-frame ROI labels from the current Labels layer."""
276
+ recording = self._require_recording()
277
+ layer = self._roi_labels_layer
278
+ if layer is None:
279
+ msg = "No ROI Labels layer is available."
280
+ raise ValueError(msg)
281
+ return roi_label_image_from_layer_for_recording(layer, recording)
282
+
283
+ def _require_recording(self) -> RecordingData:
284
+ """Return the selected recording or raise a user-facing error."""
285
+ if self._recording is None:
286
+ msg = "No recording loaded."
287
+ raise ValueError(msg)
288
+ return self._recording
289
+
290
+ def _connect_layer_events(self, layer: object | None) -> None:
291
+ """Connect Labels data-change events to debounced updates."""
292
+ if layer is None:
293
+ return
294
+ events = getattr(layer, "events", None)
295
+ if events is None:
296
+ return
297
+ for event_name in ("paint", "data"):
298
+ emitter = getattr(events, event_name, None)
299
+ if emitter is None:
300
+ continue
301
+ connected = cast(_EventEmitter, emitter)
302
+ connected.connect(self.request_update)
303
+ self._connections.append(_ConnectedEmitter(connected))
304
+
305
+ def _disconnect_layer_events(self) -> None:
306
+ """Disconnect all Labels event handlers owned by the controller."""
307
+ for connection in self._connections:
308
+ with suppress(RuntimeError, ValueError):
309
+ connection.emitter.disconnect(self.request_update)
310
+ self._connections.clear()