filament_deficit.py 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559
  1. """Filament-deficit check used by every queue dispatch path.
  2. The PrintModal warns when an assigned spool can't satisfy a print's per-slot
  3. filament weight (``Pre-print checks now also warn when the spool has
  4. insufficient material`` — #720). That check only runs when the user clicks
  5. "Print" inside PrintModal; ``QueuePage`` Play button, ``start_queue_item``
  6. route, and the VP intake + scheduler auto-dispatch path all skip it (#1496).
  7. This module is the single source of truth for the check. Both the route
  8. handler (``POST /print-queue/{id}/start``) and the dispatch scheduler call
  9. ``compute_deficit_for_queue_item`` against live spool state.
  10. Design notes:
  11. * The 3MF parser is the same one used by PrintModal: per-slot ``used_grams``
  12. comes from ``extract_filament_requirements`` (#1188's filament-overrides
  13. pipeline) or — when the item points at an unsliced library file — falls
  14. through to the file's archive copy. Anything that yields no requirements
  15. is treated as "no deficit" so a malformed or stripped 3MF never blocks.
  16. * Both internal-inventory and Spoolman modes are covered. Internal mode
  17. resolves via ``SpoolAssignment`` joined to ``Spool`` (``label_weight``
  18. minus ``weight_used``). Spoolman mode resolves via
  19. ``SpoolmanSlotAssignment`` then ``SpoolmanClient.get_spool`` for the live
  20. remaining weight; if Spoolman is unreachable we return no deficit rather
  21. than wedge the queue on a flaky network call.
  22. * The ``disable_filament_warnings`` user setting is respected at the
  23. service boundary — callers do not have to know about it.
  24. """
  25. from __future__ import annotations
  26. import json
  27. import logging
  28. from collections import defaultdict
  29. from dataclasses import dataclass
  30. from pathlib import Path
  31. from sqlalchemy import select
  32. from sqlalchemy.ext.asyncio import AsyncSession
  33. from sqlalchemy.orm import selectinload
  34. from backend.app.core.config import settings as app_settings
  35. from backend.app.models.print_queue import PrintQueueItem
  36. from backend.app.models.spool_assignment import SpoolAssignment
  37. from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
  38. from backend.app.services.filament_requirements import extract_filament_requirements
  39. logger = logging.getLogger(__name__)
  40. @dataclass(frozen=True)
  41. class FilamentDeficit:
  42. """One slot's filament shortfall."""
  43. slot_id: int
  44. ams_id: int | None
  45. tray_id: int | None
  46. filament_type: str
  47. required_grams: float
  48. remaining_grams: float | None # None = could not determine
  49. def to_dict(self) -> dict:
  50. return {
  51. "slot_id": self.slot_id,
  52. "ams_id": self.ams_id,
  53. "tray_id": self.tray_id,
  54. "filament_type": self.filament_type,
  55. "required_grams": self.required_grams,
  56. "remaining_grams": self.remaining_grams,
  57. }
  58. def _global_to_ams_key(global_tray_id: int) -> tuple[int, int]:
  59. """Inverse of ``ams_id * 4 + tray_id`` — matches ``usage_tracker``."""
  60. if global_tray_id >= 254:
  61. return (255, global_tray_id - 254)
  62. if global_tray_id >= 128:
  63. return (global_tray_id, 0)
  64. return (global_tray_id // 4, global_tray_id % 4)
  65. def _resolve_source_3mf(item: PrintQueueItem) -> Path | None:
  66. """Locate the 3MF file backing this queue item (archive or library)."""
  67. if item.archive is not None and item.archive.file_path:
  68. return app_settings.base_dir / item.archive.file_path
  69. if item.library_file is not None and item.library_file.file_path:
  70. return Path(item.library_file.file_path)
  71. return None
  72. async def _spoolman_remaining_grams(spoolman_spool_id: int) -> float | None:
  73. """Live remaining grams for a Spoolman spool, or None if unavailable."""
  74. try:
  75. from backend.app.services.spoolman import (
  76. SpoolmanClientError,
  77. SpoolmanNotFoundError,
  78. get_spoolman_client,
  79. )
  80. except ImportError:
  81. return None
  82. try:
  83. client = await get_spoolman_client()
  84. if client is None:
  85. return None
  86. spool = await client.get_spool(spoolman_spool_id)
  87. except (SpoolmanNotFoundError, SpoolmanClientError):
  88. return None
  89. except Exception as e:
  90. logger.debug("Spoolman fetch failed for spool %s: %s", spoolman_spool_id, e)
  91. return None
  92. if not spool:
  93. return None
  94. # Spoolman exposes either an absolute remaining_weight, or used_weight +
  95. # filament.weight. Either is sufficient — prefer remaining_weight when
  96. # present (the user may have overridden it).
  97. remaining = spool.get("remaining_weight")
  98. if isinstance(remaining, (int, float)) and remaining >= 0:
  99. return float(remaining)
  100. used = spool.get("used_weight")
  101. filament = spool.get("filament") or {}
  102. total = filament.get("weight")
  103. if isinstance(used, (int, float)) and isinstance(total, (int, float)) and total > 0:
  104. return max(0.0, float(total) - float(used))
  105. return None
  106. async def _is_spoolman_mode(db: AsyncSession) -> bool:
  107. """Check whether the user has opted in to Spoolman inventory mode."""
  108. try:
  109. from backend.app.api.routes.settings import get_setting
  110. spoolman_enabled = await get_setting(db, "spoolman_enabled")
  111. return bool(spoolman_enabled) and spoolman_enabled.lower() == "true"
  112. except Exception:
  113. return False
  114. async def _warnings_disabled(db: AsyncSession) -> bool:
  115. """Honour the ``disable_filament_warnings`` setting (#720)."""
  116. try:
  117. from backend.app.api.routes.settings import get_setting
  118. disabled = await get_setting(db, "disable_filament_warnings")
  119. return bool(disabled) and disabled.lower() == "true"
  120. except Exception:
  121. return False
  122. def _normalize_color_for_id(raw: str | None) -> str:
  123. """Canonicalise a hex colour for identity comparison.
  124. Strips the leading ``#``, uppercases, and drops the alpha channel when
  125. the hex is 8 chars long (``RRGGBBAA``) so a fully-opaque 8-char hex
  126. matches a 6-char hex of the same RGB. Empty / None → empty string.
  127. """
  128. s = (raw or "").strip().lstrip("#").upper()
  129. if len(s) == 8: # RRGGBBAA → strip alpha
  130. s = s[:6]
  131. return s
  132. def _material_identity_internal(spool) -> str:
  133. """Strict same-material key for backup-peer matching in internal mode.
  134. Requires a Bambu filament preset ID (``slicer_filament``, e.g. ``GFA00``)
  135. AND a matching colour. The preset identifies the filament profile (PETG
  136. HF, PLA Basic, etc.) — same hot-end behaviour — but the firmware's
  137. switch logic also requires the spool to be the same colour (otherwise
  138. every PETG HF spool would back every other PETG HF spool regardless of
  139. colour, which would dye prints mid-run). Spools without a preset
  140. (user-tagged / non-Bambu) get a per-spool unique key so they NEVER
  141. pair with anything else; without the Bambu preset the firmware can't
  142. trust the backup decision.
  143. """
  144. preset = (spool.slicer_filament or "").strip() if spool else ""
  145. if preset:
  146. color = _normalize_color_for_id(spool.rgba if spool else None)
  147. return f"preset:{preset}|color:{color}"
  148. # Unique-per-spool key prevents grouping. Use the spool's primary key so
  149. # the same spool always resolves to the same key within a request.
  150. spool_id = getattr(spool, "id", None) if spool else None
  151. return f"unmatched:{spool_id}"
  152. def _material_identity_spoolman(spool: dict | None) -> str:
  153. """Strict same-material key for backup-peer matching in Spoolman mode.
  154. Two spools pair only when they reference the same Spoolman ``filament``
  155. catalog entry (same ``filament.id``) AND share the same colour. The
  156. catalog entry pins the profile (PETG HF / PLA Basic / ...); the colour
  157. pins the variant. Spools without a resolvable filament id get a
  158. per-spool unique key so they never pair.
  159. """
  160. if not spool:
  161. return "unmatched:none"
  162. filament = spool.get("filament") or {}
  163. fil_id = filament.get("id")
  164. if isinstance(fil_id, (int, str)) and str(fil_id).strip():
  165. # Prefer the per-spool override colour when set (Spoolman lets the user
  166. # tag a spool with a colour distinct from the filament catalog
  167. # default); fall back to the filament catalog colour.
  168. color = _normalize_color_for_id(
  169. (spool.get("color_hex") if isinstance(spool.get("color_hex"), str) else None) or filament.get("color_hex")
  170. )
  171. return f"filament:{fil_id}|color:{color}"
  172. spool_id = spool.get("id")
  173. return f"unmatched:{spool_id}"
  174. def _ams_id_from_global(global_tray_id: int) -> int:
  175. """Inverse of ``_global_to_ams_key`` returning ams_id only."""
  176. return _global_to_ams_key(global_tray_id)[0]
  177. def _extruder_side_for_ams(
  178. ams_id: int,
  179. ams_extruder_map: dict[str, int],
  180. is_dual_extruder: bool,
  181. ) -> int:
  182. """Resolve the extruder index (0=right, 1=left) for a given AMS unit.
  183. Single-extruder printers collapse everything to 0. On dual-extruder
  184. printers (H2D / H2C / X2D), the firmware can't cross extruders even with
  185. AMS Filament Backup ON, so the pool must be scoped per-side.
  186. """
  187. if not is_dual_extruder:
  188. return 0
  189. return int(ams_extruder_map.get(str(ams_id), 0))
  190. def _parse_ams_mapping(raw: str | None) -> list[int] | None:
  191. if not raw:
  192. return None
  193. try:
  194. parsed = json.loads(raw)
  195. except (json.JSONDecodeError, TypeError):
  196. return None
  197. if not isinstance(parsed, list):
  198. return None
  199. return [v for v in parsed if isinstance(v, int)]
  200. async def _get_printer_backup_context(
  201. printer_id: int,
  202. ) -> tuple[bool, dict[str, int], bool]:
  203. """Return ``(backup_on, ams_extruder_map, is_dual_extruder)`` for the printer.
  204. Read from the live MQTT state via ``printer_manager`` (no DB round-trip).
  205. Defaults conservatively to ``backup_on=False`` when the state is missing
  206. or the printer is offline — same fallback as today (per-slot deficit
  207. accounting), so an offline printer is never treated as backup-capable.
  208. """
  209. try:
  210. from backend.app.services.printer_manager import printer_manager
  211. from backend.app.utils.printer_models import is_dual_nozzle_model
  212. except ImportError:
  213. return False, {}, False
  214. state = printer_manager.get_status(printer_id)
  215. if state is None:
  216. return False, {}, False
  217. backup_on = state.ams_filament_backup is True
  218. ams_extruder_map = dict(state.ams_extruder_map or {})
  219. model = printer_manager.get_model(printer_id)
  220. is_dual = bool(model and is_dual_nozzle_model(model))
  221. return backup_on, ams_extruder_map, is_dual
  222. async def compute_deficit_for_queue_item(
  223. db: AsyncSession,
  224. item: PrintQueueItem,
  225. ) -> list[FilamentDeficit]:
  226. """Return per-slot filament shortfalls for ``item``, or [] when it's safe to dispatch.
  227. Returns an empty list whenever any of the following hold:
  228. * The ``disable_filament_warnings`` setting is on.
  229. * The item has no resolved ``printer_id`` (model-based assignment not
  230. yet picked a printer — the scheduler re-runs the check after it does).
  231. * No source 3MF is available, or the 3MF carries no per-slot
  232. requirements (treated as "nothing to verify" rather than an error,
  233. matching the PrintModal behaviour).
  234. * No AMS mapping is set yet — the scheduler computes the mapping just
  235. before dispatch; until it does we cannot map slot → tray.
  236. * Spoolman mode is on but the Spoolman server is unreachable. We do not
  237. wedge the queue on a network blip.
  238. #1762: when the printer reports ``ams_filament_backup=True`` in MQTT
  239. status, available material is pooled across ALL same-material spools on
  240. the printer (within the same extruder side for dual-nozzle models, since
  241. firmware can't cross extruders even with the backup bit set). Per-slot
  242. shortfalls are then only emitted if the POOL is too small for the
  243. print's total required of that material — matching how the printer
  244. actually behaves with Filament Backup ON.
  245. """
  246. if await _warnings_disabled(db):
  247. return []
  248. if item.printer_id is None:
  249. return []
  250. # Refresh the relationships we need without assuming the caller eagerly
  251. # loaded them — both the route and the scheduler call this from contexts
  252. # with different loading strategies.
  253. refreshed = await db.execute(
  254. select(PrintQueueItem)
  255. .options(
  256. selectinload(PrintQueueItem.archive),
  257. selectinload(PrintQueueItem.library_file),
  258. )
  259. .where(PrintQueueItem.id == item.id)
  260. )
  261. item = refreshed.scalar_one_or_none() or item
  262. source_path = _resolve_source_3mf(item)
  263. if source_path is None or not source_path.exists():
  264. return []
  265. requirements = extract_filament_requirements(source_path, item.plate_id)
  266. if not requirements:
  267. return []
  268. mapping = _parse_ams_mapping(item.ams_mapping)
  269. if not mapping:
  270. return []
  271. spoolman_mode = await _is_spoolman_mode(db)
  272. backup_on, ams_extruder_map, is_dual = await _get_printer_backup_context(item.printer_id)
  273. # ------------------------------------------------------------------ phase 1
  274. # Resolve each requirement to (ams_id, tray_id, identity, remaining_grams).
  275. # Slot identity is the identity of the spool *assigned to that slot*. A
  276. # ``None`` remaining means "couldn't determine" — treated as "no deficit"
  277. # below (preserved from pre-#1762 behaviour for non-backup paths too).
  278. @dataclass
  279. class _ReqRow:
  280. slot_id: int
  281. ams_id: int
  282. tray_id: int
  283. global_tray_id: int
  284. required: float
  285. identity: str
  286. remaining: float | None
  287. filament_type: str
  288. extruder: int
  289. resolved: list[_ReqRow] = []
  290. for req in requirements:
  291. slot_id = req.get("slot_id")
  292. used_grams = req.get("used_grams")
  293. if not isinstance(slot_id, int) or slot_id <= 0:
  294. continue
  295. if not isinstance(used_grams, (int, float)) or used_grams <= 0:
  296. continue
  297. idx = slot_id - 1
  298. if idx >= len(mapping):
  299. continue
  300. global_tray_id = mapping[idx]
  301. if global_tray_id is None or global_tray_id < 0:
  302. continue
  303. ams_id, tray_id = _global_to_ams_key(global_tray_id)
  304. identity = "attrs:|||"
  305. remaining: float | None = None
  306. if spoolman_mode:
  307. sm_result = await db.execute(
  308. select(SpoolmanSlotAssignment).where(
  309. SpoolmanSlotAssignment.printer_id == item.printer_id,
  310. SpoolmanSlotAssignment.ams_id == ams_id,
  311. SpoolmanSlotAssignment.tray_id == tray_id,
  312. )
  313. )
  314. sm_assignment = sm_result.scalar_one_or_none()
  315. if sm_assignment is None:
  316. continue
  317. # Live remaining_weight from Spoolman. The fetch also resolves the
  318. # filament identity for pooling (material + colour + name).
  319. from backend.app.services.spoolman import (
  320. SpoolmanClientError,
  321. SpoolmanNotFoundError,
  322. get_spoolman_client,
  323. )
  324. try:
  325. client = await get_spoolman_client()
  326. spool_dict = await client.get_spool(sm_assignment.spoolman_spool_id) if client else None
  327. except (SpoolmanNotFoundError, SpoolmanClientError):
  328. spool_dict = None
  329. except Exception as e:
  330. logger.debug("Spoolman fetch failed for spool %s: %s", sm_assignment.spoolman_spool_id, e)
  331. spool_dict = None
  332. if spool_dict:
  333. identity = _material_identity_spoolman(spool_dict)
  334. rw = spool_dict.get("remaining_weight")
  335. if isinstance(rw, (int, float)) and rw >= 0:
  336. remaining = float(rw)
  337. else:
  338. used = spool_dict.get("used_weight")
  339. total = (spool_dict.get("filament") or {}).get("weight")
  340. if isinstance(used, (int, float)) and isinstance(total, (int, float)) and total > 0:
  341. remaining = max(0.0, float(total) - float(used))
  342. else:
  343. internal_result = await db.execute(
  344. select(SpoolAssignment)
  345. .options(selectinload(SpoolAssignment.spool))
  346. .where(
  347. SpoolAssignment.printer_id == item.printer_id,
  348. SpoolAssignment.ams_id == ams_id,
  349. SpoolAssignment.tray_id == tray_id,
  350. )
  351. )
  352. assignment = internal_result.scalar_one_or_none()
  353. if assignment is None or assignment.spool is None:
  354. continue
  355. spool = assignment.spool
  356. identity = _material_identity_internal(spool)
  357. label_weight = float(spool.label_weight or 0)
  358. weight_used = float(spool.weight_used or 0)
  359. if label_weight <= 0:
  360. continue
  361. remaining = max(0.0, label_weight - weight_used)
  362. if remaining is None:
  363. # Unable to determine remaining grams — preserve pre-#1762 behaviour
  364. # (don't block on undetermined data).
  365. continue
  366. resolved.append(
  367. _ReqRow(
  368. slot_id=slot_id,
  369. ams_id=ams_id,
  370. tray_id=tray_id,
  371. global_tray_id=global_tray_id,
  372. required=float(used_grams),
  373. identity=identity,
  374. remaining=remaining,
  375. filament_type=str(req.get("type", "")),
  376. extruder=_extruder_side_for_ams(ams_id, ams_extruder_map, is_dual),
  377. )
  378. )
  379. # ------------------------------------------------------------------ phase 2
  380. # When backup is OFF, fall back to today's per-slot accounting (one-line
  381. # equivalence of the original loop), so this path is a strict no-op
  382. # behaviour-wise vs. the pre-#1762 code.
  383. if not backup_on:
  384. return [
  385. FilamentDeficit(
  386. slot_id=row.slot_id,
  387. ams_id=row.ams_id,
  388. tray_id=row.tray_id,
  389. filament_type=row.filament_type,
  390. required_grams=row.required,
  391. remaining_grams=row.remaining,
  392. )
  393. for row in resolved
  394. if row.remaining is not None and row.remaining < row.required
  395. ]
  396. # ------------------------------------------------------------------ phase 3
  397. # Backup ON: build (identity, extruder)-keyed pool and required-sum maps
  398. # from EVERY assigned spool on the printer (not just the slots in the
  399. # print's mapping). Then emit deficits only when the pool for a slot's
  400. # material is too small for the print's total required of that material.
  401. pool_by_key: dict[tuple[str, int], float] = defaultdict(float)
  402. required_by_key: dict[tuple[str, int], float] = defaultdict(float)
  403. if spoolman_mode:
  404. sm_all = await db.execute(
  405. select(SpoolmanSlotAssignment).where(SpoolmanSlotAssignment.printer_id == item.printer_id)
  406. )
  407. from backend.app.services.spoolman import (
  408. SpoolmanClientError,
  409. SpoolmanNotFoundError,
  410. get_spoolman_client,
  411. )
  412. try:
  413. client = await get_spoolman_client()
  414. except Exception:
  415. client = None
  416. for sa in sm_all.scalars().all():
  417. if client is None:
  418. break
  419. try:
  420. spool_dict = await client.get_spool(sa.spoolman_spool_id)
  421. except (SpoolmanNotFoundError, SpoolmanClientError):
  422. continue
  423. except Exception as e:
  424. logger.debug("Spoolman pool fetch failed for spool %s: %s", sa.spoolman_spool_id, e)
  425. continue
  426. if not spool_dict:
  427. continue
  428. identity = _material_identity_spoolman(spool_dict)
  429. rw = spool_dict.get("remaining_weight")
  430. r: float | None = None
  431. if isinstance(rw, (int, float)) and rw >= 0:
  432. r = float(rw)
  433. else:
  434. used = spool_dict.get("used_weight")
  435. total = (spool_dict.get("filament") or {}).get("weight")
  436. if isinstance(used, (int, float)) and isinstance(total, (int, float)) and total > 0:
  437. r = max(0.0, float(total) - float(used))
  438. if r is None:
  439. continue
  440. extruder = _extruder_side_for_ams(sa.ams_id, ams_extruder_map, is_dual)
  441. pool_by_key[(identity, extruder)] += r
  442. else:
  443. internal_all = await db.execute(
  444. select(SpoolAssignment)
  445. .options(selectinload(SpoolAssignment.spool))
  446. .where(SpoolAssignment.printer_id == item.printer_id)
  447. )
  448. for assignment in internal_all.scalars().all():
  449. spool = assignment.spool
  450. if spool is None:
  451. continue
  452. label_weight = float(spool.label_weight or 0)
  453. weight_used = float(spool.weight_used or 0)
  454. if label_weight <= 0:
  455. continue
  456. r = max(0.0, label_weight - weight_used)
  457. identity = _material_identity_internal(spool)
  458. extruder = _extruder_side_for_ams(assignment.ams_id, ams_extruder_map, is_dual)
  459. pool_by_key[(identity, extruder)] += r
  460. for row in resolved:
  461. required_by_key[(row.identity, row.extruder)] += row.required
  462. deficits: list[FilamentDeficit] = []
  463. for row in resolved:
  464. key = (row.identity, row.extruder)
  465. # Pool insufficient for the print's TOTAL required of this material on
  466. # this extruder side → real deficit. The per-slot remaining still gets
  467. # surfaced so the UI can point at the slot the user assigned.
  468. if pool_by_key[key] < required_by_key[key]:
  469. deficits.append(
  470. FilamentDeficit(
  471. slot_id=row.slot_id,
  472. ams_id=row.ams_id,
  473. tray_id=row.tray_id,
  474. filament_type=row.filament_type,
  475. required_grams=row.required,
  476. remaining_grams=row.remaining,
  477. )
  478. )
  479. return deficits
  480. # Re-export the most useful pieces for callers that just want the data.
  481. __all__ = [
  482. "FilamentDeficit",
  483. "compute_deficit_for_queue_item",
  484. ]