| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246 |
- """A spool swapped on one tray while a print runs (#3325).
- The tray-change log (``utils.tray_split``) splits a print across trays when the
- AMS switches to a backup on a runout. A spool replaced by hand in the *same*
- tray leaves no trace there: the external spool on a printer without an AMS is
- tray 254 before and after, and so is an AMS slot refilled with a new roll. The
- only record of the swap is the operator assigning the new spool, and without a
- layer to put it at, completion charged the whole print to whichever spool the
- tray held at the end.
- So a print keeps a second log: the spool on every assigned tray at print start,
- and every later assignment of a different spool to a tray, with the layer the
- printer was on. At completion a tray's grams are divided between its spools in
- proportion to the filament the G-code feeds between those layers (by layer
- count without G-code data). Proportional, so the shares always add up to the
- tray's total and the last spool is never left with nothing.
- A tray with no spool at print start is not split by its first assignment:
- that is the operator catching up on a forgotten one, and that spool takes the
- print from its start, as before. Only a later, different spool splits it.
- The log lives on ``PrinterState`` (Spoolman reports after the print session
- row is gone) and is mirrored to ``active_print_sessions`` for a restart.
- """
- from __future__ import annotations
- import logging
- from typing import Any
- from sqlalchemy import event
- from backend.app.utils import threemf_tools
- logger = logging.getLogger(__name__)
- INVENTORY = "inventory"
- SPOOLMAN = "spoolman"
- def entry(backend: str, ams_id: int, tray_id: int, spool_id: int, layer: int, start: bool = False) -> dict:
- return {"backend": backend, "ams": ams_id, "tray": tray_id, "spool": spool_id, "layer": layer, "start": start}
- def start_entries(backend: str, assignments: dict[tuple[int, int], int]) -> list[dict]:
- """The spool on every assigned tray as the print starts."""
- return [
- entry(backend, ams_id, tray_id, spool_id, 0, start=True) for (ams_id, tray_id), spool_id in assignments.items()
- ]
- def _valid(item) -> bool:
- return isinstance(item, dict) and {"backend", "ams", "tray", "spool", "layer"} <= item.keys()
- def segments(log: list | None, backend: str, ams_id: int, tray_id: int) -> list[tuple[int, int]]:
- """``[(spool_id, from_layer), ...]`` for one tray, or ``[]`` when it was not swapped.
- On a tray that had no spool at print start, the first assignment is a
- forgotten one caught up: that spool covers the print from its start, and
- only a later, different spool is a swap. Repeats of the same spool
- collapse; a swap back to an earlier spool is its own segment.
- """
- items = [
- item
- for item in (log or [])
- if _valid(item) and item["backend"] == backend and item["ams"] == ams_id and item["tray"] == tray_id
- ]
- result: list[tuple[int, int]] = []
- for item in items:
- if result and result[-1][0] == item["spool"]:
- continue
- result.append((item["spool"], max(0, int(item["layer"] or 0)) if result else 0))
- return result if len(result) > 1 else []
- def split(
- segs: list[tuple[int, int]],
- grams: float,
- filament_id: int | None,
- layer_usage: dict[int, dict[int, float]] | None,
- end_layer: int,
- from_layer: int = 0,
- ) -> list[tuple[int, float]]:
- """Divide ``grams`` fed between ``from_layer`` and ``end_layer`` among the spools of ``segs``.
- Returns ``[(spool_id, grams), ...]`` in order, one entry per spool, the
- shares summing to ``grams``. With nothing to weigh by (no G-code data, no
- layer range) everything goes to the spool loaded at ``end_layer``.
- """
- end_layer = max(end_layer, from_layer)
- bounds: list[tuple[int, int]] = [] # (spool_id, from_layer) restricted to the range
- for spool_id, layer in segs:
- if layer <= from_layer:
- bounds = [(spool_id, from_layer)]
- elif layer < end_layer:
- bounds.append((spool_id, layer))
- if not bounds:
- bounds = [(segs[-1][0], from_layer)]
- edges = [layer for _, layer in bounds] + [end_layer]
- weights: list[float] = []
- if layer_usage and filament_id is not None:
- def mm_at(layer: int) -> float:
- return threemf_tools.get_cumulative_usage_at_layer(layer_usage, layer).get(filament_id, 0.0)
- weights = [max(0.0, mm_at(edges[i + 1]) - mm_at(edges[i])) for i in range(len(bounds))]
- if sum(weights) <= 0:
- weights = [float(edges[i + 1] - edges[i]) for i in range(len(bounds))]
- total = sum(weights)
- shares: dict[int, float] = {}
- if total <= 0:
- shares[bounds[-1][0]] = grams
- else:
- for (spool_id, _), weight in zip(bounds, weights, strict=True):
- shares[spool_id] = shares.get(spool_id, 0.0) + grams * weight / total
- return list(shares.items())
- class Splitter:
- """One print's swap log, ready to share a tray's grams out (one per backend).
- ``layer_usage`` is the G-code's per-layer usage, or a callable returning it,
- read only when a tray was actually swapped. ``end_layer`` is where the
- print stopped; for a completed print the G-code's last layer wins over it.
- """
- def __init__(self, log: list | None, backend: str, layer_usage, end_layer: int, completed: bool) -> None:
- self.log = list(log or [])
- self.backend = backend
- # The per-layer usage, or until first read, the callable that returns it.
- self._layer_usage: Any = layer_usage
- self.end_layer = end_layer
- self.completed = completed
- def _usage(self) -> dict[int, dict[int, float]] | None:
- if callable(self._layer_usage):
- try:
- self._layer_usage = self._layer_usage()
- except Exception: # noqa: BLE001 -- the split falls back to layer counts
- logger.debug("[SPOOL-SWAP] No per-layer usage for the split", exc_info=True)
- self._layer_usage = None
- return self._layer_usage
- def swapped(self, ams_id: int, tray_id: int) -> bool:
- return bool(segments(self.log, self.backend, ams_id, tray_id))
- def shares(
- self,
- ams_id: int,
- tray_id: int,
- slot_id: int,
- grams: float,
- from_layer: int = 0,
- to_layer: int | None = None,
- ) -> list[tuple[int, float]] | None:
- """``[(spool_id, grams), ...]`` for a swapped tray, None for one that kept its spool."""
- segs = segments(self.log, self.backend, ams_id, tray_id)
- if not segs:
- return None
- usage = self._usage()
- if to_layer is not None:
- end = to_layer
- elif self.completed and usage:
- end = max(usage)
- else:
- end = self.end_layer
- result = split(segs, grams, slot_id - 1, usage, end, from_layer)
- logger.info(
- "[SPOOL-SWAP] %s AMS%d-T%d swapped during the print %s: %.2fg split %s",
- self.backend,
- ams_id,
- tray_id,
- segs,
- grams,
- [(spool, round(share, 2)) for spool, share in result],
- )
- return result
- def _on_commit(db, apply) -> None:
- """Run ``apply`` once ``db`` commits; never if it rolls back first."""
- sync = getattr(db, "sync_session", None)
- if sync is None:
- apply()
- return
- settled: list[bool] = []
- def committed(_session) -> None:
- if not settled:
- settled.append(True)
- apply()
- def rolled_back(_session) -> None:
- settled.append(False)
- event.listen(sync, "after_commit", committed, once=True)
- event.listen(sync, "after_rollback", rolled_back, once=True)
- async def record_assignment(db, printer_id: int, ams_id: int, tray_id: int, spool_id: int, backend: str) -> None:
- """Note that ``spool_id`` was assigned to a tray, if that printer is printing.
- Runs on the caller's session, before the caller commits the assignment:
- a second session would wait on the SQLite write lock the assignment holds.
- The log is the persisted row's merged with ``PrinterState``'s: after a
- restart the state is empty until the printer reports RUNNING again, and a
- runout leaves it paused, so the row alone holds the print's start spools.
- ``PrinterState`` takes the new entry only once the assignment commits; one
- that fails must not split the print. Best-effort: an assignment must never
- fail because of this.
- """
- try:
- from backend.app.models.active_print_session import ActivePrintSession
- from backend.app.services.printer_manager import printer_manager
- state = printer_manager.get_status(printer_id)
- if state is None:
- return
- # The row exists from print start to completion: a print Bambuddy is
- # tracking is running.
- row = await db.get(ActivePrintSession, printer_id)
- if row is None:
- return
- log = [item for item in (row.spool_change_log or []) if _valid(item)]
- log += [item for item in (getattr(state, "spool_change_log", None) or []) if _valid(item) and item not in log]
- same_tray = [i for i in log if _valid(i) and (i["backend"], i["ams"], i["tray"]) == (backend, ams_id, tray_id)]
- if same_tray and same_tray[-1]["spool"] == spool_id:
- return
- layer = int(getattr(state, "layer_num", 0) or 0)
- log.append(entry(backend, ams_id, tray_id, spool_id, layer))
- row.spool_change_log = list(log)
- _on_commit(db, lambda: setattr(state, "spool_change_log", log))
- logger.info(
- "[SPOOL-SWAP] Printer %d %s AMS%d-T%d: spool %d assigned at layer %d during the print",
- printer_id,
- backend,
- ams_id,
- tray_id,
- spool_id,
- layer,
- )
- except Exception: # noqa: BLE001 -- never fail an assignment over its usage note
- logger.warning("[SPOOL-SWAP] Could not record a mid-print assignment on printer %d", printer_id, exc_info=True)
|