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
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
"""Correlation-based quality control for grouped ROI responses.
|
|
2
|
+
|
|
3
|
+
Inputs: grouped trial responses with explicit relative time vectors.
|
|
4
|
+
Outputs: ROI-level correlation scores and inclusion masks.
|
|
5
|
+
|
|
6
|
+
The functions here score response consistency; they do not delete raw data.
|
|
7
|
+
Callers can use the inclusion mask for plotting or summaries while preserving
|
|
8
|
+
the scores as an audit trail.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
|
|
13
|
+
import numpy as np
|
|
14
|
+
import numpy.typing as npt
|
|
15
|
+
|
|
16
|
+
from twopy.analysis.response_processing.options import (
|
|
17
|
+
CorrelationFilterReference,
|
|
18
|
+
CorrelationWindowSeconds,
|
|
19
|
+
)
|
|
20
|
+
from twopy.analysis.responses import GroupedRoiResponses, RoiResponseTrial
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"RoiCorrelationScores",
|
|
24
|
+
"score_roi_correlations",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True)
|
|
29
|
+
class RoiCorrelationScores:
|
|
30
|
+
"""ROI-level trial-consistency scores.
|
|
31
|
+
|
|
32
|
+
Inputs: grouped responses, a reference strategy, and a time window.
|
|
33
|
+
Outputs: one score per ROI plus a Boolean inclusion mask.
|
|
34
|
+
|
|
35
|
+
Scores are averaged across repeated epoch trials. ROIs with no estimable
|
|
36
|
+
score are excluded when correlation filtering is enabled.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
roi_labels: tuple[str, ...]
|
|
40
|
+
scores: npt.NDArray[np.float64]
|
|
41
|
+
included_mask: npt.NDArray[np.bool_]
|
|
42
|
+
minimum_correlation: float
|
|
43
|
+
reference: CorrelationFilterReference
|
|
44
|
+
window_seconds: CorrelationWindowSeconds
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def score_roi_correlations(
|
|
48
|
+
grouped: GroupedRoiResponses,
|
|
49
|
+
*,
|
|
50
|
+
minimum_correlation: float,
|
|
51
|
+
reference: CorrelationFilterReference = "epoch_mean",
|
|
52
|
+
window_seconds: CorrelationWindowSeconds = (0.0, None),
|
|
53
|
+
) -> RoiCorrelationScores:
|
|
54
|
+
"""Score each ROI by correlation to repeated epoch responses.
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
grouped: Trial responses grouped by stimulus epoch.
|
|
58
|
+
minimum_correlation: Minimum score required for inclusion.
|
|
59
|
+
reference: Reference strategy. ``"epoch_mean"`` compares each trial to
|
|
60
|
+
the other trials' mean. ``"epoch_peak"`` compares each non-peak
|
|
61
|
+
trial to the highest-peak trial for the same epoch and ROI.
|
|
62
|
+
window_seconds: Half-open epoch-relative time window used for each
|
|
63
|
+
correlation. ``None`` leaves that side unbounded.
|
|
64
|
+
|
|
65
|
+
Returns:
|
|
66
|
+
ROI correlation scores and inclusion mask.
|
|
67
|
+
|
|
68
|
+
Raises:
|
|
69
|
+
ValueError: If grouped response shapes, threshold, or reference are
|
|
70
|
+
invalid.
|
|
71
|
+
|
|
72
|
+
Correlations are computed only over samples inside ``window_seconds``. Mean
|
|
73
|
+
references are leave-one-out when an epoch has more than one trial, which
|
|
74
|
+
avoids scoring a trial against itself.
|
|
75
|
+
"""
|
|
76
|
+
_validate_correlation_inputs(grouped, minimum_correlation, reference)
|
|
77
|
+
correlations: list[list[float]] = [[] for _ in grouped.roi_labels]
|
|
78
|
+
for trials in _trials_by_epoch(grouped).values():
|
|
79
|
+
cube, time_seconds = _aligned_epoch_cube(trials, grouped)
|
|
80
|
+
window_mask = _correlation_window_mask(time_seconds, window_seconds)
|
|
81
|
+
windowed_cube = cube[:, window_mask, :]
|
|
82
|
+
if windowed_cube.shape[1] == 0:
|
|
83
|
+
continue
|
|
84
|
+
if reference == "epoch_mean":
|
|
85
|
+
_append_epoch_mean_correlations(correlations, windowed_cube)
|
|
86
|
+
elif reference == "epoch_peak":
|
|
87
|
+
_append_epoch_peak_correlations(correlations, windowed_cube)
|
|
88
|
+
|
|
89
|
+
scores = np.full(len(grouped.roi_labels), np.nan, dtype=np.float64)
|
|
90
|
+
for roi_index, roi_scores in enumerate(correlations):
|
|
91
|
+
if len(roi_scores) > 0:
|
|
92
|
+
scores[roi_index] = float(np.nanmean(roi_scores))
|
|
93
|
+
included = np.isfinite(scores) & (scores >= minimum_correlation)
|
|
94
|
+
return RoiCorrelationScores(
|
|
95
|
+
roi_labels=grouped.roi_labels,
|
|
96
|
+
scores=scores,
|
|
97
|
+
included_mask=included,
|
|
98
|
+
minimum_correlation=float(minimum_correlation),
|
|
99
|
+
reference=reference,
|
|
100
|
+
window_seconds=window_seconds,
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _append_epoch_mean_correlations(
|
|
105
|
+
correlations: list[list[float]],
|
|
106
|
+
cube: npt.NDArray[np.float64],
|
|
107
|
+
) -> None:
|
|
108
|
+
"""Append correlations against leave-one-out epoch means."""
|
|
109
|
+
finite_trial_counts = np.sum(np.isfinite(cube), axis=0)
|
|
110
|
+
for trial_index in range(cube.shape[0]):
|
|
111
|
+
trial_values = cube[trial_index, :, :]
|
|
112
|
+
reference_values = _reference_values_for_trial(
|
|
113
|
+
cube,
|
|
114
|
+
finite_trial_counts,
|
|
115
|
+
trial_index=trial_index,
|
|
116
|
+
)
|
|
117
|
+
for roi_index in range(cube.shape[2]):
|
|
118
|
+
score = _correlation(
|
|
119
|
+
trial_values[:, roi_index],
|
|
120
|
+
reference_values[:, roi_index],
|
|
121
|
+
)
|
|
122
|
+
if not np.isnan(score):
|
|
123
|
+
correlations[roi_index].append(score)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _append_epoch_peak_correlations(
|
|
127
|
+
correlations: list[list[float]],
|
|
128
|
+
cube: npt.NDArray[np.float64],
|
|
129
|
+
) -> None:
|
|
130
|
+
"""Append correlations against the highest-peak trial per ROI."""
|
|
131
|
+
for roi_index in range(cube.shape[2]):
|
|
132
|
+
peak_trial_index = _peak_trial_index(cube[:, :, roi_index])
|
|
133
|
+
if peak_trial_index is None:
|
|
134
|
+
continue
|
|
135
|
+
reference_values = cube[peak_trial_index, :, roi_index]
|
|
136
|
+
for trial_index in range(cube.shape[0]):
|
|
137
|
+
if trial_index == peak_trial_index:
|
|
138
|
+
continue
|
|
139
|
+
score = _correlation(cube[trial_index, :, roi_index], reference_values)
|
|
140
|
+
if not np.isnan(score):
|
|
141
|
+
correlations[roi_index].append(score)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _peak_trial_index(
|
|
145
|
+
values: npt.NDArray[np.float64],
|
|
146
|
+
) -> int | None:
|
|
147
|
+
"""Return the trial index with the largest finite peak response."""
|
|
148
|
+
peaks = np.full(values.shape[0], np.nan, dtype=np.float64)
|
|
149
|
+
for trial_index in range(values.shape[0]):
|
|
150
|
+
if np.any(np.isfinite(values[trial_index, :])):
|
|
151
|
+
peaks[trial_index] = float(np.nanmax(values[trial_index, :]))
|
|
152
|
+
if not np.any(np.isfinite(peaks)):
|
|
153
|
+
return None
|
|
154
|
+
return int(np.nanargmax(peaks))
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _correlation_window_mask(
|
|
158
|
+
time_seconds: npt.NDArray[np.float64],
|
|
159
|
+
window_seconds: CorrelationWindowSeconds,
|
|
160
|
+
) -> npt.NDArray[np.bool_]:
|
|
161
|
+
"""Return a Boolean mask for a half-open relative-time window."""
|
|
162
|
+
start_seconds, stop_seconds = window_seconds
|
|
163
|
+
mask = np.ones(time_seconds.shape, dtype=np.bool_)
|
|
164
|
+
if start_seconds is not None:
|
|
165
|
+
mask &= time_seconds >= start_seconds
|
|
166
|
+
if stop_seconds is not None:
|
|
167
|
+
mask &= time_seconds < stop_seconds
|
|
168
|
+
return mask
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _validate_correlation_inputs(
|
|
172
|
+
grouped: GroupedRoiResponses,
|
|
173
|
+
minimum_correlation: float,
|
|
174
|
+
reference: CorrelationFilterReference,
|
|
175
|
+
) -> None:
|
|
176
|
+
"""Validate grouped-response correlation inputs."""
|
|
177
|
+
if not -1.0 <= minimum_correlation <= 1.0:
|
|
178
|
+
msg = f"minimum_correlation must be between -1 and 1; got {minimum_correlation}"
|
|
179
|
+
raise ValueError(msg)
|
|
180
|
+
if reference not in {"epoch_mean", "epoch_peak"}:
|
|
181
|
+
msg = f"Unknown correlation reference {reference!r}"
|
|
182
|
+
raise ValueError(msg)
|
|
183
|
+
if grouped.data_rate_hz <= 0:
|
|
184
|
+
msg = f"data_rate_hz must be positive; got {grouped.data_rate_hz}"
|
|
185
|
+
raise ValueError(msg)
|
|
186
|
+
for trial in grouped.trials:
|
|
187
|
+
if trial.values.ndim != 2:
|
|
188
|
+
msg = (
|
|
189
|
+
f"trial values must have shape (frames, rois); got {trial.values.shape}"
|
|
190
|
+
)
|
|
191
|
+
raise ValueError(msg)
|
|
192
|
+
if trial.values.shape[1] != len(grouped.roi_labels):
|
|
193
|
+
msg = (
|
|
194
|
+
"trial ROI width does not match labels: "
|
|
195
|
+
f"{trial.values.shape[1]} values, {len(grouped.roi_labels)} labels"
|
|
196
|
+
)
|
|
197
|
+
raise ValueError(msg)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _trials_by_epoch(
|
|
201
|
+
grouped: GroupedRoiResponses,
|
|
202
|
+
) -> dict[tuple[int, str], list[RoiResponseTrial]]:
|
|
203
|
+
"""Group trials by stable epoch identity."""
|
|
204
|
+
by_epoch: dict[tuple[int, str], list[RoiResponseTrial]] = {}
|
|
205
|
+
for trial in grouped.trials:
|
|
206
|
+
by_epoch.setdefault((trial.epoch_number, trial.epoch_name), []).append(trial)
|
|
207
|
+
return by_epoch
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _aligned_epoch_cube(
|
|
211
|
+
trials: list[RoiResponseTrial],
|
|
212
|
+
grouped: GroupedRoiResponses,
|
|
213
|
+
) -> tuple[npt.NDArray[np.float64], npt.NDArray[np.float64]]:
|
|
214
|
+
"""Return trials aligned onto one relative-time frame axis."""
|
|
215
|
+
first_time = min(float(trial.time_seconds[0]) for trial in trials)
|
|
216
|
+
last_time = max(float(trial.time_seconds[-1]) for trial in trials)
|
|
217
|
+
frame_count = int(round((last_time - first_time) * grouped.data_rate_hz)) + 1
|
|
218
|
+
cube = np.full(
|
|
219
|
+
(len(trials), frame_count, len(grouped.roi_labels)),
|
|
220
|
+
np.nan,
|
|
221
|
+
dtype=np.float64,
|
|
222
|
+
)
|
|
223
|
+
for trial_index, trial in enumerate(trials):
|
|
224
|
+
offsets = np.rint(
|
|
225
|
+
(trial.time_seconds - first_time) * grouped.data_rate_hz
|
|
226
|
+
).astype(np.int64, copy=False)
|
|
227
|
+
cube[trial_index, offsets, :] = trial.values
|
|
228
|
+
time_seconds = first_time + (
|
|
229
|
+
np.arange(frame_count, dtype=np.float64) / grouped.data_rate_hz
|
|
230
|
+
)
|
|
231
|
+
return cube, time_seconds
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _reference_values_for_trial(
|
|
235
|
+
cube: npt.NDArray[np.float64],
|
|
236
|
+
finite_trial_counts: npt.NDArray[np.int64],
|
|
237
|
+
*,
|
|
238
|
+
trial_index: int,
|
|
239
|
+
) -> npt.NDArray[np.float64]:
|
|
240
|
+
"""Return a leave-one-out epoch mean for one trial when possible."""
|
|
241
|
+
if cube.shape[0] == 1:
|
|
242
|
+
return np.full(cube.shape[1:], np.nan, dtype=np.float64)
|
|
243
|
+
trial_values = cube[trial_index, :, :]
|
|
244
|
+
sums = np.nansum(cube, axis=0) - np.where(
|
|
245
|
+
np.isfinite(trial_values),
|
|
246
|
+
trial_values,
|
|
247
|
+
0.0,
|
|
248
|
+
)
|
|
249
|
+
counts = finite_trial_counts - np.isfinite(trial_values).astype(np.int64)
|
|
250
|
+
reference = np.full(cube.shape[1:], np.nan, dtype=np.float64)
|
|
251
|
+
np.divide(sums, counts, out=reference, where=counts > 0)
|
|
252
|
+
return reference
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _correlation(
|
|
256
|
+
values: npt.NDArray[np.float64],
|
|
257
|
+
reference: npt.NDArray[np.float64],
|
|
258
|
+
) -> float:
|
|
259
|
+
"""Return finite-sample Pearson correlation for two traces."""
|
|
260
|
+
mask = np.isfinite(values) & np.isfinite(reference)
|
|
261
|
+
if np.count_nonzero(mask) < 2:
|
|
262
|
+
return float("nan")
|
|
263
|
+
centered_values = values[mask] - float(np.nanmean(values[mask]))
|
|
264
|
+
centered_reference = reference[mask] - float(np.nanmean(reference[mask]))
|
|
265
|
+
denominator = float(
|
|
266
|
+
np.sqrt(np.sum(centered_values**2) * np.sum(centered_reference**2))
|
|
267
|
+
)
|
|
268
|
+
if denominator == 0.0:
|
|
269
|
+
return float("nan")
|
|
270
|
+
return float(np.sum(centered_values * centered_reference) / denominator)
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
"""Reusable numeric kernels for response post-processing.
|
|
2
|
+
|
|
3
|
+
Inputs: frame-by-channel response arrays and explicit signal-processing
|
|
4
|
+
parameters.
|
|
5
|
+
Outputs: arrays with the same shape and NaN positions handled audibly.
|
|
6
|
+
|
|
7
|
+
The functions in this module know nothing about ROIs, epochs, napari, HDF5, or
|
|
8
|
+
workflow state. They are pure signal transforms used by higher-level response
|
|
9
|
+
processing code.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from collections.abc import Iterator
|
|
13
|
+
|
|
14
|
+
import numpy as np
|
|
15
|
+
import numpy.typing as npt
|
|
16
|
+
from scipy.signal import butter, sosfiltfilt
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"butterworth_low_pass",
|
|
20
|
+
"nan_aware_moving_average",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def nan_aware_moving_average(
|
|
25
|
+
values: npt.NDArray[np.float64],
|
|
26
|
+
*,
|
|
27
|
+
window_frames: int,
|
|
28
|
+
) -> npt.NDArray[np.float64]:
|
|
29
|
+
"""Smooth frame-by-channel values with a NaN-aware moving average.
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
values: One-dimensional or ``(frames, channels)`` response values.
|
|
33
|
+
window_frames: Number of frames in the moving-average window.
|
|
34
|
+
|
|
35
|
+
Returns:
|
|
36
|
+
Smoothed values with the same shape as ``values``.
|
|
37
|
+
|
|
38
|
+
Raises:
|
|
39
|
+
ValueError: If ``window_frames`` is less than one or the input rank is
|
|
40
|
+
not one or two.
|
|
41
|
+
|
|
42
|
+
NaN samples do not contribute to neighboring averages. Output positions
|
|
43
|
+
whose whole window is NaN remain NaN.
|
|
44
|
+
"""
|
|
45
|
+
if window_frames < 1:
|
|
46
|
+
msg = f"window_frames must be at least 1; got {window_frames}"
|
|
47
|
+
raise ValueError(msg)
|
|
48
|
+
matrix, was_1d = _as_frame_channel_matrix(values)
|
|
49
|
+
if window_frames == 1:
|
|
50
|
+
return _restore_shape(matrix.copy(), was_1d=was_1d)
|
|
51
|
+
|
|
52
|
+
kernel = np.ones(window_frames, dtype=np.float64)
|
|
53
|
+
finite = np.isfinite(matrix)
|
|
54
|
+
weighted_values = np.where(finite, matrix, 0.0)
|
|
55
|
+
counts = _convolve_columns(finite.astype(np.float64), kernel)
|
|
56
|
+
sums = _convolve_columns(weighted_values, kernel)
|
|
57
|
+
smoothed = np.full(matrix.shape, np.nan, dtype=np.float64)
|
|
58
|
+
np.divide(sums, counts, out=smoothed, where=counts > 0)
|
|
59
|
+
return _restore_shape(smoothed, was_1d=was_1d)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def butterworth_low_pass(
|
|
63
|
+
values: npt.NDArray[np.float64],
|
|
64
|
+
*,
|
|
65
|
+
sample_rate_hz: float,
|
|
66
|
+
cutoff_hz: float,
|
|
67
|
+
order: int = 2,
|
|
68
|
+
) -> npt.NDArray[np.float64]:
|
|
69
|
+
"""Apply a zero-phase Butterworth low-pass filter to response values.
|
|
70
|
+
|
|
71
|
+
Args:
|
|
72
|
+
values: One-dimensional or ``(frames, channels)`` response values.
|
|
73
|
+
sample_rate_hz: Sampling rate in hertz.
|
|
74
|
+
cutoff_hz: Low-pass cutoff in hertz. Must be below Nyquist.
|
|
75
|
+
order: Butterworth filter order.
|
|
76
|
+
|
|
77
|
+
Returns:
|
|
78
|
+
Filtered values with the same shape as ``values``.
|
|
79
|
+
|
|
80
|
+
Raises:
|
|
81
|
+
ValueError: If the filter parameters or input rank are invalid.
|
|
82
|
+
|
|
83
|
+
Each finite contiguous run is filtered independently so motion-masked NaNs
|
|
84
|
+
remain NaN and do not leak into nearby valid frames. Runs too short for
|
|
85
|
+
stable zero-phase filtering are copied unchanged.
|
|
86
|
+
"""
|
|
87
|
+
_validate_low_pass_parameters(
|
|
88
|
+
sample_rate_hz=sample_rate_hz,
|
|
89
|
+
cutoff_hz=cutoff_hz,
|
|
90
|
+
order=order,
|
|
91
|
+
)
|
|
92
|
+
matrix, was_1d = _as_frame_channel_matrix(values)
|
|
93
|
+
filtered = np.full(matrix.shape, np.nan, dtype=np.float64)
|
|
94
|
+
sos = butter(order, cutoff_hz, btype="lowpass", fs=sample_rate_hz, output="sos")
|
|
95
|
+
for column_index in range(matrix.shape[1]):
|
|
96
|
+
column = matrix[:, column_index]
|
|
97
|
+
for start, stop in _finite_runs(np.isfinite(column)):
|
|
98
|
+
segment = column[start:stop]
|
|
99
|
+
filtered[start:stop, column_index] = _filter_segment_or_copy(segment, sos)
|
|
100
|
+
return _restore_shape(filtered, was_1d=was_1d)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _as_frame_channel_matrix(
|
|
104
|
+
values: npt.NDArray[np.float64],
|
|
105
|
+
) -> tuple[npt.NDArray[np.float64], bool]:
|
|
106
|
+
"""Return values as a ``(frames, channels)`` float64 matrix."""
|
|
107
|
+
array = np.asarray(values, dtype=np.float64)
|
|
108
|
+
if array.ndim == 1:
|
|
109
|
+
return array.reshape(-1, 1), True
|
|
110
|
+
if array.ndim == 2:
|
|
111
|
+
return array, False
|
|
112
|
+
msg = f"response values must be 1D or 2D; got shape {array.shape}"
|
|
113
|
+
raise ValueError(msg)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _restore_shape(
|
|
117
|
+
matrix: npt.NDArray[np.float64],
|
|
118
|
+
*,
|
|
119
|
+
was_1d: bool,
|
|
120
|
+
) -> npt.NDArray[np.float64]:
|
|
121
|
+
"""Return a filtered matrix with the original vector rank."""
|
|
122
|
+
if was_1d:
|
|
123
|
+
return matrix[:, 0]
|
|
124
|
+
return matrix
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _convolve_columns(
|
|
128
|
+
values: npt.NDArray[np.float64],
|
|
129
|
+
kernel: npt.NDArray[np.float64],
|
|
130
|
+
) -> npt.NDArray[np.float64]:
|
|
131
|
+
"""Convolve each column with the same one-dimensional kernel."""
|
|
132
|
+
convolved = np.empty(values.shape, dtype=np.float64)
|
|
133
|
+
for column_index in range(values.shape[1]):
|
|
134
|
+
column = np.convolve(
|
|
135
|
+
values[:, column_index],
|
|
136
|
+
kernel,
|
|
137
|
+
mode="same",
|
|
138
|
+
)
|
|
139
|
+
convolved[:, column_index] = _center_crop(column, values.shape[0])
|
|
140
|
+
return convolved
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _center_crop(
|
|
144
|
+
values: npt.NDArray[np.float64],
|
|
145
|
+
length: int,
|
|
146
|
+
) -> npt.NDArray[np.float64]:
|
|
147
|
+
"""Return the centered part of a convolution result."""
|
|
148
|
+
if values.shape[0] == length:
|
|
149
|
+
return values
|
|
150
|
+
start = (values.shape[0] - length) // 2
|
|
151
|
+
return values[start : start + length]
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _validate_low_pass_parameters(
|
|
155
|
+
*,
|
|
156
|
+
sample_rate_hz: float,
|
|
157
|
+
cutoff_hz: float,
|
|
158
|
+
order: int,
|
|
159
|
+
) -> None:
|
|
160
|
+
"""Validate Butterworth filter parameters."""
|
|
161
|
+
if sample_rate_hz <= 0:
|
|
162
|
+
msg = f"sample_rate_hz must be positive; got {sample_rate_hz}"
|
|
163
|
+
raise ValueError(msg)
|
|
164
|
+
if cutoff_hz <= 0:
|
|
165
|
+
msg = f"cutoff_hz must be positive; got {cutoff_hz}"
|
|
166
|
+
raise ValueError(msg)
|
|
167
|
+
nyquist_hz = sample_rate_hz / 2.0
|
|
168
|
+
if cutoff_hz >= nyquist_hz:
|
|
169
|
+
msg = (
|
|
170
|
+
"cutoff_hz must be below Nyquist; "
|
|
171
|
+
f"got {cutoff_hz} Hz for {sample_rate_hz} Hz data"
|
|
172
|
+
)
|
|
173
|
+
raise ValueError(msg)
|
|
174
|
+
if order < 1:
|
|
175
|
+
msg = f"order must be at least 1; got {order}"
|
|
176
|
+
raise ValueError(msg)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _finite_runs(mask: npt.NDArray[np.bool_]) -> Iterator[tuple[int, int]]:
|
|
180
|
+
"""Yield half-open contiguous ``True`` runs from one Boolean mask."""
|
|
181
|
+
start: int | None = None
|
|
182
|
+
for index, value in enumerate(mask):
|
|
183
|
+
if bool(value) and start is None:
|
|
184
|
+
start = index
|
|
185
|
+
elif not bool(value) and start is not None:
|
|
186
|
+
yield start, index
|
|
187
|
+
start = None
|
|
188
|
+
if start is not None:
|
|
189
|
+
yield start, int(mask.size)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _filter_segment_or_copy(
|
|
193
|
+
segment: npt.NDArray[np.float64],
|
|
194
|
+
sos: npt.NDArray[np.float64],
|
|
195
|
+
) -> npt.NDArray[np.float64]:
|
|
196
|
+
"""Filter one finite segment, copying it when it is too short."""
|
|
197
|
+
try:
|
|
198
|
+
return sosfiltfilt(sos, segment)
|
|
199
|
+
except ValueError:
|
|
200
|
+
return segment.copy()
|