Repository navigation
Expand file tree
/
Copy pathstreaming_waveforms.py
More file actions
2026 lines (1607 loc) · 55.5 KB
/
Copy pathstreaming_waveforms.py
File metadata and controls
2026 lines (1607 loc) · 55.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
"""
streaming_waveforms.py
Full reimplementation of:
- load_cupid_waveform
- CR–RC², triangular, trapezoid filters
- Baseline RMS estimators (MAD, P², t-digest)
- Triggering
Plus streaming-capable backends suitable for GUI / long files:
- CUPID binary reader in streaming mode
- Streaming filters
- Streaming RMS estimators
- Streaming trigger state machine
"""
from __future__ import annotations
import os
import struct
from dataclasses import dataclass
from typing import Any, Callable, Dict, List, Optional, Protocol, Union, Iterator
import numpy as np
# ----------------------------------------------------------------------
# CUPID waveform loader (batch) + streaming reader
# ----------------------------------------------------------------------
def load_cupid_waveform(
path: str,
seconds: Union[float, str] = "all",
adc_units: bool = False,
scale: Optional[float] = None,
):
"""
Load CUPID waveform from binary file, preserving the DC level.
Parameters
----------
path : str
Full path to .bin file.
seconds : float or "all"
Duration to load. If "all", loads the full run.
adc_units : bool
If True, return ADC counts (24-bit) instead of Volts.
scale : float or None
Optional multiplicative factor (e.g. convert to mV, A, etc.)
Returns
-------
t : ndarray
Time axis (seconds)
y : ndarray
Waveform in Volts (or ADC units, or scaled units)
fs : float
Sampling frequency
fullscale : float
ADC full-scale voltage from header
"""
# === read header ===
with open(path, "rb") as f:
header = f.read(12)
magic, fs, fullscale = struct.unpack("<Iff", header)
# === number of samples ===
filesize = os.path.getsize(path)
bytes_data = filesize - 12
total_samples = bytes_data // 4
if seconds == "all":
N = total_samples
else:
N = min(int(fs * float(seconds)), total_samples)
# === load raw 32-bit samples in streaming blocks (but return full arrays) ===
# block size in samples (tunable)
block_size = 1_000_000
y_chunks: List[np.ndarray] = []
t_chunks: List[np.ndarray] = []
remaining = N
offset = 0
with open(path, "rb") as f:
f.seek(12) # skip header
while remaining > 0:
count = min(block_size, remaining)
data = np.fromfile(f, dtype="<u4", count=count)
if data.size == 0:
break
# === decode 24-bit ADC data ===
ADC_raw = (data >> 8) & 0xFFFFFF # still uint32
if adc_units:
# return raw 24-bit ADC counts
y_block = ADC_raw.astype(np.float64)
else:
# convert ADC → Volt avoiding uint32 underflow
ADC_centered = ADC_raw.astype(np.int32) - 0x800000
y_block = fullscale * ADC_centered / 0x800000
# === optional physical scaling (e.g. -> mV or amps) ===
if scale is not None:
y_block = y_block * float(scale)
# === time axis for this block ===
t0 = offset / fs
t_block = t0 + np.arange(data.size, dtype=np.float64) / fs
y_chunks.append(y_block)
t_chunks.append(t_block)
remaining -= data.size
offset += data.size
if not y_chunks:
return np.array([]), np.array([]), fs, fullscale
y = np.concatenate(y_chunks)
t = np.concatenate(t_chunks)
return t, y, fs, fullscale
class CupidWaveformReader:
"""
Streaming reader for CUPID binary waveforms.
Usage
-----
reader = CupidWaveformReader(path, adc_units=False, scale=None, block_size=100000)
for t_block, y_block in reader:
... process block ...
Attributes
----------
fs : float
Sampling frequency [Hz]
fullscale : float
ADC full-scale voltage from header
total_samples : int
Total number of samples in the file
"""
def __init__(
self,
path: str,
*,
adc_units: bool = False,
scale: Optional[float] = None,
block_size: int = 100_000,
) -> None:
self.path = path
self.adc_units = adc_units
self.scale = None if scale is None else float(scale)
self.block_size = int(block_size)
self._file = open(path, "rb")
header = self._file.read(12)
self.magic, self.fs, self.fullscale = struct.unpack("<Iff", header)
filesize = os.path.getsize(path)
bytes_data = filesize - 12
self.total_samples = bytes_data // 4
self._read_samples = 0
self._closed = False
def __iter__(self) -> Iterator[tuple[np.ndarray, np.ndarray]]:
return self
def __next__(self) -> tuple[np.ndarray, np.ndarray]:
if self._closed or self._read_samples >= self.total_samples:
self.close()
raise StopIteration
remaining = self.total_samples - self._read_samples
count = min(self.block_size, remaining)
data = np.fromfile(self._file, dtype="<u4", count=count)
if data.size == 0:
self.close()
raise StopIteration
ADC_raw = (data >> 8) & 0xFFFFFF
if self.adc_units:
y = ADC_raw.astype(np.float64)
else:
ADC_centered = ADC_raw.astype(np.int32) - 0x800000
y = self.fullscale * ADC_centered / 0x800000
if self.scale is not None:
y = y * self.scale
t0 = self._read_samples / self.fs
t = t0 + np.arange(data.size, dtype=np.float64) / self.fs
self._read_samples += data.size
return t, y
def read_samples(self, n_samples: int) -> tuple[np.ndarray, np.ndarray]:
"""
Read up to n_samples in one go.
Returns
-------
t_block, y_block : np.ndarray
Time axis and waveform block.
"""
if self._closed or self._read_samples >= self.total_samples:
return np.array([]), np.array([])
remaining = self.total_samples - self._read_samples
count = min(int(n_samples), remaining)
data = np.fromfile(self._file, dtype="<u4", count=count)
if data.size == 0:
self.close()
return np.array([]), np.array([])
ADC_raw = (data >> 8) & 0xFFFFFF
if self.adc_units:
y = ADC_raw.astype(np.float64)
else:
ADC_centered = ADC_raw.astype(np.int32) - 0x800000
y = self.fullscale * ADC_centered / 0x800000
if self.scale is not None:
y = y * self.scale
t0 = self._read_samples / self.fs
t = t0 + np.arange(data.size, dtype=np.float64) / self.fs
self._read_samples += data.size
return t, y
def close(self) -> None:
if not self._closed:
try:
self._file.close()
finally:
self._closed = True
def remaining_samples(self) -> int:
return max(0, self.total_samples - self._read_samples)
# ----------------------------------------------------------------------
# Optional Numba
# ----------------------------------------------------------------------
HAVE_NUMBA = False
try:
from numba import njit # type: ignore
print("Numba found; JIT acceleration enabled.")
HAVE_NUMBA = True
except ImportError: # pragma: no cover - optional dependency
print("Numba not found; proceeding without JIT acceleration.")
def njit(*args, **kwargs): # type: ignore
# Dummy decorator if numba is not installed
def deco(f):
return f
return deco
# ----------------------------------------------------------------------
# Units handling (dependency-injected, completely separated)
# ----------------------------------------------------------------------
try:
import pint # type: ignore
_HAVE_PINT = True
except ImportError: # pragma: no cover - optional dependency
pint = None # type: ignore
_HAVE_PINT = False
NumberLike = Union[int, float]
TimeLike = Union[int, float, str, Any]
FreqLike = Union[int, float, str, Any]
class UnitsAdapter(Protocol):
"""
Abstract interface for unit handling.
Implementations must convert arbitrary user inputs to *floats*:
- Frequencies in Hz
- Times in seconds
The rest of the library only sees plain floats.
"""
def to_frequency_hz(self, value: FreqLike) -> float:
...
def to_time_s(self, value: TimeLike) -> float:
...
class SIUnitsAdapter:
"""
Minimal adapter assuming SI floats.
- float/int => used as-is (Hz or s)
- str => parsed via float(value); no units allowed.
This is the fallback if pint is not available.
"""
def to_frequency_hz(self, value: FreqLike) -> float:
if isinstance(value, str):
return float(value)
return float(value)
def to_time_s(self, value: TimeLike) -> float:
if isinstance(value, str):
return float(value)
return float(value)
if _HAVE_PINT:
class PintUnitsAdapter:
"""
Units adapter using pint.
Allows:
- "2 kHz"
- "10 ms"
- pint.Quantity
- floats (SI)
"""
def __init__(
self, ureg: Optional["pint.UnitRegistry"] = None
) -> None: # type: ignore[name-defined]
self.ureg = ureg or pint.UnitRegistry() # type: ignore[arg-type]
def to_frequency_hz(self, value: FreqLike) -> float:
if isinstance(value, str):
q = self.ureg.Quantity(value)
return q.to(self.ureg.hertz).magnitude
if isinstance(value, self.ureg.Quantity):
return value.to(self.ureg.hertz).magnitude
return float(value)
def to_time_s(self, value: TimeLike) -> float:
if isinstance(value, str):
q = self.ureg.Quantity(value)
return q.to(self.ureg.second).magnitude
if isinstance(value, self.ureg.Quantity):
return value.to(self.ureg.second).magnitude
return float(value)
def get_default_units_adapter() -> UnitsAdapter:
"""
Default units adapter:
- PintUnitsAdapter if pint is installed
- SIUnitsAdapter otherwise
"""
if _HAVE_PINT:
return PintUnitsAdapter() # type: ignore[return-value]
return SIUnitsAdapter()
# ----------------------------------------------------------------------
# Result object (dict-like, with convenience methods)
# ----------------------------------------------------------------------
@dataclass
class FilterResult:
stages: List[np.ndarray]
params: Dict[str, Any]
def __getitem__(self, key: str) -> Any:
if key == "stages":
return self.stages
if key == "params":
return self.params
raise KeyError(f"Unknown key {key!r}; valid keys are 'stages' and 'params'")
def get_filtered_waveform(self) -> np.ndarray:
"""Return the last stage (final shaped waveform)."""
return self.stages[-1]
# Type alias for a filter implementation taking x and returning FilterResult
FilterImpl = Callable[[np.ndarray], FilterResult]
# ----------------------------------------------------------------------
# 1) CR–RC² Filter (single-τ, core implementation with Numba loop)
# from https://inspirehep.net/files/b3953dda9b8dff1e5fa260e274279ec1
# ----------------------------------------------------------------------
@njit(cache=True, fastmath=True)
def _crrc2_inner_loop(
x: np.ndarray, alpha: float, beta_RC: float, beta_CR: float
) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
"""
Numba-accelerated inner loop for CR–RC².
Parameters
----------
x : 1D float64 array
alpha, beta_RC, beta_CR : floats
Returns
-------
(y1, y2, y3) : three 1D float64 arrays
"""
N = x.shape[0]
y1 = np.empty(N, dtype=np.float64)
y2 = np.empty(N, dtype=np.float64)
y3 = np.empty(N, dtype=np.float64)
a = alpha
b_rc = beta_RC
b_cr = beta_CR
x0 = x[0]
y1[0] = x0
y2[0] = 0.0
y3[0] = 0.0
a = alpha
b_rc = beta_RC
b_cr = beta_CR
for n in range(1, N):
y1m1 = y1[n - 1]
xn = x[n]
y1n = a * y1m1 + b_rc * xn
y1[n] = y1n
dy1 = y1n - y1m1
y2m1 = y2[n - 1]
y2n = a * y2m1 + b_cr * dy1
y2[n] = y2n
dy2 = y2n - y2m1
y3[n] = a * y3[n - 1] + b_cr * dy2
return y1, y2, y3
def _crrc2_core(
x: np.ndarray,
fs_hz: float,
rise_time_s: float,
m: float = 1.0,
) -> FilterResult:
"""
Core CR–RC² implementation (no units, floats only).
Uses Numba-accelerated inner loop if numba is available.
"""
x = np.asarray(x, dtype=np.float64)
if x.ndim != 1:
raise ValueError("x must be a 1D array")
Ts = 1.0 / fs_hz # [s]
tau = m * rise_time_s # [s]
Ts_over_tau = Ts / tau
tau_over_Ts = tau / Ts
alpha = float(np.exp(-Ts_over_tau))
beta_RC = 1.0 - alpha
beta_CR = (1.0 - alpha) * tau_over_Ts
y1, y2, y3 = _crrc2_inner_loop(x, alpha, beta_RC, beta_CR)
params = {
"samp_freq_hz": fs_hz,
"Ts_s": Ts,
"rise_time_s": rise_time_s,
"tau_s": tau,
"m": m,
"alpha": alpha,
"beta_RC": beta_RC,
"beta_CR": beta_CR,
"tau_over_Ts": tau_over_Ts,
}
stages = [y1, y2, y3]
return FilterResult(stages=stages, params=params)
def crrc2_filter(
x: Optional[np.ndarray] = None,
*,
samp_freq: FreqLike,
rise_time: TimeLike,
m: float = 1.0,
units: Optional[UnitsAdapter] = None,
) -> Union[FilterResult, FilterImpl]:
"""
User-facing CR–RC² filter.
Usage
-----
1) Immediate filtering:
result = crrc2_filter(x, samp_freq="2 kHz", rise_time="10 ms")
2) Preconfigured filter (for later use / triggering):
filt = crrc2_filter(samp_freq=2000, rise_time=0.01)
result = filt(x)
Returns
-------
- If x is not None: FilterResult
- If x is None: Callable[[np.ndarray], FilterResult]
"""
units = units or get_default_units_adapter()
fs_hz = units.to_frequency_hz(samp_freq)
rt_s = units.to_time_s(rise_time)
def impl(data: np.ndarray) -> FilterResult:
return _crrc2_core(data, fs_hz=fs_hz, rise_time_s=rt_s, m=m)
if x is None:
return impl
return impl(np.asarray(x, dtype=np.float64))
# ----------------------------------------------------------------------
# 2) Triangular filter
# ----------------------------------------------------------------------
def _triangular_core(
x: np.ndarray,
fs_hz: float,
rise_time_s: float,
normalize: bool = True,
) -> FilterResult:
"""
Symmetric triangular shaping using cumulative sums, float-only core.
(Already highly optimized via NumPy; loops are minimal.)
"""
x = np.asarray(x, dtype=np.float64)
if x.ndim != 1:
raise ValueError("x must be a 1D array")
Ts = 1.0 / fs_hz
L = max(1, int(round(rise_time_s / Ts)))
N = x.size
# cumulative sum with S[0] = 0
S = np.empty(N + 1, dtype=np.float64)
S[0] = 0.0
np.cumsum(x, out=S[1:])
y = np.zeros(N, dtype=np.float64)
if 2 * L <= N:
# corresponds to n indices from 2L-1 to N-1
part = S[2 * L :] - 2.0 * S[L:-L] + S[:-2 * L]
if normalize:
part /= float(L)
y[2 * L - 1 :] = part
params = {
"samp_freq_hz": fs_hz,
"Ts_s": Ts,
"rise_time_s": rise_time_s,
"L": L,
"normalized": normalize,
}
stages = [y]
return FilterResult(stages=stages, params=params)
def triangular_filter(
x: Optional[np.ndarray] = None,
*,
samp_freq: FreqLike,
rise_time: TimeLike,
normalize: bool = True,
units: Optional[UnitsAdapter] = None,
) -> Union[FilterResult, FilterImpl]:
"""
Triangular shaper (moving-slope) using cumulative sums.
Same calling convention as crrc2_filter.
"""
units = units or get_default_units_adapter()
fs_hz = units.to_frequency_hz(samp_freq)
rt_s = units.to_time_s(rise_time)
def impl(data: np.ndarray) -> FilterResult:
return _triangular_core(data, fs_hz=fs_hz, rise_time_s=rt_s, normalize=normalize)
if x is None:
return impl
return impl(np.asarray(x, dtype=np.float64))
# ----------------------------------------------------------------------
# 3) Trapezoidal filter (Jordanov–Knoll)
# ----------------------------------------------------------------------
@njit(cache=True, fastmath=True)
def _trapezoid_knoll_inner(
x: np.ndarray,
k: int,
l: int,
M: float,
) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
"""
Numba-accelerated inner loop for trapezoidal shaper.
"""
N = x.shape[0]
d = np.zeros(N, dtype=np.float64)
p = np.zeros(N, dtype=np.float64)
r = np.zeros(N, dtype=np.float64)
s = np.zeros(N, dtype=np.float64)
k_local = k
l_local = l
M_local = M
for n in range(N):
x0 = x[0]
xn = x[n]
xk = x[n - k_local] if n >= k_local else x0
xl = x[n - l_local] if n >= l_local else x0
xkl = x[n - k_local - l_local] if n >= k_local + l_local else x0
dn = xn - xk - xl + xkl
d[n] = dn
if n > 0:
pn = p[n - 1] + dn
else:
pn = dn
p[n] = pn
rn = pn + M_local * dn
r[n] = rn
if n > 0:
sn = s[n - 1] + rn
else:
sn = rn
s[n] = sn
return d, p, r, s
def _trapezoid_knoll_core(
x: np.ndarray,
fs_hz: float,
rise_time_s: float,
flat_top_s: float = 0.0,
tau_s: Optional[float] = None,
) -> FilterResult:
"""
Jordanov–Knoll trapezoidal shaper core (float-only).
"""
x = np.asarray(x, dtype=np.float64)
if x.ndim != 1:
raise ValueError("x must be a 1D array")
Ts = 1.0 / fs_hz
total_s = rise_time_s + flat_top_s
k = int(round(rise_time_s / Ts))
l = int(round(total_s / Ts))
if k <= 0:
k = 1
if l <= 0:
l = k
if tau_s is None:
M = 0.0
else:
ratio = tau_s / Ts
M = ratio - 0.5
d, p, r, s = _trapezoid_knoll_inner(x, k, l, M)
params = {
"samp_freq_hz": fs_hz,
"Ts_s": Ts,
"rise_time_s": rise_time_s,
"flat_top_s": flat_top_s,
"tau_s": tau_s,
"k": k,
"l": l,
"M": M,
}
stages = [d, p, r, s]
return FilterResult(stages=stages, params=params)
def trapezoid_knoll_filter(
x: Optional[np.ndarray] = None,
*,
samp_freq: FreqLike,
rise_time: TimeLike,
flat_top: TimeLike = 0.0,
tau: Optional[TimeLike] = None,
units: Optional[UnitsAdapter] = None,
) -> Union[FilterResult, FilterImpl]:
"""
Jordanov–Knoll trapezoidal shaper.
Same dual interface as other filters.
"""
units = units or get_default_units_adapter()
fs_hz = units.to_frequency_hz(samp_freq)
rise_s = units.to_time_s(rise_time)
flat_s = units.to_time_s(flat_top) if flat_top is not None else 0.0
tau_s: Optional[float] = None
if tau is not None:
tau_s = units.to_time_s(tau)
def impl(data: np.ndarray) -> FilterResult:
return _trapezoid_knoll_core(
data,
fs_hz=fs_hz,
rise_time_s=rise_s,
flat_top_s=flat_s,
tau_s=tau_s,
)
if x is None:
return impl
return impl(np.asarray(x, dtype=np.float64))
__all__: List[str] = [
"UnitsAdapter",
"SIUnitsAdapter",
"get_default_units_adapter",
"FilterResult",
"crrc2_filter",
"triangular_filter",
"trapezoid_knoll_filter",
]
if _HAVE_PINT:
__all__.append("PintUnitsAdapter")
# ----------------------------------------------------------------------
# 4) Baseline RMS estimators (MAD and P²)
# ----------------------------------------------------------------------
@njit(cache=True, fastmath=True)
def _mad_first_diff_segment(dseg: np.ndarray) -> float:
"""
Numba-accelerated MAD estimator on a first-difference segment.
Returns sigma_d (the std of the first difference).
"""
N = dseg.shape[0]
if N <= 2:
return 0.0
# compute |d|
absd = np.empty(N, dtype=np.float64)
for i in range(N):
v = dseg[i]
if v < 0.0:
v = -v
absd[i] = v
# median(|d|)
absd_sorted = np.sort(absd)
if N % 2 == 1:
m = absd_sorted[N // 2]
else:
m = 0.5 * (absd_sorted[N // 2 - 1] + absd_sorted[N // 2])
return 1.4826 * m # MAD → sigma_d
# ----------------------------------------------------------------------
# P² median core (Numba-compatible) and RMS computation
# ----------------------------------------------------------------------
@njit(cache=True, fastmath=True)
def _p2_update(
q: np.ndarray,
n: np.ndarray,
np_des: np.ndarray,
dn: np.ndarray,
x: float,
):
"""
Single P² median update step for new sample x = |d[n]|.
"""
# Step 1: classification
if x < q[0]:
q[0] = x
k = 0
elif x < q[1]:
k = 0
elif x < q[2]:
k = 1
elif x < q[3]:
k = 2
elif x <= q[4]:
k = 3
else:
q[4] = x
k = 3
# Step 2: increment positions
for i in range(k + 1, 5):
n[i] += 1
for i in range(5):
np_des[i] += dn[i]
# Step 3: adjust internal markers 1..3
for i in range(1, 4):
d = np_des[i] - n[i]
if (d >= 1.0 and (n[i + 1] - n[i]) > 1) or (d <= -1.0 and (n[i] - n[i - 1]) > 1):
ds = 1 if d > 0.0 else -1
# Parabolic estimate
n_im1 = n[i - 1]
n_i = n[i]
n_ip1 = n[i + 1]
denom = float(n_ip1 - n_im1)
if denom == 0.0:
continue
q_im1 = q[i - 1]
q_i = q[i]
q_ip1 = q[i + 1]
term1_d = float(n_ip1 - n_i)
term2_d = float(n_i - n_im1)
if term1_d == 0.0 or term2_d == 0.0:
continue
qip = q_i + ds / denom * (
(n_i - n_im1 + ds) * (q_ip1 - q_i) / term1_d
+ (n_ip1 - n_i - ds) * (q_i - q_im1) / term2_d
)
# guardrails: fallback to linear
if qip <= q_im1 or qip >= q_ip1:
idx = i + ds
num = q[idx] - q_i
den = float(n[idx] - n_i)
if den != 0.0:
qip = q_i + ds * (num / den)
else:
qip = q_i
q[i] = qip
n[i] += ds
@njit(cache=True, fastmath=True)
def _baseline_rms_p2_core(y: np.ndarray) -> np.ndarray:
"""
Numba-accelerated streaming P² median–MAD RMS estimator.
sigma_x = 1.4826 * median(|d|) / sqrt(2)
"""
N = y.size
rms = np.zeros(N, dtype=np.float64)
if N == 0:
return rms
rms[0] = 0.0
prev = y[0]
# P² data
q = np.zeros(5, dtype=np.float64)
n = np.zeros(5, dtype=np.int64)
np_des = np.zeros(5, dtype=np.float64)
dn = np.array([0.0, 0.5, 1.0, 0.5, 0.0], dtype=np.float64)
buf = np.zeros(5, dtype=np.float64)
count = 0
initialized = False
sqrt2 = np.sqrt(2.0)
for i in range(1, N):
# |d|
dval = y[i] - prev
if dval < 0.0:
dval = -dval
prev = y[i]
# Not enough samples yet → fill buffer and compute median directly
if not initialized:
buf[count] = dval
count += 1
if count < 5:
# direct median over 1,2,3,4 samples
tmp = np.empty(count, dtype=np.float64)
for k in range(count):
tmp[k] = buf[k]
tmp_sorted = np.sort(tmp)
if count % 2 == 1:
m = tmp_sorted[count // 2]
else:
m = 0.5 * (tmp_sorted[count // 2 - 1] + tmp_sorted[count // 2])
sigma_d = 1.4826 * m
rms[i] = sigma_d / sqrt2
continue
# count == 5 → initialize P² markers
tmp = np.empty(5, dtype=np.float64)
for k in range(5):
tmp[k] = buf[k]
tmp_sorted = np.sort(tmp)
for k in range(5):
q[k] = tmp_sorted[k]
n[k] = k + 1
np_des[k] = k + 1
m = q[2]
sigma_d = 1.4826 * m
rms[i] = sigma_d / sqrt2
initialized = True
continue
# Fully initialized → apply P² update
_p2_update(q, n, np_des, dn, dval)
m = q[2]
sigma_d = 1.4826 * m
rms[i] = sigma_d / sqrt2
return rms
# ----------------------------------------------------------------------
# 4a) RMS Estimator – Solid MAD sliding-window (sparse sampling)
# ----------------------------------------------------------------------
def baseline_rms_mad(
x: Union[np.ndarray, FilterResult, None] = None,
*,
time: TimeLike,
samp_freq: Optional[FreqLike] = None,
units: Optional[UnitsAdapter] = None,
step: Optional[TimeLike] = "200 ms",
) -> Union[Dict[str, np.ndarray], Callable[[Union[np.ndarray, FilterResult]], Dict[str, np.ndarray]]]:
"""
Running baseline RMS estimator using sparse-window MAD.
Returns
-------
dict with one key:
"rms" : array of same length as waveform
"""
units = units or get_default_units_adapter()
def impl(data: Union[np.ndarray, FilterResult]) -> Dict[str, np.ndarray]:
# extract waveform and sampling freq
if isinstance(data, FilterResult):
y = data.get_filtered_waveform()
fs_hz = data["params"]["samp_freq_hz"]
else:
y = np.asarray(data, dtype=np.float64)
if samp_freq is None:
raise ValueError("samp_freq must be provided for ndarray input")
fs_hz = units.to_frequency_hz(samp_freq)
N = y.size
if N == 0:
return {"rms": np.zeros_like(y)}
# window & step sizes
win_s = units.to_time_s(time)
win_n = max(4, int(round(fs_hz * win_s)))
step_s = units.to_time_s(step)
step_n = max(1, int(round(fs_hz * step_s)))