Files
bambuddy/backend/tests/unit/test_slot_nozzle_resolution.py
maziggy e5a18bf58b Key a K profile on its nozzle's flow type
A printer files each calibration under a nozzle id of the form HH00-0.4
(high flow) or HS00-0.4 (standard) and can hold both for one diameter -- a
maintainer's H2D carries 102 high-flow entries against 6 standard --
because the same filament reads a different K through each. Nothing read
that, so a standard-flow profile could be selected for a high-flow nozzle
and vice versa.

The flow is now stored with the profile, shown against each option in the
picker, and checked before a stored profile is applied. Two spellings have
to agree for that: a calibration entry says HH00-0.4 while the fitted
nozzle reports HH01, so the comparison is two characters rather than four
-- the trailing digits are a hardware variant the calibration table
normalises to 00.

Unknown flow on either side matches anything, which is what it has to do.
Every profile stored before this has none. And an X1C declares none on any
profile at all -- probed live, all eight come back with an empty nozzle id,
against a four-digit cali_idx and a populated setting_id -- even though the
machine really does take either nozzle. supports_nozzle_flow_type is
therefore the wrong thing to gate on: it returns True for an X1C, and
treating that silence as Standard would have dropped every X1C profile the
moment a high-flow nozzle was fitted. What the printer's own table declares
per profile is the test.

NozzleInfo.nozzle_type carries two vocabularies by printer generation --
the nozzle material on legacy printers, the flow code on H2 -- and the
comment claiming only the former is corrected. Anything that is not HH or
HS reads as unknown, which is what makes the material spelling harmless.

Storing both flows for one hotend and diameter is deliberately not done:
spoolman_k_profile is UNIQUE on (spool, printer, extruder, diameter) with
no flow column, and allowing a second row in internal mode alone would
break inventory-mode parity. The picker marks a profile whose flow does not
match what is fitted instead of letting it look configured while doing
nothing.
2026-08-27 13:42:55 +02:00

180 lines
7.8 KiB
Python

