test_scheduler_preheat_ams_mapping_2886.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383
  1. """Chamber preheat must read the trays the print loads, not the whole AMS (#2886).
  2. A P2S with PLA in slot 1 and ASA in slot 2 preheated every PLA job to a 45°C
  3. chamber target, because ``_derive_chamber_target`` took the max over every
  4. loaded tray regardless of which ones the job mapped. The reporter's log shows
  5. the cost: bed driven to 90°C and the full 900s max-wait plus 300s soak burned
  6. before each upload, on a printer whose chamber tops out around 33°C and so
  7. never satisfies the wait early.
  8. The reporter's AMS, from ``push-status/printer-1.json`` in their support
  9. bundle, is reproduced in ``_reporter_ams`` below, and the mapping the dispatch
  10. actually sent — ``[-1, -1, -1, 1]`` — in ``PLA_ONLY_MAPPING``.
  11. """
  12. import json
  13. from types import SimpleNamespace
  14. from unittest.mock import AsyncMock, MagicMock, patch
  15. import pytest
  16. from backend.app.services.print_scheduler import PrintScheduler
  17. # Global tray ids for AMS unit 0: ams_id * 4 + tray_id.
  18. PETG_PRO_TRAY = 0
  19. PLA_TRAY = 1
  20. ASA_TRAY = 2
  21. PETG_TRAY = 3
  22. # What the dispatcher put on the wire for the reporter's PLA job.
  23. PLA_ONLY_MAPPING = json.dumps([-1, -1, -1, PLA_TRAY])
  24. @pytest.fixture
  25. def scheduler():
  26. return PrintScheduler()
  27. def _make_item(ams_mapping=None, **overrides):
  28. """A queue item shaped the way `_preheat_and_soak` reads it.
  29. `ams_mapping` is passed through verbatim so a test can hand in the JSON
  30. string the column stores, a raw list, or junk.
  31. """
  32. fields = {
  33. "id": 96,
  34. "preheat_override": "inherit",
  35. "preheat_chamber_target_override": None,
  36. "ams_mapping": ams_mapping,
  37. }
  38. fields.update(overrides)
  39. return SimpleNamespace(**fields)
  40. def _make_client():
  41. client = MagicMock()
  42. client.set_bed_temperature = MagicMock(return_value=True)
  43. client.set_chamber_temperature = MagicMock(return_value=True)
  44. client.set_airduct_mode = MagicMock(return_value=True)
  45. return client
  46. def _reporter_ams():
  47. """The four trays in the reporter's AMS unit 0, ids and all.
  48. Ids are strings because that is how their firmware reports them; the
  49. derivation has to coerce before it can compare against a mapping's ints.
  50. """
  51. return [
  52. {
  53. "id": "0",
  54. "tray": [
  55. {"id": "0", "tray_type": "PETG Pro"},
  56. {"id": "1", "tray_type": "PLA"},
  57. {"id": "2", "tray_type": "ASA"},
  58. {"id": "3", "tray_type": "PETG"},
  59. ],
  60. }
  61. ]
  62. def _make_state(ams=None, vt_tray=None, bed_temp=0.0, chamber_temp=0.0):
  63. raw_data: dict = {}
  64. if ams is not None:
  65. raw_data["ams"] = ams
  66. if vt_tray is not None:
  67. raw_data["vt_tray"] = vt_tray
  68. return SimpleNamespace(
  69. temperatures={"bed": bed_temp, "chamber": chamber_temp},
  70. raw_data=raw_data,
  71. airduct_mode=0,
  72. )
  73. def _ints(**values):
  74. return AsyncMock(side_effect=lambda _db, key, default: values.get(key, default))
  75. def _derive(scheduler, state, item, targets=None):
  76. with patch("backend.app.services.print_scheduler.printer_manager") as pm:
  77. pm.get_status.return_value = state
  78. return scheduler._derive_chamber_target(
  79. SimpleNamespace(id=1, model="P2S"),
  80. targets if targets is not None else PrintScheduler._bundled_preheat_targets(),
  81. item,
  82. )
  83. # ----------------------------------------------------------------------------
  84. # The reported case
  85. # ----------------------------------------------------------------------------
  86. def test_the_reporters_pla_job_derives_no_chamber_target(scheduler):
  87. """PLA mapped, ASA merely parked two slots over → 0, not 45."""
  88. result = _derive(scheduler, _make_state(ams=_reporter_ams()), _make_item(PLA_ONLY_MAPPING))
  89. assert result == 0
  90. def test_the_same_ams_still_derives_45_for_a_job_that_maps_the_asa(scheduler):
  91. """The narrowing must not disarm preheat — mapping the ASA tray still asks
  92. for its 45°C."""
  93. mapping = json.dumps([-1, -1, -1, ASA_TRAY])
  94. result = _derive(scheduler, _make_state(ams=_reporter_ams()), _make_item(mapping))
  95. assert result == 45
  96. def test_a_multi_material_job_takes_the_max_of_the_trays_it_maps(scheduler):
  97. """PLA + ASA in one print: ASA is the binding constraint, exactly as the
  98. all-trays scan used to conclude for every job."""
  99. mapping = json.dumps([PLA_TRAY, ASA_TRAY])
  100. result = _derive(scheduler, _make_state(ams=_reporter_ams()), _make_item(mapping))
  101. assert result == 45
  102. def test_a_mapped_petg_pro_job_ignores_the_asa(scheduler):
  103. """PETG normalises to PETG (0), not PETG-CF (40) — and the ASA next to it
  104. contributes nothing."""
  105. mapping = json.dumps([PETG_PRO_TRAY, PETG_TRAY])
  106. result = _derive(scheduler, _make_state(ams=_reporter_ams()), _make_item(mapping))
  107. assert result == 0
  108. @pytest.mark.asyncio
  109. async def test_end_to_end_the_pla_job_skips_preheat_entirely(scheduler):
  110. """The whole stage short-circuits: no bed command, no chamber command, no
  111. 900s wait. Their archive carries no bed_temperature (the log line reads
  112. "archive has no bed_temperature metadata"), so with the chamber target back
  113. at 0 this lands on the pre-existing skip branch.
  114. The wait and soak are pinned to 0 and `asyncio.sleep` is patched even
  115. though a passing run reaches neither: without that, a regression here does
  116. not fail, it blocks for the full 900s wall-clock deadline.
  117. """
  118. db = AsyncMock()
  119. client = _make_client()
  120. archive = SimpleNamespace(bed_temperature=None)
  121. with (
  122. patch.object(scheduler, "_get_bool_setting", AsyncMock(return_value=True)),
  123. patch.object(
  124. scheduler,
  125. "_get_int_setting",
  126. _ints(queue_keep_warm_bed_temp=90, preheat_soak_seconds=0, preheat_max_wait_seconds=0),
  127. ),
  128. patch.object(scheduler, "_get_setting", AsyncMock(return_value=None)),
  129. patch("backend.app.services.print_scheduler.printer_manager") as pm,
  130. patch("backend.app.services.print_scheduler.asyncio.sleep", AsyncMock()),
  131. ):
  132. pm.get_client.return_value = client
  133. pm.get_status.return_value = _make_state(ams=_reporter_ams())
  134. proceeded = await scheduler._preheat_and_soak(
  135. db, _make_item(PLA_ONLY_MAPPING), SimpleNamespace(id=1, model="P2S"), archive
  136. )
  137. assert proceeded is True
  138. client.set_bed_temperature.assert_not_called()
  139. client.set_chamber_temperature.assert_not_called()
  140. @pytest.mark.asyncio
  141. async def test_end_to_end_a_mapped_asa_job_still_heats_the_bed_to_drive_the_chamber(scheduler):
  142. """The other half of the reported behaviour is correct and must survive:
  143. an ASA job with no bed metadata still falls back to the configured
  144. chamber-heating bed temperature."""
  145. db = AsyncMock()
  146. client = _make_client()
  147. archive = SimpleNamespace(bed_temperature=None)
  148. mapping = json.dumps([-1, -1, -1, ASA_TRAY])
  149. with (
  150. patch.object(scheduler, "_get_bool_setting", AsyncMock(return_value=True)),
  151. patch.object(
  152. scheduler,
  153. "_get_int_setting",
  154. _ints(queue_keep_warm_bed_temp=90, preheat_soak_seconds=0, preheat_max_wait_seconds=0),
  155. ),
  156. patch.object(scheduler, "_get_setting", AsyncMock(return_value=None)),
  157. patch("backend.app.services.print_scheduler.printer_manager") as pm,
  158. patch("backend.app.services.print_scheduler.asyncio.sleep", AsyncMock()),
  159. ):
  160. pm.get_client.return_value = client
  161. # Already at temperature so the convergence loop exits on its first pass.
  162. pm.get_status.return_value = _make_state(ams=_reporter_ams(), bed_temp=90.0, chamber_temp=46.0)
  163. await scheduler._preheat_and_soak(db, _make_item(mapping), SimpleNamespace(id=1, model="P2S"), archive)
  164. client.set_bed_temperature.assert_called_once_with(90)
  165. # ----------------------------------------------------------------------------
  166. # Fallback: an item with no usable mapping keeps the all-trays scan
  167. # ----------------------------------------------------------------------------
  168. @pytest.mark.parametrize(
  169. "mapping",
  170. [
  171. pytest.param(None, id="never-set"),
  172. pytest.param("", id="empty-string"),
  173. pytest.param("[-1, -1]", id="all-unresolved"),
  174. pytest.param("[null, null]", id="all-null"),
  175. pytest.param("[]", id="empty-list"),
  176. pytest.param("not json", id="unparseable"),
  177. pytest.param('{"tray": 1}', id="not-a-list"),
  178. ],
  179. )
  180. def test_an_item_without_a_usable_mapping_scans_every_tray(scheduler, mapping):
  181. """No statement about which trays are used means we cannot narrow. Falling
  182. back to the whole unit keeps preheat firing for prints that need it; the
  183. alternative — narrowing to nothing — would silently disable the feature."""
  184. result = _derive(scheduler, _make_state(ams=_reporter_ams()), _make_item(mapping))
  185. assert result == 45
  186. def test_an_item_object_without_the_attribute_at_all_scans_every_tray(scheduler):
  187. """`_apply_keep_warm` reaches for `next_item.ams_mapping` on rows loaded by
  188. other code paths; a missing attribute must fall back, not raise."""
  189. item = SimpleNamespace(id=7)
  190. result = _derive(scheduler, _make_state(ams=_reporter_ams()), item)
  191. assert result == 45
  192. def test_passing_no_item_at_all_scans_every_tray(scheduler):
  193. """The parameter is optional so existing callers keep compiling; omitting
  194. it is the pre-#2886 behaviour."""
  195. with patch("backend.app.services.print_scheduler.printer_manager") as pm:
  196. pm.get_status.return_value = _make_state(ams=_reporter_ams())
  197. result = scheduler._derive_chamber_target(
  198. SimpleNamespace(id=1, model="P2S"), PrintScheduler._bundled_preheat_targets()
  199. )
  200. assert result == 45
  201. def test_a_partially_resolved_mapping_narrows_to_the_slots_that_resolved(scheduler):
  202. """`[-1, 2]` is NOT all-unresolved: slot 2 matched the ASA tray, so it is a
  203. genuine statement and the ASA counts."""
  204. result = _derive(scheduler, _make_state(ams=_reporter_ams()), _make_item(json.dumps([-1, ASA_TRAY])))
  205. assert result == 45
  206. def test_a_mapping_already_stored_as_a_list_is_read_without_json(scheduler):
  207. """The column is Text, but rows written in-process can still hold a list."""
  208. result = _derive(scheduler, _make_state(ams=_reporter_ams()), _make_item([PLA_TRAY]))
  209. assert result == 0
  210. # ----------------------------------------------------------------------------
  211. # Tray addressing
  212. # ----------------------------------------------------------------------------
  213. def test_a_second_ams_unit_is_addressed_with_the_four_slot_stride(scheduler):
  214. """Unit 1 tray 2 is global id 6, not 2 — getting the stride wrong would
  215. match the ASA in unit 0 instead."""
  216. ams = _reporter_ams() + [{"id": 1, "tray": [{"id": 2, "tray_type": "ABS"}]}]
  217. assert _derive(scheduler, _make_state(ams=ams), _make_item(json.dumps([6]))) == 45
  218. assert _derive(scheduler, _make_state(ams=ams), _make_item(json.dumps([2]))) == 45 # unit 0's ASA
  219. assert _derive(scheduler, _make_state(ams=ams), _make_item(json.dumps([PLA_TRAY]))) == 0
  220. def test_an_ams_ht_is_addressed_by_its_unit_id(scheduler):
  221. """AMS-HT units number from 128 and hold one tray, so the global id is the
  222. unit id itself — 128 * 4 + 0 would address nothing."""
  223. ams = [{"id": 128, "tray": [{"id": 0, "tray_type": "ABS"}]}]
  224. assert _derive(scheduler, _make_state(ams=ams), _make_item(json.dumps([128]))) == 45
  225. assert _derive(scheduler, _make_state(ams=ams), _make_item(json.dumps([0]))) == 0
  226. def test_unparseable_tray_ids_do_not_take_the_derivation_down(scheduler):
  227. """A junk id falls to 0, which addresses unit 0 slot 0. It must not raise —
  228. preheat is best-effort and an exception here aborts the dispatch stage."""
  229. ams = [{"id": None, "tray": [{"id": "x", "tray_type": "ASA"}]}]
  230. assert _derive(scheduler, _make_state(ams=ams), _make_item(json.dumps([0]))) == 45
  231. def test_a_tray_with_no_type_contributes_nothing(scheduler):
  232. """An empty slot the mapping happens to name is not an error, just a 0."""
  233. ams = [{"id": 0, "tray": [{"id": 0, "tray_type": ""}, {"id": 1, "tray_type": "ASA"}]}]
  234. assert _derive(scheduler, _make_state(ams=ams), _make_item(json.dumps([0]))) == 0
  235. def test_a_non_dict_tray_entry_is_stepped_over(scheduler):
  236. """Nothing between the derivation and `_dispatch_one`'s try/finally catches
  237. an exception, so a junk tray entry would leave the item holding its
  238. dispatch claim. The real tray beside it is still read."""
  239. ams = [{"id": 0, "tray": ["junk", {"id": 1, "tray_type": "ASA"}]}]
  240. assert _derive(scheduler, _make_state(ams=ams), _make_item(json.dumps([1]))) == 45
  241. assert _derive(scheduler, _make_state(ams=ams), _make_item(None)) == 45
  242. def test_no_ams_telemetry_derives_zero(scheduler):
  243. assert _derive(scheduler, _make_state(), _make_item(PLA_ONLY_MAPPING)) == 0
  244. def test_no_printer_status_derives_zero(scheduler):
  245. assert _derive(scheduler, None, _make_item(PLA_ONLY_MAPPING)) == 0
  246. # ----------------------------------------------------------------------------
  247. # External spool
  248. # ----------------------------------------------------------------------------
  249. def test_an_external_spool_the_mapping_names_is_read(scheduler):
  250. """254/255 address the external feeds. Before the mapping was consulted the
  251. external spool was invisible to the derivation, so an ASA print fed from it
  252. got no preheat at all.
  253. The AMS deliberately holds only PLA: an ASA tray here would let this pass
  254. without `vt_tray` ever being read."""
  255. state = _make_state(
  256. ams=[{"id": 0, "tray": [{"id": 0, "tray_type": "PLA"}]}],
  257. vt_tray=[{"id": 254, "tray_type": "ASA"}],
  258. )
  259. assert _derive(scheduler, state, _make_item(json.dumps([254]))) == 45
  260. def test_an_external_spool_without_an_id_defaults_to_254(scheduler):
  261. """`_build_loaded_filaments` writes the same default, so a mapping built
  262. from it addresses the entry as 254."""
  263. state = _make_state(vt_tray=[{"tray_type": "ABS"}])
  264. assert _derive(scheduler, state, _make_item(json.dumps([254]))) == 45
  265. def test_an_external_spool_the_mapping_does_not_name_is_ignored(scheduler):
  266. """The PLA job maps an AMS tray; the ASA hanging off the back is not part
  267. of this print."""
  268. state = _make_state(ams=_reporter_ams(), vt_tray=[{"id": 254, "tray_type": "ASA"}])
  269. assert _derive(scheduler, state, _make_item(PLA_ONLY_MAPPING)) == 0
  270. def test_the_external_spool_stays_out_of_the_unnarrowed_scan(scheduler):
  271. """Without a mapping the scan is AMS-only, as it always was. Reading
  272. `vt_tray` here would newly preheat for a spool that may not be in use, so
  273. the fix is scoped to what the mapping positively states."""
  274. state = _make_state(
  275. ams=[{"id": 0, "tray": [{"id": 0, "tray_type": "PLA"}]}], vt_tray=[{"id": 254, "tray_type": "ASA"}]
  276. )
  277. assert _derive(scheduler, state, _make_item(None)) == 0
  278. def test_a_non_dict_vt_tray_entry_is_skipped(scheduler):
  279. """Older firmware surfaced `vt_tray` as a dict; iterating it yields keys.
  280. The junk entry must be stepped over rather than raise, and the real one
  281. after it still read."""
  282. state = _make_state(vt_tray=["255", {"id": 254, "tray_type": "ASA"}])
  283. assert _derive(scheduler, state, _make_item(json.dumps([254]))) == 45
  284. # ----------------------------------------------------------------------------
  285. # Keep-warm reads the same narrowing
  286. # ----------------------------------------------------------------------------
  287. def test_keep_warm_uses_the_next_items_mapping(scheduler):
  288. """`_apply_keep_warm` gates the bed hold on the same derivation, so an ASA
  289. spool the next job never touches must not hold the bed at 90°C through the
  290. plate-clearing window."""
  291. pla_next = _make_item(PLA_ONLY_MAPPING)
  292. asa_next = _make_item(json.dumps([ASA_TRAY]))
  293. state = _make_state(ams=_reporter_ams())
  294. assert _derive(scheduler, state, pla_next) == 0
  295. assert _derive(scheduler, state, asa_next) == 45