| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346 |
- """Price a print from its spools when it starts, not only when it ends (#3261).
- ``archive.py`` prices an archive at creation from the built-in Filament catalogue
- matched on the primary type, falling back to the global default rate. The
- spools that actually feed the print only price it at completion -- the
- built-in inventory in ``usage_tracker.on_print_complete``, Spoolman in
- ``spoolman_tracking._apply_spool_cost_to_archive`` (#2591). In between, the
- archive card showed the placeholder, and anyone writing the cost down while a
- long print ran recorded a number that later changed without notice.
- This runs the completion-time pricing once at print start, on the 3MF's
- per-slot estimates instead of charged grams: each slot is priced at the spool
- in the tray it is mapped to, grams no spool could price are covered at the
- default rate, and the result replaces the placeholder. The figure is still an
- estimate -- a failed or stopped print ends up cheaper -- and the archive card
- says so while the print runs.
- Only mappings that are trustworthy at print start are used: the print
- command's own ``ams_mapping``, the queue item's stored mapping, and a colour
- match against the trays loaded right now. The printer's ``mapping`` field is
- deliberately not read here: it still describes the previous job until the
- printer pushes an update (see ``spoolman_tracking._resolve_slot_to_tray_fallback``).
- With no trustworthy mapping, nothing is priced and the placeholder stays.
- Strictly best-effort. Nothing here may fail a print start.
- """
- import asyncio
- import json
- import logging
- from sqlalchemy import func, select
- from sqlalchemy.ext.asyncio import AsyncSession
- from backend.app.core.config import settings as app_settings
- logger = logging.getLogger(__name__)
- _EXTERNAL_TRAY_IDS = (254, 255)
- def _mapped_tray(slot_id: int, slot_to_tray: list, ams_trays: dict[int, dict]) -> int | None:
- """The global tray a 1-based slot is mapped to, or None if the mapping doesn't say.
- Unlike ``spoolman_tracking._resolve_global_tray_id`` there is no positional
- default: a guess would price the slot at an unrelated spool, and the
- default rate is the honest answer for a slot nobody mapped.
- """
- if slot_id <= 0 or slot_id > len(slot_to_tray):
- return None
- tray = slot_to_tray[slot_id - 1]
- if not isinstance(tray, int) or isinstance(tray, bool):
- return None
- if tray >= 0:
- return tray
- if tray == -1:
- # The flat ams_mapping writes the external spool as -1 (see
- # _resolve_global_tray_id).
- for ext_id in _EXTERNAL_TRAY_IDS:
- if ext_id in ams_trays:
- return ext_id
- return None
- async def _default_cost_per_kg(db: AsyncSession) -> float:
- from backend.app.api.routes.settings import get_setting
- try:
- value = await get_setting(db, "default_filament_cost")
- return float(value) if value else 25.0
- except (TypeError, ValueError):
- return 25.0
- async def _internal_rates(
- db: AsyncSession, printer_id: int, trays: set[int], default_cost_per_kg: float
- ) -> dict[int, float]:
- """Price per gram of the built-in inventory spool in each tray.
- Same rule as ``usage_tracker``'s completion writer: the spool's own
- ``cost_per_kg``, or the default rate when the spool has none.
- """
- from backend.app.models.spool import Spool
- from backend.app.models.spool_assignment import SpoolAssignment
- from backend.app.services.spoolman_tracking import _global_tray_id_to_ams_slot
- rates: dict[int, float] = {}
- for tray in trays:
- ams_id, tray_id = _global_tray_id_to_ams_slot(tray)
- spool = (
- await db.execute(
- select(Spool)
- .join(SpoolAssignment, SpoolAssignment.spool_id == Spool.id)
- .where(
- SpoolAssignment.printer_id == printer_id,
- SpoolAssignment.ams_id == ams_id,
- SpoolAssignment.tray_id == tray_id,
- )
- )
- ).scalar_one_or_none() # one assignment per tray (UniqueConstraint)
- if spool is None:
- continue
- cost_per_kg = spool.cost_per_kg if spool.cost_per_kg is not None else default_cost_per_kg
- rates[tray] = cost_per_kg / 1000.0
- return rates
- async def _spoolman_rates(printer_id: int, trays: set[int], ams_trays: dict[int, dict]) -> dict[int, float]:
- """Price per gram of the Spoolman spool in each tray.
- Resolves the spool the way usage reporting does (tag first, then the local
- slot assignment, #1459) and prices it with ``_spool_cost_per_gram``, so the
- estimate and the completion figure read the same price fields.
- """
- from backend.app.services.spoolman import get_spoolman_client
- from backend.app.services.spoolman_tracking import (
- _get_printer_serial,
- _global_tray_id_to_ams_slot,
- _resolve_spool_id_via_slot_assignment,
- _resolve_spool_tag,
- _spool_cost_per_gram,
- )
- # The client the integration already set up. No health check: that's an
- # extra request per print, and its failure log says usage reporting is
- # being skipped. An unreachable Spoolman fails per tray below instead.
- client = await get_spoolman_client()
- if client is None:
- return {}
- serial = await _get_printer_serial(printer_id)
- rates: dict[int, float] = {}
- for tray in trays:
- spool = None
- try:
- tray_info = ams_trays.get(tray)
- if tray_info:
- tag = _resolve_spool_tag(tray_info, serial, tray)
- if tag:
- spool = await client.find_spool_by_tag(tag)
- if spool is None:
- ams_id, tray_id = _global_tray_id_to_ams_slot(tray)
- spool_id = await _resolve_spool_id_via_slot_assignment(printer_id, ams_id, tray_id)
- if spool_id is not None:
- spool = await client.get_spool(spool_id)
- except Exception as exc: # noqa: BLE001 -- one unreachable spool leaves its slot at the default rate
- logger.debug("[COST] Tray %s: could not fetch the Spoolman spool: %s", tray, exc)
- continue
- rate = _spool_cost_per_gram(spool)
- if rate is not None:
- rates[tray] = rate
- return rates
- def schedule_archive_cost_estimate(
- printer_id: int,
- archive_id: int,
- printer_manager,
- ams_mapping: list | None = None,
- plate_id: int | None = None,
- session_factory=None,
- ) -> asyncio.Task | None:
- """Run ``estimate_archive_cost_at_start`` in the background, on its own session.
- Print start must not wait for it: in Spoolman mode it makes a request per
- tray, and a slow Spoolman would hold up everything after it.
- ``session_factory`` is the caller's ``async_session``, so the estimate
- opens its session the same way the rest of the caller does.
- """
- from backend.app.core.tasks import spawn_background_task
- if session_factory is None:
- from backend.app.core.database import async_session as session_factory
- try:
- mapping = list(ams_mapping) if ams_mapping else None
- except TypeError:
- mapping = None
- async def _run() -> None:
- async with session_factory() as db:
- await estimate_archive_cost_at_start(db, printer_id, archive_id, printer_manager, mapping, plate_id)
- try:
- return spawn_background_task(_run(), name=f"archive-cost-estimate-{archive_id}")
- except Exception: # noqa: BLE001 -- an estimate must never fail a print start
- logger.warning("[COST] Archive %s: could not schedule the cost estimate", archive_id, exc_info=True)
- return None
- async def estimate_archive_cost_at_start(
- db: AsyncSession,
- printer_id: int,
- archive_id: int,
- printer_manager,
- ams_mapping: list | None = None,
- plate_id: int | None = None,
- ) -> None:
- """Replace a just-started archive's placeholder cost with a spool-based estimate."""
- try:
- await _estimate(db, printer_id, archive_id, printer_manager, ams_mapping, plate_id)
- except Exception: # noqa: BLE001 -- an estimate must never fail a print start
- logger.warning("[COST] Archive %s: could not estimate the cost at print start", archive_id, exc_info=True)
- async def _estimate(
- db: AsyncSession,
- printer_id: int,
- archive_id: int,
- printer_manager,
- ams_mapping: list | None,
- plate_id: int | None,
- ) -> None:
- from backend.app.api.routes.settings import get_setting
- from backend.app.models.archive import PrintArchive
- from backend.app.models.print_log import PrintLogEntry
- from backend.app.models.print_queue import PrintQueueItem
- from backend.app.services.spoolman_tracking import build_ams_tray_lookup
- from backend.app.services.usage_tracker import _match_slots_by_color
- from backend.app.utils.threemf_tools import extract_filament_usage_from_3mf
- archive = (await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))).scalar_one_or_none()
- if archive is None or not archive.file_path or not archive.filament_used_grams:
- return
- # A reprint keeps its first run's cost on the card (#1378); completion
- # doesn't overwrite it either.
- existing_runs = (
- await db.execute(select(func.count(PrintLogEntry.id)).where(PrintLogEntry.archive_id == archive_id))
- ).scalar()
- if existing_runs:
- return
- full_path = (
- app_settings.base_dir / archive.file_path
- ) # SEC-PATH-OK: archive.file_path is DB-stored, internally generated
- if not full_path.exists():
- return
- queue_item = (
- (
- await db.execute(
- select(PrintQueueItem)
- .where(PrintQueueItem.archive_id == archive_id)
- .where(PrintQueueItem.printer_id == printer_id)
- .where(PrintQueueItem.status == "printing")
- )
- )
- .scalars()
- .first()
- )
- if plate_id is None and queue_item is not None:
- plate_id = queue_item.plate_id
- filament_usage = extract_filament_usage_from_3mf(full_path, plate_id) or []
- used = [(u.get("slot_id", 0), u.get("used_g", 0) or 0) for u in filament_usage]
- used = [(slot, grams) for slot, grams in used if grams > 0]
- if not used:
- return
- state = printer_manager.get_status(printer_id)
- raw_data = getattr(state, "raw_data", None) or {}
- ams_trays = build_ams_tray_lookup(raw_data)
- slot_to_tray = ams_mapping or None
- source = "print_cmd" if slot_to_tray else None
- if not slot_to_tray and queue_item is not None and queue_item.ams_mapping:
- try:
- slot_to_tray = json.loads(queue_item.ams_mapping)
- source = "queue"
- except json.JSONDecodeError:
- slot_to_tray = None
- if not slot_to_tray:
- slot_to_tray = _match_slots_by_color(filament_usage, raw_data.get("ams"))
- source = "color_match" if slot_to_tray else None
- if not slot_to_tray:
- logger.info("[COST] Archive %s: no slot-to-tray mapping at print start, keeping the placeholder", archive_id)
- return
- slot_trays = {slot: _mapped_tray(slot, slot_to_tray, ams_trays) for slot, _ in used}
- trays = {tray for tray in slot_trays.values() if tray is not None}
- if not trays:
- return
- default_cost_per_kg = await _default_cost_per_kg(db)
- spoolman_enabled = (await get_setting(db, "spoolman_enabled") or "").lower() == "true"
- if spoolman_enabled:
- rates = await _spoolman_rates(printer_id, trays, ams_trays)
- else:
- rates = await _internal_rates(db, printer_id, trays, default_cost_per_kg)
- cost = 0.0
- priced_grams = 0.0
- for slot, grams in used:
- rate = rates.get(slot_trays[slot])
- if rate is None:
- continue
- cost += grams * rate
- priced_grams += grams
- if priced_grams <= 0:
- return
- # Grams no spool priced -- unmapped slots, empty trays, unpriced spools, and
- # anything the 3MF didn't attribute to a slot -- at the default rate, in
- # one subtraction against the archive's own total, as both completion
- # writers do.
- unpriced_grams = max(0.0, archive.filament_used_grams - priced_grams)
- if unpriced_grams > 0 and default_cost_per_kg > 0:
- cost += (unpriced_grams / 1000.0) * default_cost_per_kg
- if cost <= 0:
- return
- # The Spoolman lookups above can take a while. If the print already ended
- # -- a failure seconds in -- completion has written the real figure, and
- # an estimate must not replace it.
- await db.refresh(archive)
- still_running_first_run = (
- archive.status == "printing"
- and not (
- await db.execute(select(func.count(PrintLogEntry.id)).where(PrintLogEntry.archive_id == archive_id))
- ).scalar()
- )
- if not still_running_first_run:
- return
- new_cost = round(cost, 2)
- if new_cost == archive.cost:
- return
- logger.info(
- "[COST] Archive %s: estimated cost %s -> %s at print start (%.2fg priced from %s spools via %s mapping, "
- "%.2fg at the default rate)",
- archive_id,
- archive.cost,
- new_cost,
- priced_grams,
- "Spoolman" if spoolman_enabled else "inventory",
- source,
- unpriced_grams,
- )
- archive.cost = new_cost
- await db.commit()
- from backend.app.core.websocket import ws_manager
- await ws_manager.send_archive_updated({"id": archive_id, "cost": new_cost})
|