test_ams_alarm_gating.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272
  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
  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