Files
bambuddy/backend/app/services/filament_deficit.py
T
maziggy 7ea4410b21 fix(queue): insufficient-filament warning now fires on every dispatch path (#1496)
The pre-print deficit warning from #720 only ran inside the PrintModal
  submit flow. Both the green ▶ button on a staged queue row (POST
  /queue/{id}/start) and the Virtual Printer queue-mode intake bypassed
  it — auto_dispatch=True VP intakes would dispatch unsupervised onto
  spools that physically can't complete the print.

  Extracted the deficit check into backend/app/services/filament_deficit.py
  (single source of truth, both internal inventory and Spoolman modes).
  POST /queue/{id}/start returns 409 with a structured deficit payload
  unless ?skip_filament_check=true. The dispatch scheduler runs the same
  check before each _start_print; a deficit promotes the item to
  manual_start + sets a new filament_short flag (idempotent migration on
  print_queue). The flag clears automatically on the next tick when the
  operator swaps a spool to one with enough material.

  Frontend ▶ catches the 409 and opens a confirm modal showing each
  shorted slot's required vs remaining grams; the row now renders a
  yellow "Insufficient filament" badge when filament_short is set.
  Translated across all 9 locales.
2026-05-23 08:47:46 +02:00

288 lines
10 KiB
Python

"""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",
]