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.
- twopy/__init__.py +168 -0
- twopy/_segments.py +53 -0
- twopy/analysis/__init__.py +101 -0
- twopy/analysis/background_subtraction.py +672 -0
- twopy/analysis/dff.py +524 -0
- twopy/analysis/epoch_mapping.py +223 -0
- twopy/analysis/motion.py +66 -0
- twopy/analysis/persistence.py +975 -0
- twopy/analysis/response_processing/__init__.py +55 -0
- twopy/analysis/response_processing/apply.py +298 -0
- twopy/analysis/response_processing/options.py +192 -0
- twopy/analysis/response_processing/qc.py +270 -0
- twopy/analysis/response_processing/signals.py +200 -0
- twopy/analysis/responses.py +345 -0
- twopy/analysis/trials.py +341 -0
- twopy/analysis/workflow.py +457 -0
- twopy/api.py +78 -0
- twopy/config.py +200 -0
- twopy/conversion/__init__.py +40 -0
- twopy/conversion/alignment_crop.py +329 -0
- twopy/conversion/api.py +113 -0
- twopy/conversion/frame_ranges.py +35 -0
- twopy/conversion/hdf5_writing.py +362 -0
- twopy/conversion/matlab_values.py +123 -0
- twopy/conversion/source_loading.py +403 -0
- twopy/conversion/stimulus_code.py +248 -0
- twopy/conversion/types.py +295 -0
- twopy/converted.py +681 -0
- twopy/database/__init__.py +33 -0
- twopy/database/cache.py +166 -0
- twopy/database/catalog.py +90 -0
- twopy/database/connection.py +27 -0
- twopy/database/generic.py +254 -0
- twopy/database/modeled.py +419 -0
- twopy/database/sql.py +46 -0
- twopy/database/types.py +116 -0
- twopy/inspection.py +492 -0
- twopy/matlab.py +398 -0
- twopy/napari/__init__.py +27 -0
- twopy/napari/__main__.py +10 -0
- twopy/napari/constants.py +13 -0
- twopy/napari/controls.py +739 -0
- twopy/napari/display.py +181 -0
- twopy/napari/display_paths.py +197 -0
- twopy/napari/interactive.py +310 -0
- twopy/napari/launcher.py +175 -0
- twopy/napari/layout.py +147 -0
- twopy/napari/loading.py +207 -0
- twopy/napari/movie.py +71 -0
- twopy/napari/paths.py +181 -0
- twopy/napari/plotting/__init__.py +21 -0
- twopy/napari/plotting/data.py +266 -0
- twopy/napari/plotting/docks.py +1052 -0
- twopy/napari/plotting/export.py +803 -0
- twopy/napari/plotting/export_controls.py +209 -0
- twopy/napari/plotting/label_visibility.py +260 -0
- twopy/napari/plotting/options.py +269 -0
- twopy/napari/plotting/panels.py +119 -0
- twopy/napari/plotting/widgets.py +767 -0
- twopy/napari/protocols.py +90 -0
- twopy/napari/responses.py +91 -0
- twopy/napari/roi.py +238 -0
- twopy/napari/session.py +381 -0
- twopy/napari/state.py +106 -0
- twopy/napari/text.py +26 -0
- twopy/napari/types.py +39 -0
- twopy/napari/viewer.py +405 -0
- twopy/parity/__init__.py +30 -0
- twopy/parity/dff.py +399 -0
- twopy/parity/roi.py +145 -0
- twopy/parity/saved_analysis.py +174 -0
- twopy/photodiode.py +375 -0
- twopy/photodiode_classification.py +484 -0
- twopy/roi.py +462 -0
- twopy/session.py +167 -0
- twopy/spatial.py +154 -0
- twopy/stimulus.py +233 -0
- twopy/synchronization.py +235 -0
- twopy/typing_guards.py +255 -0
- twopy-0.1.0.dist-info/METADATA +311 -0
- twopy-0.1.0.dist-info/RECORD +84 -0
- twopy-0.1.0.dist-info/WHEEL +5 -0
- twopy-0.1.0.dist-info/entry_points.txt +2 -0
- twopy-0.1.0.dist-info/top_level.txt +1 -0
twopy/analysis/dff.py
ADDED
|
@@ -0,0 +1,524 @@
|
|
|
1
|
+
"""Compute ROI-level delta F over F traces.
|
|
2
|
+
|
|
3
|
+
Inputs: background-corrected ROI fluorescence traces and gray-interleave frame
|
|
4
|
+
windows.
|
|
5
|
+
Outputs: ROI dF/F traces, fitted baselines, shared exponential tau, and
|
|
6
|
+
per-ROI amplitudes.
|
|
7
|
+
|
|
8
|
+
This module owns ROI-level dF/F calculation from twopy Python objects. It does
|
|
9
|
+
not decide which stimulus events are interleaves; callers pass explicit
|
|
10
|
+
interleave windows derived from converted data and photodiode alignment.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from collections.abc import Sequence
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from typing import Literal
|
|
16
|
+
|
|
17
|
+
import numpy as np
|
|
18
|
+
import numpy.typing as npt
|
|
19
|
+
from scipy.optimize import least_squares
|
|
20
|
+
|
|
21
|
+
from twopy.analysis.background_subtraction import BackgroundCorrectedRoiTraces
|
|
22
|
+
from twopy.analysis.trials import FrameWindow
|
|
23
|
+
|
|
24
|
+
__all__ = [
|
|
25
|
+
"DeltaFOverFFitMode",
|
|
26
|
+
"RoiDeltaFOverF",
|
|
27
|
+
"compute_roi_delta_f_over_f",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
DeltaFOverFFitMode = Literal["robust", "source_bounds"]
|
|
31
|
+
DffMetadataValue = str | int | float | bool
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True)
|
|
35
|
+
class RoiDeltaFOverF:
|
|
36
|
+
"""ROI-level dF/F and the fitted baseline used to compute it.
|
|
37
|
+
|
|
38
|
+
Inputs: background-corrected ROI fluorescence and gray-interleave windows.
|
|
39
|
+
Outputs: fluorescence, baseline, dF/F, ROI labels, shared tau, per-ROI
|
|
40
|
+
amplitudes, and fit metadata.
|
|
41
|
+
|
|
42
|
+
Keeping fluorescence and baseline beside dF/F makes the calculation easy to
|
|
43
|
+
audit. The final trace is ``(fluorescence - baseline) / baseline``.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
fluorescence: npt.NDArray[np.float64]
|
|
47
|
+
baseline: npt.NDArray[np.float64]
|
|
48
|
+
values: npt.NDArray[np.float64]
|
|
49
|
+
labels: tuple[str, ...]
|
|
50
|
+
start_frame: int
|
|
51
|
+
stop_frame: int
|
|
52
|
+
tau: float
|
|
53
|
+
amplitudes: npt.NDArray[np.float64]
|
|
54
|
+
interleave_frame_numbers: npt.NDArray[np.float64]
|
|
55
|
+
interleave_fluorescence: npt.NDArray[np.float64]
|
|
56
|
+
metadata: dict[str, DffMetadataValue]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def compute_roi_delta_f_over_f(
|
|
60
|
+
traces: BackgroundCorrectedRoiTraces,
|
|
61
|
+
interleave_windows: Sequence[FrameWindow],
|
|
62
|
+
*,
|
|
63
|
+
data_rate_hz: float,
|
|
64
|
+
seconds_interleave_use: float | None = 1.0,
|
|
65
|
+
fit_mode: DeltaFOverFFitMode = "robust",
|
|
66
|
+
) -> RoiDeltaFOverF:
|
|
67
|
+
"""Compute ROI-level dF/F from corrected fluorescence.
|
|
68
|
+
|
|
69
|
+
Args:
|
|
70
|
+
traces: Background-corrected ROI traces. ``corrected_values`` is treated
|
|
71
|
+
as final ROI fluorescence ``F_roi(t)``.
|
|
72
|
+
interleave_windows: Gray-interleave frame windows in the same absolute
|
|
73
|
+
frame coordinates as ``traces``.
|
|
74
|
+
data_rate_hz: Imaging frame rate in frames per second.
|
|
75
|
+
seconds_interleave_use: Number of seconds to keep from the end of each
|
|
76
|
+
interleave window. ``None`` uses the full window.
|
|
77
|
+
fit_mode: Exponential fit bounds. ``"robust"`` keeps the shared tau
|
|
78
|
+
bounded but leaves log-amplitude unbounded and ignores nonpositive
|
|
79
|
+
fit samples. ``"source_bounds"`` uses bounded log-amplitude from the
|
|
80
|
+
original analysis source for audit comparisons. ``"robust"`` is the
|
|
81
|
+
default because the source log-amplitude upper bound depends on the
|
|
82
|
+
first baseline sample; if that sample is small, nonpositive, or
|
|
83
|
+
noisy, the bound can become invalid or overly restrictive.
|
|
84
|
+
|
|
85
|
+
Returns:
|
|
86
|
+
``RoiDeltaFOverF`` with dF/F values shaped ``(frames, rois)``.
|
|
87
|
+
|
|
88
|
+
Raises:
|
|
89
|
+
ValueError: If no interleave windows are supplied, windows fall outside
|
|
90
|
+
the traces, or the fit has no positive finite baseline samples.
|
|
91
|
+
|
|
92
|
+
The calculation uses the current twopy analysis contract: first use the
|
|
93
|
+
background-corrected mean fluorescence inside each ROI, then fit one
|
|
94
|
+
exponential decay constant shared by all ROIs from gray interleaves, then
|
|
95
|
+
estimate one amplitude per ROI.
|
|
96
|
+
"""
|
|
97
|
+
_validate_dff_inputs(
|
|
98
|
+
traces=traces,
|
|
99
|
+
interleave_windows=interleave_windows,
|
|
100
|
+
data_rate_hz=data_rate_hz,
|
|
101
|
+
seconds_interleave_use=seconds_interleave_use,
|
|
102
|
+
fit_mode=fit_mode,
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
fluorescence = traces.corrected_values.astype(np.float64, copy=False)
|
|
106
|
+
interleave_samples = _interleave_samples(
|
|
107
|
+
fluorescence=fluorescence,
|
|
108
|
+
trace_start_frame=traces.start_frame,
|
|
109
|
+
interleave_windows=interleave_windows,
|
|
110
|
+
data_rate_hz=data_rate_hz,
|
|
111
|
+
seconds_interleave_use=seconds_interleave_use,
|
|
112
|
+
)
|
|
113
|
+
tau = _fit_shared_tau(
|
|
114
|
+
frame_numbers=interleave_samples.frame_numbers,
|
|
115
|
+
mean_fluorescence=interleave_samples.values.mean(axis=1),
|
|
116
|
+
fit_mode=fit_mode,
|
|
117
|
+
)
|
|
118
|
+
amplitudes = _compute_roi_amplitudes(
|
|
119
|
+
interleave_values=interleave_samples.values,
|
|
120
|
+
interleave_frame_numbers=interleave_samples.frame_numbers,
|
|
121
|
+
tau=tau,
|
|
122
|
+
)
|
|
123
|
+
baseline = _evaluate_baseline(
|
|
124
|
+
frame_count=fluorescence.shape[0],
|
|
125
|
+
start_frame=traces.start_frame,
|
|
126
|
+
amplitudes=amplitudes,
|
|
127
|
+
tau=tau,
|
|
128
|
+
)
|
|
129
|
+
values = (fluorescence - baseline) / baseline
|
|
130
|
+
|
|
131
|
+
return RoiDeltaFOverF(
|
|
132
|
+
fluorescence=fluorescence,
|
|
133
|
+
baseline=baseline,
|
|
134
|
+
values=values,
|
|
135
|
+
labels=traces.labels,
|
|
136
|
+
start_frame=traces.start_frame,
|
|
137
|
+
stop_frame=traces.stop_frame,
|
|
138
|
+
tau=tau,
|
|
139
|
+
amplitudes=amplitudes,
|
|
140
|
+
interleave_frame_numbers=interleave_samples.frame_numbers,
|
|
141
|
+
interleave_fluorescence=interleave_samples.values,
|
|
142
|
+
metadata={
|
|
143
|
+
"method": "shared_tau_roi_amplitude",
|
|
144
|
+
"data_rate_hz": float(data_rate_hz),
|
|
145
|
+
"seconds_interleave_use": (
|
|
146
|
+
"full"
|
|
147
|
+
if seconds_interleave_use is None
|
|
148
|
+
else float(seconds_interleave_use)
|
|
149
|
+
),
|
|
150
|
+
"interleave_window_count": len(interleave_windows),
|
|
151
|
+
"fit_mode": fit_mode,
|
|
152
|
+
},
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
@dataclass(frozen=True)
|
|
157
|
+
class _InterleaveSamples:
|
|
158
|
+
"""Mean fluorescence samples from interleave windows.
|
|
159
|
+
|
|
160
|
+
Inputs: gray-interleave frame windows and ROI fluorescence traces.
|
|
161
|
+
Outputs: one mean fluorescence value per interleave window and ROI, plus
|
|
162
|
+
the frame number used for exponential fitting.
|
|
163
|
+
"""
|
|
164
|
+
|
|
165
|
+
frame_numbers: npt.NDArray[np.float64]
|
|
166
|
+
values: npt.NDArray[np.float64]
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _validate_dff_inputs(
|
|
170
|
+
*,
|
|
171
|
+
traces: BackgroundCorrectedRoiTraces,
|
|
172
|
+
interleave_windows: Sequence[FrameWindow],
|
|
173
|
+
data_rate_hz: float,
|
|
174
|
+
seconds_interleave_use: float | None,
|
|
175
|
+
fit_mode: DeltaFOverFFitMode,
|
|
176
|
+
) -> None:
|
|
177
|
+
"""Validate the public dF/F inputs.
|
|
178
|
+
|
|
179
|
+
Args:
|
|
180
|
+
traces: Candidate background-corrected ROI traces.
|
|
181
|
+
interleave_windows: Candidate baseline windows.
|
|
182
|
+
data_rate_hz: Imaging frame rate.
|
|
183
|
+
seconds_interleave_use: Optional seconds from each interleave end.
|
|
184
|
+
fit_mode: Requested exponential fit mode.
|
|
185
|
+
|
|
186
|
+
Returns:
|
|
187
|
+
None.
|
|
188
|
+
"""
|
|
189
|
+
if traces.corrected_values.ndim != 2:
|
|
190
|
+
msg = "corrected_values must have shape (frames, rois)"
|
|
191
|
+
raise ValueError(msg)
|
|
192
|
+
if traces.corrected_values.shape[1] != len(traces.labels):
|
|
193
|
+
msg = (
|
|
194
|
+
"ROI labels must match corrected_values width; "
|
|
195
|
+
f"got {len(traces.labels)} labels for "
|
|
196
|
+
f"{traces.corrected_values.shape[1]} ROIs"
|
|
197
|
+
)
|
|
198
|
+
raise ValueError(msg)
|
|
199
|
+
if len(interleave_windows) == 0:
|
|
200
|
+
msg = "At least one interleave window is required for dF/F"
|
|
201
|
+
raise ValueError(msg)
|
|
202
|
+
if data_rate_hz <= 0:
|
|
203
|
+
msg = f"data_rate_hz must be positive; got {data_rate_hz}"
|
|
204
|
+
raise ValueError(msg)
|
|
205
|
+
if seconds_interleave_use is not None and seconds_interleave_use <= 0:
|
|
206
|
+
msg = (
|
|
207
|
+
"seconds_interleave_use must be positive or None; "
|
|
208
|
+
f"got {seconds_interleave_use}"
|
|
209
|
+
)
|
|
210
|
+
raise ValueError(msg)
|
|
211
|
+
if fit_mode not in {"robust", "source_bounds"}:
|
|
212
|
+
msg = f"Unknown dF/F fit_mode {fit_mode!r}"
|
|
213
|
+
raise ValueError(msg)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _interleave_samples(
|
|
217
|
+
*,
|
|
218
|
+
fluorescence: npt.NDArray[np.float64],
|
|
219
|
+
trace_start_frame: int,
|
|
220
|
+
interleave_windows: Sequence[FrameWindow],
|
|
221
|
+
data_rate_hz: float,
|
|
222
|
+
seconds_interleave_use: float | None,
|
|
223
|
+
) -> _InterleaveSamples:
|
|
224
|
+
"""Average the chosen part of each interleave window.
|
|
225
|
+
|
|
226
|
+
Args:
|
|
227
|
+
fluorescence: ROI fluorescence shaped ``(frames, rois)``.
|
|
228
|
+
trace_start_frame: Absolute frame index for ``fluorescence[0]``.
|
|
229
|
+
interleave_windows: Gray-interleave windows in absolute frame indices.
|
|
230
|
+
data_rate_hz: Imaging frame rate.
|
|
231
|
+
seconds_interleave_use: Optional seconds from each interleave end.
|
|
232
|
+
|
|
233
|
+
Returns:
|
|
234
|
+
Mean interleave fluorescence and one-based fit frame numbers.
|
|
235
|
+
|
|
236
|
+
Each interleave is first narrowed to the last requested second, then the
|
|
237
|
+
selected frames are averaged into one point per interleave. That keeps
|
|
238
|
+
longer interleaves from dominating the exponential fit.
|
|
239
|
+
"""
|
|
240
|
+
frame_numbers: list[float] = []
|
|
241
|
+
values: list[npt.NDArray[np.float64]] = []
|
|
242
|
+
for window in interleave_windows:
|
|
243
|
+
local_start = window.start_frame - trace_start_frame
|
|
244
|
+
local_stop = window.stop_frame - trace_start_frame
|
|
245
|
+
_validate_interleave_local_range(
|
|
246
|
+
window=window,
|
|
247
|
+
local_start=local_start,
|
|
248
|
+
local_stop=local_stop,
|
|
249
|
+
frame_count=fluorescence.shape[0],
|
|
250
|
+
)
|
|
251
|
+
sample_start, sample_stop = _last_interleave_sample_range(
|
|
252
|
+
local_start=local_start,
|
|
253
|
+
local_stop=local_stop,
|
|
254
|
+
data_rate_hz=data_rate_hz,
|
|
255
|
+
seconds_interleave_use=seconds_interleave_use,
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
duration = sample_stop - sample_start
|
|
259
|
+
absolute_sample_start = trace_start_frame + sample_start
|
|
260
|
+
# The fit uses one-based movie frame numbers centered on the averaged
|
|
261
|
+
# half-open sample range. Local indices slice the trace array; absolute
|
|
262
|
+
# frame numbers preserve the recording-wide time coordinate.
|
|
263
|
+
frame_numbers.append(float(absolute_sample_start + 1) + (duration / 2.0))
|
|
264
|
+
values.append(fluorescence[sample_start:sample_stop, :].mean(axis=0))
|
|
265
|
+
|
|
266
|
+
return _InterleaveSamples(
|
|
267
|
+
frame_numbers=np.asarray(frame_numbers, dtype=np.float64),
|
|
268
|
+
values=np.asarray(values, dtype=np.float64),
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _validate_interleave_local_range(
|
|
273
|
+
*,
|
|
274
|
+
window: FrameWindow,
|
|
275
|
+
local_start: int,
|
|
276
|
+
local_stop: int,
|
|
277
|
+
frame_count: int,
|
|
278
|
+
) -> None:
|
|
279
|
+
"""Check that one interleave window can slice the trace array.
|
|
280
|
+
|
|
281
|
+
Args:
|
|
282
|
+
window: Window in absolute frame coordinates.
|
|
283
|
+
local_start: Window start after subtracting trace start.
|
|
284
|
+
local_stop: Window stop after subtracting trace start.
|
|
285
|
+
frame_count: Number of trace frames available.
|
|
286
|
+
|
|
287
|
+
Returns:
|
|
288
|
+
None.
|
|
289
|
+
"""
|
|
290
|
+
if local_start < 0 or local_stop > frame_count or local_start >= local_stop:
|
|
291
|
+
msg = (
|
|
292
|
+
f"Interleave window [{window.start_frame}, {window.stop_frame}) is "
|
|
293
|
+
f"outside trace range with {frame_count} frames"
|
|
294
|
+
)
|
|
295
|
+
raise ValueError(msg)
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def _last_interleave_sample_range(
|
|
299
|
+
*,
|
|
300
|
+
local_start: int,
|
|
301
|
+
local_stop: int,
|
|
302
|
+
data_rate_hz: float,
|
|
303
|
+
seconds_interleave_use: float | None,
|
|
304
|
+
) -> tuple[int, int]:
|
|
305
|
+
"""Choose the final part of one interleave window.
|
|
306
|
+
|
|
307
|
+
Args:
|
|
308
|
+
local_start: Window start in local trace coordinates.
|
|
309
|
+
local_stop: Window stop in local trace coordinates.
|
|
310
|
+
data_rate_hz: Imaging frame rate.
|
|
311
|
+
seconds_interleave_use: Optional seconds from the window end.
|
|
312
|
+
|
|
313
|
+
Returns:
|
|
314
|
+
Half-open local frame range to average.
|
|
315
|
+
"""
|
|
316
|
+
if seconds_interleave_use is None:
|
|
317
|
+
return local_start, local_stop
|
|
318
|
+
|
|
319
|
+
frame_count = int(round(seconds_interleave_use * data_rate_hz))
|
|
320
|
+
frame_count = max(frame_count, 1)
|
|
321
|
+
duration = local_stop - local_start
|
|
322
|
+
if frame_count > duration:
|
|
323
|
+
msg = (
|
|
324
|
+
"Requested interleave baseline is longer than a window: "
|
|
325
|
+
f"requested_frames={frame_count}, window_frames={duration}"
|
|
326
|
+
)
|
|
327
|
+
raise ValueError(msg)
|
|
328
|
+
return local_stop - frame_count, local_stop
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def _fit_shared_tau(
|
|
332
|
+
*,
|
|
333
|
+
frame_numbers: npt.NDArray[np.float64],
|
|
334
|
+
mean_fluorescence: npt.NDArray[np.float64],
|
|
335
|
+
fit_mode: DeltaFOverFFitMode,
|
|
336
|
+
) -> float:
|
|
337
|
+
"""Fit one exponential decay constant shared across ROIs.
|
|
338
|
+
|
|
339
|
+
Args:
|
|
340
|
+
frame_numbers: One-based frame number for each interleave sample.
|
|
341
|
+
mean_fluorescence: Mean fluorescence across ROIs for each sample.
|
|
342
|
+
fit_mode: Fit bounds to use.
|
|
343
|
+
|
|
344
|
+
Returns:
|
|
345
|
+
Shared exponential ``tau``.
|
|
346
|
+
|
|
347
|
+
The fit model is ``exp(tau * t + b)`` on the ROI-averaged interleave trace.
|
|
348
|
+
Only ``tau`` is shared; per-ROI amplitudes are computed in a separate
|
|
349
|
+
transparent step. ``robust`` mode handles nonpositive outliers defensively;
|
|
350
|
+
``source_bounds`` preserves the original bounded log-amplitude fit for
|
|
351
|
+
audit comparisons.
|
|
352
|
+
"""
|
|
353
|
+
if fit_mode == "source_bounds":
|
|
354
|
+
return _fit_shared_tau_with_source_bounds(
|
|
355
|
+
frame_numbers=frame_numbers,
|
|
356
|
+
mean_fluorescence=mean_fluorescence,
|
|
357
|
+
)
|
|
358
|
+
return _fit_shared_tau_robust(
|
|
359
|
+
frame_numbers=frame_numbers,
|
|
360
|
+
mean_fluorescence=mean_fluorescence,
|
|
361
|
+
)
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def _fit_shared_tau_robust(
|
|
365
|
+
*,
|
|
366
|
+
frame_numbers: npt.NDArray[np.float64],
|
|
367
|
+
mean_fluorescence: npt.NDArray[np.float64],
|
|
368
|
+
) -> float:
|
|
369
|
+
"""Fit shared tau with defensive handling for nonpositive samples.
|
|
370
|
+
|
|
371
|
+
Args:
|
|
372
|
+
frame_numbers: One-based frame number for each interleave sample.
|
|
373
|
+
mean_fluorescence: Mean fluorescence across ROIs for each sample.
|
|
374
|
+
|
|
375
|
+
Returns:
|
|
376
|
+
Shared exponential ``tau``.
|
|
377
|
+
|
|
378
|
+
This is the default fit path because background correction can create
|
|
379
|
+
nonpositive values and the first interleave sample can be noisy. The source
|
|
380
|
+
log-amplitude upper bound is ``10 * log(first baseline)``; that becomes
|
|
381
|
+
invalid when the first baseline is not greater than one and can be overly
|
|
382
|
+
restrictive when the first baseline is an outlier. This path keeps the
|
|
383
|
+
decay-rate bound while fitting amplitude scale from all usable positive
|
|
384
|
+
samples.
|
|
385
|
+
"""
|
|
386
|
+
finite_positive = np.isfinite(mean_fluorescence) & (mean_fluorescence > 0)
|
|
387
|
+
if not np.any(finite_positive):
|
|
388
|
+
msg = "Cannot fit dF/F baseline: interleave fluorescence has no positive values"
|
|
389
|
+
raise ValueError(msg)
|
|
390
|
+
if np.count_nonzero(finite_positive) == 1:
|
|
391
|
+
return 0.0
|
|
392
|
+
|
|
393
|
+
x = frame_numbers[finite_positive]
|
|
394
|
+
y = mean_fluorescence[finite_positive]
|
|
395
|
+
initial_log_amplitude = float(np.log(y[0]))
|
|
396
|
+
|
|
397
|
+
def residual(parameters: npt.NDArray[np.float64]) -> npt.NDArray[np.float64]:
|
|
398
|
+
"""Return direct exponential residuals for SciPy least squares."""
|
|
399
|
+
tau = parameters[0]
|
|
400
|
+
log_amplitude = parameters[1]
|
|
401
|
+
return np.exp((tau * x) + log_amplitude) - y
|
|
402
|
+
|
|
403
|
+
fit = least_squares(
|
|
404
|
+
residual,
|
|
405
|
+
x0=np.asarray([0.0, initial_log_amplitude], dtype=np.float64),
|
|
406
|
+
bounds=(
|
|
407
|
+
np.asarray([-0.01, -np.inf], dtype=np.float64),
|
|
408
|
+
np.asarray([0.01, np.inf], dtype=np.float64),
|
|
409
|
+
),
|
|
410
|
+
)
|
|
411
|
+
return float(fit.x[0])
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def _fit_shared_tau_with_source_bounds(
|
|
415
|
+
*,
|
|
416
|
+
frame_numbers: npt.NDArray[np.float64],
|
|
417
|
+
mean_fluorescence: npt.NDArray[np.float64],
|
|
418
|
+
) -> float:
|
|
419
|
+
"""Fit shared tau using the original bounded log-amplitude contract.
|
|
420
|
+
|
|
421
|
+
Args:
|
|
422
|
+
frame_numbers: One-based frame number for each interleave sample.
|
|
423
|
+
mean_fluorescence: Mean fluorescence across ROIs for each sample.
|
|
424
|
+
|
|
425
|
+
Returns:
|
|
426
|
+
Shared exponential ``tau``.
|
|
427
|
+
|
|
428
|
+
Raises:
|
|
429
|
+
ValueError: If any fit sample is nonfinite or the first sample cannot
|
|
430
|
+
define a positive log-amplitude bound.
|
|
431
|
+
|
|
432
|
+
This mode exists for audit comparisons. Its log-amplitude bound is
|
|
433
|
+
``[0, 10 * log(first baseline)]``. That contract makes the first baseline
|
|
434
|
+
sample unusually important, so this mode intentionally fails on nonfinite,
|
|
435
|
+
nonpositive, or too-small first samples instead of silently switching to the
|
|
436
|
+
robust behavior.
|
|
437
|
+
"""
|
|
438
|
+
if np.any(~np.isfinite(mean_fluorescence)):
|
|
439
|
+
msg = "source_bounds dF/F fit requires finite interleave fluorescence"
|
|
440
|
+
raise ValueError(msg)
|
|
441
|
+
if mean_fluorescence.size == 0:
|
|
442
|
+
msg = "source_bounds dF/F fit requires at least one interleave sample"
|
|
443
|
+
raise ValueError(msg)
|
|
444
|
+
first_value = float(mean_fluorescence[0])
|
|
445
|
+
if first_value <= 1.0:
|
|
446
|
+
msg = (
|
|
447
|
+
"source_bounds dF/F fit requires first interleave fluorescence "
|
|
448
|
+
"greater than 1.0 to define an ordered log-amplitude bound; "
|
|
449
|
+
f"got {first_value}"
|
|
450
|
+
)
|
|
451
|
+
raise ValueError(msg)
|
|
452
|
+
if np.any(mean_fluorescence <= 0):
|
|
453
|
+
msg = "source_bounds dF/F fit requires positive interleave fluorescence"
|
|
454
|
+
raise ValueError(msg)
|
|
455
|
+
if mean_fluorescence.size == 1:
|
|
456
|
+
return 0.0
|
|
457
|
+
|
|
458
|
+
initial_log_amplitude = float(np.log(first_value))
|
|
459
|
+
upper_log_amplitude = 10.0 * initial_log_amplitude
|
|
460
|
+
|
|
461
|
+
def residual(parameters: npt.NDArray[np.float64]) -> npt.NDArray[np.float64]:
|
|
462
|
+
"""Return direct exponential residuals for source-bound least squares."""
|
|
463
|
+
tau = parameters[0]
|
|
464
|
+
log_amplitude = parameters[1]
|
|
465
|
+
return np.exp((tau * frame_numbers) + log_amplitude) - mean_fluorescence
|
|
466
|
+
|
|
467
|
+
fit = least_squares(
|
|
468
|
+
residual,
|
|
469
|
+
x0=np.asarray([0.0, initial_log_amplitude], dtype=np.float64),
|
|
470
|
+
bounds=(
|
|
471
|
+
np.asarray([-0.01, 0.0], dtype=np.float64),
|
|
472
|
+
np.asarray([0.01, upper_log_amplitude], dtype=np.float64),
|
|
473
|
+
),
|
|
474
|
+
)
|
|
475
|
+
return float(fit.x[0])
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def _compute_roi_amplitudes(
|
|
479
|
+
*,
|
|
480
|
+
interleave_values: npt.NDArray[np.float64],
|
|
481
|
+
interleave_frame_numbers: npt.NDArray[np.float64],
|
|
482
|
+
tau: float,
|
|
483
|
+
) -> npt.NDArray[np.float64]:
|
|
484
|
+
"""Estimate one exponential amplitude per ROI.
|
|
485
|
+
|
|
486
|
+
Args:
|
|
487
|
+
interleave_values: Mean interleave fluorescence shaped
|
|
488
|
+
``(interleaves, rois)``.
|
|
489
|
+
interleave_frame_numbers: One-based fit frame number per interleave.
|
|
490
|
+
tau: Shared exponential decay constant.
|
|
491
|
+
|
|
492
|
+
Returns:
|
|
493
|
+
One amplitude per ROI.
|
|
494
|
+
"""
|
|
495
|
+
decay = np.exp(tau * interleave_frame_numbers)[:, None]
|
|
496
|
+
if tau == 0:
|
|
497
|
+
return interleave_values.mean(axis=0)
|
|
498
|
+
return (interleave_values / decay).mean(axis=0)
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
def _evaluate_baseline(
|
|
502
|
+
*,
|
|
503
|
+
frame_count: int,
|
|
504
|
+
start_frame: int,
|
|
505
|
+
amplitudes: npt.NDArray[np.float64],
|
|
506
|
+
tau: float,
|
|
507
|
+
) -> npt.NDArray[np.float64]:
|
|
508
|
+
"""Evaluate the fitted baseline for every frame and ROI.
|
|
509
|
+
|
|
510
|
+
Args:
|
|
511
|
+
frame_count: Number of fluorescence frames.
|
|
512
|
+
start_frame: Absolute frame index for the first fluorescence frame.
|
|
513
|
+
amplitudes: Per-ROI amplitudes.
|
|
514
|
+
tau: Shared exponential decay constant.
|
|
515
|
+
|
|
516
|
+
Returns:
|
|
517
|
+
Baseline shaped ``(frames, rois)``.
|
|
518
|
+
"""
|
|
519
|
+
frame_numbers = np.arange(
|
|
520
|
+
start_frame + 1,
|
|
521
|
+
start_frame + frame_count + 1,
|
|
522
|
+
dtype=np.float64,
|
|
523
|
+
)
|
|
524
|
+
return amplitudes[None, :] * np.exp(tau * frame_numbers[:, None])
|