Add generic peak cycle detector

This commit is contained in:
2026-09-04 08:00:59 +02:00
parent f017d187eb
commit 5068c95158
9 changed files with 521 additions and 8 deletions
@@ -1,5 +1,12 @@
"""Versioned, pure calculation operators and their contracts."""
from .base import Calculation
from .peak_cycles import DetectedPeak, PeakCycleDetector, PeakDetectorConfig, TimeSeriesSample
__all__ = ["Calculation"]
__all__ = [
"Calculation",
"DetectedPeak",
"PeakCycleDetector",
"PeakDetectorConfig",
"TimeSeriesSample",
]
@@ -0,0 +1,157 @@
"""Generic, timestamp-based detection of completed sawtooth peak cycles.
The detector is deliberately independent of input transport and persistence.
Call :meth:`PeakCycleDetector.process` for each chronological sample, or use
``process_many`` for an iterable of :class:`TimeSeriesSample` values.
"""
from collections.abc import Iterable, Iterator
from dataclasses import dataclass
from datetime import datetime
from math import isfinite
@dataclass(frozen=True, slots=True)
class TimeSeriesSample:
"""One numeric observation from a chronological time series."""
timestamp: datetime
value: float
@dataclass(frozen=True, slots=True)
class PeakDetectorConfig:
"""Configuration for :class:`PeakCycleDetector`."""
min_peak: float
drop_ratio: float
hold_seconds: float
max_sample_gap_seconds: float
def __post_init__(self) -> None:
if not isfinite(self.min_peak):
raise ValueError("min_peak must be finite")
if not isfinite(self.drop_ratio) or not 0 < self.drop_ratio < 1:
raise ValueError("drop_ratio must be greater than 0 and less than 1")
if not isfinite(self.hold_seconds) or self.hold_seconds < 0:
raise ValueError("hold_seconds must be finite and at least 0")
if not isfinite(self.max_sample_gap_seconds) or self.max_sample_gap_seconds <= 0:
raise ValueError("max_sample_gap_seconds must be finite and greater than 0")
@dataclass(frozen=True, slots=True)
class DetectedPeak:
"""A maximum whose following reset phase has been confirmed."""
value: float
peak_timestamp: datetime
confirmed_at: datetime
cycle_started_at: datetime
class PeakCycleDetector:
"""Maintain one maximum and emit it once its reset phase is confirmed.
A confirmation requires a sample below ``drop_ratio * current_peak`` to
start the reset phase and later observed below-threshold samples spanning
``hold_seconds``. A reset candidate is restarted when the interval between
observations exceeds ``max_sample_gap_seconds``: elapsed time is used, but
an unobserved gap on its own never provides evidence that the signal stayed
below the threshold.
Equal timestamps are accepted in input order and contribute no elapsed
time; timestamps that move backwards are rejected.
"""
def __init__(self, config: PeakDetectorConfig) -> None:
self.config = config
self._last_timestamp: datetime | None = None
self._cycle_started_at: datetime | None = None
self._current_peak: float | None = None
self._peak_timestamp: datetime | None = None
self._below_since: datetime | None = None
# A completed reset starts the next cycle at a low value. Do not let
# that same low/reset phase become a second cycle before it rises.
self._needs_rise_after_reset = False
self._reset_baseline: float | None = None
def process(self, timestamp: datetime, value: float) -> DetectedPeak | None:
"""Consume one sample and return a newly confirmed peak, if any."""
if not isfinite(value):
raise ValueError("sample value must be finite")
if self._last_timestamp is not None and timestamp < self._last_timestamp:
raise ValueError("sample timestamps must not move backwards")
previous_timestamp = self._last_timestamp
self._last_timestamp = timestamp
if self._current_peak is None:
self._start_cycle(timestamp, value, needs_rise=False)
return None
if self._needs_rise_after_reset:
assert self._reset_baseline is not None
if value > self._reset_baseline:
self._needs_rise_after_reset = False
else:
# Keep collecting the highest reset-phase value, while still
# requiring a genuine rise before a new reset can be detected.
if value > self._current_peak:
self._current_peak = value
self._peak_timestamp = timestamp
self._reset_baseline = value
return None
assert self._peak_timestamp is not None
assert self._cycle_started_at is not None
if value > self._current_peak:
self._current_peak = value
self._peak_timestamp = timestamp
self._below_since = None
return None
reset_threshold = self.config.drop_ratio * self._current_peak
if value >= reset_threshold:
self._below_since = None
return None
if self._below_since is None:
self._below_since = timestamp
elif (
previous_timestamp is not None
and (timestamp - previous_timestamp).total_seconds()
> self.config.max_sample_gap_seconds
):
# We have no evidence that the signal remained below threshold
# during the unobserved portion of this gap, so this sample begins
# a new candidate.
self._below_since = timestamp
return None
elapsed_seconds = (timestamp - self._below_since).total_seconds()
if elapsed_seconds < self.config.hold_seconds:
return None
event = None
if self._current_peak >= self.config.min_peak:
event = DetectedPeak(
value=self._current_peak,
peak_timestamp=self._peak_timestamp,
confirmed_at=timestamp,
cycle_started_at=self._cycle_started_at,
)
self._start_cycle(timestamp, value, needs_rise=True)
return event
def process_many(self, samples: Iterable[TimeSeriesSample]) -> Iterator[DetectedPeak]:
"""Yield each peak confirmed while consuming ``samples``."""
for sample in samples:
event = self.process(sample.timestamp, sample.value)
if event is not None:
yield event
def _start_cycle(self, timestamp: datetime, value: float, *, needs_rise: bool) -> None:
self._cycle_started_at = timestamp
self._current_peak = value
self._peak_timestamp = timestamp
self._below_since = None
self._needs_rise_after_reset = needs_rise
self._reset_baseline = value if needs_rise else None