test_slot_nozzle_resolution.py 7.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179
  1. """Which nozzle an AMS slot feeds.
  2. Every path that configures a slot needs the extruder and that nozzle's
  3. diameter, and both the filament preset and the K profile are stored per
  4. diameter -- so a wrong answer here silently selects the wrong preset AND the
  5. wrong K value. Seven call sites used to work it out independently, each reading
  6. ``nozzles[0]`` for every slot on the machine.
  7. ``nozzles[0]`` is right on a single-nozzle printer, and right on a dual-nozzle
  8. printer with matching nozzles, which is why it survived. These pin the case it
  9. is wrong for: two different sizes fitted, which is the machine the per-nozzle
  10. feature exists for.
  11. """
  12. from __future__ import annotations
  13. from backend.app.services.slot_nozzle import (
  14. DEFAULT_NOZZLE_DIAMETER,
  15. SlotNozzle,
  16. normalise_flow,
  17. nozzle_diameter_for_extruder,
  18. nozzle_flow_for_extruder,
  19. resolve_slot_nozzle,
  20. )
  21. class _Nozzle:
  22. def __init__(self, diameter: str):
  23. self.nozzle_diameter = diameter
  24. self.nozzle_type = ""
  25. class _State:
  26. def __init__(self, diameters, ams_extruder_map=None, ams_switch_inlet=None, types=()):
  27. self.nozzles = [_Nozzle(d) for d in diameters]
  28. for nozzle, nozzle_type in zip(self.nozzles, types, strict=False):
  29. nozzle.nozzle_type = nozzle_type
  30. self.ams_extruder_map = ams_extruder_map
  31. self.ams_switch_inlet = ams_switch_inlet
  32. # extruder 0 is the RIGHT hotend, 1 the left.
  33. MIXED = ["0.4", "0.2"]
  34. class TestDualNozzleMixedDiameters:
  35. """The case the old shortcut got wrong."""
  36. def test_each_hotend_reports_its_own_diameter(self):
  37. state = _State(MIXED)
  38. assert nozzle_diameter_for_extruder(state, 0, "H2C") == "0.4"
  39. assert nozzle_diameter_for_extruder(state, 1, "H2C") == "0.2"
  40. def test_the_slots_extruder_decides_which_one(self):
  41. # AMS 0 is bound to the left hotend, AMS 1 to the right.
  42. state = _State(MIXED, ams_extruder_map={"0": 1, "1": 0})
  43. left = resolve_slot_nozzle(state, 0, 0, "H2C")
  44. right = resolve_slot_nozzle(state, 1, 0, "H2C")
  45. assert (left.extruder, left.diameter) == (1, "0.2")
  46. assert (right.extruder, right.diameter) == (0, "0.4")
  47. def test_external_slots_name_their_side_by_tray_id(self):
  48. # Ext-L is tray 0 -> extruder 1, Ext-R is tray 1 -> extruder 0.
  49. state = _State(MIXED)
  50. assert resolve_slot_nozzle(state, 255, 0, "H2D").diameter == "0.2"
  51. assert resolve_slot_nozzle(state, 255, 1, "H2D").diameter == "0.4"
  52. def test_an_fts_inlet_answers_when_there_is_no_extruder_map(self):
  53. state = _State(MIXED, ams_switch_inlet={"0": "A"})
  54. resolved = resolve_slot_nozzle(state, 0, 0, "H2C")
  55. assert resolved.extruder is not None
  56. assert resolved.diameter in {"0.4", "0.2"}
  57. class TestSingleNozzle:
  58. """Must behave exactly as the old shortcut did."""
  59. def test_index_zero_whatever_the_map_says(self):
  60. # A single-nozzle model has one entry; an extruder id from a stale map
  61. # must not index past it.
  62. state = _State(["0.6"], ams_extruder_map={"0": 1})
  63. assert nozzle_diameter_for_extruder(state, 1, "X1C") == "0.6"
  64. assert resolve_slot_nozzle(state, 0, 0, "X1C").diameter == "0.6"
  65. def test_no_map_means_unknown_extruder_not_zero(self):
  66. # "I don't know" and "the right-hand nozzle" are different answers; the
  67. # caller's own default of 0 is correct on a single-nozzle machine, but
  68. # storing it as a fact is what bound a left K-profile to a right slot.
  69. state = _State(["0.4"])
  70. resolved = resolve_slot_nozzle(state, 0, 0, "X1C")
  71. assert resolved.extruder is None
  72. assert resolved.extruder_or_default == 0
  73. class TestMissingHardware:
  74. """Called on every assign, so it must never raise."""
  75. def test_no_state_at_all(self):
  76. resolved = resolve_slot_nozzle(None, 0, 0, "H2C")
  77. assert (resolved.extruder, resolved.diameter) == (None, DEFAULT_NOZZLE_DIAMETER)
  78. def test_printer_has_reported_no_nozzles(self):
  79. assert nozzle_diameter_for_extruder(_State([]), 0, "H2C") == DEFAULT_NOZZLE_DIAMETER
  80. def test_the_second_hotend_is_absent_from_the_report(self):
  81. # H2C parks a nozzle back in its rack and the entry can go missing;
  82. # falling back to the other hotend beats returning nothing.
  83. assert nozzle_diameter_for_extruder(_State(["0.4"]), 1, "H2C") == "0.4"
  84. def test_a_blank_diameter_is_not_a_diameter(self):
  85. # NozzleInfo starts life with an empty string before MQTT fills it in.
  86. # Falling back to the OTHER hotend's size would invent a fact about a
  87. # different nozzle, so a blank primary takes the default instead --
  88. # which is what every call site did before this module existed.
  89. assert nozzle_diameter_for_extruder(_State(["", "0.6"]), 0, "H2C") == DEFAULT_NOZZLE_DIAMETER
  90. assert nozzle_diameter_for_extruder(_State(["", ""]), 0, "H2C") == DEFAULT_NOZZLE_DIAMETER
  91. class TestMatchingNozzles:
  92. """The common fleet: both hotends the same size."""
  93. def test_both_conventions_agree_so_the_answer_cannot_be_wrong(self):
  94. state = _State(["0.4", "0.4"], ams_extruder_map={"0": 1})
  95. assert nozzle_diameter_for_extruder(state, 0, "H2C") == "0.4"
  96. assert nozzle_diameter_for_extruder(state, 1, "H2C") == "0.4"
  97. class TestFlowType:
  98. """High Flow vs Standard. The same filament reads a different K through
  99. each, and a printer files them as separate calibration entries."""
  100. def test_the_fitted_nozzles_flow_is_read_per_hotend(self):
  101. # Measured spelling: a fitted nozzle reports HH01/HS01, while a
  102. # calibration entry says HH00-0.4 -- two characters is the comparison.
  103. state = _State(MIXED, types=("HH01", "HS01"))
  104. assert nozzle_flow_for_extruder(state, 0, "H2C") == "HH"
  105. assert nozzle_flow_for_extruder(state, 1, "H2C") == "HS"
  106. def test_a_printer_that_declares_no_flow_answers_none(self):
  107. # An X1C sends no flow on any profile, and legacy printers put the
  108. # nozzle MATERIAL in this field. Neither is a flow type.
  109. assert nozzle_flow_for_extruder(_State(["0.4"], types=("",)), 0, "X1C") is None
  110. assert nozzle_flow_for_extruder(_State(["0.4"], types=("hardened_steel",)), 0, "X1C") is None
  111. def test_normalise_accepts_both_spellings(self):
  112. assert normalise_flow("HH00-0.4") == "HH"
  113. assert normalise_flow("HH01") == "HH"
  114. assert normalise_flow("hs00-0.4") == "HS"
  115. assert normalise_flow("") is None
  116. assert normalise_flow(None) is None
  117. class TestFlowMatching:
  118. """Which stored profiles apply to the nozzle now fitted."""
  119. def test_the_flows_must_agree_once_both_are_known(self):
  120. high = SlotNozzle(extruder=0, diameter="0.4", flow="HH")
  121. assert high.flow_matches("HH00") is True
  122. assert high.flow_matches("HS00") is False
  123. def test_a_profile_with_no_stored_flow_still_applies(self):
  124. # Every profile saved before flow was recorded has NULL here -- a
  125. # strict comparison would stop applying all of them at once.
  126. high = SlotNozzle(extruder=0, diameter="0.4", flow="HH")
  127. assert high.flow_matches(None) is True
  128. assert high.flow_matches("") is True
  129. def test_a_printer_with_no_fitted_flow_applies_everything(self):
  130. # The X1C case: filtering on an invented Standard would drop every
  131. # profile the moment a high-flow nozzle was fitted.
  132. unknown = SlotNozzle(extruder=0, diameter="0.4", flow=None)
  133. assert unknown.flow_matches("HH00") is True
  134. assert unknown.flow_matches("HS00") is True
  135. def test_resolve_carries_the_flow_with_the_diameter(self):
  136. state = _State(MIXED, ams_extruder_map={"0": 1, "1": 0}, types=("HH01", "HS01"))
  137. left = resolve_slot_nozzle(state, 0, 0, "H2C")
  138. assert (left.extruder, left.diameter, left.flow) == (1, "0.2", "HS")