fix(scheduler): skip per-nozzle filter under FTS so dispatch feeds the right spool (#2186)

On a dual-nozzle H2C with a Filament Track Switch, a queued print targeting
one nozzle fed a same-type wrong-colour spool: the backend mapping
(_match_filaments_to_slots) hard-filtered candidate trays to the requested
extruder, excluding the correct spool loaded in the other nozzle's AMS — which
the FTS can route across. Confirmed from the reporter's captures: same model
mapped to AMS-A slot 2 (black) on the left nozzle but AMS-B slot 3 (red) on the
right. The #1162 FTS-skip existed only in the frontend mapping, never the
queue-dispatch path.

Read fila_switch.installed in _compute_ams_mapping_for_printer and skip the
per-nozzle filter when an FTS is present. Single-nozzle printers are unaffected
(no nozzle_id in the 3MF, no FTS). Regression tests in TestFtsNozzleBypass.
This commit is contained in:
maziggy
2026-07-07 12:17:46 +02:00
parent 5d64935658
commit 70312b0a11
4 changed files with 62 additions and 2770 deletions
-2767
View File
File diff suppressed because one or more lines are too long
+14 -2
View File
@@ -890,6 +890,14 @@ class PrintScheduler:
logger.warning("Cannot compute AMS mapping: printer %s status unavailable", printer_id)
return None
# Filament Track Switch (FTS): when installed it routes any AMS slot to
# either extruder, so the per-nozzle hard filter below must NOT apply.
# Otherwise a print on one nozzle can't use a spool physically loaded in
# an AMS on the *other* nozzle, and the matcher falls through to a
# same-type wrong-colour spool on the target nozzle — the H2C + FTS
# wrong-filament bug (#2186). Mirrors the frontend skip added for #1162.
fts_installed = bool(getattr(getattr(status, "fila_switch", None), "installed", False))
# Get filament requirements from source file
filament_reqs = await self._get_filament_requirements(db, item)
if not filament_reqs:
@@ -964,7 +972,7 @@ class PrintScheduler:
# Compute mapping: match required filaments to available slots
return self._match_filaments_to_slots(
filament_reqs, loaded_filaments, prefer_lowest, inventory_remain_overrides
filament_reqs, loaded_filaments, prefer_lowest, inventory_remain_overrides, fts_installed
)
def _build_override_direct_mapping(self, force_overrides: list[dict], status) -> list[int] | None:
@@ -1274,6 +1282,7 @@ class PrintScheduler:
loaded: list[dict],
prefer_lowest: bool = False,
inventory_remain_overrides: dict[int, float] | None = None,
fts_installed: bool = False,
) -> list[int] | None:
"""Match required filaments to loaded filaments and build AMS mapping.
@@ -1316,8 +1325,11 @@ class PrintScheduler:
# Nozzle-aware filtering: restrict to trays on the correct nozzle.
# Hard filter — cross-nozzle assignment causes print failures
# ("position of left hotend is abnormal"), so never fall back.
# Skipped when an FTS is installed: it routes any AMS slot to either
# extruder, so restricting to one nozzle would wrongly exclude the
# correct spool sitting in the other nozzle's AMS (#2186).
req_nozzle_id = req.get("nozzle_id")
if req_nozzle_id is not None:
if req_nozzle_id is not None and not fts_installed:
available = [f for f in available if f.get("extruder_id") == req_nozzle_id]
# Sort by remaining filament (ascending) so lowest-remain spool wins .find().
@@ -1501,3 +1501,50 @@ class TestX1CModel:
]
result = scheduler._match_filaments_to_slots(required, loaded)
assert result == [5, 7]
class TestFtsNozzleBypass:
"""#2186: with a Filament Track Switch (FTS) installed, the per-nozzle hard
filter in the dispatch mapping must be skipped so a spool loaded in the OTHER
nozzle's AMS can be routed to the requested nozzle. Without an FTS the filter
still applies (cross-nozzle assignment fails on the printer).
"""
@pytest.fixture
def scheduler(self):
return PrintScheduler()
def _repro_loaded(self):
# Mirrors the reporter's H2C: AMS-A (id 0) on the LEFT extruder (1) holds
# the black PLA the model wants; AMS-B (id 1) on the RIGHT extruder (0)
# holds a red PLA.
return [
{"type": "PLA", "color": "#000000", "global_tray_id": 1, "extruder_id": 1}, # AMS-A slot2 black, LEFT
{"type": "PLA", "color": "#FF0000", "global_tray_id": 6, "extruder_id": 0}, # AMS-B slot3 red, RIGHT
]
def test_fts_routes_correct_colour_from_other_nozzle(self, scheduler):
"""Model wants black PLA on the RIGHT nozzle; the black spool sits on the
LEFT AMS. With FTS the nozzle filter is skipped, so it picks the black spool
(gtid 1) — not the wrong-colour red already on the right (gtid 6).
Reproduces #2186 (b3 fed red instead of black)."""
required = [{"slot_id": 1, "type": "PLA", "color": "#000000", "nozzle_id": 0}]
result = scheduler._match_filaments_to_slots(required, self._repro_loaded(), fts_installed=True)
assert result == [1] # black PLA from AMS-A, routed to the right nozzle by the FTS
def test_without_fts_nozzle_filter_still_applies(self, scheduler):
"""No FTS: cross-nozzle assignment isn't possible, so a RIGHT-nozzle request
is restricted to right-side trays and matches the red PLA by type. Correct
non-FTS behaviour — and exactly why #2186 needed the FTS bypass."""
required = [{"slot_id": 1, "type": "PLA", "color": "#000000", "nozzle_id": 0}]
result = scheduler._match_filaments_to_slots(required, self._repro_loaded(), fts_installed=False)
assert result == [6] # only the right-side PLA is eligible
def test_fts_flag_is_noop_without_nozzle_id(self, scheduler):
"""Single-nozzle 3MFs carry no nozzle_id, so the per-nozzle filter never
runs and the FTS flag changes nothing — the exact-colour match wins either
way. (Single-nozzle printers also never have an FTS.)"""
required = [{"slot_id": 1, "type": "PLA", "color": "#000000"}]
for fts in (True, False):
result = scheduler._match_filaments_to_slots(required, self._repro_loaded(), fts_installed=fts)
assert result == [1] # exact black match, no nozzle involved
@@ -50,7 +50,7 @@ async def _run_with_backup(scheduler, backup_state, prefer_lowest_setting):
captured: dict = {}
def _capture_match(reqs, loaded_, prefer, overrides):
def _capture_match(reqs, loaded_, prefer, overrides, fts_installed=False):
captured["prefer_lowest"] = prefer
return [0]