test_rfid_assign_picks_the_right_hotend.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321
  1. """RFID auto-assign has to pick the K profile for the hotend the slot feeds.
  2. ``auto_assign_spool`` runs unattended every time a Bambu spool is detected in a
  3. slot. It sends no ``ams_filament_setting`` -- the firmware already has the
  4. filament from the tag, and overwriting it turns the eye icon into a pen in
  5. Studio -- but it does select a K profile with ``extrusion_cali_sel``.
  6. It used to take the first stored row matching (printer, nozzle diameter) with
  7. no extruder test at all. On a dual-nozzle printer a spool calibrated on both
  8. hotends therefore had a coin toss decide which K value the slot got, and the
  9. losing side prints with the other nozzle's pressure advance.
  10. """
  11. from __future__ import annotations
  12. from unittest.mock import MagicMock
  13. import pytest
  14. from sqlalchemy import select
  15. from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
  16. from sqlalchemy.orm import selectinload
  17. from backend.app.models.spool import Spool
  18. from backend.app.models.spool_k_profile import SpoolKProfile
  19. from backend.app.services.spool_tag_matcher import auto_assign_spool
  20. pytestmark = pytest.mark.asyncio
  21. RIGHT, LEFT = 0, 1
  22. class _Nozzle:
  23. def __init__(self, diameter, nozzle_type=""):
  24. self.nozzle_diameter = diameter
  25. self.nozzle_type = nozzle_type
  26. class _State:
  27. """Dual-nozzle printer, AMS 0 on the left hotend and AMS 1 on the right."""
  28. def __init__(self, diameters=("0.4", "0.4"), types=("", "")):
  29. self.nozzles = [_Nozzle(d, t) for d, t in zip(diameters, types, strict=False)]
  30. self.ams_extruder_map = {"0": LEFT, "1": RIGHT}
  31. self.ams_switch_inlet = None
  32. self.raw_data = {}
  33. def _manager(state, client):
  34. manager = MagicMock()
  35. manager.get_status = MagicMock(return_value=state)
  36. manager.get_client = MagicMock(return_value=client)
  37. manager.get_model = MagicMock(return_value="H2D")
  38. return manager
  39. async def _spool_with_both_hotends(engine, printer_id, diameters=("0.4", "0.4")):
  40. maker = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
  41. async with maker() as db:
  42. spool = Spool(brand="Bambu", material="PLA", color_name="Black", slicer_filament="GFSA00")
  43. db.add(spool)
  44. await db.commit()
  45. await db.refresh(spool)
  46. # Same spool, calibrated on both hotends -- the maintainer's H2C reads
  47. # 0.018 left and 0.020 right for one black PLA.
  48. db.add_all(
  49. [
  50. SpoolKProfile(
  51. spool_id=spool.id,
  52. printer_id=printer_id,
  53. extruder=RIGHT,
  54. nozzle_diameter=diameters[RIGHT],
  55. k_value=0.020,
  56. cali_idx=15,
  57. ),
  58. SpoolKProfile(
  59. spool_id=spool.id,
  60. printer_id=printer_id,
  61. extruder=LEFT,
  62. nozzle_diameter=diameters[LEFT],
  63. k_value=0.018,
  64. cali_idx=16,
  65. ),
  66. ]
  67. )
  68. await db.commit()
  69. return maker, spool.id
  70. async def _load(maker, spool_id) -> Spool:
  71. async with maker() as db:
  72. result = await db.execute(select(Spool).options(selectinload(Spool.k_profiles)).where(Spool.id == spool_id))
  73. return result.scalar_one()
  74. async def _assign(maker, spool, printer, ams_id, state):
  75. client = MagicMock()
  76. async with maker() as db:
  77. await auto_assign_spool(printer.id, ams_id, 0, spool, _manager(state, client), db, tray_info_idx="GFA00")
  78. return client
  79. class TestWhichHotendsProfileIsSelected:
  80. async def test_a_slot_on_the_left_gets_the_left_profile(self, test_engine, printer_factory):
  81. printer = await printer_factory(model="H2D")
  82. maker, spool_id = await _spool_with_both_hotends(test_engine, printer.id)
  83. spool = await _load(maker, spool_id)
  84. client = await _assign(maker, spool, printer, ams_id=0, state=_State())
  85. client.extrusion_cali_sel.assert_called_once()
  86. assert client.extrusion_cali_sel.call_args.kwargs["cali_idx"] == 16
  87. async def test_a_slot_on_the_right_gets_the_right_profile(self, test_engine, printer_factory):
  88. printer = await printer_factory(model="H2D")
  89. maker, spool_id = await _spool_with_both_hotends(test_engine, printer.id)
  90. spool = await _load(maker, spool_id)
  91. client = await _assign(maker, spool, printer, ams_id=1, state=_State())
  92. assert client.extrusion_cali_sel.call_args.kwargs["cali_idx"] == 15
  93. async def test_the_slots_own_nozzle_size_decides_the_diameter(self, test_engine, printer_factory):
  94. """0.4 right, 0.2 left: the left slot must look up 0.2 profiles, which
  95. is what reading nozzles[0] for every slot got wrong."""
  96. printer = await printer_factory(model="H2D")
  97. maker, spool_id = await _spool_with_both_hotends(test_engine, printer.id, diameters=("0.4", "0.2"))
  98. spool = await _load(maker, spool_id)
  99. client = await _assign(maker, spool, printer, ams_id=0, state=_State(("0.4", "0.2")))
  100. kwargs = client.extrusion_cali_sel.call_args.kwargs
  101. assert kwargs["nozzle_diameter"] == "0.2"
  102. assert kwargs["cali_idx"] == 16
  103. class TestTheFilamentIdSentWithTheSelection:
  104. """extrusion_cali_sel carries a filament id so the printer can link the
  105. calibration index to the slot. A cloud USER preset id (PFUS/PFCN) is not one
  106. the slicer accepts -- and a per-model override can now BE such an id, since
  107. picking your own cloud preset for a model stores exactly that."""
  108. async def test_a_cloud_user_preset_override_is_not_sent_to_the_printer(self, test_engine, printer_factory):
  109. from backend.app.models.spool_filament_preset import SpoolFilamentPreset
  110. printer = await printer_factory(model="H2D")
  111. maker, spool_id = await _spool_with_both_hotends(test_engine, printer.id)
  112. async with maker() as db:
  113. db.add(
  114. SpoolFilamentPreset(
  115. spool_id=spool_id,
  116. printer_model="H2D",
  117. nozzle_diameter="0.4",
  118. slicer_filament="PFUS279c9bd2c689d5",
  119. slicer_filament_name="# Bambu PETG HF @BBL H2D 0.4 nozzle",
  120. )
  121. )
  122. await db.commit()
  123. spool = await _load(maker, spool_id)
  124. client = await _assign(maker, spool, printer, ams_id=0, state=_State())
  125. # Falls through to the spool's own preset rather than sending a value
  126. # the printer rejects, which would silently lose the K-profile link.
  127. assert client.extrusion_cali_sel.call_args.kwargs["filament_id"] == "GFSA00"
  128. async def test_a_normal_override_is_sent(self, test_engine, printer_factory):
  129. from backend.app.models.spool_filament_preset import SpoolFilamentPreset
  130. printer = await printer_factory(model="H2D")
  131. maker, spool_id = await _spool_with_both_hotends(test_engine, printer.id)
  132. async with maker() as db:
  133. db.add(
  134. SpoolFilamentPreset(
  135. spool_id=spool_id,
  136. printer_model="H2D",
  137. nozzle_diameter="0.4",
  138. slicer_filament="GFSG02_15",
  139. slicer_filament_name="Bambu PETG HF @BBL H2D",
  140. )
  141. )
  142. await db.commit()
  143. spool = await _load(maker, spool_id)
  144. client = await _assign(maker, spool, printer, ams_id=0, state=_State())
  145. assert client.extrusion_cali_sel.call_args.kwargs["filament_id"] == "GFSG02_15"
  146. class TestFallbacks:
  147. async def test_a_profile_for_the_other_hotend_is_better_than_none(self, test_engine, printer_factory):
  148. """An operator who calibrated one side only should still get that
  149. profile rather than nothing -- the fallback the old code had by
  150. accident, kept deliberately."""
  151. printer = await printer_factory(model="H2D")
  152. maker = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False)
  153. async with maker() as db:
  154. spool = Spool(brand="Bambu", material="PLA", color_name="Black")
  155. db.add(spool)
  156. await db.commit()
  157. await db.refresh(spool)
  158. db.add(
  159. SpoolKProfile(
  160. spool_id=spool.id,
  161. printer_id=printer.id,
  162. extruder=RIGHT,
  163. nozzle_diameter="0.4",
  164. k_value=0.020,
  165. cali_idx=15,
  166. )
  167. )
  168. await db.commit()
  169. loaded = await _load(maker, spool.id)
  170. client = await _assign(maker, loaded, printer, ams_id=0, state=_State())
  171. assert client.extrusion_cali_sel.call_args.kwargs["cali_idx"] == 15
  172. async def test_a_profile_for_a_different_nozzle_size_is_not_used(self, test_engine, printer_factory):
  173. """Diameter is not negotiable the way the hotend is: a K value measured
  174. on a 0.6 says nothing about a 0.4."""
  175. printer = await printer_factory(model="H2D")
  176. maker = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False)
  177. async with maker() as db:
  178. spool = Spool(brand="Bambu", material="PLA", color_name="Black")
  179. db.add(spool)
  180. await db.commit()
  181. await db.refresh(spool)
  182. db.add(
  183. SpoolKProfile(
  184. spool_id=spool.id,
  185. printer_id=printer.id,
  186. extruder=LEFT,
  187. nozzle_diameter="0.6",
  188. k_value=0.030,
  189. cali_idx=20,
  190. )
  191. )
  192. await db.commit()
  193. loaded = await _load(maker, spool.id)
  194. client = await _assign(maker, loaded, printer, ams_id=0, state=_State())
  195. # No stored profile for 0.4 -- nothing is selected from the store.
  196. selected = [c for c in client.extrusion_cali_sel.call_args_list if c.kwargs.get("cali_idx") == 20]
  197. assert selected == []
  198. class TestFlowType:
  199. """A K value measured through a high-flow nozzle is not a fact about a
  200. standard one -- the printer files them as separate calibration entries, and
  201. a machine can hold both for the same diameter."""
  202. async def _spool_with_flows(self, engine, printer_id):
  203. from backend.app.models.spool_k_profile import SpoolKProfile
  204. maker = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
  205. async with maker() as db:
  206. spool = Spool(brand="Bambu", material="PLA", color_name="Black", slicer_filament="GFSA00")
  207. db.add(spool)
  208. await db.commit()
  209. await db.refresh(spool)
  210. db.add_all(
  211. [
  212. SpoolKProfile(
  213. spool_id=spool.id,
  214. printer_id=printer_id,
  215. extruder=LEFT,
  216. nozzle_diameter="0.4",
  217. nozzle_type="HS",
  218. k_value=0.019,
  219. cali_idx=30,
  220. ),
  221. SpoolKProfile(
  222. spool_id=spool.id,
  223. printer_id=printer_id,
  224. extruder=LEFT,
  225. nozzle_diameter="0.4",
  226. nozzle_type="HH",
  227. k_value=0.026,
  228. cali_idx=31,
  229. ),
  230. ]
  231. )
  232. await db.commit()
  233. return maker, spool.id
  234. async def test_the_fitted_flow_decides_which_profile_applies(self, test_engine, printer_factory):
  235. printer = await printer_factory(model="H2D")
  236. maker, spool_id = await self._spool_with_flows(test_engine, printer.id)
  237. spool = await _load(maker, spool_id)
  238. high = await _assign(maker, spool, printer, 0, _State(types=("HH01", "HH01")))
  239. assert high.extrusion_cali_sel.call_args.kwargs["cali_idx"] == 31
  240. spool = await _load(maker, spool_id)
  241. standard = await _assign(maker, spool, printer, 0, _State(types=("HS01", "HS01")))
  242. assert standard.extrusion_cali_sel.call_args.kwargs["cali_idx"] == 30
  243. async def test_a_profile_with_no_stored_flow_still_applies(self, test_engine, printer_factory):
  244. """Every profile saved before flow was recorded has none, so a strict
  245. comparison would stop applying all of them at once."""
  246. printer = await printer_factory(model="H2D")
  247. maker, spool_id = await _spool_with_both_hotends(test_engine, printer.id)
  248. spool = await _load(maker, spool_id)
  249. client = await _assign(maker, spool, printer, 0, _State(types=("HH01", "HH01")))
  250. assert client.extrusion_cali_sel.call_args.kwargs["cali_idx"] == 16
  251. async def test_a_printer_that_declares_no_flow_applies_everything(self, test_engine, printer_factory):
  252. """The X1C case, measured: it answers with nozzle_id '' on every
  253. profile, so filtering on an invented Standard would drop the lot."""
  254. printer = await printer_factory(model="H2D")
  255. maker, spool_id = await self._spool_with_flows(test_engine, printer.id)
  256. spool = await _load(maker, spool_id)
  257. client = await _assign(maker, spool, printer, 0, _State(types=("", "")))
  258. # Nothing is excluded, so the first stored row wins as it always did.
  259. assert client.extrusion_cali_sel.call_args.kwargs["cali_idx"] in (30, 31)