test_obico_smoothing.py 6.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169
  1. """Unit tests for Obico detection smoothing math.
  2. The decision is Obico's own is_failing() (#3459): the score is the EWM minus
  3. the printer's long-term mean, so what the camera always sees is baseline.
  4. """
  5. import pytest
  6. from backend.app.services.obico_smoothing import (
  7. BASE_HIGH,
  8. BASE_LOW,
  9. ESCALATING_FACTOR,
  10. WARMUP_FRAMES,
  11. Baseline,
  12. PrintState,
  13. Reading,
  14. classify,
  15. score_from_detections,
  16. thresholds,
  17. triggers_action,
  18. )
  19. class TestThresholds:
  20. def test_medium_matches_base(self):
  21. low, high = thresholds("medium")
  22. assert low == pytest.approx(BASE_LOW)
  23. assert high == pytest.approx(BASE_HIGH)
  24. def test_low_sensitivity_is_stricter(self):
  25. low, high = thresholds("low")
  26. assert low > BASE_LOW
  27. assert high > BASE_HIGH
  28. def test_high_sensitivity_is_looser(self):
  29. low, high = thresholds("high")
  30. assert low < BASE_LOW
  31. assert high < BASE_HIGH
  32. def test_unknown_falls_back_to_medium(self):
  33. assert thresholds("bogus") == thresholds("medium")
  34. class TestScoreFromDetections:
  35. def test_empty(self):
  36. assert score_from_detections([]) == 0.0
  37. assert score_from_detections(None) == 0.0
  38. def test_sums_confidences(self):
  39. dets = [["failure", 0.3, [0, 0, 10, 10]], ["failure", 0.5, [0, 0, 10, 10]]]
  40. assert score_from_detections(dets) == pytest.approx(0.8)
  41. def test_ignores_malformed(self):
  42. dets = [["failure", 0.4, []], ["bad"], ["failure", "nan", []]]
  43. assert score_from_detections(dets) == pytest.approx(0.4)
  44. def _feed(state: PrintState, value: float, frames: int) -> Reading:
  45. reading = Reading()
  46. for _ in range(frames):
  47. reading = state.update(value)
  48. return reading
  49. class TestPrintState:
  50. def test_warmup_is_always_safe(self):
  51. state = PrintState()
  52. for _ in range(WARMUP_FRAMES):
  53. assert classify(state.update(5.0), "high") == "safe"
  54. def test_sustained_zero_stays_safe(self):
  55. state = PrintState()
  56. assert classify(_feed(state, 0.0, WARMUP_FRAMES + 50), "high") == "safe"
  57. def test_a_steady_background_from_the_start_is_baseline(self):
  58. # The plate artwork in #3459: 5 boxes summing to 0.82 in every frame.
  59. # The raw EWM sat above HIGH; minus the baseline it is nothing.
  60. state = PrintState()
  61. reading = _feed(state, 0.82, WARMUP_FRAMES + 2000)
  62. assert state.ewm_mean > BASE_HIGH
  63. assert reading.score == pytest.approx(0.0, abs=1e-9)
  64. assert classify(reading, "high") == "safe"
  65. def test_the_baseline_outlives_the_print(self):
  66. # Seen on every print of this printer: the next print starts with it.
  67. baseline = Baseline()
  68. _feed(PrintState(baseline=baseline), 0.82, 3000)
  69. reading = _feed(PrintState(baseline=baseline), 0.82, WARMUP_FRAMES + 5)
  70. assert classify(reading, "medium") == "safe"
  71. # Without it, the same frames appearing on a clean printer are a
  72. # sudden sustained rise, which is what Obico pauses for.
  73. fresh = PrintState()
  74. _feed(fresh, 0.0, 600)
  75. assert classify(_feed(fresh, 0.82, 40), "medium") == "failure"
  76. def test_a_sudden_sustained_rise_pauses(self):
  77. state = PrintState()
  78. _feed(state, 0.0, 600)
  79. reading = _feed(state, 3.0, 40)
  80. assert reading.score / ESCALATING_FACTOR > BASE_HIGH
  81. assert classify(reading, "medium") == "failure"
  82. def test_a_late_sudden_background_is_a_rise_then_settles(self):
  83. # #3459: ~4 h of nothing, then boxes (~0.8) in every frame. That is a
  84. # sudden sustained rise, which Obico pauses for, like spaghetti; once
  85. # the last ~52 min account for it, it is no longer failing.
  86. state = PrintState()
  87. _feed(state, 0.0, 1550)
  88. verdicts = [classify(state.update(0.8), "medium") for _ in range(200)]
  89. assert "failure" in verdicts[:40]
  90. assert verdicts[-40:] == ["safe"] * 40
  91. def test_a_slow_build_up_warns_without_pausing(self):
  92. # The same 0.8 reached over ~25 minutes: Obico alerts, never pauses.
  93. state = PrintState()
  94. _feed(state, 0.0, 1550)
  95. verdicts = {classify(state.update(0.8 * min(1.0, i / 150)), "medium") for i in range(1, 300)}
  96. assert verdicts == {"safe", "warning"}
  97. def test_in_the_band_the_short_window_rise_must_be_beaten(self):
  98. # Between LOW and HIGH, a score that a slow build-up over the last
  99. # ~52 min accounts for (x3.8) is not failing.
  100. between = (BASE_LOW + BASE_HIGH) / 2
  101. assert classify(Reading(score=between, short_rise=between / 3.8 + 0.01, warmup=False), "medium") == "safe"
  102. assert classify(Reading(score=between, short_rise=between / 3.8 - 0.01, warmup=False), "medium") == "warning"
  103. def test_an_isolated_spike_does_not_alert(self):
  104. state = PrintState()
  105. _feed(state, 0.0, WARMUP_FRAMES + 100)
  106. assert classify(state.update(1.0), "medium") == "safe"
  107. class TestClassify:
  108. @staticmethod
  109. def _r(score: float, short_rise: float = 0.0) -> Reading:
  110. return Reading(score=score, short_rise=short_rise, warmup=False)
  111. def test_safe_below_low(self):
  112. assert classify(self._r(BASE_LOW - 0.01), "medium") == "safe"
  113. def test_warning_above_high(self):
  114. assert classify(self._r(BASE_HIGH + 0.01, short_rise=1.0), "medium") == "warning"
  115. def test_failure_needs_the_escalated_score(self):
  116. assert classify(self._r(BASE_HIGH * ESCALATING_FACTOR - 0.01, short_rise=1.0), "medium") == "warning"
  117. assert classify(self._r(BASE_HIGH * ESCALATING_FACTOR + 0.01), "medium") == "failure"
  118. def test_sensitivity_scales_the_short_window_check_too(self):
  119. # Obico multiplies the score by the sensitivity; we scale thresholds.
  120. # Medium in the band, 0.5 vs 0.13 * 3.8 = 0.494: failing. Low (x1.25
  121. # thresholds, 0.475..0.975): 0.5 vs 0.494 * 1.25 = 0.6175: not.
  122. r = self._r(0.5, short_rise=0.13)
  123. assert classify(r, "medium") == "warning"
  124. assert classify(r, "low") == "safe"
  125. def test_warmup_is_safe_whatever_the_score(self):
  126. assert classify(Reading(score=10.0, short_rise=0.0, warmup=True), "high") == "safe"
  127. class TestTriggersAction:
  128. def test_notify_fires_at_the_alert_level(self):
  129. assert triggers_action("warning", "notify")
  130. assert triggers_action("failure", "notify")
  131. assert not triggers_action("safe", "notify")
  132. @pytest.mark.parametrize("action", ["pause", "pause_and_off"])
  133. def test_pausing_waits_for_the_pause_level(self, action):
  134. assert not triggers_action("warning", action)
  135. assert triggers_action("failure", action)