Add generic peak cycle detector
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user