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/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