test_ams_alarm_gating.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377
  1. """Tests for the gates that hold back AMS humidity / temperature alarms.
  2. Two independent gates, both sitting in ``record_ams_history``'s dispatch: the
  3. empty-AMS gate (#1619) documented below, and the drying gate (#1802) that stops
  4. the temperature alarm firing throughout a drying cycle and the cool-down after
  5. it.
  6. Empty-AMS alarm gate (#1619).
  7. Empty AMS units still emit humidity/temperature sensor readings, but those
  8. readings are ambient and not actionable — there's no filament to dry. Without
  9. the gate every empty AMS spammed an hourly alarm. ``_ams_has_filament``
  10. inspects the firmware-reported ``tray_exist_bits`` bitmap (fallback: ``tray``
  11. array's ``tray_type`` strings) so the alarm dispatch in ``record_ams_history``
  12. can skip empty units while still alarming on loaded ones in the same printer.
  13. """
  14. from datetime import datetime, timedelta, timezone
  15. from backend.app.main import _ams_has_filament, _resolve_temp_alarm_threshold
  16. from backend.app.utils.ams_drying import is_drying_active, temperature_alarm_suppressed
  17. class TestAmsHasFilament:
  18. def test_tray_exist_bits_zero_means_empty(self):
  19. assert _ams_has_filament({"tray_exist_bits": "0"}) is False
  20. # Real firmware sometimes pads with extra zeros or prefixes; all
  21. # parseable forms of zero should resolve to "empty".
  22. assert _ams_has_filament({"tray_exist_bits": "00"}) is False
  23. assert _ams_has_filament({"tray_exist_bits": "0x0"}) is False
  24. def test_tray_exist_bits_nonzero_means_loaded(self):
  25. # Single tray loaded — e.g. AMS-Lite or AMS-HT.
  26. assert _ams_has_filament({"tray_exist_bits": "1"}) is True
  27. # Four-slot AMS with all slots full (bitmap 0xf == 0b1111).
  28. assert _ams_has_filament({"tray_exist_bits": "f"}) is True
  29. # Mixed — 0xa == 0b1010, two slots loaded.
  30. assert _ams_has_filament({"tray_exist_bits": "a"}) is True
  31. # The exact bitmap seen in #1622 / #1602 logs.
  32. assert _ams_has_filament({"tray_exist_bits": "ed"}) is True
  33. def test_falls_back_to_tray_array_when_bits_missing(self):
  34. # Empty tray_type strings across the whole tray array → empty AMS.
  35. ams_empty = {
  36. "tray": [
  37. {"id": 0, "tray_type": ""},
  38. {"id": 1, "tray_type": ""},
  39. ]
  40. }
  41. assert _ams_has_filament(ams_empty) is False
  42. # Any non-empty tray_type → loaded AMS.
  43. ams_loaded = {
  44. "tray": [
  45. {"id": 0, "tray_type": ""},
  46. {"id": 1, "tray_type": "PLA"},
  47. ]
  48. }
  49. assert _ams_has_filament(ams_loaded) is True
  50. def test_missing_both_signals_returns_false(self):
  51. # No tray_exist_bits AND no tray array — early-pushall shape; we
  52. # treat it as "no info → don't alarm" rather than guessing loaded.
  53. assert _ams_has_filament({}) is False
  54. def test_unparseable_bitmap_falls_back_to_tray_array(self):
  55. # Garbage in tray_exist_bits — must not raise and must fall through
  56. # to the tray array check.
  57. loaded = {"tray_exist_bits": "garbage", "tray": [{"id": 0, "tray_type": "PETG"}]}
  58. assert _ams_has_filament(loaded) is True
  59. empty = {"tray_exist_bits": "garbage", "tray": []}
  60. assert _ams_has_filament(empty) is False
  61. def test_empty_bits_string_falls_back_to_tray_array(self):
  62. # Some pre-handshake pushall shapes set the field but leave it blank.
  63. loaded = {"tray_exist_bits": "", "tray": [{"id": 0, "tray_type": "ABS"}]}
  64. assert _ams_has_filament(loaded) is True
  65. def test_whitespace_tray_type_is_not_loaded(self):
  66. # A tray_type that's all whitespace doesn't count as a real material.
  67. assert _ams_has_filament({"tray": [{"id": 0, "tray_type": " "}]}) is False
  68. def test_non_dict_tray_entries_are_skipped(self):
  69. # Defensive: malformed tray array shouldn't crash the helper.
  70. assert _ams_has_filament({"tray": [None, "junk", 42]}) is False
  71. def test_non_string_bits_falls_back(self):
  72. # Some MQTT shapes send tray_exist_bits as int; we only parse strings,
  73. # so an int falls through to the tray array.
  74. loaded = {"tray_exist_bits": 0xED, "tray": [{"id": 0, "tray_type": "PLA"}]}
  75. assert _ams_has_filament(loaded) is True
  76. empty_int = {"tray_exist_bits": 0xED} # no tray array, int ignored
  77. assert _ams_has_filament(empty_int) is False
  78. class TestIsDryingActive:
  79. """The two firmware signals that mean "a drying cycle is running" (#1802)."""
  80. def test_countdown_running_is_active(self):
  81. assert is_drying_active({"dry_time": 720}) is True
  82. # Strings appear in some payload shapes.
  83. assert is_drying_active({"dry_time": "45"}) is True
  84. def test_idle_unit_is_not_active(self):
  85. assert is_drying_active({"dry_time": 0, "dry_status": 0}) is False
  86. assert is_drying_active({}) is False
  87. def test_cooling_phase_counts_as_active(self):
  88. # The reason dry_time alone is not enough: the cycle's own cooling phase
  89. # runs with the countdown already at 0.
  90. assert is_drying_active({"dry_time": 0, "dry_status": 3}) is True
  91. def test_checking_and_drying_phases_count_as_active(self):
  92. assert is_drying_active({"dry_time": 0, "dry_status": 1}) is True
  93. assert is_drying_active({"dry_time": 0, "dry_status": 2}) is True
  94. def test_ending_phases_do_not_count_as_active(self):
  95. # 4=Stopping, 5=Error — the cycle is over or aborting.
  96. assert is_drying_active({"dry_time": 0, "dry_status": 4}) is False
  97. assert is_drying_active({"dry_time": 0, "dry_status": 5}) is False
  98. def test_heat_out_of_control_is_not_active(self):
  99. # 6=HeatOutOfControl is the one phase where a high-temperature alarm is
  100. # exactly what the user needs, so it must never read as expected heat.
  101. assert is_drying_active({"dry_time": 0, "dry_status": 6}) is False
  102. def test_missing_dry_status_falls_back_to_countdown(self):
  103. # Firmware that never sends a parseable `info` has no dry_status at all.
  104. assert is_drying_active({"dry_time": 30}) is True
  105. assert is_drying_active({"dry_time": 0}) is False
  106. def test_unparseable_values_do_not_raise(self):
  107. assert is_drying_active({"dry_time": "junk", "dry_status": 2}) is True
  108. assert is_drying_active({"dry_time": None, "dry_status": None}) is False
  109. assert is_drying_active({"dry_time": "junk", "dry_status": "junk"}) is False
  110. def test_non_mapping_input_is_not_active(self):
  111. assert is_drying_active(None) is False
  112. assert is_drying_active("drying") is False
  113. assert is_drying_active(42) is False
  114. class TestTemperatureAlarmSuppressed:
  115. """Latch behaviour for the AMS high-temperature alarm during drying (#1802)."""
  116. NOW = datetime(2026, 8, 16, 12, 0, tzinfo=timezone.utc)
  117. GRACE = 120
  118. def _call(self, **overrides):
  119. kwargs = {
  120. "drying_active": False,
  121. "temperature": 50.0,
  122. "threshold": 35.0,
  123. "latched_at": None,
  124. "now": self.NOW,
  125. "grace_minutes": self.GRACE,
  126. }
  127. kwargs.update(overrides)
  128. return temperature_alarm_suppressed(**kwargs)
  129. def test_no_drying_no_latch_alarms_normally(self):
  130. # The pre-#1802 behaviour has to survive untouched for units that never dry.
  131. suppress, latch = self._call(temperature=40.0)
  132. assert suppress is False
  133. assert latch is None
  134. def test_drying_suppresses_and_sets_latch(self):
  135. suppress, latch = self._call(drying_active=True, temperature=65.0)
  136. assert suppress is True
  137. assert latch == self.NOW
  138. def test_drying_latches_even_when_below_threshold(self):
  139. # Early in a cycle the unit is still heating up. The latch has to be set
  140. # then too, or the cool-down afterwards starts unprotected.
  141. suppress, latch = self._call(drying_active=True, temperature=28.0)
  142. assert suppress is True
  143. assert latch == self.NOW
  144. def test_still_hot_after_cycle_stays_suppressed(self):
  145. # The reported symptom: alarms kept arriving while the unit cooled.
  146. suppress, latch = self._call(
  147. temperature=52.0,
  148. latched_at=self.NOW - timedelta(minutes=20),
  149. )
  150. assert suppress is True
  151. assert latch == self.NOW - timedelta(minutes=20)
  152. def test_cooled_back_to_normal_clears_latch(self):
  153. suppress, latch = self._call(
  154. temperature=34.0,
  155. latched_at=self.NOW - timedelta(minutes=40),
  156. )
  157. assert suppress is False
  158. assert latch is None
  159. def test_exactly_at_threshold_counts_as_cooled(self):
  160. # The alarm itself fires on `> threshold`, so `== threshold` is not hot.
  161. suppress, latch = self._call(
  162. temperature=35.0,
  163. latched_at=self.NOW - timedelta(minutes=40),
  164. )
  165. assert suppress is False
  166. assert latch is None
  167. def test_alarms_again_after_the_latch_is_cleared(self):
  168. # Having cooled once, a later genuine overheat is not swallowed.
  169. _, latch = self._call(temperature=34.0, latched_at=self.NOW - timedelta(minutes=40))
  170. suppress, latch = self._call(temperature=48.0, latched_at=latch)
  171. assert suppress is False
  172. assert latch is None
  173. def test_grace_cap_releases_a_unit_that_never_cools(self):
  174. # A unit stuck above the threshold would have alarmed with no drying
  175. # involved, so the cap restores that rather than inventing an alert.
  176. suppress, latch = self._call(
  177. temperature=45.0,
  178. latched_at=self.NOW - timedelta(minutes=self.GRACE + 1),
  179. )
  180. assert suppress is False
  181. assert latch is None
  182. def test_grace_cap_boundary_releases(self):
  183. suppress, _ = self._call(
  184. temperature=45.0,
  185. latched_at=self.NOW - timedelta(minutes=self.GRACE),
  186. )
  187. assert suppress is False
  188. def test_just_inside_the_grace_cap_still_suppresses(self):
  189. suppress, _ = self._call(
  190. temperature=45.0,
  191. latched_at=self.NOW - timedelta(minutes=self.GRACE - 1),
  192. )
  193. assert suppress is True
  194. def test_a_new_cycle_refreshes_the_latch(self):
  195. # Starting a second dry inside the grace window must restart the clock,
  196. # otherwise the cap could expire midway through the new cycle.
  197. suppress, latch = self._call(
  198. drying_active=True,
  199. temperature=60.0,
  200. latched_at=self.NOW - timedelta(minutes=self.GRACE - 5),
  201. )
  202. assert suppress is True
  203. assert latch == self.NOW
  204. def test_unreadable_temperature_holds_the_latch(self):
  205. # A dropped reading is not evidence the unit cooled, and there is no
  206. # alarm to fire on this pass anyway.
  207. suppress, latch = self._call(
  208. temperature=None,
  209. latched_at=self.NOW - timedelta(minutes=10),
  210. )
  211. assert suppress is True
  212. assert latch == self.NOW - timedelta(minutes=10)
  213. def test_the_cap_is_measured_from_the_latch(self):
  214. # Guards the precondition the loader's clamp exists to maintain: with a
  215. # non-future latch, suppression expires exactly one cap after it, so the
  216. # cap is a real bound rather than a floor. A future latch would push the
  217. # release out by the skew as well, which is why the clamp is at the read
  218. # — see _load_ams_drying_latch and its persistence tests.
  219. latched = self.NOW - timedelta(minutes=self.GRACE)
  220. suppress, latch = temperature_alarm_suppressed(
  221. drying_active=False,
  222. temperature=45.0,
  223. threshold=35.0,
  224. latched_at=latched,
  225. now=self.NOW,
  226. grace_minutes=self.GRACE,
  227. )
  228. assert suppress is False
  229. assert latch is None
  230. class TestTempAlarmThresholdResolution:
  231. """The alarm gets its own threshold, falling back to the display band (#2905).
  232. ``ams_temp_fair`` decides when the AMS card turns amber and used to decide
  233. when a notification was sent as well. 35 C is a reasonable place to change a
  234. colour and not a reasonable place to page someone: a room above it made the
  235. alarm fire once an hour for as long as the weather lasted, and the only way
  236. to stop it was to raise the display band and lose the colour that says the
  237. unit is warm.
  238. """
  239. def test_unset_falls_back_to_the_fair_threshold(self):
  240. """Every install that has never set one keeps behaving exactly as it does
  241. now — that is what makes this safe to ship without a migration."""
  242. assert _resolve_temp_alarm_threshold(35.0, None) == 35.0
  243. def test_the_literal_none_string_falls_back_too(self):
  244. """Settings storage stringifies None, so "not set" arrives as a string.
  245. Handled by the same branch as any other unparseable value rather than by
  246. a sentinel that has to be kept in sync."""
  247. assert _resolve_temp_alarm_threshold(35.0, "None") == 35.0
  248. def test_a_set_value_wins(self):
  249. assert _resolve_temp_alarm_threshold(35.0, "45") == 45.0
  250. assert _resolve_temp_alarm_threshold(35.0, "45.5") == 45.5
  251. def test_it_tracks_an_edited_fair_threshold_while_unset(self):
  252. """Seeded from the resolved fair value, not from a hardcoded 35."""
  253. assert _resolve_temp_alarm_threshold(40.0, None) == 40.0
  254. def test_a_value_below_the_display_band_is_honoured(self):
  255. """Nothing requires the alarm to sit above the amber band. Someone who
  256. wants to be told before the card even changes colour may say so."""
  257. assert _resolve_temp_alarm_threshold(35.0, "30") == 30.0
  258. def test_garbage_falls_back_rather_than_raising(self):
  259. assert _resolve_temp_alarm_threshold(35.0, "") == 35.0
  260. assert _resolve_temp_alarm_threshold(35.0, "warm") == 35.0
  261. def test_zero_and_negative_are_refused(self):
  262. """Zero would alarm permanently, and is far more likely to be a cleared
  263. field than a deliberate choice."""
  264. assert _resolve_temp_alarm_threshold(35.0, "0") == 35.0
  265. assert _resolve_temp_alarm_threshold(35.0, "-5") == 35.0
  266. def test_nan_and_infinity_are_refused(self):
  267. assert _resolve_temp_alarm_threshold(35.0, "nan") == 35.0
  268. assert _resolve_temp_alarm_threshold(35.0, "inf") == 35.0
  269. class TestLatchReleasesAtTheAlarmThreshold:
  270. """The latch's release check takes the alarm threshold, not the display band.
  271. ``temperature_alarm_suppressed`` releases once the unit reads at or below
  272. ``threshold``. Handing it the display band strands the latch on any unit that
  273. settles back above it — which is not hypothetical: the AMS this was reported
  274. from rests at 37.7 C in a warm room and never returns under a 35 C band, so
  275. the latch could only ever expire on the grace cap.
  276. """
  277. def test_a_unit_resting_above_the_display_band_releases_on_the_alarm_threshold(self):
  278. """37.7 C after a cycle, alarm threshold 45: released promptly."""
  279. latched = datetime.now(timezone.utc) - timedelta(minutes=5)
  280. suppress, latch = temperature_alarm_suppressed(
  281. drying_active=False,
  282. temperature=37.7,
  283. threshold=45.0,
  284. latched_at=latched,
  285. now=datetime.now(timezone.utc),
  286. grace_minutes=120,
  287. )
  288. assert suppress is False
  289. assert latch is None
  290. def test_the_same_reading_stays_latched_against_the_display_band(self):
  291. """The behaviour before this change, kept as the contrast: 37.7 C never
  292. drops under 35, so the latch survives and can only expire on the cap."""
  293. latched = datetime.now(timezone.utc) - timedelta(minutes=5)
  294. suppress, latch = temperature_alarm_suppressed(
  295. drying_active=False,
  296. temperature=37.7,
  297. threshold=35.0,
  298. latched_at=latched,
  299. now=datetime.now(timezone.utc),
  300. grace_minutes=120,
  301. )
  302. assert suppress is True
  303. assert latch == latched
  304. def test_a_genuinely_hot_unit_still_alarms_after_the_cap(self):
  305. """Releasing on the cap is not a new alert — a unit that stays that hot
  306. would have been alarming with no drying involved."""
  307. latched = datetime.now(timezone.utc) - timedelta(minutes=121)
  308. suppress, latch = temperature_alarm_suppressed(
  309. drying_active=False,
  310. temperature=70.0,
  311. threshold=45.0,
  312. latched_at=latched,
  313. now=datetime.now(timezone.utc),
  314. grace_minutes=120,
  315. )
  316. assert suppress is False
  317. assert latch is None