225 lines
7.8 KiB
Python
225 lines
7.8 KiB
Python
import unittest
|
|
from datetime import UTC, datetime, timedelta
|
|
|
|
from production_analytics.calculations import (
|
|
PeakCycleDetector,
|
|
PeakDetectorConfig,
|
|
TimeSeriesSample,
|
|
)
|
|
|
|
|
|
BASE = datetime(2026, 9, 4, tzinfo=UTC)
|
|
|
|
|
|
def samples(*values_and_seconds: tuple[float, float]) -> list[TimeSeriesSample]:
|
|
return [
|
|
TimeSeriesSample(BASE + timedelta(seconds=seconds), value)
|
|
for value, seconds in values_and_seconds
|
|
]
|
|
|
|
|
|
class PeakCycleDetectorTests(unittest.TestCase):
|
|
def detector(self, **overrides: float) -> PeakCycleDetector:
|
|
config = PeakDetectorConfig(
|
|
min_peak=10.0,
|
|
drop_ratio=0.75,
|
|
hold_seconds=60.0,
|
|
max_sample_gap_seconds=20.0,
|
|
**overrides,
|
|
)
|
|
return PeakCycleDetector(config)
|
|
|
|
def events(self, detector: PeakCycleDetector, sample_values: list[TimeSeriesSample]):
|
|
return list(detector.process_many(sample_values))
|
|
|
|
def test_simple_sawtooth_emits_one_peak_after_hold(self) -> None:
|
|
events = self.events(
|
|
self.detector(),
|
|
samples(
|
|
(2, 0), (12, 10), (20, 20), (14, 30), (13, 40), (12, 50),
|
|
(11, 60), (10, 70), (9, 80), (8, 90),
|
|
),
|
|
)
|
|
|
|
self.assertEqual(len(events), 1)
|
|
self.assertEqual(events[0].value, 20)
|
|
self.assertEqual(events[0].peak_timestamp, BASE + timedelta(seconds=20))
|
|
self.assertEqual(events[0].confirmed_at, BASE + timedelta(seconds=90))
|
|
|
|
def test_multiple_cycles_emit_once_each(self) -> None:
|
|
events = self.events(
|
|
self.detector(),
|
|
samples(
|
|
(15, 0), (30, 10), (5, 20), (4, 30), (4, 40), (4, 50),
|
|
(4, 60), (4, 70), (4, 80), (8, 90), (25, 100), (6, 110),
|
|
(5, 120), (5, 130), (5, 140), (5, 150), (5, 160), (5, 170),
|
|
),
|
|
)
|
|
|
|
self.assertEqual([event.value for event in events], [30, 25])
|
|
self.assertEqual(
|
|
[event.confirmed_at for event in events],
|
|
[BASE + timedelta(seconds=80), BASE + timedelta(seconds=170)],
|
|
)
|
|
|
|
def test_peak_below_minimum_is_not_emitted(self) -> None:
|
|
events = self.events(self.detector(), samples((2, 0), (9, 10), (1, 20), (1, 80)))
|
|
|
|
self.assertEqual(events, [])
|
|
|
|
def test_recovery_before_hold_cancels_reset_candidate(self) -> None:
|
|
events = self.events(
|
|
self.detector(),
|
|
samples(
|
|
(10, 0), (20, 10), (14, 20), (16, 50), (16, 80), (10, 90),
|
|
(10, 100), (10, 110), (10, 120), (10, 130), (10, 140), (10, 150),
|
|
),
|
|
)
|
|
|
|
self.assertEqual(len(events), 1)
|
|
self.assertEqual(events[0].confirmed_at, BASE + timedelta(seconds=150))
|
|
|
|
def test_sustained_drop_emits_at_one_actual_confirmation_sample(self) -> None:
|
|
events = self.events(
|
|
self.detector(),
|
|
samples(
|
|
(20, 0), (14, 10), (13, 20), (12, 30), (12, 40), (12, 50),
|
|
(12, 60), (12, 70),
|
|
),
|
|
)
|
|
|
|
self.assertEqual(len(events), 1)
|
|
self.assertEqual(events[0].confirmed_at, BASE + timedelta(seconds=70))
|
|
|
|
def test_prolonged_low_phase_does_not_duplicate_event(self) -> None:
|
|
events = self.events(
|
|
self.detector(),
|
|
samples(
|
|
(20, 0), (5, 10), (4, 20), (4, 30), (4, 40), (4, 50),
|
|
(4, 60), (4, 70), (3, 80), (2, 90), (-1, 100),
|
|
),
|
|
)
|
|
|
|
self.assertEqual([event.value for event in events], [20])
|
|
|
|
def test_new_cycle_after_completed_reset(self) -> None:
|
|
events = self.events(
|
|
self.detector(),
|
|
samples(
|
|
(20, 0), (5, 10), (4, 20), (4, 30), (4, 40), (4, 50),
|
|
(4, 60), (4, 70), (6, 80), (30, 90), (10, 100), (9, 110),
|
|
(9, 120), (9, 130), (9, 140), (9, 150), (9, 160),
|
|
),
|
|
)
|
|
|
|
self.assertEqual([event.value for event in events], [20, 30])
|
|
self.assertEqual(events[1].cycle_started_at, BASE + timedelta(seconds=70))
|
|
|
|
def test_negative_reset_values_are_valid(self) -> None:
|
|
events = self.events(
|
|
self.detector(),
|
|
samples(
|
|
(50, 0), (-0.7, 10), (-0.5, 20), (-0.5, 30), (-0.5, 40),
|
|
(-0.5, 50), (-0.5, 60), (-0.5, 70),
|
|
),
|
|
)
|
|
|
|
self.assertEqual([event.value for event in events], [50])
|
|
|
|
def test_regular_intervals_use_elapsed_time_not_sample_count(self) -> None:
|
|
events = self.events(
|
|
self.detector(),
|
|
samples(
|
|
(20, 0), (10, 10), (9, 20), (8, 30), (7, 40), (6, 50),
|
|
(5, 60), (4, 70),
|
|
),
|
|
)
|
|
|
|
self.assertEqual(len(events), 1)
|
|
self.assertEqual(events[0].confirmed_at, BASE + timedelta(seconds=70))
|
|
|
|
def test_mildly_irregular_intervals_below_gap_limit_confirm_reset(self) -> None:
|
|
events = self.events(
|
|
self.detector(),
|
|
samples(
|
|
(20, 0), (10, 10), (9, 21), (8, 30), (7, 40), (6, 51),
|
|
(5, 60), (4, 70),
|
|
),
|
|
)
|
|
|
|
self.assertEqual(len(events), 1)
|
|
self.assertEqual(events[0].confirmed_at, BASE + timedelta(seconds=70))
|
|
|
|
def test_backwards_timestamps_are_rejected(self) -> None:
|
|
detector = self.detector()
|
|
detector.process(BASE + timedelta(seconds=10), 20)
|
|
|
|
with self.assertRaisesRegex(ValueError, "must not move backwards"):
|
|
detector.process(BASE, 10)
|
|
|
|
def test_duplicate_timestamps_are_accepted_but_do_not_advance_hold_time(self) -> None:
|
|
events = self.events(
|
|
self.detector(),
|
|
samples(
|
|
(20, 0), (10, 10), (9, 10), (8, 20), (8, 30), (8, 40),
|
|
(8, 50), (8, 60), (8, 70),
|
|
),
|
|
)
|
|
|
|
self.assertEqual(len(events), 1)
|
|
self.assertEqual(events[0].confirmed_at, BASE + timedelta(seconds=70))
|
|
|
|
def test_large_gap_to_first_low_sample_does_not_manufacture_confirmation(self) -> None:
|
|
events = self.events(self.detector(), samples((20, 0), (5, 3600)))
|
|
|
|
self.assertEqual(events, [])
|
|
|
|
def test_large_gap_during_reset_does_not_prove_continuous_below_threshold(self) -> None:
|
|
events = self.events(self.detector(), samples((20, 0), (5, 10), (4, 3600), (3, 3650)))
|
|
|
|
self.assertEqual(events, [])
|
|
|
|
def test_missing_observations_during_reset_restart_the_candidate(self) -> None:
|
|
detector = self.detector()
|
|
events = self.events(
|
|
detector,
|
|
samples(
|
|
(20, 0), (5, 10), (4, 20), (3, 70), (3, 80), (3, 90),
|
|
(3, 100), (3, 110), (3, 120), (2, 130),
|
|
),
|
|
)
|
|
|
|
self.assertEqual(len(events), 1)
|
|
self.assertEqual(events[0].confirmed_at, BASE + timedelta(seconds=130))
|
|
|
|
def test_config_validation(self) -> None:
|
|
for ratio in (0.0, 1.0, -0.1, 1.1):
|
|
with self.assertRaisesRegex(ValueError, "drop_ratio"):
|
|
PeakDetectorConfig(
|
|
min_peak=1,
|
|
drop_ratio=ratio,
|
|
hold_seconds=0,
|
|
max_sample_gap_seconds=1,
|
|
)
|
|
with self.assertRaisesRegex(ValueError, "hold_seconds"):
|
|
PeakDetectorConfig(
|
|
min_peak=1,
|
|
drop_ratio=0.5,
|
|
hold_seconds=-1,
|
|
max_sample_gap_seconds=1,
|
|
)
|
|
with self.assertRaisesRegex(ValueError, "max_sample_gap_seconds"):
|
|
PeakDetectorConfig(
|
|
min_peak=1,
|
|
drop_ratio=0.5,
|
|
hold_seconds=0,
|
|
max_sample_gap_seconds=0,
|
|
)
|
|
with self.assertRaisesRegex(ValueError, "min_peak"):
|
|
PeakDetectorConfig(
|
|
min_peak=float("nan"),
|
|
drop_ratio=0.5,
|
|
hold_seconds=0,
|
|
max_sample_gap_seconds=1,
|
|
)
|