| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483 |
- """Choosing which rack position each filament group prints from (#1784).
- An H2C's rack carriage hosts six hotends. Which one a filament group takes is
- the operator's choice and is stated nowhere in the 3MF -- established by
- dispatching one plate twice from BambuStudio with different picks and diffing
- the two files: `group_id` values, the toolchange stream, the `NOZZLE_CHANGE`
- markers and `project_settings.config` were all identical, and only extrusion
- floats differed in the last digit. The pick lives solely in the dispatched
- `nozzle_mapping`.
- The numbers pinned here are measured on the maintainer's own H2C
- (`31B8BP610600650`), not invented:
- - 2026-08-14 09:32, picking R1 and R2 -> `nozzle_mapping [16, 1, 17, -1 x29]`
- - 2026-08-13 17:20, same plate picking R1 and R3 -> `[16, 1, 18, -1 x29]`
- - 2026-08-14 09:02 rack telemetry -> `IDs: [16, 1, 21, 19, 18, 0, 20]`, i.e.
- both carriages present and rack id 17 the lone gap, because its nozzle was
- mounted at the time.
- """
- import json
- import zipfile
- import pytest
- from backend.app.services.bambu_mqtt import (
- RACK_POSITIONS,
- _rack_by_position,
- rack_position_to_nozzle_id,
- resolve_rack_plan_mapping,
- )
- from backend.app.utils.threemf_tools import extract_rack_plan_from_3mf
- # The plate this whole feature was built against: three PLA filaments, groups
- # 2/0/1, with groups 1 and 2 both on the rack carriage (slicer extruder 2).
- BENCHY_FILAMENTS = {
- 1: {"group": 2, "color": "#DE4343"},
- 2: {"group": 0, "color": "#F4EE2A"},
- 3: {"group": 1, "color": "#0078BF"},
- }
- BENCHY_NOZZLES = {0: 1, 1: 2, 2: 2}
- # The machine half of the file, for the tests that hand-write slice_info.
- _RACK_SETTINGS = json.dumps(
- {
- "physical_extruder_map": ["1", "0"],
- "extruder_max_nozzle_count": ["1", "6"],
- "extruder_nozzle_stats": ["High Flow#1", "High Flow#6"],
- }
- )
- def _write_rack_3mf(path, filaments, nozzles, *, plate_index=1, max_nozzles=("1", "6"), diameter="0.40"):
- """A 3MF in the shape BambuStudio writes for a nozzle-rack machine."""
- elems = "".join(
- f'<filament id="{slot}" group_id="{f["group"]}" color="{f["color"]}" '
- f'nozzle_diameter="{f.get("diameter", diameter)}" '
- f'volume_type="{f.get("volume_type", "High Flow")}"/>'
- for slot, f in filaments.items()
- )
- elems += "".join(f'<nozzle id="{group}" extruder_id="{ext}"/>' for group, ext in nozzles.items())
- body = f'<plate><metadata key="index" value="{plate_index}"/>{elems}</plate>'
- with zipfile.ZipFile(path, "w") as zf:
- zf.writestr(
- "Metadata/project_settings.config",
- json.dumps(
- {
- "physical_extruder_map": ["1", "0"],
- "extruder_max_nozzle_count": list(max_nozzles),
- "extruder_nozzle_stats": ["High Flow#1", "High Flow#6"],
- }
- ),
- )
- zf.writestr("Metadata/slice_info.config", f"<config>{body}</config>")
- return path
- def _rack(present=(1, 2, 3, 4, 5, 6), *, diameters=None, types=None, colors=None, carriage=None):
- """Live rack telemetry: a nozzle at each named 1-based position.
- Positions left out are absent from the payload entirely, which is how the
- firmware reports both an empty position and one whose nozzle is currently
- mounted (#943).
- """
- diameters = diameters or {}
- types = types or {}
- colors = colors or {}
- slots = [
- {
- "id": 15 + position,
- "diameter": diameters.get(position, "0.4"),
- "type": types.get(position, "HH01"),
- "filament_color": colors.get(position, ""),
- }
- for position in present
- ]
- # The fixed carriage is always reported; the rack carriage only when it
- # actually holds a nozzle.
- slots.append({"id": 1, "diameter": "0.4", "type": "HH01", "filament_color": ""})
- if carriage:
- slots.append({"id": 0, **carriage})
- return slots
- @pytest.fixture
- def benchy(tmp_path):
- return _write_rack_3mf(tmp_path / "benchy.3mf", BENCHY_FILAMENTS, BENCHY_NOZZLES)
- class TestRackPositionNumbering:
- """Rack position n is physical nozzle id 15 + n."""
- def test_the_six_positions_map_to_the_ids_the_printer_uses(self):
- assert [rack_position_to_nozzle_id(p) for p in RACK_POSITIONS] == [16, 17, 18, 19, 20, 21]
- @pytest.mark.parametrize("position", [0, -1, 7, 16])
- def test_a_position_outside_the_rack_names_no_nozzle(self, position):
- assert rack_position_to_nozzle_id(position) is None
- def test_a_bool_is_not_a_position(self):
- """`True` is an int in Python and would otherwise resolve to R1."""
- assert rack_position_to_nozzle_id(True) is None
- class TestReadingThePlan:
- def test_the_maintainers_plate_reads_as_two_rack_groups_and_one_fixed(self, benchy):
- plan = extract_rack_plan_from_3mf(benchy, plate_id=1)
- assert plan.slot_groups == [2, 0, 1]
- assert plan.rack_group_ids == [1, 2]
- assert plan.groups[0].on_rack is False
- assert plan.groups[1].on_rack is True
- assert plan.groups[2].nozzle_diameter == "0.40"
- assert plan.groups[2].volume_type == "High Flow"
- def test_the_rack_carriage_is_read_from_the_file_not_assumed(self, tmp_path):
- """`extruder_max_nozzle_count` names the rack: the one addressing >1.
- A fourth independent confirmation of which extruder index is the rack,
- and the only one that comes from the file itself rather than telemetry.
- """
- source = _write_rack_3mf(tmp_path / "flipped.3mf", BENCHY_FILAMENTS, BENCHY_NOZZLES, max_nozzles=("6", "1"))
- plan = extract_rack_plan_from_3mf(source, plate_id=1)
- # Groups 1 and 2 sit on slicer extruder 2 -> index 1, which is now the
- # single-nozzle carriage; group 0 is on index 0, which is now the rack.
- # Exactly inverted from the same file read with ('1', '6').
- assert plan.rack_group_ids == [0]
- assert plan.groups[1].on_rack is False
- assert plan.groups[2].on_rack is False
- def test_a_machine_with_no_multi_nozzle_carriage_has_no_plan(self, tmp_path):
- source = _write_rack_3mf(tmp_path / "h2d.3mf", BENCHY_FILAMENTS, BENCHY_NOZZLES, max_nozzles=("1", "1"))
- assert extract_rack_plan_from_3mf(source, plate_id=1) is None
- def test_an_ungrouped_filament_makes_the_plan_partial_so_there_is_none(self, tmp_path):
- """A partial plan dispatches the ungrouped slot as unprinted.
- Which contradicts an ams_mapping that does name a tray for it, and the
- firmware rejects the contradiction outright as HMS 0500-4047.
- """
- path = _write_rack_3mf(tmp_path / "partial.3mf", BENCHY_FILAMENTS, BENCHY_NOZZLES)
- with zipfile.ZipFile(path, "w") as zf:
- zf.writestr("Metadata/project_settings.config", _RACK_SETTINGS)
- zf.writestr(
- "Metadata/slice_info.config",
- '<config><plate><metadata key="index" value="1"/>'
- '<filament id="1" group_id="2" nozzle_diameter="0.40" volume_type="High Flow"/>'
- '<filament id="2"/>'
- '<nozzle id="2" extruder_id="2"/></plate></config>',
- )
- assert extract_rack_plan_from_3mf(path, plate_id=1) is None
- def test_two_filaments_may_share_a_group_and_differ_in_colour(self, tmp_path):
- """Colour is a hint for auto-assignment, not part of the group identity.
- Rejecting the file over it would be wrong: a group is one hotend, and a
- hotend can print more than one colour in sequence.
- """
- source = _write_rack_3mf(
- tmp_path / "shared.3mf",
- {1: {"group": 1, "color": "#FF0000"}, 2: {"group": 1, "color": "#00FF00"}},
- {1: 2},
- )
- plan = extract_rack_plan_from_3mf(source, plate_id=1)
- assert plan.slot_groups == [1, 1]
- assert plan.rack_group_ids == [1]
- def test_filaments_in_one_group_wanting_different_nozzles_is_unresolvable(self, tmp_path):
- """One group is one hotend, so no single position can serve both."""
- source = _write_rack_3mf(
- tmp_path / "contradiction.3mf",
- {
- 1: {"group": 1, "color": "#FF0000"},
- 2: {"group": 1, "color": "#00FF00", "diameter": "0.60"},
- },
- {1: 2},
- )
- assert extract_rack_plan_from_3mf(source, plate_id=1) is None
- def test_an_unreadable_file_yields_no_plan_rather_than_raising(self, tmp_path):
- broken = tmp_path / "broken.3mf"
- broken.write_bytes(b"not a zip")
- assert extract_rack_plan_from_3mf(broken, plate_id=1) is None
- def test_a_missing_file_yields_no_plan(self, tmp_path):
- assert extract_rack_plan_from_3mf(tmp_path / "absent.3mf", plate_id=1) is None
- class TestResolvingAgainstTheLiveRack:
- """The measured dispatches, reproduced from a plan plus a pick."""
- def test_picking_r1_and_r2_reproduces_the_dispatch_of_2026_08_14(self, benchy):
- plan = extract_rack_plan_from_3mf(benchy, plate_id=1)
- wire, error = resolve_rack_plan_mapping(plan.slot_groups, plan.group_dicts(), {2: 1, 1: 2}, _rack())
- assert error is None
- assert wire[:3] == [16, 1, 17]
- assert wire[3:] == [-1] * 29
- assert len(wire) == 32
- def test_picking_r1_and_r3_reproduces_the_dispatch_of_2026_08_13(self, benchy):
- plan = extract_rack_plan_from_3mf(benchy, plate_id=1)
- wire, error = resolve_rack_plan_mapping(plan.slot_groups, plan.group_dicts(), {2: 1, 1: 3}, _rack())
- assert error is None
- assert wire[:3] == [16, 1, 18]
- def test_the_fixed_group_always_lands_on_physical_nozzle_1(self, benchy):
- """Whichever slot it occupies -- confirmed in both captures."""
- plan = extract_rack_plan_from_3mf(benchy, plate_id=1)
- wire, _ = resolve_rack_plan_mapping(plan.slot_groups, plan.group_dicts(), {2: 4, 1: 5}, _rack())
- # Slot 2 is group 0, the fixed carriage.
- assert wire[1] == 1
- class TestAutoAssignment:
- def test_an_unpicked_plate_is_assigned_by_colour(self, benchy):
- """Preferring the position already loaded with the group's own colour
- means the operator does not have to move filament to get what they
- asked for. Here that reproduces BambuStudio's own pick exactly.
- """
- plan = extract_rack_plan_from_3mf(benchy, plate_id=1)
- rack = _rack(colors={1: "DE4343FF", 2: "0078BFFF", 4: "FFFFFFFF"})
- wire, error = resolve_rack_plan_mapping(plan.slot_groups, plan.group_dicts(), {}, rack)
- assert error is None
- assert wire[:3] == [16, 1, 17]
- def test_without_a_colour_match_it_takes_the_lowest_free_position(self, benchy):
- plan = extract_rack_plan_from_3mf(benchy, plate_id=1)
- wire, error = resolve_rack_plan_mapping(plan.slot_groups, plan.group_dicts(), {}, _rack())
- assert error is None
- # Groups are assigned lowest-id first: group 1 takes R1, group 2 takes R2.
- assert wire[:3] == [17, 1, 16]
- def test_an_explicit_pick_is_never_stolen_by_an_auto_assignment(self, benchy):
- """The half-picked case: one group named, the other filled in around it."""
- plan = extract_rack_plan_from_3mf(benchy, plate_id=1)
- wire, error = resolve_rack_plan_mapping(plan.slot_groups, plan.group_dicts(), {1: 1}, _rack())
- assert error is None
- assert wire[2] == 16 # group 1, as picked
- assert wire[0] == 17 # group 2, assigned around it
- def test_only_eligible_positions_are_assigned(self, benchy):
- """A 0.2 nozzle cannot lay down a 0.4 extrusion."""
- plan = extract_rack_plan_from_3mf(benchy, plate_id=1)
- rack = _rack(present=(1, 2, 3), diameters={1: "0.2", 2: "0.6"})
- wire, error = resolve_rack_plan_mapping(plan.slot_groups, plan.group_dicts(), {}, rack)
- assert wire is None
- assert "no free rack position" in error
- def test_flow_type_is_matched_as_well_as_diameter(self, benchy):
- plan = extract_rack_plan_from_3mf(benchy, plate_id=1)
- rack = _rack(present=(1, 2), types={1: "HS", 2: "HS"})
- _, error = resolve_rack_plan_mapping(plan.slot_groups, plan.group_dicts(), {}, rack)
- assert "no free rack position" in error
- def test_a_printer_reporting_no_nozzle_type_is_not_ruled_out(self, benchy):
- """Compared only when both sides state it, so a terse firmware still works."""
- plan = extract_rack_plan_from_3mf(benchy, plate_id=1)
- rack = _rack(present=(1, 2), types={1: "", 2: ""})
- wire, error = resolve_rack_plan_mapping(plan.slot_groups, plan.group_dicts(), {}, rack)
- assert error is None
- assert wire[:3] == [17, 1, 16]
- def test_a_padded_diameter_matches_an_unpadded_one(self, benchy):
- """The 3MF writes "0.40" and the printer reports "0.4"."""
- plan = extract_rack_plan_from_3mf(benchy, plate_id=1)
- wire, error = resolve_rack_plan_mapping(
- plan.slot_groups, plan.group_dicts(), {}, _rack(diameters={1: "0.400", 2: "0.4"})
- )
- assert error is None
- assert wire is not None
- class TestTheMountedNozzle:
- """#943: a mounted rack nozzle is omitted from telemetry, not blanked."""
- def test_the_lone_gap_is_recovered_from_the_carriage(self):
- """Measured 09:02: IDs [16, 1, 21, 19, 18, 0, 20] -- id 17 the only gap."""
- slots = _rack(present=(1, 3, 4, 5, 6), carriage={"diameter": "0.4", "type": "HH01", "filament_color": ""})
- by_position = _rack_by_position(slots)
- assert set(by_position) == {1, 2, 3, 4, 5, 6}
- assert by_position[2]["id"] == 0 # the carriage's nozzle, standing in for R2
- def test_the_mounted_nozzle_can_be_picked(self, benchy):
- """It is the likeliest pick of all -- the last print left it there."""
- plan = extract_rack_plan_from_3mf(benchy, plate_id=1)
- slots = _rack(present=(1, 3, 4, 5, 6), carriage={"diameter": "0.4", "type": "HH01", "filament_color": ""})
- wire, error = resolve_rack_plan_mapping(plan.slot_groups, plan.group_dicts(), {2: 1, 1: 2}, slots)
- assert error is None
- assert wire[:3] == [16, 1, 17]
- def test_two_gaps_are_ambiguous_and_stay_absent(self):
- """Four nozzles in six positions looks identical to two mounted ones,
- and only one can be mounted at a time. Guessing would offer a position
- that holds nothing.
- """
- slots = _rack(present=(1, 4, 5, 6), carriage={"diameter": "0.4", "type": "HH01"})
- by_position = _rack_by_position(slots)
- assert set(by_position) == {1, 4, 5, 6}
- def test_an_empty_carriage_fills_no_gap(self):
- slots = _rack(present=(1, 3, 4, 5, 6), carriage={"diameter": "", "type": ""})
- assert set(_rack_by_position(slots)) == {1, 3, 4, 5, 6}
- class TestRefusals:
- """Every one of these stops a print rather than guessing a hotend.
- A wrong physical id levels with one nozzle and prints with another, several
- millimetres off the bed -- the failure this whole area exists to prevent.
- """
- def test_a_position_holding_nothing_is_refused_with_a_reason(self, benchy):
- plan = extract_rack_plan_from_3mf(benchy, plate_id=1)
- _, error = resolve_rack_plan_mapping(plan.slot_groups, plan.group_dicts(), {2: 1, 1: 3}, _rack(present=(1, 2)))
- assert error == "the printer reports nothing at rack position 3"
- def test_the_wrong_nozzle_names_what_it_holds_and_what_is_needed(self, benchy):
- plan = extract_rack_plan_from_3mf(benchy, plate_id=1)
- _, error = resolve_rack_plan_mapping(
- plan.slot_groups, plan.group_dicts(), {2: 1, 1: 5}, _rack(diameters={5: "0.6"})
- )
- assert "rack position 5 holds a 0.6" in error
- assert "needs 0.40 High Flow" in error
- def test_two_groups_cannot_share_one_position(self, benchy):
- """They are different hotends by definition -- that is what a group is."""
- plan = extract_rack_plan_from_3mf(benchy, plate_id=1)
- _, error = resolve_rack_plan_mapping(plan.slot_groups, plan.group_dicts(), {2: 1, 1: 1}, _rack())
- assert error == "rack position 1 is picked for more than one filament group"
- def test_a_position_beyond_the_rack_is_refused(self, benchy):
- plan = extract_rack_plan_from_3mf(benchy, plate_id=1)
- _, error = resolve_rack_plan_mapping(plan.slot_groups, plan.group_dicts(), {2: 1, 1: 9}, _rack())
- assert error == "rack position 9 does not exist"
- def test_a_plate_with_more_slots_than_the_wire_carries_is_refused(self):
- groups = {0: {"on_rack": False, "nozzle_diameter": "0.4", "volume_type": "High Flow"}}
- _, error = resolve_rack_plan_mapping([0] * 33, groups, {}, _rack())
- assert "33 filament slots" in error
- def test_an_empty_plate_is_refused(self):
- _, error = resolve_rack_plan_mapping([], {}, {}, _rack())
- assert error == "the plate lists no filament slots"
- def test_a_slot_naming_an_undescribed_group_is_refused(self):
- groups = {0: {"on_rack": False, "nozzle_diameter": "0.4", "volume_type": "High Flow"}}
- _, error = resolve_rack_plan_mapping([0, 7], groups, {}, _rack())
- assert error == "filament slot 2 names group 7, which the plate does not describe"
- def test_a_plate_assigning_nothing_is_refused(self):
- """All -1 would tell the printer to print with no nozzle at all."""
- _, error = resolve_rack_plan_mapping([-1, -1], {}, {}, _rack())
- assert error == "the plate assigns no filament to a nozzle"
- class TestFixedOnlyPlates:
- def test_a_plate_using_only_the_fixed_hotend_needs_no_rack_position(self, tmp_path):
- source = _write_rack_3mf(tmp_path / "fixed.3mf", {1: {"group": 0, "color": "#FFFFFF"}}, {0: 1})
- plan = extract_rack_plan_from_3mf(source, plate_id=1)
- wire, error = resolve_rack_plan_mapping(plan.slot_groups, plan.group_dicts(), {}, _rack())
- assert error is None
- assert wire[0] == 1
- assert wire[1:] == [-1] * 31
- def test_it_resolves_even_with_an_entirely_empty_rack(self, tmp_path):
- """Nothing is being asked of the rack, so its contents cannot matter."""
- source = _write_rack_3mf(tmp_path / "fixed.3mf", {1: {"group": 0, "color": "#FFFFFF"}}, {0: 1})
- plan = extract_rack_plan_from_3mf(source, plate_id=1)
- wire, error = resolve_rack_plan_mapping(plan.slot_groups, plan.group_dicts(), {}, _rack(present=()))
- assert error is None
- assert wire[0] == 1
- class TestPlateScoping:
- def test_the_dispatched_plate_is_the_one_read(self, tmp_path):
- """A multi-plate file may assign the same slot differently per plate."""
- path = tmp_path / "multi.3mf"
- with zipfile.ZipFile(path, "w") as zf:
- zf.writestr("Metadata/project_settings.config", _RACK_SETTINGS)
- zf.writestr(
- "Metadata/slice_info.config",
- "<config>"
- '<plate><metadata key="index" value="1"/>'
- '<filament id="1" group_id="0" nozzle_diameter="0.40" volume_type="High Flow"/>'
- '<nozzle id="0" extruder_id="1"/></plate>'
- '<plate><metadata key="index" value="2"/>'
- '<filament id="1" group_id="1" nozzle_diameter="0.40" volume_type="High Flow"/>'
- '<nozzle id="1" extruder_id="2"/></plate>'
- "</config>",
- )
- assert extract_rack_plan_from_3mf(path, plate_id=1).rack_group_ids == []
- assert extract_rack_plan_from_3mf(path, plate_id=2).rack_group_ids == [1]
- class TestEveryRequirementsPathIsAnnotated:
- """The three filament-requirements paths must all carry the group data.
- They each build their filament list differently — the archive route and the
- library route parse `slice_info.config` themselves rather than calling
- `extract_filament_requirements` — so annotating only one of them shipped a
- picker that never appeared in the print dialog. `annotate_rack_groups` is
- the single implementation; these pin that all three reach it.
- """
- def test_the_shared_annotator_tags_a_route_built_filament_list(self, benchy):
- from backend.app.services.filament_requirements import annotate_rack_groups
- # The shape the archive/library routes build, with no group keys.
- filaments = [{"slot_id": 1}, {"slot_id": 2}, {"slot_id": 3}]
- annotate_rack_groups(filaments, benchy, 1)
- assert [f["group_id"] for f in filaments] == [2, 0, 1]
- assert filaments[0]["group"]["on_rack"] is True
- assert filaments[1]["group"]["on_rack"] is False
- def test_a_slot_the_plate_does_not_print_is_left_untagged(self, benchy):
- from backend.app.services.filament_requirements import annotate_rack_groups
- filaments = [{"slot_id": 9}]
- annotate_rack_groups(filaments, benchy, 1)
- assert "group_id" not in filaments[0]
- def test_a_non_rack_file_leaves_every_filament_untouched(self, tmp_path):
- from backend.app.services.filament_requirements import annotate_rack_groups
- source = _write_rack_3mf(tmp_path / "h2d.3mf", BENCHY_FILAMENTS, BENCHY_NOZZLES, max_nozzles=("1", "1"))
- filaments = [{"slot_id": 1}]
- annotate_rack_groups(filaments, source, 1)
- assert filaments == [{"slot_id": 1}]
- @pytest.mark.parametrize("module", ["archives", "library"])
- def test_both_routes_import_the_annotator(self, module):
- """A guard against the two hand-rolled routes drifting out again."""
- import importlib
- route = importlib.import_module(f"backend.app.api.routes.{module}")
- assert hasattr(route, "annotate_rack_groups")
|