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, )