Fix AMS auto-matching when multiple trays have same tray_info_idx

The tray_info_idx field is a filament TYPE identifier (e.g., "GFA00" for
generic PLA), not unique per spool. When multiple AMS trays are loaded
with the same filament type, the previous code used find() which always
returned the first match regardless of color.

Now checks if tray_info_idx is unique among available trays:
- If unique: use that tray as definitive match (existing behavior)
- If not unique: fall back to color matching among matching trays

Fixed in both backend (print_scheduler.py) and frontend (useFilamentMapping.ts).

Closes #245
This commit is contained in:
maziggy
2026-02-04 14:03:50 +01:00
parent 81d18cb8bd
commit 3571cd1a42
7 changed files with 309 additions and 205 deletions
+1 -1
View File
@@ -543,9 +543,9 @@ _cover_cache: dict[int, dict[tuple[str, str], bytes]] = {}
async def get_printer_cover(
printer_id: int,
view: str | None = None,
_=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
db: AsyncSession = Depends(get_db),
):
# Note: No auth required - this is an image asset loaded via <img src> which can't send auth headers
"""Get the cover image for the current print job.
Args:
+4
View File
@@ -727,7 +727,11 @@ async def on_print_start(printer_id: int, data: dict):
printer = result.scalar_one_or_none()
# Plate detection check - pause if objects detected on build plate
logger.info(
f"[PLATE CHECK] printer_id={printer_id}, plate_detection_enabled={printer.plate_detection_enabled if printer else 'NO PRINTER'}"
)
if printer and printer.plate_detection_enabled:
logger.info(f"[PLATE CHECK] ENTERING plate detection code for printer {printer_id}")
try:
from backend.app.services.plate_detection import check_plate_empty
+50 -30
View File
@@ -591,12 +591,13 @@ class PrintScheduler:
def _match_filaments_to_slots(self, required: list[dict], loaded: list[dict]) -> list[int] | None:
"""Match required filaments to loaded filaments and build AMS mapping.
Priority: tray_info_idx match > exact color match > similar color match > type-only match
Priority: unique tray_info_idx match > exact color match > similar color match > type-only match
The tray_info_idx is a unique spool identifier stored in the 3MF file when the user
slices in Bambu Studio. If the same spool is still loaded in the printer (same
tray_info_idx), we use that tray. This ensures the exact spool selected during
slicing is used, even if multiple trays have the same type and color.
The tray_info_idx is a filament type identifier stored in the 3MF file when the user
slices (e.g., "GFA00" for generic PLA, "P4d64437" for custom presets). If the same
tray_info_idx appears in only ONE available tray, we use that tray. If multiple trays
have the same tray_info_idx (e.g., two spools of generic PLA), we fall back to color
matching among those trays.
Args:
required: List of required filaments with slot_id, type, color, tray_info_idx
@@ -617,41 +618,60 @@ class PrintScheduler:
req_color = req.get("color", "")
req_tray_info_idx = req.get("tray_info_idx", "")
# Find best match: tray_info_idx > exact color > similar color > type-only
# Find best match: unique tray_info_idx > exact color > similar color > type-only
idx_match = None
exact_match = None
similar_match = None
type_only_match = None
for f in loaded:
if f["global_tray_id"] in used_tray_ids:
continue
# Get available trays (not already used)
available = [f for f in loaded if f["global_tray_id"] not in used_tray_ids]
# First priority: match by tray_info_idx (exact spool identity)
# Only match if both have non-empty tray_info_idx
f_tray_info_idx = f.get("tray_info_idx", "")
if req_tray_info_idx and f_tray_info_idx and req_tray_info_idx == f_tray_info_idx:
idx_match = f
# Check if tray_info_idx is unique among available trays
if req_tray_info_idx:
idx_matches = [f for f in available if f.get("tray_info_idx") == req_tray_info_idx]
if len(idx_matches) == 1:
# Unique tray_info_idx - use it as definitive match
idx_match = idx_matches[0]
logger.debug(
f"Matched filament slot {req.get('slot_id')} by tray_info_idx={req_tray_info_idx} "
f"-> tray {f['global_tray_id']}"
f"Matched filament slot {req.get('slot_id')} by unique tray_info_idx={req_tray_info_idx} "
f"-> tray {idx_match['global_tray_id']}"
)
break # Best possible match - exact spool
elif len(idx_matches) > 1:
# Multiple trays with same tray_info_idx - use color matching among them
logger.debug(
f"Non-unique tray_info_idx={req_tray_info_idx} found in {len(idx_matches)} trays, "
f"using color matching among trays: {[f['global_tray_id'] for f in idx_matches]}"
)
# Use color matching within this subset
for f in idx_matches:
f_color = f.get("color", "")
if self._normalize_color_for_compare(f_color) == self._normalize_color_for_compare(req_color):
if not exact_match:
exact_match = f
elif self._colors_are_similar(f_color, req_color):
if not similar_match:
similar_match = f
elif not type_only_match:
type_only_match = f
f_type = (f.get("type") or "").upper()
if f_type != req_type:
continue
# If no idx_match yet, do standard type/color matching on all available trays
if not idx_match and not exact_match and not similar_match and not type_only_match:
for f in available:
f_type = (f.get("type") or "").upper()
if f_type != req_type:
continue
# Type matches - check color
f_color = f.get("color", "")
if self._normalize_color_for_compare(f_color) == self._normalize_color_for_compare(req_color):
if not exact_match:
exact_match = f
elif self._colors_are_similar(f_color, req_color):
if not similar_match:
similar_match = f
elif not type_only_match:
type_only_match = f
# Type matches - check color
f_color = f.get("color", "")
if self._normalize_color_for_compare(f_color) == self._normalize_color_for_compare(req_color):
if not exact_match:
exact_match = f
elif self._colors_are_similar(f_color, req_color):
if not similar_match:
similar_match = f
elif not type_only_match:
type_only_match = f
match = idx_match or exact_match or similar_match or type_only_match
if match:
@@ -370,6 +370,45 @@ class TestMatchFilamentsToSlots:
result = scheduler._match_filaments_to_slots(required, loaded)
assert result == [0, 1] # Each slot gets its specific tray
def test_match_non_unique_tray_info_idx_uses_color(self, scheduler):
"""Non-unique tray_info_idx should fall back to color matching.
This is the scenario where multiple trays have the same tray_info_idx
(e.g., two spools of generic PLA both have GFA00). The color should
be used as tiebreaker instead of just picking the first match.
"""
# User sliced with green PLA (tray_info_idx=GFA00)
# Two trays have GFA00: tray 3 (white) and tray 4 (green)
# Should pick tray 4 because the color matches
required = [
{"slot_id": 2, "type": "PLA", "color": "#00FF00", "tray_info_idx": "GFA00"}, # Green PLA
]
loaded = [
{"type": "PLA", "color": "#FFFFFF", "global_tray_id": 3, "tray_info_idx": "GFA00"}, # White PLA
{"type": "PLA", "color": "#00FF00", "global_tray_id": 4, "tray_info_idx": "GFA00"}, # Green PLA
]
result = scheduler._match_filaments_to_slots(required, loaded)
assert result == [-1, 4] # Should pick tray 4 (color match), not tray 3 (first match)
def test_match_non_unique_tray_info_idx_same_color(self, scheduler):
"""Non-unique tray_info_idx with identical colors picks first match.
When multiple trays have the same tray_info_idx AND same color,
there's no way to differentiate, so first match is used.
"""
required = [
{"slot_id": 2, "type": "PLA", "color": "#FFFFFF", "tray_info_idx": "GFA00"},
]
loaded = [
{"type": "PLA", "color": "#FFFFFF", "global_tray_id": 3, "tray_info_idx": "GFA00"},
{"type": "PLA", "color": "#FFFFFF", "global_tray_id": 4, "tray_info_idx": "GFA00"},
]
result = scheduler._match_filaments_to_slots(required, loaded)
# Both have same color, so first is used
assert result == [-1, 3]
class TestBuildLoadedFilamentsTrayInfoIdx:
"""Test tray_info_idx extraction in _build_loaded_filaments."""
+111 -70
View File
@@ -62,12 +62,13 @@ export function buildLoadedFilaments(printerStatus: PrinterStatus | undefined):
* Compute AMS mapping for a printer given filament requirements and printer status.
* This is a non-hook version that can be called imperatively (e.g., in a loop for multiple printers).
*
* Priority: tray_info_idx match > exact color match > similar color match > type-only match
* Priority: unique tray_info_idx match > exact color match > similar color match > type-only match
*
* The tray_info_idx is a unique spool identifier stored in the 3MF file when the user
* slices in Bambu Studio. If the same spool is still loaded in the printer (same
* tray_info_idx), we use that tray. This ensures the exact spool selected during
* slicing is used, even if multiple trays have the same type and color.
* The tray_info_idx is a filament type identifier stored in the 3MF file when the user
* slices (e.g., "GFA00" for generic PLA, "P4d64437" for custom presets). If the same
* tray_info_idx appears in only ONE available tray, we use that tray. If multiple trays
* have the same tray_info_idx (e.g., two spools of generic PLA), we fall back to color
* matching among those trays.
*
* @param filamentReqs - Required filaments from the 3MF file
* @param printerStatus - Current printer status with AMS information
@@ -88,43 +89,63 @@ export function computeAmsMapping(
const comparisons = filamentReqs.filaments.map((req) => {
const reqTrayInfoIdx = req.tray_info_idx || '';
// First priority: match by tray_info_idx (exact spool identity)
// Only match if both have non-empty tray_info_idx
const idxMatch = reqTrayInfoIdx
? loadedFilaments.find(
(f) =>
!usedTrayIds.has(f.globalTrayId) &&
f.trayInfoIdx &&
f.trayInfoIdx === reqTrayInfoIdx
)
: undefined;
// Get available trays (not already used)
const available = loadedFilaments.filter((f) => !usedTrayIds.has(f.globalTrayId));
// Fall back to type+color matching
const exactMatch =
!idxMatch &&
loadedFilaments.find(
let idxMatch: LoadedFilament | undefined;
let exactMatch: LoadedFilament | undefined;
let similarMatch: LoadedFilament | undefined;
let typeOnlyMatch: LoadedFilament | undefined;
// Check if tray_info_idx is unique among available trays
if (reqTrayInfoIdx) {
const idxMatches = available.filter((f) => f.trayInfoIdx === reqTrayInfoIdx);
if (idxMatches.length === 1) {
// Unique tray_info_idx - use it as definitive match
idxMatch = idxMatches[0];
} else if (idxMatches.length > 1) {
// Multiple trays with same tray_info_idx - use color matching among them
exactMatch = idxMatches.find(
(f) =>
f.type?.toUpperCase() === req.type?.toUpperCase() &&
normalizeColorForCompare(f.color) === normalizeColorForCompare(req.color)
);
if (!exactMatch) {
similarMatch = idxMatches.find(
(f) =>
f.type?.toUpperCase() === req.type?.toUpperCase() &&
colorsAreSimilar(f.color, req.color)
);
}
if (!exactMatch && !similarMatch) {
typeOnlyMatch = idxMatches.find(
(f) => f.type?.toUpperCase() === req.type?.toUpperCase()
);
}
}
}
// If no idx match, do standard type/color matching on all available trays
if (!idxMatch && !exactMatch && !similarMatch && !typeOnlyMatch) {
exactMatch = available.find(
(f) =>
!usedTrayIds.has(f.globalTrayId) &&
f.type?.toUpperCase() === req.type?.toUpperCase() &&
normalizeColorForCompare(f.color) === normalizeColorForCompare(req.color)
);
const similarMatch =
!idxMatch &&
!exactMatch &&
loadedFilaments.find(
(f) =>
!usedTrayIds.has(f.globalTrayId) &&
f.type?.toUpperCase() === req.type?.toUpperCase() &&
colorsAreSimilar(f.color, req.color)
);
const typeOnlyMatch =
!idxMatch &&
!exactMatch &&
!similarMatch &&
loadedFilaments.find(
(f) =>
!usedTrayIds.has(f.globalTrayId) && f.type?.toUpperCase() === req.type?.toUpperCase()
);
if (!exactMatch) {
similarMatch = available.find(
(f) =>
f.type?.toUpperCase() === req.type?.toUpperCase() &&
colorsAreSimilar(f.color, req.color)
);
}
if (!exactMatch && !similarMatch) {
typeOnlyMatch = available.find(
(f) => f.type?.toUpperCase() === req.type?.toUpperCase()
);
}
}
const loaded = idxMatch || exactMatch || similarMatch || typeOnlyMatch || undefined;
// Mark this tray as used so it won't be assigned to another slot
@@ -329,47 +350,67 @@ export function useFilamentMapping(
}
// Auto-match: Find a loaded filament
// Priority: tray_info_idx match > exact color match > similar color match > type-only match
// Priority: unique tray_info_idx match > exact color match > similar color match > type-only match
// IMPORTANT: Exclude trays that are already assigned (manually or auto)
const reqTrayInfoIdx = req.tray_info_idx || '';
// First priority: match by tray_info_idx (exact spool identity)
// This ensures the exact spool selected during slicing is used
const idxMatch = reqTrayInfoIdx
? loadedFilaments.find(
(f) =>
!usedTrayIds.has(f.globalTrayId) &&
f.trayInfoIdx &&
f.trayInfoIdx === reqTrayInfoIdx
)
: undefined;
// Get available trays (not already used)
const available = loadedFilaments.filter((f) => !usedTrayIds.has(f.globalTrayId));
// Fall back to type+color matching
const exactMatch =
!idxMatch &&
loadedFilaments.find(
let idxMatch: LoadedFilament | undefined;
let exactMatch: LoadedFilament | undefined;
let similarMatch: LoadedFilament | undefined;
let typeOnlyMatch: LoadedFilament | undefined;
// Check if tray_info_idx is unique among available trays
if (reqTrayInfoIdx) {
const idxMatches = available.filter((f) => f.trayInfoIdx === reqTrayInfoIdx);
if (idxMatches.length === 1) {
// Unique tray_info_idx - use it as definitive match
idxMatch = idxMatches[0];
} else if (idxMatches.length > 1) {
// Multiple trays with same tray_info_idx - use color matching among them
exactMatch = idxMatches.find(
(f) =>
f.type?.toUpperCase() === req.type?.toUpperCase() &&
normalizeColorForCompare(f.color) === normalizeColorForCompare(req.color)
);
if (!exactMatch) {
similarMatch = idxMatches.find(
(f) =>
f.type?.toUpperCase() === req.type?.toUpperCase() &&
colorsAreSimilar(f.color, req.color)
);
}
if (!exactMatch && !similarMatch) {
typeOnlyMatch = idxMatches.find(
(f) => f.type?.toUpperCase() === req.type?.toUpperCase()
);
}
}
}
// If no idx match, do standard type/color matching on all available trays
if (!idxMatch && !exactMatch && !similarMatch && !typeOnlyMatch) {
exactMatch = available.find(
(f) =>
!usedTrayIds.has(f.globalTrayId) &&
f.type?.toUpperCase() === req.type?.toUpperCase() &&
normalizeColorForCompare(f.color) === normalizeColorForCompare(req.color)
);
const similarMatch =
!idxMatch &&
!exactMatch &&
loadedFilaments.find(
(f) =>
!usedTrayIds.has(f.globalTrayId) &&
f.type?.toUpperCase() === req.type?.toUpperCase() &&
colorsAreSimilar(f.color, req.color)
);
const typeOnlyMatch =
!idxMatch &&
!exactMatch &&
!similarMatch &&
loadedFilaments.find(
(f) =>
!usedTrayIds.has(f.globalTrayId) && f.type?.toUpperCase() === req.type?.toUpperCase()
);
if (!exactMatch) {
similarMatch = available.find(
(f) =>
f.type?.toUpperCase() === req.type?.toUpperCase() &&
colorsAreSimilar(f.color, req.color)
);
}
if (!exactMatch && !similarMatch) {
typeOnlyMatch = available.find(
(f) => f.type?.toUpperCase() === req.type?.toUpperCase()
);
}
}
const loaded = idxMatch || exactMatch || similarMatch || typeOnlyMatch || undefined;
// Mark this tray as used so it won't be assigned to another slot
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -23,7 +23,7 @@
<!-- Splash screens for iOS -->
<link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
<script type="module" crossorigin src="/assets/index-CIJ4LJuv.js"></script>
<script type="module" crossorigin src="/assets/index-DSQkZ1L5.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-fdAEMOwp.css">
</head>
<body>