| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287 |
- """Filament-deficit check used by every queue dispatch path.
- The PrintModal warns when an assigned spool can't satisfy a print's per-slot
- filament weight (``Pre-print checks now also warn when the spool has
- insufficient material`` — #720). That check only runs when the user clicks
- "Print" inside PrintModal; ``QueuePage`` Play button, ``start_queue_item``
- route, and the VP intake + scheduler auto-dispatch path all skip it (#1496).
- This module is the single source of truth for the check. Both the route
- handler (``POST /print-queue/{id}/start``) and the dispatch scheduler call
- ``compute_deficit_for_queue_item`` against live spool state.
- Design notes:
- * The 3MF parser is the same one used by PrintModal: per-slot ``used_grams``
- comes from ``extract_filament_requirements`` (#1188's filament-overrides
- pipeline) or — when the item points at an unsliced library file — falls
- through to the file's archive copy. Anything that yields no requirements
- is treated as "no deficit" so a malformed or stripped 3MF never blocks.
- * Both internal-inventory and Spoolman modes are covered. Internal mode
- resolves via ``SpoolAssignment`` joined to ``Spool`` (``label_weight``
- minus ``weight_used``). Spoolman mode resolves via
- ``SpoolmanSlotAssignment`` then ``SpoolmanClient.get_spool`` for the live
- remaining weight; if Spoolman is unreachable we return no deficit rather
- than wedge the queue on a flaky network call.
- * The ``disable_filament_warnings`` user setting is respected at the
- service boundary — callers do not have to know about it.
- """
- from __future__ import annotations
- import json
- import logging
- from dataclasses import dataclass
- from pathlib import Path
- from sqlalchemy import select
- from sqlalchemy.ext.asyncio import AsyncSession
- from sqlalchemy.orm import selectinload
- from backend.app.core.config import settings as app_settings
- from backend.app.models.print_queue import PrintQueueItem
- from backend.app.models.spool_assignment import SpoolAssignment
- from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
- from backend.app.services.filament_requirements import extract_filament_requirements
- logger = logging.getLogger(__name__)
- @dataclass(frozen=True)
- class FilamentDeficit:
- """One slot's filament shortfall."""
- slot_id: int
- ams_id: int | None
- tray_id: int | None
- filament_type: str
- required_grams: float
- remaining_grams: float | None # None = could not determine
- def to_dict(self) -> dict:
- return {
- "slot_id": self.slot_id,
- "ams_id": self.ams_id,
- "tray_id": self.tray_id,
- "filament_type": self.filament_type,
- "required_grams": self.required_grams,
- "remaining_grams": self.remaining_grams,
- }
- def _global_to_ams_key(global_tray_id: int) -> tuple[int, int]:
- """Inverse of ``ams_id * 4 + tray_id`` — matches ``usage_tracker``."""
- if global_tray_id >= 254:
- return (255, global_tray_id - 254)
- if global_tray_id >= 128:
- return (global_tray_id, 0)
- return (global_tray_id // 4, global_tray_id % 4)
- def _resolve_source_3mf(item: PrintQueueItem) -> Path | None:
- """Locate the 3MF file backing this queue item (archive or library)."""
- if item.archive is not None and item.archive.file_path:
- return app_settings.base_dir / item.archive.file_path
- if item.library_file is not None and item.library_file.file_path:
- return Path(item.library_file.file_path)
- return None
- async def _spoolman_remaining_grams(spoolman_spool_id: int) -> float | None:
- """Live remaining grams for a Spoolman spool, or None if unavailable."""
- try:
- from backend.app.services.spoolman import (
- SpoolmanClientError,
- SpoolmanNotFoundError,
- get_spoolman_client,
- )
- except ImportError:
- return None
- try:
- client = await get_spoolman_client()
- if client is None:
- return None
- spool = await client.get_spool(spoolman_spool_id)
- except (SpoolmanNotFoundError, SpoolmanClientError):
- return None
- except Exception as e:
- logger.debug("Spoolman fetch failed for spool %s: %s", spoolman_spool_id, e)
- return None
- if not spool:
- return None
- # Spoolman exposes either an absolute remaining_weight, or used_weight +
- # filament.weight. Either is sufficient — prefer remaining_weight when
- # present (the user may have overridden it).
- remaining = spool.get("remaining_weight")
- if isinstance(remaining, (int, float)) and remaining >= 0:
- return float(remaining)
- used = spool.get("used_weight")
- filament = spool.get("filament") or {}
- total = filament.get("weight")
- if isinstance(used, (int, float)) and isinstance(total, (int, float)) and total > 0:
- return max(0.0, float(total) - float(used))
- return None
- async def _is_spoolman_mode(db: AsyncSession) -> bool:
- """Check whether the user has opted in to Spoolman inventory mode."""
- try:
- from backend.app.api.routes.settings import get_setting
- spoolman_enabled = await get_setting(db, "spoolman_enabled")
- return bool(spoolman_enabled) and spoolman_enabled.lower() == "true"
- except Exception:
- return False
- async def _warnings_disabled(db: AsyncSession) -> bool:
- """Honour the ``disable_filament_warnings`` setting (#720)."""
- try:
- from backend.app.api.routes.settings import get_setting
- disabled = await get_setting(db, "disable_filament_warnings")
- return bool(disabled) and disabled.lower() == "true"
- except Exception:
- return False
- def _parse_ams_mapping(raw: str | None) -> list[int] | None:
- if not raw:
- return None
- try:
- parsed = json.loads(raw)
- except (json.JSONDecodeError, TypeError):
- return None
- if not isinstance(parsed, list):
- return None
- return [v for v in parsed if isinstance(v, int)]
- async def compute_deficit_for_queue_item(
- db: AsyncSession,
- item: PrintQueueItem,
- ) -> list[FilamentDeficit]:
- """Return per-slot filament shortfalls for ``item``, or [] when it's safe to dispatch.
- Returns an empty list whenever any of the following hold:
- * The ``disable_filament_warnings`` setting is on.
- * The item has no resolved ``printer_id`` (model-based assignment not
- yet picked a printer — the scheduler re-runs the check after it does).
- * No source 3MF is available, or the 3MF carries no per-slot
- requirements (treated as "nothing to verify" rather than an error,
- matching the PrintModal behaviour).
- * No AMS mapping is set yet — the scheduler computes the mapping just
- before dispatch; until it does we cannot map slot → tray.
- * Spoolman mode is on but the Spoolman server is unreachable. We do not
- wedge the queue on a network blip.
- """
- if await _warnings_disabled(db):
- return []
- if item.printer_id is None:
- return []
- # Refresh the relationships we need without assuming the caller eagerly
- # loaded them — both the route and the scheduler call this from contexts
- # with different loading strategies.
- refreshed = await db.execute(
- select(PrintQueueItem)
- .options(
- selectinload(PrintQueueItem.archive),
- selectinload(PrintQueueItem.library_file),
- )
- .where(PrintQueueItem.id == item.id)
- )
- item = refreshed.scalar_one_or_none() or item
- source_path = _resolve_source_3mf(item)
- if source_path is None or not source_path.exists():
- return []
- requirements = extract_filament_requirements(source_path, item.plate_id)
- if not requirements:
- return []
- mapping = _parse_ams_mapping(item.ams_mapping)
- if not mapping:
- return []
- spoolman_mode = await _is_spoolman_mode(db)
- deficits: list[FilamentDeficit] = []
- for req in requirements:
- slot_id = req.get("slot_id")
- used_grams = req.get("used_grams")
- if not isinstance(slot_id, int) or slot_id <= 0:
- continue
- if not isinstance(used_grams, (int, float)) or used_grams <= 0:
- continue
- idx = slot_id - 1
- if idx >= len(mapping):
- continue
- global_tray_id = mapping[idx]
- if global_tray_id is None or global_tray_id < 0:
- continue
- ams_id, tray_id = _global_to_ams_key(global_tray_id)
- remaining: float | None = None
- if spoolman_mode:
- sm_result = await db.execute(
- select(SpoolmanSlotAssignment).where(
- SpoolmanSlotAssignment.printer_id == item.printer_id,
- SpoolmanSlotAssignment.ams_id == ams_id,
- SpoolmanSlotAssignment.tray_id == tray_id,
- )
- )
- sm_assignment = sm_result.scalar_one_or_none()
- if sm_assignment is None:
- continue
- remaining = await _spoolman_remaining_grams(sm_assignment.spoolman_spool_id)
- else:
- internal_result = await db.execute(
- select(SpoolAssignment)
- .options(selectinload(SpoolAssignment.spool))
- .where(
- SpoolAssignment.printer_id == item.printer_id,
- SpoolAssignment.ams_id == ams_id,
- SpoolAssignment.tray_id == tray_id,
- )
- )
- assignment = internal_result.scalar_one_or_none()
- if assignment is None or assignment.spool is None:
- continue
- spool = assignment.spool
- label_weight = float(spool.label_weight or 0)
- weight_used = float(spool.weight_used or 0)
- if label_weight <= 0:
- continue
- remaining = max(0.0, label_weight - weight_used)
- if remaining is None:
- # Spoolman unreachable for this spool — skip rather than block.
- continue
- if remaining >= float(used_grams):
- continue
- deficits.append(
- FilamentDeficit(
- slot_id=slot_id,
- ams_id=ams_id,
- tray_id=tray_id,
- filament_type=str(req.get("type", "")),
- required_grams=float(used_grams),
- remaining_grams=remaining,
- )
- )
- return deficits
- # Re-export the most useful pieces for callers that just want the data.
- __all__ = [
- "FilamentDeficit",
- "compute_deficit_for_queue_item",
- ]
|