fibphot 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.
- fibphot/__init__.py +6 -0
- fibphot/analysis/__init__.py +0 -0
- fibphot/analysis/aggregate.py +257 -0
- fibphot/analysis/auc.py +354 -0
- fibphot/analysis/irls.py +350 -0
- fibphot/analysis/peaks.py +1163 -0
- fibphot/analysis/photobleaching.py +290 -0
- fibphot/analysis/plotting.py +105 -0
- fibphot/analysis/report.py +56 -0
- fibphot/collection.py +207 -0
- fibphot/fit/__init__.py +0 -0
- fibphot/fit/regression.py +269 -0
- fibphot/io/__init__.py +6 -0
- fibphot/io/doric.py +435 -0
- fibphot/io/excel.py +76 -0
- fibphot/io/h5.py +321 -0
- fibphot/misc.py +11 -0
- fibphot/peaks.py +628 -0
- fibphot/pipeline.py +14 -0
- fibphot/plotting.py +594 -0
- fibphot/stages/__init__.py +22 -0
- fibphot/stages/base.py +101 -0
- fibphot/stages/baseline.py +354 -0
- fibphot/stages/control_dff.py +214 -0
- fibphot/stages/filters.py +273 -0
- fibphot/stages/normalisation.py +260 -0
- fibphot/stages/regression.py +139 -0
- fibphot/stages/smooth.py +442 -0
- fibphot/stages/trim.py +141 -0
- fibphot/state.py +309 -0
- fibphot/tags.py +130 -0
- fibphot/types.py +6 -0
- fibphot-0.1.0.dist-info/METADATA +63 -0
- fibphot-0.1.0.dist-info/RECORD +37 -0
- fibphot-0.1.0.dist-info/WHEEL +5 -0
- fibphot-0.1.0.dist-info/licenses/LICENSE.md +21 -0
- fibphot-0.1.0.dist-info/top_level.txt +1 -0
fibphot/peaks.py
ADDED
|
@@ -0,0 +1,628 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
from dataclasses import dataclass, field, replace
|
|
5
|
+
from typing import TYPE_CHECKING, Any, Literal
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
import scipy.optimize
|
|
9
|
+
import scipy.signal
|
|
10
|
+
|
|
11
|
+
from .types import FloatArray
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
import pandas as pd
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
PeakKind = Literal["peak", "valley"]
|
|
18
|
+
SelectKind = Literal["peak", "valley", "both"]
|
|
19
|
+
AreaRegion = Literal["bases", "fwhm"]
|
|
20
|
+
BaselineMode = Literal["line", "flat"]
|
|
21
|
+
FitModelName = Literal["gaussian", "lorentzian", "alpha"]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _as_1d_float(x: FloatArray | None, n: int) -> FloatArray:
|
|
25
|
+
if x is None:
|
|
26
|
+
return np.arange(n, dtype=float)
|
|
27
|
+
x = np.asarray(x, dtype=float)
|
|
28
|
+
if x.ndim != 1 or x.shape[0] != n:
|
|
29
|
+
raise ValueError(f"x must be 1D with length {n}; got {x.shape}.")
|
|
30
|
+
return x
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _interp_x_at_positions(x: FloatArray, positions: FloatArray) -> FloatArray:
|
|
34
|
+
"""
|
|
35
|
+
Convert fractional sample positions (e.g. left_ips/right_ips) into x-units.
|
|
36
|
+
"""
|
|
37
|
+
idx = np.arange(x.shape[0], dtype=float)
|
|
38
|
+
return np.interp(positions, idx, x)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _baseline_flat(y: FloatArray, left_i: int, right_i: int) -> float:
|
|
42
|
+
"""Flat baseline between two indices."""
|
|
43
|
+
return float(0.5 * (y[left_i] + y[right_i]))
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _baseline_line(
|
|
47
|
+
x: FloatArray, y: FloatArray, left_i: int, right_i: int
|
|
48
|
+
) -> FloatArray:
|
|
49
|
+
"""Linear baseline between two indices."""
|
|
50
|
+
x0 = float(x[left_i])
|
|
51
|
+
x1 = float(x[right_i])
|
|
52
|
+
if np.isclose(x1, x0):
|
|
53
|
+
return np.full_like(x, fill_value=float(y[left_i]), dtype=float)
|
|
54
|
+
|
|
55
|
+
m = (float(y[right_i]) - float(y[left_i])) / (x1 - x0)
|
|
56
|
+
c = float(y[left_i]) - m * x0
|
|
57
|
+
return m * x + c
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _r2(y: FloatArray, yhat: FloatArray) -> float:
|
|
61
|
+
"""Coefficient of determination."""
|
|
62
|
+
y = np.asarray(y, dtype=float)
|
|
63
|
+
yhat = np.asarray(yhat, dtype=float)
|
|
64
|
+
ss_res = float(np.sum((y - yhat) ** 2))
|
|
65
|
+
ss_tot = float(np.sum((y - np.mean(y)) ** 2))
|
|
66
|
+
if ss_tot < 1e-20:
|
|
67
|
+
return float("nan")
|
|
68
|
+
return 1.0 - ss_res / ss_tot
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _rmse(y: FloatArray, yhat: FloatArray) -> float:
|
|
72
|
+
"""Root mean square error."""
|
|
73
|
+
y = np.asarray(y, dtype=float)
|
|
74
|
+
yhat = np.asarray(yhat, dtype=float)
|
|
75
|
+
return float(np.sqrt(np.mean((y - yhat) ** 2)))
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def gaussian(
|
|
79
|
+
x: FloatArray, amp: float, mu: float, sigma: float, offset: float
|
|
80
|
+
) -> FloatArray:
|
|
81
|
+
"""Gaussian function."""
|
|
82
|
+
x = np.asarray(x, dtype=float)
|
|
83
|
+
sigma = max(float(sigma), 1e-12)
|
|
84
|
+
return offset + amp * np.exp(-0.5 * ((x - mu) / sigma) ** 2)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def lorentzian(
|
|
88
|
+
x: FloatArray, amp: float, x0: float, gamma: float, offset: float
|
|
89
|
+
) -> FloatArray:
|
|
90
|
+
"""Lorentzian function."""
|
|
91
|
+
x = np.asarray(x, dtype=float)
|
|
92
|
+
gamma = max(float(gamma), 1e-12)
|
|
93
|
+
return offset + amp * (gamma**2) / ((x - x0) ** 2 + gamma**2)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def alpha_transient(
|
|
97
|
+
x: FloatArray,
|
|
98
|
+
amp: float,
|
|
99
|
+
t0: float,
|
|
100
|
+
tau: float,
|
|
101
|
+
offset: float,
|
|
102
|
+
) -> FloatArray:
|
|
103
|
+
"""
|
|
104
|
+
Simple alpha-like transient:
|
|
105
|
+
offset + amp * ((t - t0)/tau) * exp(1 - (t - t0)/tau) for t >= t0
|
|
106
|
+
offset otherwise
|
|
107
|
+
|
|
108
|
+
This is a convenient, stable-ish phenomenological model for brief transients.
|
|
109
|
+
"""
|
|
110
|
+
x = np.asarray(x, dtype=float)
|
|
111
|
+
tau = max(float(tau), 1e-12)
|
|
112
|
+
dt = (x - t0) / tau
|
|
113
|
+
out = np.full_like(x, fill_value=offset, dtype=float)
|
|
114
|
+
mask = x >= t0
|
|
115
|
+
out[mask] = offset + amp * dt[mask] * np.exp(1.0 - dt[mask])
|
|
116
|
+
return out
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
_MODEL_FUNCS: dict[FitModelName, Callable[..., FloatArray]] = {
|
|
120
|
+
"gaussian": gaussian,
|
|
121
|
+
"lorentzian": lorentzian,
|
|
122
|
+
"alpha": alpha_transient,
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@dataclass(frozen=True, slots=True)
|
|
127
|
+
class PeakFit:
|
|
128
|
+
model: FitModelName
|
|
129
|
+
params: tuple[float, ...]
|
|
130
|
+
covariance: FloatArray | None
|
|
131
|
+
r2: float
|
|
132
|
+
rmse: float
|
|
133
|
+
success: bool
|
|
134
|
+
message: str | None = None
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@dataclass(frozen=True, slots=True)
|
|
138
|
+
class Peak:
|
|
139
|
+
"""
|
|
140
|
+
A single detected extremum with derived measurements.
|
|
141
|
+
|
|
142
|
+
Notes
|
|
143
|
+
-----
|
|
144
|
+
- `kind="peak"` refers to a local maximum.
|
|
145
|
+
- `kind="valley"` refers to a local minimum.
|
|
146
|
+
- Measurements are reported in x-units if x was provided, otherwise in samples.
|
|
147
|
+
"""
|
|
148
|
+
|
|
149
|
+
kind: PeakKind
|
|
150
|
+
index: int
|
|
151
|
+
x: float
|
|
152
|
+
y: float
|
|
153
|
+
|
|
154
|
+
prominence: float | None = None
|
|
155
|
+
left_base_index: int | None = None
|
|
156
|
+
right_base_index: int | None = None
|
|
157
|
+
|
|
158
|
+
height: float | None = None
|
|
159
|
+
"""Height relative to the baseline at the peak. Peaks tend to be positive, valleys negative."""
|
|
160
|
+
|
|
161
|
+
fwhm: float | None = None
|
|
162
|
+
"""Full-width at half-maximum (or half-depth for valleys), in x-units/samples."""
|
|
163
|
+
|
|
164
|
+
left_ip: float | None = None
|
|
165
|
+
right_ip: float | None = None
|
|
166
|
+
"""Interpolated left/right positions (in x-units/samples) used for width computations."""
|
|
167
|
+
|
|
168
|
+
area: float | None = None
|
|
169
|
+
"""Signed area under the (baseline-corrected) peak region. Peaks positive, valleys negative."""
|
|
170
|
+
|
|
171
|
+
fit: PeakFit | None = None
|
|
172
|
+
meta: dict[str, Any] = field(default_factory=dict)
|
|
173
|
+
|
|
174
|
+
@property
|
|
175
|
+
def amplitude(self) -> float | None:
|
|
176
|
+
"""Absolute height (useful when treating peaks and valleys uniformly)."""
|
|
177
|
+
if self.height is None:
|
|
178
|
+
return None
|
|
179
|
+
return float(abs(self.height))
|
|
180
|
+
|
|
181
|
+
def with_fit(self, fit: PeakFit) -> Peak:
|
|
182
|
+
return replace(self, fit=fit)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
@dataclass(frozen=True, slots=True)
|
|
186
|
+
class PeakSet:
|
|
187
|
+
"""
|
|
188
|
+
Collection of peaks/valleys detected from a single 1D trace.
|
|
189
|
+
"""
|
|
190
|
+
|
|
191
|
+
x: FloatArray
|
|
192
|
+
y: FloatArray
|
|
193
|
+
peaks: tuple[Peak, ...]
|
|
194
|
+
kind: SelectKind
|
|
195
|
+
meta: dict[str, Any] = field(default_factory=dict)
|
|
196
|
+
|
|
197
|
+
def __len__(self) -> int:
|
|
198
|
+
return len(self.peaks)
|
|
199
|
+
|
|
200
|
+
def indices(self, kind: SelectKind | None = None) -> list[int]:
|
|
201
|
+
if kind is None or kind == "both":
|
|
202
|
+
return [p.index for p in self.peaks]
|
|
203
|
+
return [p.index for p in self.peaks if p.kind == kind]
|
|
204
|
+
|
|
205
|
+
def to_dataframe(self) -> pd.DataFrame:
|
|
206
|
+
import pandas as pd
|
|
207
|
+
|
|
208
|
+
rows: list[dict[str, Any]] = []
|
|
209
|
+
for p in self.peaks:
|
|
210
|
+
rows.append(
|
|
211
|
+
{
|
|
212
|
+
"kind": p.kind,
|
|
213
|
+
"index": p.index,
|
|
214
|
+
"x": p.x,
|
|
215
|
+
"y": p.y,
|
|
216
|
+
"prominence": p.prominence,
|
|
217
|
+
"height": p.height,
|
|
218
|
+
"fwhm": p.fwhm,
|
|
219
|
+
"left_ip": p.left_ip,
|
|
220
|
+
"right_ip": p.right_ip,
|
|
221
|
+
"left_base_index": p.left_base_index,
|
|
222
|
+
"right_base_index": p.right_base_index,
|
|
223
|
+
"area": p.area,
|
|
224
|
+
"fit_model": None if p.fit is None else p.fit.model,
|
|
225
|
+
"fit_r2": None if p.fit is None else p.fit.r2,
|
|
226
|
+
"fit_rmse": None if p.fit is None else p.fit.rmse,
|
|
227
|
+
"fit_success": None if p.fit is None else p.fit.success,
|
|
228
|
+
}
|
|
229
|
+
)
|
|
230
|
+
return pd.DataFrame(rows)
|
|
231
|
+
|
|
232
|
+
def plot(
|
|
233
|
+
self,
|
|
234
|
+
*,
|
|
235
|
+
ax=None,
|
|
236
|
+
show_bases: bool = False,
|
|
237
|
+
annotate: bool = False,
|
|
238
|
+
label: str | None = None,
|
|
239
|
+
):
|
|
240
|
+
"""
|
|
241
|
+
Quick visualisation: signal + markers at detected peaks.
|
|
242
|
+
"""
|
|
243
|
+
import matplotlib.pyplot as plt
|
|
244
|
+
|
|
245
|
+
if ax is None:
|
|
246
|
+
fig, ax = plt.subplots(dpi=150)
|
|
247
|
+
else:
|
|
248
|
+
fig = ax.figure
|
|
249
|
+
|
|
250
|
+
ax.plot(self.x, self.y, label=label)
|
|
251
|
+
|
|
252
|
+
xs = [p.x for p in self.peaks if p.kind == "peak"]
|
|
253
|
+
ys = [p.y for p in self.peaks if p.kind == "peak"]
|
|
254
|
+
if xs:
|
|
255
|
+
ax.scatter(xs, ys, label="peaks")
|
|
256
|
+
|
|
257
|
+
xs = [p.x for p in self.peaks if p.kind == "valley"]
|
|
258
|
+
ys = [p.y for p in self.peaks if p.kind == "valley"]
|
|
259
|
+
if xs:
|
|
260
|
+
ax.scatter(xs, ys, label="valleys")
|
|
261
|
+
|
|
262
|
+
if show_bases:
|
|
263
|
+
for p in self.peaks:
|
|
264
|
+
if p.left_base_index is None or p.right_base_index is None:
|
|
265
|
+
continue
|
|
266
|
+
ax.scatter(
|
|
267
|
+
[self.x[p.left_base_index], self.x[p.right_base_index]],
|
|
268
|
+
[self.y[p.left_base_index], self.y[p.right_base_index]],
|
|
269
|
+
marker="x",
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
if annotate:
|
|
273
|
+
for p in self.peaks:
|
|
274
|
+
ax.annotate(
|
|
275
|
+
f"{p.kind}@{p.x:.3g}",
|
|
276
|
+
(p.x, p.y),
|
|
277
|
+
textcoords="offset points",
|
|
278
|
+
xytext=(4, 4),
|
|
279
|
+
fontsize=8,
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
ax.legend(frameon=False, fontsize=8)
|
|
283
|
+
return fig, ax
|
|
284
|
+
|
|
285
|
+
def fit_all(
|
|
286
|
+
self,
|
|
287
|
+
*,
|
|
288
|
+
model: FitModelName = "gaussian",
|
|
289
|
+
window: float | None = None,
|
|
290
|
+
window_samples: int | None = None,
|
|
291
|
+
maxfev: int = 5000,
|
|
292
|
+
) -> PeakSet:
|
|
293
|
+
"""
|
|
294
|
+
Fit a model to each peak in the set.
|
|
295
|
+
|
|
296
|
+
You can specify the fitting window as either:
|
|
297
|
+
- `window` in x-units (seconds), or
|
|
298
|
+
- `window_samples` in sample count.
|
|
299
|
+
|
|
300
|
+
If neither is provided, defaults to a modest window around each peak
|
|
301
|
+
based on its FWHM when available.
|
|
302
|
+
"""
|
|
303
|
+
fitted: list[Peak] = []
|
|
304
|
+
for p in self.peaks:
|
|
305
|
+
fitted.append(
|
|
306
|
+
fit_peak(
|
|
307
|
+
self.x,
|
|
308
|
+
self.y,
|
|
309
|
+
p,
|
|
310
|
+
model=model,
|
|
311
|
+
window=window,
|
|
312
|
+
window_samples=window_samples,
|
|
313
|
+
maxfev=maxfev,
|
|
314
|
+
)
|
|
315
|
+
)
|
|
316
|
+
return replace(self, peaks=tuple(fitted))
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def _fit_window_indices(
|
|
320
|
+
x: FloatArray,
|
|
321
|
+
peak: Peak,
|
|
322
|
+
*,
|
|
323
|
+
window: float | None,
|
|
324
|
+
window_samples: int | None,
|
|
325
|
+
) -> tuple[int, int]:
|
|
326
|
+
n = x.shape[0]
|
|
327
|
+
i0 = peak.index
|
|
328
|
+
|
|
329
|
+
if window_samples is not None:
|
|
330
|
+
half = max(int(window_samples // 2), 1)
|
|
331
|
+
lo = max(0, i0 - half)
|
|
332
|
+
hi = min(n, i0 + half + 1)
|
|
333
|
+
return lo, hi
|
|
334
|
+
|
|
335
|
+
if window is not None:
|
|
336
|
+
half = float(window) / 2.0
|
|
337
|
+
lo_x = peak.x - half
|
|
338
|
+
hi_x = peak.x + half
|
|
339
|
+
lo = int(np.searchsorted(x, lo_x, side="left"))
|
|
340
|
+
hi = int(np.searchsorted(x, hi_x, side="right"))
|
|
341
|
+
return max(0, lo), min(n, hi)
|
|
342
|
+
|
|
343
|
+
# default: use FWHM if available, otherwise a small fallback region
|
|
344
|
+
if peak.fwhm is not None and np.isfinite(peak.fwhm):
|
|
345
|
+
half = float(peak.fwhm) * 2.0
|
|
346
|
+
lo_x = peak.x - half
|
|
347
|
+
hi_x = peak.x + half
|
|
348
|
+
lo = int(np.searchsorted(x, lo_x, side="left"))
|
|
349
|
+
hi = int(np.searchsorted(x, hi_x, side="right"))
|
|
350
|
+
return max(0, lo), min(n, hi)
|
|
351
|
+
|
|
352
|
+
half = 25
|
|
353
|
+
lo = max(0, i0 - half)
|
|
354
|
+
hi = min(n, i0 + half + 1)
|
|
355
|
+
return lo, hi
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def fit_peak(
|
|
359
|
+
x: FloatArray,
|
|
360
|
+
y: FloatArray,
|
|
361
|
+
peak: Peak,
|
|
362
|
+
*,
|
|
363
|
+
model: FitModelName = "gaussian",
|
|
364
|
+
window: float | None = None,
|
|
365
|
+
window_samples: int | None = None,
|
|
366
|
+
maxfev: int = 5000,
|
|
367
|
+
) -> Peak:
|
|
368
|
+
"""
|
|
369
|
+
Fit a parametric model to a single peak.
|
|
370
|
+
|
|
371
|
+
Returns a new Peak with `.fit` populated. Failures are captured in the fit.
|
|
372
|
+
"""
|
|
373
|
+
x = np.asarray(x, dtype=float)
|
|
374
|
+
y = np.asarray(y, dtype=float)
|
|
375
|
+
|
|
376
|
+
func = _MODEL_FUNCS[model]
|
|
377
|
+
lo, hi = _fit_window_indices(
|
|
378
|
+
x, peak, window=window, window_samples=window_samples
|
|
379
|
+
)
|
|
380
|
+
|
|
381
|
+
xf = x[lo:hi]
|
|
382
|
+
yf = y[lo:hi]
|
|
383
|
+
|
|
384
|
+
if xf.size < 5:
|
|
385
|
+
return peak.with_fit(
|
|
386
|
+
PeakFit(
|
|
387
|
+
model=model,
|
|
388
|
+
params=(),
|
|
389
|
+
covariance=None,
|
|
390
|
+
r2=float("nan"),
|
|
391
|
+
rmse=float("nan"),
|
|
392
|
+
success=False,
|
|
393
|
+
message="Not enough points in fitting window.",
|
|
394
|
+
)
|
|
395
|
+
)
|
|
396
|
+
|
|
397
|
+
# guesses / bounds
|
|
398
|
+
offset0 = float(np.median(yf))
|
|
399
|
+
amp0 = float(peak.y - offset0)
|
|
400
|
+
if peak.kind == "valley":
|
|
401
|
+
# keep the sign consistent for the optimiser
|
|
402
|
+
amp0 = float(peak.y - offset0)
|
|
403
|
+
|
|
404
|
+
mu0 = float(peak.x)
|
|
405
|
+
|
|
406
|
+
# a rough scale guess
|
|
407
|
+
if peak.fwhm is not None and np.isfinite(peak.fwhm) and peak.fwhm > 0:
|
|
408
|
+
sigma0 = float(peak.fwhm) / 2.355
|
|
409
|
+
gamma0 = float(peak.fwhm) / 2.0
|
|
410
|
+
tau0 = float(max(peak.fwhm, 1e-6))
|
|
411
|
+
else:
|
|
412
|
+
dx = float(np.median(np.diff(xf))) if xf.size > 2 else 1.0
|
|
413
|
+
sigma0 = 3.0 * dx
|
|
414
|
+
gamma0 = 3.0 * dx
|
|
415
|
+
tau0 = 3.0 * dx
|
|
416
|
+
|
|
417
|
+
try:
|
|
418
|
+
if model == "gaussian":
|
|
419
|
+
p0 = (amp0, mu0, sigma0, offset0)
|
|
420
|
+
bounds = (
|
|
421
|
+
(-np.inf, float(xf.min()), 1e-12, -np.inf),
|
|
422
|
+
(np.inf, float(xf.max()), np.inf, np.inf),
|
|
423
|
+
)
|
|
424
|
+
elif model == "lorentzian":
|
|
425
|
+
p0 = (amp0, mu0, gamma0, offset0)
|
|
426
|
+
bounds = (
|
|
427
|
+
(-np.inf, float(xf.min()), 1e-12, -np.inf),
|
|
428
|
+
(np.inf, float(xf.max()), np.inf, np.inf),
|
|
429
|
+
)
|
|
430
|
+
else: # alpha
|
|
431
|
+
p0 = (amp0, mu0, tau0, offset0)
|
|
432
|
+
bounds = (
|
|
433
|
+
(-np.inf, float(xf.min()), 1e-12, -np.inf),
|
|
434
|
+
(np.inf, float(xf.max()), np.inf, np.inf),
|
|
435
|
+
)
|
|
436
|
+
|
|
437
|
+
popt, pcov = scipy.optimize.curve_fit(
|
|
438
|
+
f=func,
|
|
439
|
+
xdata=xf,
|
|
440
|
+
ydata=yf,
|
|
441
|
+
p0=p0,
|
|
442
|
+
bounds=bounds,
|
|
443
|
+
maxfev=maxfev,
|
|
444
|
+
)
|
|
445
|
+
yhat = func(xf, *popt)
|
|
446
|
+
fit = PeakFit(
|
|
447
|
+
model=model,
|
|
448
|
+
params=tuple(float(v) for v in popt),
|
|
449
|
+
covariance=np.asarray(pcov, dtype=float),
|
|
450
|
+
r2=_r2(yf, yhat),
|
|
451
|
+
rmse=_rmse(yf, yhat),
|
|
452
|
+
success=True,
|
|
453
|
+
)
|
|
454
|
+
except Exception as exc: # noqa: BLE001
|
|
455
|
+
fit = PeakFit(
|
|
456
|
+
model=model,
|
|
457
|
+
params=(),
|
|
458
|
+
covariance=None,
|
|
459
|
+
r2=float("nan"),
|
|
460
|
+
rmse=float("nan"),
|
|
461
|
+
success=False,
|
|
462
|
+
message=str(exc),
|
|
463
|
+
)
|
|
464
|
+
|
|
465
|
+
return peak.with_fit(fit)
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
@dataclass(frozen=True, slots=True)
|
|
469
|
+
class PeakFinder:
|
|
470
|
+
"""
|
|
471
|
+
Robust peak/valley detection and analysis for 1D traces.
|
|
472
|
+
"""
|
|
473
|
+
|
|
474
|
+
kind: SelectKind = "peak"
|
|
475
|
+
|
|
476
|
+
# scipy.signal.find_peaks parameters.
|
|
477
|
+
height: float | tuple[float, float] | None = None
|
|
478
|
+
threshold: float | tuple[float, float] | None = None
|
|
479
|
+
distance: int | None = None
|
|
480
|
+
prominence: float | tuple[float, float] | None = None
|
|
481
|
+
width: float | tuple[float, float] | None = None
|
|
482
|
+
wlen: int | None = None
|
|
483
|
+
plateau_size: int | tuple[int, int] | None = None
|
|
484
|
+
|
|
485
|
+
rel_height: float = 0.5
|
|
486
|
+
baseline_mode: BaselineMode = "line"
|
|
487
|
+
area_region: AreaRegion = "bases"
|
|
488
|
+
|
|
489
|
+
def fit(self, y: FloatArray, *, x: FloatArray | None = None) -> PeakSet:
|
|
490
|
+
y = np.asarray(y, dtype=float)
|
|
491
|
+
if y.ndim != 1:
|
|
492
|
+
raise ValueError("y must be 1D.")
|
|
493
|
+
|
|
494
|
+
x = _as_1d_float(x, y.shape[0])
|
|
495
|
+
|
|
496
|
+
peaks: list[Peak] = []
|
|
497
|
+
|
|
498
|
+
if self.kind in ("peak", "both"):
|
|
499
|
+
peaks.extend(self._fit_one_kind(y, x, kind="peak"))
|
|
500
|
+
if self.kind in ("valley", "both"):
|
|
501
|
+
peaks.extend(self._fit_one_kind(y, x, kind="valley"))
|
|
502
|
+
|
|
503
|
+
peaks.sort(key=lambda p: p.index)
|
|
504
|
+
|
|
505
|
+
meta = {
|
|
506
|
+
"kind": self.kind,
|
|
507
|
+
"rel_height": self.rel_height,
|
|
508
|
+
"baseline_mode": self.baseline_mode,
|
|
509
|
+
"area_region": self.area_region,
|
|
510
|
+
}
|
|
511
|
+
return PeakSet(x=x, y=y, peaks=tuple(peaks), kind=self.kind, meta=meta)
|
|
512
|
+
|
|
513
|
+
def _find_peaks(self, y: FloatArray) -> tuple[np.ndarray, dict[str, Any]]:
|
|
514
|
+
kwargs: dict[str, Any] = {
|
|
515
|
+
"height": self.height,
|
|
516
|
+
"threshold": self.threshold,
|
|
517
|
+
"distance": self.distance,
|
|
518
|
+
"prominence": self.prominence,
|
|
519
|
+
"width": self.width,
|
|
520
|
+
"wlen": self.wlen,
|
|
521
|
+
"plateau_size": self.plateau_size,
|
|
522
|
+
}
|
|
523
|
+
kwargs = {k: v for k, v in kwargs.items() if v is not None}
|
|
524
|
+
idx, props = scipy.signal.find_peaks(y, **kwargs)
|
|
525
|
+
return idx, props
|
|
526
|
+
|
|
527
|
+
def _fit_one_kind(
|
|
528
|
+
self, y: FloatArray, x: FloatArray, *, kind: PeakKind
|
|
529
|
+
) -> list[Peak]:
|
|
530
|
+
if kind == "peak":
|
|
531
|
+
y_work = y
|
|
532
|
+
sign = 1.0
|
|
533
|
+
else:
|
|
534
|
+
y_work = -y
|
|
535
|
+
sign = -1.0
|
|
536
|
+
|
|
537
|
+
idx, _ = self._find_peaks(y_work)
|
|
538
|
+
if idx.size == 0:
|
|
539
|
+
return []
|
|
540
|
+
|
|
541
|
+
# prominences and bases
|
|
542
|
+
prom, left_bases, right_bases = scipy.signal.peak_prominences(
|
|
543
|
+
y_work,
|
|
544
|
+
idx,
|
|
545
|
+
wlen=self.wlen,
|
|
546
|
+
)
|
|
547
|
+
|
|
548
|
+
# widths at rel_height (FWHM if rel_height=0.5)
|
|
549
|
+
widths, width_heights, left_ips, right_ips = scipy.signal.peak_widths(
|
|
550
|
+
y_work,
|
|
551
|
+
idx,
|
|
552
|
+
rel_height=self.rel_height,
|
|
553
|
+
wlen=self.wlen,
|
|
554
|
+
)
|
|
555
|
+
|
|
556
|
+
# map fractional positions to x-units
|
|
557
|
+
left_ip_x = _interp_x_at_positions(x, left_ips.astype(float))
|
|
558
|
+
right_ip_x = _interp_x_at_positions(x, right_ips.astype(float))
|
|
559
|
+
|
|
560
|
+
out: list[Peak] = []
|
|
561
|
+
|
|
562
|
+
for j, i0 in enumerate(idx.tolist()):
|
|
563
|
+
lb = int(left_bases[j])
|
|
564
|
+
rb = int(right_bases[j])
|
|
565
|
+
|
|
566
|
+
# baseline at peak
|
|
567
|
+
if self.baseline_mode == "flat":
|
|
568
|
+
base_at_peak = _baseline_flat(y, lb, rb)
|
|
569
|
+
base_line = None
|
|
570
|
+
else:
|
|
571
|
+
base_line = _baseline_line(x, y, lb, rb)
|
|
572
|
+
base_at_peak = float(base_line[i0])
|
|
573
|
+
|
|
574
|
+
height = float(y[i0] - base_at_peak)
|
|
575
|
+
|
|
576
|
+
# width in x-units
|
|
577
|
+
fwhm = float(right_ip_x[j] - left_ip_x[j])
|
|
578
|
+
|
|
579
|
+
# area under curve over chosen region
|
|
580
|
+
if self.area_region == "fwhm":
|
|
581
|
+
a_lo_x = float(left_ip_x[j])
|
|
582
|
+
a_hi_x = float(right_ip_x[j])
|
|
583
|
+
lo = int(np.searchsorted(x, a_lo_x, side="left"))
|
|
584
|
+
hi = int(np.searchsorted(x, a_hi_x, side="right"))
|
|
585
|
+
lo = max(0, lo)
|
|
586
|
+
hi = min(x.shape[0], hi)
|
|
587
|
+
else: # bases
|
|
588
|
+
lo = min(lb, rb)
|
|
589
|
+
hi = max(lb, rb) + 1
|
|
590
|
+
|
|
591
|
+
if hi - lo >= 2:
|
|
592
|
+
xs = x[lo:hi]
|
|
593
|
+
ys = y[lo:hi]
|
|
594
|
+
if self.baseline_mode == "flat":
|
|
595
|
+
base = float(_baseline_flat(y, lb, rb))
|
|
596
|
+
area = float(np.trapezoid(ys - base, xs))
|
|
597
|
+
else:
|
|
598
|
+
assert base_line is not None
|
|
599
|
+
area = float(np.trapezoid(ys - base_line[lo:hi], xs))
|
|
600
|
+
else:
|
|
601
|
+
area = float("nan")
|
|
602
|
+
|
|
603
|
+
# convert prominence and width_height back to original orientation
|
|
604
|
+
prominence = float(prom[j]) # always positive in y_work space
|
|
605
|
+
|
|
606
|
+
# width_height is in y_work space; map back to y space
|
|
607
|
+
# for peaks: y_work == y, so same; for valleys: y_work == -y
|
|
608
|
+
width_height_y = float(sign * width_heights[j])
|
|
609
|
+
|
|
610
|
+
out.append(
|
|
611
|
+
Peak(
|
|
612
|
+
kind=kind,
|
|
613
|
+
index=int(i0),
|
|
614
|
+
x=float(x[i0]),
|
|
615
|
+
y=float(y[i0]),
|
|
616
|
+
prominence=prominence,
|
|
617
|
+
left_base_index=lb,
|
|
618
|
+
right_base_index=rb,
|
|
619
|
+
height=height,
|
|
620
|
+
fwhm=fwhm,
|
|
621
|
+
left_ip=float(left_ip_x[j]),
|
|
622
|
+
right_ip=float(right_ip_x[j]),
|
|
623
|
+
area=area,
|
|
624
|
+
meta={"width_height": width_height_y},
|
|
625
|
+
)
|
|
626
|
+
)
|
|
627
|
+
|
|
628
|
+
return out
|
fibphot/pipeline.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
|
|
5
|
+
from .state import PhotometryState
|
|
6
|
+
|
|
7
|
+
StageFn = Callable[[PhotometryState], PhotometryState]
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def run(state: PhotometryState, *stages: StageFn) -> PhotometryState:
|
|
11
|
+
"""Run a sequence of stages on a PhotometryState."""
|
|
12
|
+
for stage in stages:
|
|
13
|
+
state = stage(state)
|
|
14
|
+
return state
|