"""Which nozzle an AMS slot feeds.
Every path that configures a slot needs the extruder and that nozzle's
diameter, and both the filament preset and the K profile are stored per
diameter -- so a wrong answer here silently selects the wrong preset AND the
wrong K value. Seven call sites used to work it out independently, each reading
``nozzles[0]`` for every slot on the machine.
``nozzles[0]`` is right on a single-nozzle printer, and right on a dual-nozzle
printer with matching nozzles, which is why it survived. These pin the case it
is wrong for: two different sizes fitted, which is the machine the per-nozzle
feature exists for.
"""
from __future__ import annotations
from backend.app.services.slot_nozzle import (
DEFAULT_NOZZLE_DIAMETER,
SlotNozzle,
normalise_flow,
nozzle_diameter_for_extruder,
nozzle_flow_for_extruder,
resolve_slot_nozzle,
)
class _Nozzle:
def __init__(self, diameter: str):
self.nozzle_diameter = diameter
self.nozzle_type = ""
class _State:
def __init__(self, diameters, ams_extruder_map=None, ams_switch_inlet=None, types=()):
self.nozzles = [_Nozzle(d) for d in diameters]
for nozzle, nozzle_type in zip(self.nozzles, types, strict=False):
nozzle.nozzle_type = nozzle_type
self.ams_extruder_map = ams_extruder_map
self.ams_switch_inlet = ams_switch_inlet
# extruder 0 is the RIGHT hotend, 1 the left.
MIXED = ["0.4", "0.2"]
class TestDualNozzleMixedDiameters:
"""The case the old shortcut got wrong."""
def test_each_hotend_reports_its_own_diameter(self):
state = _State(MIXED)
assert nozzle_diameter_for_extruder(state, 0, "H2C") == "0.4"
assert nozzle_diameter_for_extruder(state, 1, "H2C") == "0.2"
def test_the_slots_extruder_decides_which_one(self):
# AMS 0 is bound to the left hotend, AMS 1 to the right.
state = _State(MIXED, ams_extruder_map={"0": 1, "1": 0})
left = resolve_slot_nozzle(state, 0, 0, "H2C")
right = resolve_slot_nozzle(state, 1, 0, "H2C")
assert (left.extruder, left.diameter) == (1, "0.2")
assert (right.extruder, right.diameter) == (0, "0.4")
def test_external_slots_name_their_side_by_tray_id(self):
# Ext-L is tray 0 -> extruder 1, Ext-R is tray 1 -> extruder 0.
state = _State(MIXED)
assert resolve_slot_nozzle(state, 255, 0, "H2D").diameter == "0.2"
assert resolve_slot_nozzle(state, 255, 1, "H2D").diameter == "0.4"
def test_an_fts_inlet_answers_when_there_is_no_extruder_map(self):
state = _State(MIXED, ams_switch_inlet={"0": "A"})
resolved = resolve_slot_nozzle(state, 0, 0, "H2C")
assert resolved.extruder is not None
assert resolved.diameter in {"0.4", "0.2"}
class TestSingleNozzle:
"""Must behave exactly as the old shortcut did."""
def test_index_zero_whatever_the_map_says(self):
# A single-nozzle model has one entry; an extruder id from a stale map
# must not index past it.
state = _State(["0.6"], ams_extruder_map={"0": 1})
assert nozzle_diameter_for_extruder(state, 1, "X1C") == "0.6"
assert resolve_slot_nozzle(state, 0, 0, "X1C").diameter == "0.6"
def test_no_map_means_unknown_extruder_not_zero(self):
# "I don't know" and "the right-hand nozzle" are different answers; the
# caller's own default of 0 is correct on a single-nozzle machine, but
# storing it as a fact is what bound a left K-profile to a right slot.
state = _State(["0.4"])
resolved = resolve_slot_nozzle(state, 0, 0, "X1C")
assert resolved.extruder is None
assert resolved.extruder_or_default == 0
class TestMissingHardware:
"""Called on every assign, so it must never raise."""
def test_no_state_at_all(self):
resolved = resolve_slot_nozzle(None, 0, 0, "H2C")
assert (resolved.extruder, resolved.diameter) == (None, DEFAULT_NOZZLE_DIAMETER)
def test_printer_has_reported_no_nozzles(self):
assert nozzle_diameter_for_extruder(_State([]), 0, "H2C") == DEFAULT_NOZZLE_DIAMETER
def test_the_second_hotend_is_absent_from_the_report(self):
# H2C parks a nozzle back in its rack and the entry can go missing;
# falling back to the other hotend beats returning nothing.
assert nozzle_diameter_for_extruder(_State(["0.4"]), 1, "H2C") == "0.4"
def test_a_blank_diameter_is_not_a_diameter(self):
# NozzleInfo starts life with an empty string before MQTT fills it in.
# Falling back to the OTHER hotend's size would invent a fact about a
# different nozzle, so a blank primary takes the default instead --
# which is what every call site did before this module existed.
assert nozzle_diameter_for_extruder(_State(["", "0.6"]), 0, "H2C") == DEFAULT_NOZZLE_DIAMETER
assert nozzle_diameter_for_extruder(_State(["", ""]), 0, "H2C") == DEFAULT_NOZZLE_DIAMETER
class TestMatchingNozzles:
"""The common fleet: both hotends the same size."""
def test_both_conventions_agree_so_the_answer_cannot_be_wrong(self):
state = _State(["0.4", "0.4"], ams_extruder_map={"0": 1})
assert nozzle_diameter_for_extruder(state, 0, "H2C") == "0.4"
assert nozzle_diameter_for_extruder(state, 1, "H2C") == "0.4"
class TestFlowType:
"""High Flow vs Standard. The same filament reads a different K through
each, and a printer files them as separate calibration entries."""
def test_the_fitted_nozzles_flow_is_read_per_hotend(self):
# Measured spelling: a fitted nozzle reports HH01/HS01, while a
# calibration entry says HH00-0.4 -- two characters is the comparison.
state = _State(MIXED, types=("HH01", "HS01"))
assert nozzle_flow_for_extruder(state, 0, "H2C") == "HH"
assert nozzle_flow_for_extruder(state, 1, "H2C") == "HS"
def test_a_printer_that_declares_no_flow_answers_none(self):
# An X1C sends no flow on any profile, and legacy printers put the
# nozzle MATERIAL in this field. Neither is a flow type.
assert nozzle_flow_for_extruder(_State(["0.4"], types=("",)), 0, "X1C") is None
assert nozzle_flow_for_extruder(_State(["0.4"], types=("hardened_steel",)), 0, "X1C") is None
def test_normalise_accepts_both_spellings(self):
assert normalise_flow("HH00-0.4") == "HH"
assert normalise_flow("HH01") == "HH"
assert normalise_flow("hs00-0.4") == "HS"
assert normalise_flow("") is None
assert normalise_flow(None) is None
class TestFlowMatching:
"""Which stored profiles apply to the nozzle now fitted."""
def test_the_flows_must_agree_once_both_are_known(self):
high = SlotNozzle(extruder=0, diameter="0.4", flow="HH")
assert high.flow_matches("HH00") is True
assert high.flow_matches("HS00") is False
def test_a_profile_with_no_stored_flow_still_applies(self):
# Every profile saved before flow was recorded has NULL here -- a
# strict comparison would stop applying all of them at once.
high = SlotNozzle(extruder=0, diameter="0.4", flow="HH")
assert high.flow_matches(None) is True
assert high.flow_matches("") is True
def test_a_printer_with_no_fitted_flow_applies_everything(self):
# The X1C case: filtering on an invented Standard would drop every
# profile the moment a high-flow nozzle was fitted.
unknown = SlotNozzle(extruder=0, diameter="0.4", flow=None)
assert unknown.flow_matches("HH00") is True
assert unknown.flow_matches("HS00") is True
def test_resolve_carries_the_flow_with_the_diameter(self):
state = _State(MIXED, ams_extruder_map={"0": 1, "1": 0}, types=("HH01", "HS01"))
left = resolve_slot_nozzle(state, 0, 0, "H2C")
assert (left.extruder, left.diameter, left.flow) == (1, "0.2", "HS")