| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514 |
- """Tests for chamber-soak history tracking and smart soak-time reduction.
- `_chamber_soak_remaining()` scans a per-printer deque of
- (monotonic_timestamp, celsius) samples and returns how many soak seconds
- are still needed, crediting time the chamber has already spent above the
- target threshold. Real samples arrive every 3–30 s while a printer is
- connected; tests use `_dense_history` to model that cadence, or `_history`
- (sparse) when specifically exercising gap-detection behaviour.
- Key invariants:
- - Empty history → full soak (conservative)
- - Chamber never dipped, contiguous run < soak → credit the run's span
- - Chamber never dipped, contiguous run ≥ soak → skip (return 0)
- - Chamber dipped → credit only time since last below-threshold sample
- - Chamber currently below → full soak (time_above ≈ 0)
- - Gap in samples larger than the cadence threshold → credit only the
- last contiguous run (disconnect must not be counted as time at temp)
- `_sample_chamber_temps()` records one sample per connected printer per
- tick, prunes entries older than the 2 h TTL, and evicts per-printer state
- whose printer_id disappeared from the manager (printer deleted).
- """
- from collections import deque
- from types import SimpleNamespace
- from unittest.mock import patch
- import pytest
- from backend.app.services.print_scheduler import (
- _CHAMBER_HISTORY_TTL_SECONDS,
- _CHAMBER_SAMPLE_MAX_GAP_SECONDS,
- PrintScheduler,
- )
- SOAK = 1800 # seconds (30 min, the typical configured value)
- TARGET = 50.0 # °C
- PRINTER_ID = 1
- NOW = 10_000.0
- @pytest.fixture
- def scheduler():
- return PrintScheduler()
- def _history(*entries):
- """Build a deque of (monotonic_ts, celsius) from sparse offset-celsius pairs.
- Offsets are relative to NOW (negative = seconds before now). Use this
- directly when the test needs an explicit gap between samples
- (disconnect/reconnect scenarios). Otherwise prefer `_dense_history`.
- """
- d = deque()
- for offset, temp in entries:
- d.append((NOW + offset, float(temp)))
- return d, NOW
- def _dense_history(*entries, interval=30):
- """Build a deque with samples every `interval` seconds between entries,
- step-filled with the value of the previous entry. Mirrors the real
- sampling cadence, so the contiguity guard sees an unbroken run.
- """
- d = deque()
- if not entries:
- return d, NOW
- sorted_entries = sorted(entries, key=lambda e: e[0])
- prev_offset, prev_temp = sorted_entries[0]
- d.append((NOW + prev_offset, float(prev_temp)))
- for offset, temp in sorted_entries[1:]:
- cur = prev_offset + interval
- while cur < offset:
- d.append((NOW + cur, float(prev_temp)))
- cur += interval
- d.append((NOW + offset, float(temp)))
- prev_offset, prev_temp = offset, temp
- return d, NOW
- # ---------------------------------------------------------------------------
- # No history
- # ---------------------------------------------------------------------------
- def test_empty_history_returns_full_soak(scheduler):
- """No samples at all → conservative: return configured soak in full."""
- with patch("backend.app.services.print_scheduler.time") as t:
- t.monotonic.return_value = NOW
- result = scheduler._chamber_soak_remaining(PRINTER_ID, TARGET, SOAK)
- assert result == SOAK
- # ---------------------------------------------------------------------------
- # Chamber never dropped below threshold — contiguous run credit
- # ---------------------------------------------------------------------------
- def test_history_shorter_than_soak_credits_span(scheduler):
- """Chamber above target for 600 s of contiguous samples.
- Old behaviour returned full soak (wrong). New behaviour credits the
- 600 s we have evidence for → remaining = 1800 - 600 = 1200 s.
- """
- hist, now = _dense_history((-600, 55), (0, 53))
- scheduler._chamber_history[PRINTER_ID] = hist
- with patch("backend.app.services.print_scheduler.time") as t:
- t.monotonic.return_value = now
- result = scheduler._chamber_soak_remaining(PRINTER_ID, TARGET, SOAK)
- assert result == SOAK - 600
- def test_history_equal_to_soak_returns_zero(scheduler):
- """Chamber above target for exactly soak_seconds → remaining = 0."""
- hist, now = _dense_history((-SOAK, 55), (0, 52))
- scheduler._chamber_history[PRINTER_ID] = hist
- with patch("backend.app.services.print_scheduler.time") as t:
- t.monotonic.return_value = now
- result = scheduler._chamber_soak_remaining(PRINTER_ID, TARGET, SOAK)
- assert result == 0
- def test_history_longer_than_soak_returns_zero(scheduler):
- """Chamber above target for longer than soak_seconds → skip entirely."""
- hist, now = _dense_history((-3600, 56), (0, 52))
- scheduler._chamber_history[PRINTER_ID] = hist
- with patch("backend.app.services.print_scheduler.time") as t:
- t.monotonic.return_value = now
- result = scheduler._chamber_soak_remaining(PRINTER_ID, TARGET, SOAK)
- assert result == 0
- # ---------------------------------------------------------------------------
- # Chamber dipped below threshold at some point
- # ---------------------------------------------------------------------------
- def test_recent_dip_credits_only_time_since_dip(scheduler):
- """A real cooldown (10 min below threshold) restarts the credit at its end.
- Samples run at the real 30 s cadence: hot until -1500 s, below threshold
- from -1500 s to -900 s, hot again from -870 s. Credit starts at the last
- below-threshold sample (-900 s), so remaining = 1800 - 900 = 900.
- """
- hist, now = _dense_history((-3000, 55), (-1500, 44), (-870, 55), (0, 52))
- scheduler._chamber_history[PRINTER_ID] = hist
- with patch("backend.app.services.print_scheduler.time") as t:
- t.monotonic.return_value = now
- result = scheduler._chamber_soak_remaining(PRINTER_ID, TARGET, SOAK)
- assert result == SOAK - 900
- def test_dip_long_enough_ago_returns_zero(scheduler):
- """A real cooldown that ended longer ago than the soak → fully credited → 0."""
- hist, now = _dense_history((-4000, 55), (-2600, 44), (-1970, 55), (0, 52))
- scheduler._chamber_history[PRINTER_ID] = hist
- with patch("backend.app.services.print_scheduler.time") as t:
- t.monotonic.return_value = now
- result = scheduler._chamber_soak_remaining(PRINTER_ID, TARGET, SOAK)
- assert result == 0
- # ---------------------------------------------------------------------------
- # Dip debounce: brief sub-threshold readings are artifacts, not lost soak
- # ---------------------------------------------------------------------------
- def test_brief_dip_does_not_reset_credit(scheduler):
- """A single stray low sample must not discard hours of accumulated soak.
- The chamber cannot physically lose and regain 8°C in one sampling interval
- (measured: ~0.2 C/min), so this is a sensor artifact. Crediting from before
- the blip leaves the full hour, i.e. no soak needed.
- """
- hist, now = _dense_history((-3600, 55), (-600, 47), (-540, 55), (0, 55))
- scheduler._chamber_history[PRINTER_ID] = hist
- with patch("backend.app.services.print_scheduler.time") as t:
- t.monotonic.return_value = now
- result = scheduler._chamber_soak_remaining(PRINTER_ID, TARGET, SOAK)
- assert result == 0
- def test_four_minute_door_open_dip_does_not_reset_credit(scheduler):
- """The real-world case: opening the door to clear the plate.
- Modelled on an excursion actually recorded on an X1C — roughly four minutes
- below threshold, bottoming one degree under it, then straight back. That is
- air exchange, not the chamber mass cooling, so the soak still counts.
- """
- hist, now = _dense_history((-3600, 55), (-900, 47), (-660, 55), (0, 55))
- scheduler._chamber_history[PRINTER_ID] = hist
- with patch("backend.app.services.print_scheduler.time") as t:
- t.monotonic.return_value = now
- result = scheduler._chamber_soak_remaining(PRINTER_ID, TARGET, SOAK)
- assert result == 0
- def test_dip_past_grace_period_does_reset_credit(scheduler):
- """An excursion longer than the grace is real cooling and does reset it.
- Guards the other side of the debounce: 25 minutes below threshold is far
- slower than any artifact and well within the measured cooling rate, so the
- credit restarts at the end of the dip (-1530 s) → 1800 - 1530 = 270.
- """
- hist, now = _dense_history((-5000, 55), (-3000, 45), (-1500, 55), (0, 55))
- scheduler._chamber_history[PRINTER_ID] = hist
- with patch("backend.app.services.print_scheduler.time") as t:
- t.monotonic.return_value = now
- result = scheduler._chamber_soak_remaining(PRINTER_ID, TARGET, SOAK)
- assert result == SOAK - 1530
- # ---------------------------------------------------------------------------
- # Freshness: an old history is not evidence about the chamber right now
- # ---------------------------------------------------------------------------
- def test_stale_history_requires_full_soak(scheduler):
- """Hot history whose newest sample predates the max gap → full soak.
- The printer stopped reporting; at the measured cooling rate the chamber can
- cross the threshold inside such a window, so nothing may be credited.
- """
- hist, _ = _dense_history((-7200, 55), (-1800, 55))
- scheduler._chamber_history[PRINTER_ID] = hist
- with patch("backend.app.services.print_scheduler.time") as t:
- t.monotonic.return_value = NOW
- result = scheduler._chamber_soak_remaining(PRINTER_ID, TARGET, SOAK)
- assert result == SOAK
- def test_fresh_history_within_max_gap_is_credited(scheduler):
- """Boundary partner: a newest sample inside the max gap still counts."""
- hist, _ = _dense_history((-7200, 55), (-30, 55))
- scheduler._chamber_history[PRINTER_ID] = hist
- with patch("backend.app.services.print_scheduler.time") as t:
- t.monotonic.return_value = NOW
- result = scheduler._chamber_soak_remaining(PRINTER_ID, TARGET, SOAK)
- assert result == 0
- def test_currently_below_threshold_returns_full_soak(scheduler):
- """Most recent sample is below threshold → time_above ≈ 0 → full soak."""
- hist, now = _history(
- (-600, 55),
- (-300, 52),
- (0, 45), # BELOW threshold right now
- )
- scheduler._chamber_history[PRINTER_ID] = hist
- with patch("backend.app.services.print_scheduler.time") as t:
- t.monotonic.return_value = now
- result = scheduler._chamber_soak_remaining(PRINTER_ID, TARGET, SOAK)
- assert result == SOAK
- # ---------------------------------------------------------------------------
- # Contiguity / gap handling in the no-dip branch
- # ---------------------------------------------------------------------------
- def test_disconnect_gap_credits_only_last_contiguous_run(scheduler):
- """Chamber above threshold both before AND after a big gap in samples.
- Simulates a printer that was hot, disconnected for 30 min, and came back
- still hot. We cannot claim it was at temperature during the disconnect —
- only the most recent contiguous run counts. Credit = 600 s (post-gap
- run), remaining = 1800 - 600 = 1200.
- """
- pre_gap, _ = _dense_history((-3000, 55), (-2000, 55))
- post_gap, now = _dense_history((-600, 55), (0, 55))
- hist = deque(list(pre_gap) + list(post_gap))
- scheduler._chamber_history[PRINTER_ID] = hist
- with patch("backend.app.services.print_scheduler.time") as t:
- t.monotonic.return_value = now
- result = scheduler._chamber_soak_remaining(PRINTER_ID, TARGET, SOAK)
- assert result == SOAK - 600
- def test_sample_gap_at_cadence_threshold_still_contiguous(scheduler):
- """A gap exactly at the max-gap threshold does NOT break contiguity.
- The check is strictly greater-than, so a gap == threshold still credits
- across it. Guards against off-by-one drift in the contiguity heuristic.
- """
- hist, now = _history(
- (-1800, 55),
- (-1800 + int(_CHAMBER_SAMPLE_MAX_GAP_SECONDS), 55), # gap = threshold exactly
- (0, 55),
- )
- # Fill densely from the second entry onwards so only the first-to-second
- # gap is at the threshold.
- dense_tail, _ = _dense_history(
- (-1800 + int(_CHAMBER_SAMPLE_MAX_GAP_SECONDS), 55),
- (0, 55),
- )
- hist = deque([hist[0]] + list(dense_tail))
- scheduler._chamber_history[PRINTER_ID] = hist
- with patch("backend.app.services.print_scheduler.time") as t:
- t.monotonic.return_value = now
- result = scheduler._chamber_soak_remaining(PRINTER_ID, TARGET, SOAK)
- assert result == 0
- # ---------------------------------------------------------------------------
- # Tolerance boundary
- # ---------------------------------------------------------------------------
- def test_tolerance_boundary_above_counts_as_above(scheduler):
- """Sample at target - tolerance + 0.1 is above threshold → credit."""
- threshold_plus = TARGET - 2.0 + 0.1 # 48.1°C — just above threshold
- hist, now = _dense_history((-SOAK, threshold_plus), (0, threshold_plus))
- scheduler._chamber_history[PRINTER_ID] = hist
- with patch("backend.app.services.print_scheduler.time") as t:
- t.monotonic.return_value = now
- result = scheduler._chamber_soak_remaining(PRINTER_ID, TARGET, SOAK)
- assert result == 0
- def test_tolerance_boundary_at_threshold_counts_as_above(scheduler):
- """Sample exactly AT target - tolerance is NOT below (strictly less-than).
- Three contiguous samples 30 s apart: 55, 48.0, 55. The middle sample sits
- exactly at the threshold (48.0). If the at-threshold check counted as
- 'below', last_below_ts would fire on the middle sample and remaining
- would be SOAK - 30 = 1770. Because the check is strict ``temp < threshold``
- (and 48.0 < 48.0 is False), no dip is found — the whole 60 s contiguous
- span is credited and remaining = SOAK - 60 = 1740.
- Distinguishing the two branches is the point: the OLD test compared
- against 0 no matter which branch fired.
- """
- at_threshold = TARGET - 2.0 # 48.0°C
- hist, now = _history((-60, 55), (-30, at_threshold), (0, 55))
- scheduler._chamber_history[PRINTER_ID] = hist
- with patch("backend.app.services.print_scheduler.time") as t:
- t.monotonic.return_value = now
- result = scheduler._chamber_soak_remaining(PRINTER_ID, TARGET, SOAK)
- assert result == SOAK - 60
- # ---------------------------------------------------------------------------
- # Result is always non-negative
- # ---------------------------------------------------------------------------
- def test_result_never_negative(scheduler):
- """Even if the contiguous run spans many times the soak duration, floors at 0."""
- hist, now = _dense_history((-7200, 55), (0, 52))
- scheduler._chamber_history[PRINTER_ID] = hist
- with patch("backend.app.services.print_scheduler.time") as t:
- t.monotonic.return_value = now
- result = scheduler._chamber_soak_remaining(PRINTER_ID, TARGET, SOAK)
- assert result == 0
- # ---------------------------------------------------------------------------
- # _sample_chamber_temps: recording, TTL, gating, eviction
- # ---------------------------------------------------------------------------
- def _status(*, connected=True, chamber=None, bed=None):
- """Build a PrinterStatus-shaped namespace. `chamber=None` → key absent."""
- temps: dict = {}
- if chamber is not None:
- temps["chamber"] = chamber
- if bed is not None:
- temps["bed"] = bed
- return SimpleNamespace(connected=connected, temperatures=temps)
- def test_sample_chamber_temps_appends_current_reading(scheduler):
- """Each tick appends one (now, chamber_temp) sample per connected printer."""
- with (
- patch("backend.app.services.print_scheduler.time") as t,
- patch("backend.app.services.print_scheduler.printer_manager") as pm,
- ):
- t.monotonic.return_value = NOW
- pm.get_all_statuses.return_value = {PRINTER_ID: _status(chamber=52.5)}
- scheduler._sample_chamber_temps()
- hist = scheduler._chamber_history[PRINTER_ID]
- assert list(hist) == [(NOW, 52.5)]
- def test_sample_chamber_temps_prunes_entries_beyond_ttl(scheduler):
- """Samples older than _CHAMBER_HISTORY_TTL_SECONDS are popped from the deque."""
- old = NOW - _CHAMBER_HISTORY_TTL_SECONDS - 100
- recent = NOW - 30
- scheduler._chamber_history[PRINTER_ID] = deque([(old, 55.0), (recent, 55.0)])
- with (
- patch("backend.app.services.print_scheduler.time") as t,
- patch("backend.app.services.print_scheduler.printer_manager") as pm,
- ):
- t.monotonic.return_value = NOW
- pm.get_all_statuses.return_value = {PRINTER_ID: _status(chamber=55.0)}
- scheduler._sample_chamber_temps()
- ts_values = [entry[0] for entry in scheduler._chamber_history[PRINTER_ID]]
- assert old not in ts_values
- assert recent in ts_values
- def test_sample_chamber_temps_skips_absent_chamber_key(scheduler):
- """No 'chamber' key (e.g. printer without chamber sensor) → no sample recorded."""
- with (
- patch("backend.app.services.print_scheduler.time") as t,
- patch("backend.app.services.print_scheduler.printer_manager") as pm,
- ):
- t.monotonic.return_value = NOW
- pm.get_all_statuses.return_value = {PRINTER_ID: _status(bed=60.0)} # no chamber
- scheduler._sample_chamber_temps()
- assert PRINTER_ID not in scheduler._chamber_history
- def test_sample_chamber_temps_skips_disconnected_printer(scheduler):
- """A registered but disconnected printer keeps stale temps → don't sample it."""
- with (
- patch("backend.app.services.print_scheduler.time") as t,
- patch("backend.app.services.print_scheduler.printer_manager") as pm,
- ):
- t.monotonic.return_value = NOW
- pm.get_all_statuses.return_value = {PRINTER_ID: _status(connected=False, chamber=55.0)}
- scheduler._sample_chamber_temps()
- assert PRINTER_ID not in scheduler._chamber_history
- def test_sample_chamber_temps_evicts_history_for_removed_printer(scheduler):
- """A printer_id present in _chamber_history but not in the manager → evicted."""
- scheduler._chamber_history[99] = deque([(NOW - 100, 55.0)])
- scheduler._chamber_history[PRINTER_ID] = deque([(NOW - 100, 55.0)])
- with (
- patch("backend.app.services.print_scheduler.time") as t,
- patch("backend.app.services.print_scheduler.printer_manager") as pm,
- ):
- t.monotonic.return_value = NOW
- pm.get_all_statuses.return_value = {PRINTER_ID: _status(chamber=55.0)}
- scheduler._sample_chamber_temps()
- assert 99 not in scheduler._chamber_history
- assert PRINTER_ID in scheduler._chamber_history
- def test_sample_chamber_temps_evicts_keep_warm_state_for_removed_printer(scheduler):
- """A printer_id in _keep_warm but not in the manager → evicted."""
- from backend.app.services.print_scheduler import _KeepWarmEntry
- scheduler._keep_warm[99] = _KeepWarmEntry(started=NOW - 100, held_target=100)
- scheduler._keep_warm[PRINTER_ID] = _KeepWarmEntry(started=NOW - 100, held_target=100)
- with (
- patch("backend.app.services.print_scheduler.time") as t,
- patch("backend.app.services.print_scheduler.printer_manager") as pm,
- ):
- t.monotonic.return_value = NOW
- pm.get_all_statuses.return_value = {PRINTER_ID: _status(chamber=55.0)}
- scheduler._sample_chamber_temps()
- assert 99 not in scheduler._keep_warm
- assert PRINTER_ID in scheduler._keep_warm
- def test_sample_chamber_temps_none_status_ignored(scheduler):
- """get_all_statuses() can return None entries — those must not crash sampling.
- The None-check must run BEFORE `status.connected` is dereferenced, or an
- unregistered / mid-shutdown entry will AttributeError the whole tick.
- """
- with (
- patch("backend.app.services.print_scheduler.time") as t,
- patch("backend.app.services.print_scheduler.printer_manager") as pm,
- ):
- t.monotonic.return_value = NOW
- pm.get_all_statuses.return_value = {2: None, PRINTER_ID: _status(chamber=55.0)}
- scheduler._sample_chamber_temps()
- assert 2 not in scheduler._chamber_history
- assert PRINTER_ID in scheduler._chamber_history
|