filament_deficit.py 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287
  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 dataclasses import dataclass
  29. from pathlib import Path
  30. from sqlalchemy import select
  31. from sqlalchemy.ext.asyncio import AsyncSession
  32. from sqlalchemy.orm import selectinload
  33. from backend.app.core.config import settings as app_settings
  34. from backend.app.models.print_queue import PrintQueueItem
  35. from backend.app.models.spool_assignment import SpoolAssignment
  36. from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
  37. from backend.app.services.filament_requirements import extract_filament_requirements
  38. logger = logging.getLogger(__name__)
  39. @dataclass(frozen=True)
  40. class FilamentDeficit:
  41. """One slot's filament shortfall."""
  42. slot_id: int
  43. ams_id: int | None
  44. tray_id: int | None
  45. filament_type: str
  46. required_grams: float
  47. remaining_grams: float | None # None = could not determine
  48. def to_dict(self) -> dict:
  49. return {
  50. "slot_id": self.slot_id,
  51. "ams_id": self.ams_id,
  52. "tray_id": self.tray_id,
  53. "filament_type": self.filament_type,
  54. "required_grams": self.required_grams,
  55. "remaining_grams": self.remaining_grams,
  56. }
  57. def _global_to_ams_key(global_tray_id: int) -> tuple[int, int]:
  58. """Inverse of ``ams_id * 4 + tray_id`` — matches ``usage_tracker``."""
  59. if global_tray_id >= 254:
  60. return (255, global_tray_id - 254)
  61. if global_tray_id >= 128:
  62. return (global_tray_id, 0)
  63. return (global_tray_id // 4, global_tray_id % 4)
  64. def _resolve_source_3mf(item: PrintQueueItem) -> Path | None:
  65. """Locate the 3MF file backing this queue item (archive or library)."""
  66. if item.archive is not None and item.archive.file_path:
  67. return app_settings.base_dir / item.archive.file_path
  68. if item.library_file is not None and item.library_file.file_path:
  69. return Path(item.library_file.file_path)
  70. return None
  71. async def _spoolman_remaining_grams(spoolman_spool_id: int) -> float | None:
  72. """Live remaining grams for a Spoolman spool, or None if unavailable."""
  73. try:
  74. from backend.app.services.spoolman import (
  75. SpoolmanClientError,
  76. SpoolmanNotFoundError,
  77. get_spoolman_client,
  78. )
  79. except ImportError:
  80. return None
  81. try:
  82. client = await get_spoolman_client()
  83. if client is None:
  84. return None
  85. spool = await client.get_spool(spoolman_spool_id)
  86. except (SpoolmanNotFoundError, SpoolmanClientError):
  87. return None
  88. except Exception as e:
  89. logger.debug("Spoolman fetch failed for spool %s: %s", spoolman_spool_id, e)
  90. return None
  91. if not spool:
  92. return None
  93. # Spoolman exposes either an absolute remaining_weight, or used_weight +
  94. # filament.weight. Either is sufficient — prefer remaining_weight when
  95. # present (the user may have overridden it).
  96. remaining = spool.get("remaining_weight")
  97. if isinstance(remaining, (int, float)) and remaining >= 0:
  98. return float(remaining)
  99. used = spool.get("used_weight")
  100. filament = spool.get("filament") or {}
  101. total = filament.get("weight")
  102. if isinstance(used, (int, float)) and isinstance(total, (int, float)) and total > 0:
  103. return max(0.0, float(total) - float(used))
  104. return None
  105. async def _is_spoolman_mode(db: AsyncSession) -> bool:
  106. """Check whether the user has opted in to Spoolman inventory mode."""
  107. try:
  108. from backend.app.api.routes.settings import get_setting
  109. spoolman_enabled = await get_setting(db, "spoolman_enabled")
  110. return bool(spoolman_enabled) and spoolman_enabled.lower() == "true"
  111. except Exception:
  112. return False
  113. async def _warnings_disabled(db: AsyncSession) -> bool:
  114. """Honour the ``disable_filament_warnings`` setting (#720)."""
  115. try:
  116. from backend.app.api.routes.settings import get_setting
  117. disabled = await get_setting(db, "disable_filament_warnings")
  118. return bool(disabled) and disabled.lower() == "true"
  119. except Exception:
  120. return False
  121. def _parse_ams_mapping(raw: str | None) -> list[int] | None:
  122. if not raw:
  123. return None
  124. try:
  125. parsed = json.loads(raw)
  126. except (json.JSONDecodeError, TypeError):
  127. return None
  128. if not isinstance(parsed, list):
  129. return None
  130. return [v for v in parsed if isinstance(v, int)]
  131. async def compute_deficit_for_queue_item(
  132. db: AsyncSession,
  133. item: PrintQueueItem,
  134. ) -> list[FilamentDeficit]:
  135. """Return per-slot filament shortfalls for ``item``, or [] when it's safe to dispatch.
  136. Returns an empty list whenever any of the following hold:
  137. * The ``disable_filament_warnings`` setting is on.
  138. * The item has no resolved ``printer_id`` (model-based assignment not
  139. yet picked a printer — the scheduler re-runs the check after it does).
  140. * No source 3MF is available, or the 3MF carries no per-slot
  141. requirements (treated as "nothing to verify" rather than an error,
  142. matching the PrintModal behaviour).
  143. * No AMS mapping is set yet — the scheduler computes the mapping just
  144. before dispatch; until it does we cannot map slot → tray.
  145. * Spoolman mode is on but the Spoolman server is unreachable. We do not
  146. wedge the queue on a network blip.
  147. """
  148. if await _warnings_disabled(db):
  149. return []
  150. if item.printer_id is None:
  151. return []
  152. # Refresh the relationships we need without assuming the caller eagerly
  153. # loaded them — both the route and the scheduler call this from contexts
  154. # with different loading strategies.
  155. refreshed = await db.execute(
  156. select(PrintQueueItem)
  157. .options(
  158. selectinload(PrintQueueItem.archive),
  159. selectinload(PrintQueueItem.library_file),
  160. )
  161. .where(PrintQueueItem.id == item.id)
  162. )
  163. item = refreshed.scalar_one_or_none() or item
  164. source_path = _resolve_source_3mf(item)
  165. if source_path is None or not source_path.exists():
  166. return []
  167. requirements = extract_filament_requirements(source_path, item.plate_id)
  168. if not requirements:
  169. return []
  170. mapping = _parse_ams_mapping(item.ams_mapping)
  171. if not mapping:
  172. return []
  173. spoolman_mode = await _is_spoolman_mode(db)
  174. deficits: list[FilamentDeficit] = []
  175. for req in requirements:
  176. slot_id = req.get("slot_id")
  177. used_grams = req.get("used_grams")
  178. if not isinstance(slot_id, int) or slot_id <= 0:
  179. continue
  180. if not isinstance(used_grams, (int, float)) or used_grams <= 0:
  181. continue
  182. idx = slot_id - 1
  183. if idx >= len(mapping):
  184. continue
  185. global_tray_id = mapping[idx]
  186. if global_tray_id is None or global_tray_id < 0:
  187. continue
  188. ams_id, tray_id = _global_to_ams_key(global_tray_id)
  189. remaining: float | None = None
  190. if spoolman_mode:
  191. sm_result = await db.execute(
  192. select(SpoolmanSlotAssignment).where(
  193. SpoolmanSlotAssignment.printer_id == item.printer_id,
  194. SpoolmanSlotAssignment.ams_id == ams_id,
  195. SpoolmanSlotAssignment.tray_id == tray_id,
  196. )
  197. )
  198. sm_assignment = sm_result.scalar_one_or_none()
  199. if sm_assignment is None:
  200. continue
  201. remaining = await _spoolman_remaining_grams(sm_assignment.spoolman_spool_id)
  202. else:
  203. internal_result = await db.execute(
  204. select(SpoolAssignment)
  205. .options(selectinload(SpoolAssignment.spool))
  206. .where(
  207. SpoolAssignment.printer_id == item.printer_id,
  208. SpoolAssignment.ams_id == ams_id,
  209. SpoolAssignment.tray_id == tray_id,
  210. )
  211. )
  212. assignment = internal_result.scalar_one_or_none()
  213. if assignment is None or assignment.spool is None:
  214. continue
  215. spool = assignment.spool
  216. label_weight = float(spool.label_weight or 0)
  217. weight_used = float(spool.weight_used or 0)
  218. if label_weight <= 0:
  219. continue
  220. remaining = max(0.0, label_weight - weight_used)
  221. if remaining is None:
  222. # Spoolman unreachable for this spool — skip rather than block.
  223. continue
  224. if remaining >= float(used_grams):
  225. continue
  226. deficits.append(
  227. FilamentDeficit(
  228. slot_id=slot_id,
  229. ams_id=ams_id,
  230. tray_id=tray_id,
  231. filament_type=str(req.get("type", "")),
  232. required_grams=float(used_grams),
  233. remaining_grams=remaining,
  234. )
  235. )
  236. return deficits
  237. # Re-export the most useful pieces for callers that just want the data.
  238. __all__ = [
  239. "FilamentDeficit",
  240. "compute_deficit_for_queue_item",
  241. ]