mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-08 23:21:58 +02:00
Fix AMS mapping for model-based queue jobs (Issue #192)
When using "Any [Model]" queue assignment, the scheduler now computes AMS mapping after a printer is assigned, instead of requiring it upfront. This fixes H2D Pro (and other printers) failing at filament loading when queued via model-based assignment. The issue was that no AMS mapping was sent to the printer because the specific printer wasn't known at queue time. Closes #192
This commit is contained in:
@@ -149,6 +149,10 @@ All notable changes to Bambuddy will be documented in this file.
|
||||
- Tooltip shows full name on hover
|
||||
- **K-Profiles Backup Status** - Fixed GitHub backup settings showing incorrect printer connection count (e.g., "1/2 connected" when both printers are connected); now fetches status from API instead of relying on WebSocket cache
|
||||
- **GitHub Backup Timestamps** - Removed volatile timestamps from GitHub backup files so git diffs only show actual data changes
|
||||
- **Model-Based Queue AMS Mapping** - Fixed "Any [Model]" queue jobs failing at filament loading on H2D Pro and other printers (Issue #192):
|
||||
- Scheduler now computes AMS mapping after printer assignment for model-based jobs
|
||||
- Previously, no AMS mapping was sent because the specific printer wasn't known at queue time
|
||||
- Auto-matches required filaments to available AMS slots by type and color
|
||||
|
||||
### Maintenance
|
||||
- Upgraded vitest from 2.x to 3.x to resolve npm audit security vulnerabilities in dev dependencies
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
"""Print scheduler service - processes the print queue."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import xml.etree.ElementTree as ET
|
||||
import zipfile
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -139,8 +143,6 @@ class PrintScheduler:
|
||||
required_types = None
|
||||
if item.required_filament_types:
|
||||
try:
|
||||
import json
|
||||
|
||||
required_types = json.loads(item.required_filament_types)
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
@@ -203,6 +205,17 @@ class PrintScheduler:
|
||||
db=db,
|
||||
)
|
||||
|
||||
# Compute AMS mapping for the assigned printer if not already set
|
||||
# This is critical for model-based jobs where mapping wasn't computed upfront
|
||||
if not item.ams_mapping:
|
||||
computed_mapping = await self._compute_ams_mapping_for_printer(db, printer_id, item)
|
||||
if computed_mapping:
|
||||
item.ams_mapping = json.dumps(computed_mapping)
|
||||
logger.info(
|
||||
f"Queue item {item.id}: Computed AMS mapping for printer {printer_id}: {computed_mapping}"
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
await self._start_print(db, item)
|
||||
busy_printers.add(printer_id)
|
||||
|
||||
@@ -325,6 +338,294 @@ class PrintScheduler:
|
||||
|
||||
return missing
|
||||
|
||||
async def _compute_ams_mapping_for_printer(
|
||||
self, db: AsyncSession, printer_id: int, item: PrintQueueItem
|
||||
) -> list[int] | None:
|
||||
"""Compute AMS mapping for a printer based on filament requirements.
|
||||
|
||||
This is called for model-based queue items after a printer is assigned,
|
||||
to compute the correct AMS slot mapping for that specific printer's hardware.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
printer_id: The assigned printer ID
|
||||
item: The queue item (contains archive_id or library_file_id)
|
||||
|
||||
Returns:
|
||||
AMS mapping array or None if no mapping needed/possible
|
||||
"""
|
||||
# Get printer status
|
||||
status = printer_manager.get_status(printer_id)
|
||||
if not status:
|
||||
logger.warning(f"Cannot compute AMS mapping: printer {printer_id} status unavailable")
|
||||
return None
|
||||
|
||||
# Get filament requirements from source file
|
||||
filament_reqs = await self._get_filament_requirements(db, item)
|
||||
if not filament_reqs:
|
||||
logger.debug(f"No filament requirements found for queue item {item.id}")
|
||||
return None
|
||||
|
||||
# Build loaded filaments from printer status
|
||||
loaded_filaments = self._build_loaded_filaments(status)
|
||||
if not loaded_filaments:
|
||||
logger.debug(f"No filaments loaded on printer {printer_id}")
|
||||
return None
|
||||
|
||||
# Compute mapping: match required filaments to available slots
|
||||
return self._match_filaments_to_slots(filament_reqs, loaded_filaments)
|
||||
|
||||
async def _get_filament_requirements(self, db: AsyncSession, item: PrintQueueItem) -> list[dict] | None:
|
||||
"""Extract filament requirements from the source 3MF file.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
item: Queue item with archive_id or library_file_id
|
||||
|
||||
Returns:
|
||||
List of filament requirement dicts with slot_id, type, color, used_grams
|
||||
"""
|
||||
file_path: Path | None = None
|
||||
|
||||
if item.archive_id:
|
||||
result = await db.execute(select(PrintArchive).where(PrintArchive.id == item.archive_id))
|
||||
archive = result.scalar_one_or_none()
|
||||
if archive:
|
||||
file_path = settings.base_dir / archive.file_path
|
||||
elif item.library_file_id:
|
||||
result = await db.execute(select(LibraryFile).where(LibraryFile.id == item.library_file_id))
|
||||
library_file = result.scalar_one_or_none()
|
||||
if library_file:
|
||||
lib_path = Path(library_file.file_path)
|
||||
file_path = lib_path if lib_path.is_absolute() else settings.base_dir / library_file.file_path
|
||||
|
||||
if not file_path or not file_path.exists():
|
||||
return None
|
||||
|
||||
filaments = []
|
||||
try:
|
||||
with zipfile.ZipFile(file_path, "r") as zf:
|
||||
if "Metadata/slice_info.config" not in zf.namelist():
|
||||
return None
|
||||
|
||||
content = zf.read("Metadata/slice_info.config").decode()
|
||||
root = ET.fromstring(content)
|
||||
|
||||
# Check if plate_id is specified - use that plate's filaments
|
||||
plate_id = item.plate_id
|
||||
if plate_id:
|
||||
for plate_elem in root.findall("./plate"):
|
||||
plate_index = None
|
||||
for meta in plate_elem.findall("metadata"):
|
||||
if meta.get("key") == "index":
|
||||
plate_index = int(meta.get("value", "0"))
|
||||
break
|
||||
if plate_index == plate_id:
|
||||
for filament_elem in plate_elem.findall("./filament"):
|
||||
filament_id = filament_elem.get("id")
|
||||
filament_type = filament_elem.get("type", "")
|
||||
filament_color = filament_elem.get("color", "")
|
||||
used_g = filament_elem.get("used_g", "0")
|
||||
try:
|
||||
used_grams = float(used_g)
|
||||
if used_grams > 0 and filament_id:
|
||||
filaments.append(
|
||||
{
|
||||
"slot_id": int(filament_id),
|
||||
"type": filament_type,
|
||||
"color": filament_color,
|
||||
"used_grams": round(used_grams, 1),
|
||||
}
|
||||
)
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
break
|
||||
else:
|
||||
# No plate_id - extract all filaments with used_g > 0
|
||||
for filament_elem in root.findall("./filament"):
|
||||
filament_id = filament_elem.get("id")
|
||||
filament_type = filament_elem.get("type", "")
|
||||
filament_color = filament_elem.get("color", "")
|
||||
used_g = filament_elem.get("used_g", "0")
|
||||
try:
|
||||
used_grams = float(used_g)
|
||||
if used_grams > 0 and filament_id:
|
||||
filaments.append(
|
||||
{
|
||||
"slot_id": int(filament_id),
|
||||
"type": filament_type,
|
||||
"color": filament_color,
|
||||
"used_grams": round(used_grams, 1),
|
||||
}
|
||||
)
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
filaments.sort(key=lambda x: x["slot_id"])
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to parse filament requirements: {e}")
|
||||
return None
|
||||
|
||||
return filaments if filaments else None
|
||||
|
||||
def _build_loaded_filaments(self, status) -> list[dict]:
|
||||
"""Build list of loaded filaments from printer status.
|
||||
|
||||
Args:
|
||||
status: PrinterState from printer_manager
|
||||
|
||||
Returns:
|
||||
List of loaded filament dicts with type, color, ams_id, tray_id, global_tray_id
|
||||
"""
|
||||
filaments = []
|
||||
|
||||
# Parse AMS units from raw_data
|
||||
ams_data = status.raw_data.get("ams", [])
|
||||
for ams_unit in ams_data:
|
||||
ams_id = ams_unit.get("id", 0)
|
||||
trays = ams_unit.get("tray", [])
|
||||
is_ht = len(trays) == 1 # AMS-HT has single tray
|
||||
|
||||
for tray in trays:
|
||||
tray_type = tray.get("tray_type")
|
||||
if tray_type:
|
||||
tray_id = tray.get("id", 0)
|
||||
tray_color = tray.get("tray_color", "")
|
||||
# Normalize color: remove alpha, add hash
|
||||
color = self._normalize_color(tray_color)
|
||||
# Calculate global tray ID
|
||||
global_tray_id = ams_id * 4 + tray_id
|
||||
|
||||
filaments.append(
|
||||
{
|
||||
"type": tray_type,
|
||||
"color": color,
|
||||
"ams_id": ams_id,
|
||||
"tray_id": tray_id,
|
||||
"is_ht": is_ht,
|
||||
"is_external": False,
|
||||
"global_tray_id": global_tray_id,
|
||||
}
|
||||
)
|
||||
|
||||
# Check external spool (vt_tray)
|
||||
vt_tray = status.raw_data.get("vt_tray")
|
||||
if vt_tray and vt_tray.get("tray_type"):
|
||||
color = self._normalize_color(vt_tray.get("tray_color", ""))
|
||||
filaments.append(
|
||||
{
|
||||
"type": vt_tray["tray_type"],
|
||||
"color": color,
|
||||
"ams_id": -1,
|
||||
"tray_id": 0,
|
||||
"is_ht": False,
|
||||
"is_external": True,
|
||||
"global_tray_id": 254,
|
||||
}
|
||||
)
|
||||
|
||||
return filaments
|
||||
|
||||
def _normalize_color(self, color: str | None) -> str:
|
||||
"""Normalize color to #RRGGBB format."""
|
||||
if not color:
|
||||
return "#808080"
|
||||
hex_color = color.replace("#", "")[:6]
|
||||
return f"#{hex_color}"
|
||||
|
||||
def _normalize_color_for_compare(self, color: str | None) -> str:
|
||||
"""Normalize color for comparison (lowercase, no hash)."""
|
||||
if not color:
|
||||
return ""
|
||||
return color.replace("#", "").lower()[:6]
|
||||
|
||||
def _colors_are_similar(self, color1: str | None, color2: str | None, threshold: int = 40) -> bool:
|
||||
"""Check if two colors are visually similar within a threshold."""
|
||||
hex1 = self._normalize_color_for_compare(color1)
|
||||
hex2 = self._normalize_color_for_compare(color2)
|
||||
if not hex1 or not hex2 or len(hex1) < 6 or len(hex2) < 6:
|
||||
return False
|
||||
|
||||
try:
|
||||
r1 = int(hex1[0:2], 16)
|
||||
g1 = int(hex1[2:4], 16)
|
||||
b1 = int(hex1[4:6], 16)
|
||||
r2 = int(hex2[0:2], 16)
|
||||
g2 = int(hex2[2:4], 16)
|
||||
b2 = int(hex2[4:6], 16)
|
||||
return abs(r1 - r2) <= threshold and abs(g1 - g2) <= threshold and abs(b1 - b2) <= threshold
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
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: exact color match > similar color match > type-only match
|
||||
|
||||
Args:
|
||||
required: List of required filaments with slot_id, type, color
|
||||
loaded: List of loaded filaments with type, color, global_tray_id
|
||||
|
||||
Returns:
|
||||
AMS mapping array (position = slot_id - 1, value = global_tray_id or -1)
|
||||
"""
|
||||
if not required:
|
||||
return None
|
||||
|
||||
# Track used trays to avoid duplicate assignment
|
||||
used_tray_ids: set[int] = set()
|
||||
comparisons = []
|
||||
|
||||
for req in required:
|
||||
req_type = (req.get("type") or "").upper()
|
||||
req_color = req.get("color", "")
|
||||
|
||||
# Find best match: exact color > similar color > type-only
|
||||
exact_match = None
|
||||
similar_match = None
|
||||
type_only_match = None
|
||||
|
||||
for f in loaded:
|
||||
if f["global_tray_id"] in used_tray_ids:
|
||||
continue
|
||||
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):
|
||||
exact_match = f
|
||||
break # Best possible match
|
||||
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 = exact_match or similar_match or type_only_match
|
||||
if match:
|
||||
used_tray_ids.add(match["global_tray_id"])
|
||||
comparisons.append({"slot_id": req.get("slot_id", 0), "global_tray_id": match["global_tray_id"]})
|
||||
else:
|
||||
comparisons.append({"slot_id": req.get("slot_id", 0), "global_tray_id": -1})
|
||||
|
||||
# Build mapping array
|
||||
if not comparisons:
|
||||
return None
|
||||
|
||||
max_slot_id = max(c["slot_id"] for c in comparisons)
|
||||
if max_slot_id <= 0:
|
||||
return None
|
||||
|
||||
mapping = [-1] * max_slot_id
|
||||
for c in comparisons:
|
||||
slot_id = c["slot_id"]
|
||||
if slot_id and slot_id > 0:
|
||||
mapping[slot_id - 1] = c["global_tray_id"]
|
||||
|
||||
return mapping
|
||||
|
||||
def _is_printer_idle(self, printer_id: int) -> bool:
|
||||
"""Check if a printer is connected and idle."""
|
||||
if not printer_manager.is_connected(printer_id):
|
||||
@@ -621,8 +922,6 @@ class PrintScheduler:
|
||||
ams_mapping = None
|
||||
if item.ams_mapping:
|
||||
try:
|
||||
import json
|
||||
|
||||
ams_mapping = json.loads(item.ams_mapping)
|
||||
except json.JSONDecodeError:
|
||||
logger.warning(f"Queue item {item.id}: Invalid AMS mapping JSON, ignoring")
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
"""Tests for the AMS mapping computation in the print scheduler."""
|
||||
|
||||
import pytest
|
||||
|
||||
from backend.app.services.print_scheduler import PrintScheduler
|
||||
|
||||
|
||||
class TestSchedulerAmsMappingHelpers:
|
||||
"""Test the AMS mapping helper methods in PrintScheduler."""
|
||||
|
||||
@pytest.fixture
|
||||
def scheduler(self):
|
||||
return PrintScheduler()
|
||||
|
||||
def test_normalize_color_with_hash(self, scheduler):
|
||||
"""Color with hash should return #RRGGBB format."""
|
||||
result = scheduler._normalize_color("#FF5500")
|
||||
assert result == "#FF5500"
|
||||
|
||||
def test_normalize_color_without_hash(self, scheduler):
|
||||
"""Color without hash should add hash prefix."""
|
||||
result = scheduler._normalize_color("FF5500")
|
||||
assert result == "#FF5500"
|
||||
|
||||
def test_normalize_color_with_alpha(self, scheduler):
|
||||
"""Color with alpha channel should strip it."""
|
||||
result = scheduler._normalize_color("FF5500AA")
|
||||
assert result == "#FF5500"
|
||||
|
||||
def test_normalize_color_none(self, scheduler):
|
||||
"""None color should return default gray."""
|
||||
result = scheduler._normalize_color(None)
|
||||
assert result == "#808080"
|
||||
|
||||
def test_normalize_color_empty(self, scheduler):
|
||||
"""Empty color should return default gray."""
|
||||
result = scheduler._normalize_color("")
|
||||
assert result == "#808080"
|
||||
|
||||
def test_normalize_color_for_compare(self, scheduler):
|
||||
"""Color for compare should be lowercase without hash."""
|
||||
result = scheduler._normalize_color_for_compare("#FF5500")
|
||||
assert result == "ff5500"
|
||||
|
||||
def test_normalize_color_for_compare_with_alpha(self, scheduler):
|
||||
"""Alpha channel should be stripped for comparison."""
|
||||
result = scheduler._normalize_color_for_compare("#FF5500AA")
|
||||
assert result == "ff5500"
|
||||
|
||||
def test_colors_are_similar_exact_match(self, scheduler):
|
||||
"""Exact same colors should be similar."""
|
||||
assert scheduler._colors_are_similar("#FF5500", "#FF5500") is True
|
||||
|
||||
def test_colors_are_similar_within_threshold(self, scheduler):
|
||||
"""Colors within threshold should be similar."""
|
||||
# Red difference of 10, well within default threshold of 40
|
||||
assert scheduler._colors_are_similar("#FF5500", "#F55500") is True
|
||||
|
||||
def test_colors_are_similar_outside_threshold(self, scheduler):
|
||||
"""Colors outside threshold should not be similar."""
|
||||
# Red: FF (255) vs 00 (0) = 255 difference
|
||||
assert scheduler._colors_are_similar("#FF0000", "#00FF00") is False
|
||||
|
||||
def test_colors_are_similar_none_colors(self, scheduler):
|
||||
"""None colors should not be similar."""
|
||||
assert scheduler._colors_are_similar(None, "#FF5500") is False
|
||||
assert scheduler._colors_are_similar("#FF5500", None) is False
|
||||
|
||||
|
||||
class TestBuildLoadedFilaments:
|
||||
"""Test the _build_loaded_filaments method."""
|
||||
|
||||
@pytest.fixture
|
||||
def scheduler(self):
|
||||
return PrintScheduler()
|
||||
|
||||
def test_build_loaded_filaments_empty_status(self, scheduler):
|
||||
"""Empty status should return empty list."""
|
||||
|
||||
class MockStatus:
|
||||
raw_data = {}
|
||||
|
||||
result = scheduler._build_loaded_filaments(MockStatus())
|
||||
assert result == []
|
||||
|
||||
def test_build_loaded_filaments_with_ams(self, scheduler):
|
||||
"""Should extract filaments from AMS units."""
|
||||
|
||||
class MockStatus:
|
||||
raw_data = {
|
||||
"ams": [
|
||||
{
|
||||
"id": 0,
|
||||
"tray": [
|
||||
{"id": 0, "tray_type": "PLA", "tray_color": "FF0000"},
|
||||
{"id": 1, "tray_type": "PETG", "tray_color": "00FF00"},
|
||||
],
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
result = scheduler._build_loaded_filaments(MockStatus())
|
||||
assert len(result) == 2
|
||||
|
||||
# First filament
|
||||
assert result[0]["type"] == "PLA"
|
||||
assert result[0]["color"] == "#FF0000"
|
||||
assert result[0]["ams_id"] == 0
|
||||
assert result[0]["tray_id"] == 0
|
||||
assert result[0]["global_tray_id"] == 0 # 0 * 4 + 0
|
||||
|
||||
# Second filament
|
||||
assert result[1]["type"] == "PETG"
|
||||
assert result[1]["global_tray_id"] == 1 # 0 * 4 + 1
|
||||
|
||||
def test_build_loaded_filaments_with_ht_ams(self, scheduler):
|
||||
"""AMS-HT (single tray) should be marked as is_ht."""
|
||||
|
||||
class MockStatus:
|
||||
raw_data = {
|
||||
"ams": [
|
||||
{
|
||||
"id": 128,
|
||||
"tray": [{"id": 0, "tray_type": "PLA-CF", "tray_color": "000000"}],
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
result = scheduler._build_loaded_filaments(MockStatus())
|
||||
assert len(result) == 1
|
||||
assert result[0]["is_ht"] is True
|
||||
assert result[0]["global_tray_id"] == 512 # 128 * 4 + 0
|
||||
|
||||
def test_build_loaded_filaments_with_external(self, scheduler):
|
||||
"""Should include external spool."""
|
||||
|
||||
class MockStatus:
|
||||
raw_data = {"vt_tray": {"tray_type": "TPU", "tray_color": "0000FF"}}
|
||||
|
||||
result = scheduler._build_loaded_filaments(MockStatus())
|
||||
assert len(result) == 1
|
||||
assert result[0]["type"] == "TPU"
|
||||
assert result[0]["is_external"] is True
|
||||
assert result[0]["global_tray_id"] == 254
|
||||
|
||||
def test_build_loaded_filaments_skips_empty_trays(self, scheduler):
|
||||
"""Trays without tray_type should be skipped."""
|
||||
|
||||
class MockStatus:
|
||||
raw_data = {
|
||||
"ams": [
|
||||
{
|
||||
"id": 0,
|
||||
"tray": [
|
||||
{"id": 0, "tray_type": "PLA", "tray_color": "FF0000"},
|
||||
{"id": 1, "tray_type": "", "tray_color": ""}, # Empty
|
||||
{"id": 2}, # No tray_type key
|
||||
],
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
result = scheduler._build_loaded_filaments(MockStatus())
|
||||
assert len(result) == 1
|
||||
assert result[0]["type"] == "PLA"
|
||||
|
||||
|
||||
class TestMatchFilamentsToSlots:
|
||||
"""Test the _match_filaments_to_slots method."""
|
||||
|
||||
@pytest.fixture
|
||||
def scheduler(self):
|
||||
return PrintScheduler()
|
||||
|
||||
def test_match_empty_required(self, scheduler):
|
||||
"""Empty required list should return None."""
|
||||
result = scheduler._match_filaments_to_slots([], [])
|
||||
assert result is None
|
||||
|
||||
def test_match_exact_color(self, scheduler):
|
||||
"""Should prefer exact color match."""
|
||||
required = [{"slot_id": 1, "type": "PLA", "color": "#FF0000"}]
|
||||
loaded = [
|
||||
{"type": "PLA", "color": "#00FF00", "global_tray_id": 0}, # Wrong color
|
||||
{"type": "PLA", "color": "#FF0000", "global_tray_id": 1}, # Exact match
|
||||
]
|
||||
|
||||
result = scheduler._match_filaments_to_slots(required, loaded)
|
||||
assert result == [1] # Should pick tray 1 (exact color match)
|
||||
|
||||
def test_match_similar_color(self, scheduler):
|
||||
"""Should match similar colors when no exact match."""
|
||||
required = [{"slot_id": 1, "type": "PLA", "color": "#FF5500"}]
|
||||
loaded = [
|
||||
{"type": "PLA", "color": "#FF5510", "global_tray_id": 0}, # Similar
|
||||
]
|
||||
|
||||
result = scheduler._match_filaments_to_slots(required, loaded)
|
||||
assert result == [0]
|
||||
|
||||
def test_match_type_only(self, scheduler):
|
||||
"""Should match by type when colors don't match."""
|
||||
required = [{"slot_id": 1, "type": "PLA", "color": "#FF0000"}]
|
||||
loaded = [
|
||||
{"type": "PLA", "color": "#0000FF", "global_tray_id": 5}, # Type match, color way off
|
||||
]
|
||||
|
||||
result = scheduler._match_filaments_to_slots(required, loaded)
|
||||
assert result == [5]
|
||||
|
||||
def test_match_no_match_returns_minus_one(self, scheduler):
|
||||
"""Unmatched filaments should have -1 in mapping."""
|
||||
required = [{"slot_id": 1, "type": "PLA", "color": "#FF0000"}]
|
||||
loaded = [
|
||||
{"type": "PETG", "color": "#FF0000", "global_tray_id": 0}, # Wrong type
|
||||
]
|
||||
|
||||
result = scheduler._match_filaments_to_slots(required, loaded)
|
||||
assert result == [-1]
|
||||
|
||||
def test_match_multiple_filaments(self, scheduler):
|
||||
"""Should match multiple filaments correctly."""
|
||||
required = [
|
||||
{"slot_id": 1, "type": "PLA", "color": "#FF0000"},
|
||||
{"slot_id": 2, "type": "PETG", "color": "#00FF00"},
|
||||
]
|
||||
loaded = [
|
||||
{"type": "PLA", "color": "#FF0000", "global_tray_id": 0},
|
||||
{"type": "PETG", "color": "#00FF00", "global_tray_id": 1},
|
||||
]
|
||||
|
||||
result = scheduler._match_filaments_to_slots(required, loaded)
|
||||
assert result == [0, 1]
|
||||
|
||||
def test_match_avoids_duplicate_assignment(self, scheduler):
|
||||
"""Same tray should not be assigned to multiple slots."""
|
||||
required = [
|
||||
{"slot_id": 1, "type": "PLA", "color": "#FF0000"},
|
||||
{"slot_id": 2, "type": "PLA", "color": "#FF0000"}, # Same requirements
|
||||
]
|
||||
loaded = [
|
||||
{"type": "PLA", "color": "#FF0000", "global_tray_id": 0}, # Only one PLA
|
||||
]
|
||||
|
||||
result = scheduler._match_filaments_to_slots(required, loaded)
|
||||
# First slot gets the match, second slot gets -1
|
||||
assert result == [0, -1]
|
||||
|
||||
def test_match_h2d_pro_ams_ids(self, scheduler):
|
||||
"""Should work with H2D Pro's high AMS IDs (128+)."""
|
||||
required = [{"slot_id": 1, "type": "PLA", "color": "#FF0000"}]
|
||||
loaded = [
|
||||
{"type": "PLA", "color": "#FF0000", "global_tray_id": 512}, # AMS 128, slot 0
|
||||
]
|
||||
|
||||
result = scheduler._match_filaments_to_slots(required, loaded)
|
||||
assert result == [512]
|
||||
|
||||
def test_match_external_spool(self, scheduler):
|
||||
"""Should match external spool with ID 254."""
|
||||
required = [{"slot_id": 1, "type": "TPU", "color": "#0000FF"}]
|
||||
loaded = [
|
||||
{"type": "TPU", "color": "#0000FF", "global_tray_id": 254, "is_external": True},
|
||||
]
|
||||
|
||||
result = scheduler._match_filaments_to_slots(required, loaded)
|
||||
assert result == [254]
|
||||
File diff suppressed because one or more lines are too long
+1
-1
@@ -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-B6QcllFX.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-KrxdF0QX.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-B3Buyqqk.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
Reference in New Issue
Block a user