spool_swaps.py 9.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246
  1. """A spool swapped on one tray while a print runs (#3325).
  2. The tray-change log (``utils.tray_split``) splits a print across trays when the
  3. AMS switches to a backup on a runout. A spool replaced by hand in the *same*
  4. tray leaves no trace there: the external spool on a printer without an AMS is
  5. tray 254 before and after, and so is an AMS slot refilled with a new roll. The
  6. only record of the swap is the operator assigning the new spool, and without a
  7. layer to put it at, completion charged the whole print to whichever spool the
  8. tray held at the end.
  9. So a print keeps a second log: the spool on every assigned tray at print start,
  10. and every later assignment of a different spool to a tray, with the layer the
  11. printer was on. At completion a tray's grams are divided between its spools in
  12. proportion to the filament the G-code feeds between those layers (by layer
  13. count without G-code data). Proportional, so the shares always add up to the
  14. tray's total and the last spool is never left with nothing.
  15. A tray with no spool at print start is not split by its first assignment:
  16. that is the operator catching up on a forgotten one, and that spool takes the
  17. print from its start, as before. Only a later, different spool splits it.
  18. The log lives on ``PrinterState`` (Spoolman reports after the print session
  19. row is gone) and is mirrored to ``active_print_sessions`` for a restart.
  20. """
  21. from __future__ import annotations
  22. import logging
  23. from typing import Any
  24. from sqlalchemy import event
  25. from backend.app.utils import threemf_tools
  26. logger = logging.getLogger(__name__)
  27. INVENTORY = "inventory"
  28. SPOOLMAN = "spoolman"
  29. def entry(backend: str, ams_id: int, tray_id: int, spool_id: int, layer: int, start: bool = False) -> dict:
  30. return {"backend": backend, "ams": ams_id, "tray": tray_id, "spool": spool_id, "layer": layer, "start": start}
  31. def start_entries(backend: str, assignments: dict[tuple[int, int], int]) -> list[dict]:
  32. """The spool on every assigned tray as the print starts."""
  33. return [
  34. entry(backend, ams_id, tray_id, spool_id, 0, start=True) for (ams_id, tray_id), spool_id in assignments.items()
  35. ]
  36. def _valid(item) -> bool:
  37. return isinstance(item, dict) and {"backend", "ams", "tray", "spool", "layer"} <= item.keys()
  38. def segments(log: list | None, backend: str, ams_id: int, tray_id: int) -> list[tuple[int, int]]:
  39. """``[(spool_id, from_layer), ...]`` for one tray, or ``[]`` when it was not swapped.
  40. On a tray that had no spool at print start, the first assignment is a
  41. forgotten one caught up: that spool covers the print from its start, and
  42. only a later, different spool is a swap. Repeats of the same spool
  43. collapse; a swap back to an earlier spool is its own segment.
  44. """
  45. items = [
  46. item
  47. for item in (log or [])
  48. if _valid(item) and item["backend"] == backend and item["ams"] == ams_id and item["tray"] == tray_id
  49. ]
  50. result: list[tuple[int, int]] = []
  51. for item in items:
  52. if result and result[-1][0] == item["spool"]:
  53. continue
  54. result.append((item["spool"], max(0, int(item["layer"] or 0)) if result else 0))
  55. return result if len(result) > 1 else []
  56. def split(
  57. segs: list[tuple[int, int]],
  58. grams: float,
  59. filament_id: int | None,
  60. layer_usage: dict[int, dict[int, float]] | None,
  61. end_layer: int,
  62. from_layer: int = 0,
  63. ) -> list[tuple[int, float]]:
  64. """Divide ``grams`` fed between ``from_layer`` and ``end_layer`` among the spools of ``segs``.
  65. Returns ``[(spool_id, grams), ...]`` in order, one entry per spool, the
  66. shares summing to ``grams``. With nothing to weigh by (no G-code data, no
  67. layer range) everything goes to the spool loaded at ``end_layer``.
  68. """
  69. end_layer = max(end_layer, from_layer)
  70. bounds: list[tuple[int, int]] = [] # (spool_id, from_layer) restricted to the range
  71. for spool_id, layer in segs:
  72. if layer <= from_layer:
  73. bounds = [(spool_id, from_layer)]
  74. elif layer < end_layer:
  75. bounds.append((spool_id, layer))
  76. if not bounds:
  77. bounds = [(segs[-1][0], from_layer)]
  78. edges = [layer for _, layer in bounds] + [end_layer]
  79. weights: list[float] = []
  80. if layer_usage and filament_id is not None:
  81. def mm_at(layer: int) -> float:
  82. return threemf_tools.get_cumulative_usage_at_layer(layer_usage, layer).get(filament_id, 0.0)
  83. weights = [max(0.0, mm_at(edges[i + 1]) - mm_at(edges[i])) for i in range(len(bounds))]
  84. if sum(weights) <= 0:
  85. weights = [float(edges[i + 1] - edges[i]) for i in range(len(bounds))]
  86. total = sum(weights)
  87. shares: dict[int, float] = {}
  88. if total <= 0:
  89. shares[bounds[-1][0]] = grams
  90. else:
  91. for (spool_id, _), weight in zip(bounds, weights, strict=True):
  92. shares[spool_id] = shares.get(spool_id, 0.0) + grams * weight / total
  93. return list(shares.items())
  94. class Splitter:
  95. """One print's swap log, ready to share a tray's grams out (one per backend).
  96. ``layer_usage`` is the G-code's per-layer usage, or a callable returning it,
  97. read only when a tray was actually swapped. ``end_layer`` is where the
  98. print stopped; for a completed print the G-code's last layer wins over it.
  99. """
  100. def __init__(self, log: list | None, backend: str, layer_usage, end_layer: int, completed: bool) -> None:
  101. self.log = list(log or [])
  102. self.backend = backend
  103. # The per-layer usage, or until first read, the callable that returns it.
  104. self._layer_usage: Any = layer_usage
  105. self.end_layer = end_layer
  106. self.completed = completed
  107. def _usage(self) -> dict[int, dict[int, float]] | None:
  108. if callable(self._layer_usage):
  109. try:
  110. self._layer_usage = self._layer_usage()
  111. except Exception: # noqa: BLE001 -- the split falls back to layer counts
  112. logger.debug("[SPOOL-SWAP] No per-layer usage for the split", exc_info=True)
  113. self._layer_usage = None
  114. return self._layer_usage
  115. def swapped(self, ams_id: int, tray_id: int) -> bool:
  116. return bool(segments(self.log, self.backend, ams_id, tray_id))
  117. def shares(
  118. self,
  119. ams_id: int,
  120. tray_id: int,
  121. slot_id: int,
  122. grams: float,
  123. from_layer: int = 0,
  124. to_layer: int | None = None,
  125. ) -> list[tuple[int, float]] | None:
  126. """``[(spool_id, grams), ...]`` for a swapped tray, None for one that kept its spool."""
  127. segs = segments(self.log, self.backend, ams_id, tray_id)
  128. if not segs:
  129. return None
  130. usage = self._usage()
  131. if to_layer is not None:
  132. end = to_layer
  133. elif self.completed and usage:
  134. end = max(usage)
  135. else:
  136. end = self.end_layer
  137. result = split(segs, grams, slot_id - 1, usage, end, from_layer)
  138. logger.info(
  139. "[SPOOL-SWAP] %s AMS%d-T%d swapped during the print %s: %.2fg split %s",
  140. self.backend,
  141. ams_id,
  142. tray_id,
  143. segs,
  144. grams,
  145. [(spool, round(share, 2)) for spool, share in result],
  146. )
  147. return result
  148. def _on_commit(db, apply) -> None:
  149. """Run ``apply`` once ``db`` commits; never if it rolls back first."""
  150. sync = getattr(db, "sync_session", None)
  151. if sync is None:
  152. apply()
  153. return
  154. settled: list[bool] = []
  155. def committed(_session) -> None:
  156. if not settled:
  157. settled.append(True)
  158. apply()
  159. def rolled_back(_session) -> None:
  160. settled.append(False)
  161. event.listen(sync, "after_commit", committed, once=True)
  162. event.listen(sync, "after_rollback", rolled_back, once=True)
  163. async def record_assignment(db, printer_id: int, ams_id: int, tray_id: int, spool_id: int, backend: str) -> None:
  164. """Note that ``spool_id`` was assigned to a tray, if that printer is printing.
  165. Runs on the caller's session, before the caller commits the assignment:
  166. a second session would wait on the SQLite write lock the assignment holds.
  167. The log is the persisted row's merged with ``PrinterState``'s: after a
  168. restart the state is empty until the printer reports RUNNING again, and a
  169. runout leaves it paused, so the row alone holds the print's start spools.
  170. ``PrinterState`` takes the new entry only once the assignment commits; one
  171. that fails must not split the print. Best-effort: an assignment must never
  172. fail because of this.
  173. """
  174. try:
  175. from backend.app.models.active_print_session import ActivePrintSession
  176. from backend.app.services.printer_manager import printer_manager
  177. state = printer_manager.get_status(printer_id)
  178. if state is None:
  179. return
  180. # The row exists from print start to completion: a print Bambuddy is
  181. # tracking is running.
  182. row = await db.get(ActivePrintSession, printer_id)
  183. if row is None:
  184. return
  185. log = [item for item in (row.spool_change_log or []) if _valid(item)]
  186. log += [item for item in (getattr(state, "spool_change_log", None) or []) if _valid(item) and item not in log]
  187. same_tray = [i for i in log if _valid(i) and (i["backend"], i["ams"], i["tray"]) == (backend, ams_id, tray_id)]
  188. if same_tray and same_tray[-1]["spool"] == spool_id:
  189. return
  190. layer = int(getattr(state, "layer_num", 0) or 0)
  191. log.append(entry(backend, ams_id, tray_id, spool_id, layer))
  192. row.spool_change_log = list(log)
  193. _on_commit(db, lambda: setattr(state, "spool_change_log", log))
  194. logger.info(
  195. "[SPOOL-SWAP] Printer %d %s AMS%d-T%d: spool %d assigned at layer %d during the print",
  196. printer_id,
  197. backend,
  198. ams_id,
  199. tray_id,
  200. spool_id,
  201. layer,
  202. )
  203. except Exception: # noqa: BLE001 -- never fail an assignment over its usage note
  204. logger.warning("[SPOOL-SWAP] Could not record a mid-print assignment on printer %d", printer_id, exc_info=True)