|
@@ -1,4 +1,11 @@
|
|
|
-"""Tests for the empty-AMS alarm gate (#1619).
|
|
|
|
|
|
|
+"""Tests for the gates that hold back AMS humidity / temperature alarms.
|
|
|
|
|
+
|
|
|
|
|
+Two independent gates, both sitting in ``record_ams_history``'s dispatch: the
|
|
|
|
|
+empty-AMS gate (#1619) documented below, and the drying gate (#1802) that stops
|
|
|
|
|
+the temperature alarm firing throughout a drying cycle and the cool-down after
|
|
|
|
|
+it.
|
|
|
|
|
+
|
|
|
|
|
+Empty-AMS alarm gate (#1619).
|
|
|
|
|
|
|
|
Empty AMS units still emit humidity/temperature sensor readings, but those
|
|
Empty AMS units still emit humidity/temperature sensor readings, but those
|
|
|
readings are ambient and not actionable — there's no filament to dry. Without
|
|
readings are ambient and not actionable — there's no filament to dry. Without
|
|
@@ -8,7 +15,10 @@ array's ``tray_type`` strings) so the alarm dispatch in ``record_ams_history``
|
|
|
can skip empty units while still alarming on loaded ones in the same printer.
|
|
can skip empty units while still alarming on loaded ones in the same printer.
|
|
|
"""
|
|
"""
|
|
|
|
|
|
|
|
|
|
+from datetime import datetime, timedelta, timezone
|
|
|
|
|
+
|
|
|
from backend.app.main import _ams_has_filament
|
|
from backend.app.main import _ams_has_filament
|
|
|
|
|
+from backend.app.utils.ams_drying import is_drying_active, temperature_alarm_suppressed
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestAmsHasFilament:
|
|
class TestAmsHasFilament:
|
|
@@ -80,3 +90,183 @@ class TestAmsHasFilament:
|
|
|
assert _ams_has_filament(loaded) is True
|
|
assert _ams_has_filament(loaded) is True
|
|
|
empty_int = {"tray_exist_bits": 0xED} # no tray array, int ignored
|
|
empty_int = {"tray_exist_bits": 0xED} # no tray array, int ignored
|
|
|
assert _ams_has_filament(empty_int) is False
|
|
assert _ams_has_filament(empty_int) is False
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+class TestIsDryingActive:
|
|
|
|
|
+ """The two firmware signals that mean "a drying cycle is running" (#1802)."""
|
|
|
|
|
+
|
|
|
|
|
+ def test_countdown_running_is_active(self):
|
|
|
|
|
+ assert is_drying_active({"dry_time": 720}) is True
|
|
|
|
|
+ # Strings appear in some payload shapes.
|
|
|
|
|
+ assert is_drying_active({"dry_time": "45"}) is True
|
|
|
|
|
+
|
|
|
|
|
+ def test_idle_unit_is_not_active(self):
|
|
|
|
|
+ assert is_drying_active({"dry_time": 0, "dry_status": 0}) is False
|
|
|
|
|
+ assert is_drying_active({}) is False
|
|
|
|
|
+
|
|
|
|
|
+ def test_cooling_phase_counts_as_active(self):
|
|
|
|
|
+ # The reason dry_time alone is not enough: the cycle's own cooling phase
|
|
|
|
|
+ # runs with the countdown already at 0.
|
|
|
|
|
+ assert is_drying_active({"dry_time": 0, "dry_status": 3}) is True
|
|
|
|
|
+
|
|
|
|
|
+ def test_checking_and_drying_phases_count_as_active(self):
|
|
|
|
|
+ assert is_drying_active({"dry_time": 0, "dry_status": 1}) is True
|
|
|
|
|
+ assert is_drying_active({"dry_time": 0, "dry_status": 2}) is True
|
|
|
|
|
+
|
|
|
|
|
+ def test_ending_phases_do_not_count_as_active(self):
|
|
|
|
|
+ # 4=Stopping, 5=Error — the cycle is over or aborting.
|
|
|
|
|
+ assert is_drying_active({"dry_time": 0, "dry_status": 4}) is False
|
|
|
|
|
+ assert is_drying_active({"dry_time": 0, "dry_status": 5}) is False
|
|
|
|
|
+
|
|
|
|
|
+ def test_heat_out_of_control_is_not_active(self):
|
|
|
|
|
+ # 6=HeatOutOfControl is the one phase where a high-temperature alarm is
|
|
|
|
|
+ # exactly what the user needs, so it must never read as expected heat.
|
|
|
|
|
+ assert is_drying_active({"dry_time": 0, "dry_status": 6}) is False
|
|
|
|
|
+
|
|
|
|
|
+ def test_missing_dry_status_falls_back_to_countdown(self):
|
|
|
|
|
+ # Firmware that never sends a parseable `info` has no dry_status at all.
|
|
|
|
|
+ assert is_drying_active({"dry_time": 30}) is True
|
|
|
|
|
+ assert is_drying_active({"dry_time": 0}) is False
|
|
|
|
|
+
|
|
|
|
|
+ def test_unparseable_values_do_not_raise(self):
|
|
|
|
|
+ assert is_drying_active({"dry_time": "junk", "dry_status": 2}) is True
|
|
|
|
|
+ assert is_drying_active({"dry_time": None, "dry_status": None}) is False
|
|
|
|
|
+ assert is_drying_active({"dry_time": "junk", "dry_status": "junk"}) is False
|
|
|
|
|
+
|
|
|
|
|
+ def test_non_mapping_input_is_not_active(self):
|
|
|
|
|
+ assert is_drying_active(None) is False
|
|
|
|
|
+ assert is_drying_active("drying") is False
|
|
|
|
|
+ assert is_drying_active(42) is False
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+class TestTemperatureAlarmSuppressed:
|
|
|
|
|
+ """Latch behaviour for the AMS high-temperature alarm during drying (#1802)."""
|
|
|
|
|
+
|
|
|
|
|
+ NOW = datetime(2026, 8, 16, 12, 0, tzinfo=timezone.utc)
|
|
|
|
|
+ GRACE = 120
|
|
|
|
|
+
|
|
|
|
|
+ def _call(self, **overrides):
|
|
|
|
|
+ kwargs = {
|
|
|
|
|
+ "drying_active": False,
|
|
|
|
|
+ "temperature": 50.0,
|
|
|
|
|
+ "threshold": 35.0,
|
|
|
|
|
+ "latched_at": None,
|
|
|
|
|
+ "now": self.NOW,
|
|
|
|
|
+ "grace_minutes": self.GRACE,
|
|
|
|
|
+ }
|
|
|
|
|
+ kwargs.update(overrides)
|
|
|
|
|
+ return temperature_alarm_suppressed(**kwargs)
|
|
|
|
|
+
|
|
|
|
|
+ def test_no_drying_no_latch_alarms_normally(self):
|
|
|
|
|
+ # The pre-#1802 behaviour has to survive untouched for units that never dry.
|
|
|
|
|
+ suppress, latch = self._call(temperature=40.0)
|
|
|
|
|
+ assert suppress is False
|
|
|
|
|
+ assert latch is None
|
|
|
|
|
+
|
|
|
|
|
+ def test_drying_suppresses_and_sets_latch(self):
|
|
|
|
|
+ suppress, latch = self._call(drying_active=True, temperature=65.0)
|
|
|
|
|
+ assert suppress is True
|
|
|
|
|
+ assert latch == self.NOW
|
|
|
|
|
+
|
|
|
|
|
+ def test_drying_latches_even_when_below_threshold(self):
|
|
|
|
|
+ # Early in a cycle the unit is still heating up. The latch has to be set
|
|
|
|
|
+ # then too, or the cool-down afterwards starts unprotected.
|
|
|
|
|
+ suppress, latch = self._call(drying_active=True, temperature=28.0)
|
|
|
|
|
+ assert suppress is True
|
|
|
|
|
+ assert latch == self.NOW
|
|
|
|
|
+
|
|
|
|
|
+ def test_still_hot_after_cycle_stays_suppressed(self):
|
|
|
|
|
+ # The reported symptom: alarms kept arriving while the unit cooled.
|
|
|
|
|
+ suppress, latch = self._call(
|
|
|
|
|
+ temperature=52.0,
|
|
|
|
|
+ latched_at=self.NOW - timedelta(minutes=20),
|
|
|
|
|
+ )
|
|
|
|
|
+ assert suppress is True
|
|
|
|
|
+ assert latch == self.NOW - timedelta(minutes=20)
|
|
|
|
|
+
|
|
|
|
|
+ def test_cooled_back_to_normal_clears_latch(self):
|
|
|
|
|
+ suppress, latch = self._call(
|
|
|
|
|
+ temperature=34.0,
|
|
|
|
|
+ latched_at=self.NOW - timedelta(minutes=40),
|
|
|
|
|
+ )
|
|
|
|
|
+ assert suppress is False
|
|
|
|
|
+ assert latch is None
|
|
|
|
|
+
|
|
|
|
|
+ def test_exactly_at_threshold_counts_as_cooled(self):
|
|
|
|
|
+ # The alarm itself fires on `> threshold`, so `== threshold` is not hot.
|
|
|
|
|
+ suppress, latch = self._call(
|
|
|
|
|
+ temperature=35.0,
|
|
|
|
|
+ latched_at=self.NOW - timedelta(minutes=40),
|
|
|
|
|
+ )
|
|
|
|
|
+ assert suppress is False
|
|
|
|
|
+ assert latch is None
|
|
|
|
|
+
|
|
|
|
|
+ def test_alarms_again_after_the_latch_is_cleared(self):
|
|
|
|
|
+ # Having cooled once, a later genuine overheat is not swallowed.
|
|
|
|
|
+ _, latch = self._call(temperature=34.0, latched_at=self.NOW - timedelta(minutes=40))
|
|
|
|
|
+ suppress, latch = self._call(temperature=48.0, latched_at=latch)
|
|
|
|
|
+ assert suppress is False
|
|
|
|
|
+ assert latch is None
|
|
|
|
|
+
|
|
|
|
|
+ def test_grace_cap_releases_a_unit_that_never_cools(self):
|
|
|
|
|
+ # A unit stuck above the threshold would have alarmed with no drying
|
|
|
|
|
+ # involved, so the cap restores that rather than inventing an alert.
|
|
|
|
|
+ suppress, latch = self._call(
|
|
|
|
|
+ temperature=45.0,
|
|
|
|
|
+ latched_at=self.NOW - timedelta(minutes=self.GRACE + 1),
|
|
|
|
|
+ )
|
|
|
|
|
+ assert suppress is False
|
|
|
|
|
+ assert latch is None
|
|
|
|
|
+
|
|
|
|
|
+ def test_grace_cap_boundary_releases(self):
|
|
|
|
|
+ suppress, _ = self._call(
|
|
|
|
|
+ temperature=45.0,
|
|
|
|
|
+ latched_at=self.NOW - timedelta(minutes=self.GRACE),
|
|
|
|
|
+ )
|
|
|
|
|
+ assert suppress is False
|
|
|
|
|
+
|
|
|
|
|
+ def test_just_inside_the_grace_cap_still_suppresses(self):
|
|
|
|
|
+ suppress, _ = self._call(
|
|
|
|
|
+ temperature=45.0,
|
|
|
|
|
+ latched_at=self.NOW - timedelta(minutes=self.GRACE - 1),
|
|
|
|
|
+ )
|
|
|
|
|
+ assert suppress is True
|
|
|
|
|
+
|
|
|
|
|
+ def test_a_new_cycle_refreshes_the_latch(self):
|
|
|
|
|
+ # Starting a second dry inside the grace window must restart the clock,
|
|
|
|
|
+ # otherwise the cap could expire midway through the new cycle.
|
|
|
|
|
+ suppress, latch = self._call(
|
|
|
|
|
+ drying_active=True,
|
|
|
|
|
+ temperature=60.0,
|
|
|
|
|
+ latched_at=self.NOW - timedelta(minutes=self.GRACE - 5),
|
|
|
|
|
+ )
|
|
|
|
|
+ assert suppress is True
|
|
|
|
|
+ assert latch == self.NOW
|
|
|
|
|
+
|
|
|
|
|
+ def test_unreadable_temperature_holds_the_latch(self):
|
|
|
|
|
+ # A dropped reading is not evidence the unit cooled, and there is no
|
|
|
|
|
+ # alarm to fire on this pass anyway.
|
|
|
|
|
+ suppress, latch = self._call(
|
|
|
|
|
+ temperature=None,
|
|
|
|
|
+ latched_at=self.NOW - timedelta(minutes=10),
|
|
|
|
|
+ )
|
|
|
|
|
+ assert suppress is True
|
|
|
|
|
+ assert latch == self.NOW - timedelta(minutes=10)
|
|
|
|
|
+
|
|
|
|
|
+ def test_the_cap_is_measured_from_the_latch(self):
|
|
|
|
|
+ # Guards the precondition the loader's clamp exists to maintain: with a
|
|
|
|
|
+ # non-future latch, suppression expires exactly one cap after it, so the
|
|
|
|
|
+ # cap is a real bound rather than a floor. A future latch would push the
|
|
|
|
|
+ # release out by the skew as well, which is why the clamp is at the read
|
|
|
|
|
+ # — see _load_ams_drying_latch and its persistence tests.
|
|
|
|
|
+ latched = self.NOW - timedelta(minutes=self.GRACE)
|
|
|
|
|
+ suppress, latch = temperature_alarm_suppressed(
|
|
|
|
|
+ drying_active=False,
|
|
|
|
|
+ temperature=45.0,
|
|
|
|
|
+ threshold=35.0,
|
|
|
|
|
+ latched_at=latched,
|
|
|
|
|
+ now=self.NOW,
|
|
|
|
|
+ grace_minutes=self.GRACE,
|
|
|
|
|
+ )
|
|
|
|
|
+ assert suppress is False
|
|
|
|
|
+ assert latch is None
|