usage_tracker.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328
  1. """Automatic filament consumption tracking.
  2. Captures AMS tray remain% at print start, then computes consumption
  3. deltas at print complete to update spool weight_used and last_used.
  4. For non-BL spools (no RFID, AMS reports remain=-1), falls back to
  5. per-filament usage estimates from the archived 3MF file.
  6. """
  7. import logging
  8. from dataclasses import dataclass, field
  9. from datetime import datetime, timezone
  10. from sqlalchemy import select
  11. from sqlalchemy.ext.asyncio import AsyncSession
  12. from backend.app.models.spool import Spool
  13. from backend.app.models.spool_assignment import SpoolAssignment
  14. from backend.app.models.spool_usage_history import SpoolUsageHistory
  15. logger = logging.getLogger(__name__)
  16. @dataclass
  17. class PrintSession:
  18. printer_id: int
  19. print_name: str
  20. started_at: datetime
  21. tray_remain_start: dict[tuple[int, int], int] = field(default_factory=dict)
  22. # Module-level storage, keyed by printer_id
  23. _active_sessions: dict[int, PrintSession] = {}
  24. async def on_print_start(printer_id: int, data: dict, printer_manager) -> None:
  25. """Capture AMS tray remain% at print start."""
  26. state = printer_manager.get_status(printer_id)
  27. if not state or not state.raw_data:
  28. logger.debug("[UsageTracker] No state for printer %d, skipping", printer_id)
  29. return
  30. ams_raw = state.raw_data.get("ams", [])
  31. ams_data = ams_raw.get("ams", []) if isinstance(ams_raw, dict) else ams_raw if isinstance(ams_raw, list) else []
  32. if not ams_data:
  33. logger.debug("[UsageTracker] No AMS data for printer %d, skipping", printer_id)
  34. return
  35. tray_remain_start: dict[tuple[int, int], int] = {}
  36. for ams_unit in ams_data:
  37. ams_id = int(ams_unit.get("id", 0))
  38. for tray in ams_unit.get("tray", []):
  39. tray_id = int(tray.get("id", 0))
  40. remain = tray.get("remain", -1)
  41. if isinstance(remain, int) and 0 <= remain <= 100:
  42. tray_remain_start[(ams_id, tray_id)] = remain
  43. print_name = data.get("subtask_name", "") or data.get("filename", "unknown")
  44. # Always create session (even without valid remain data) so print_name
  45. # is available at completion for 3MF-based tracking
  46. session = PrintSession(
  47. printer_id=printer_id,
  48. print_name=print_name,
  49. started_at=datetime.now(timezone.utc),
  50. tray_remain_start=tray_remain_start,
  51. )
  52. _active_sessions[printer_id] = session
  53. if tray_remain_start:
  54. logger.info(
  55. "[UsageTracker] Captured start remain%% for printer %d (%d trays): %s",
  56. printer_id,
  57. len(tray_remain_start),
  58. {f"{k[0]}-{k[1]}": v for k, v in tray_remain_start.items()},
  59. )
  60. else:
  61. logger.debug("[UsageTracker] No valid remain%% for printer %d, 3MF fallback available", printer_id)
  62. async def on_print_complete(
  63. printer_id: int,
  64. data: dict,
  65. printer_manager,
  66. db: AsyncSession,
  67. archive_id: int | None = None,
  68. ) -> list[dict]:
  69. """Compute consumption deltas and update spool weight_used/last_used.
  70. Uses two tracking strategies:
  71. 1. AMS remain% delta — for BL spools with valid RFID remain data
  72. 2. 3MF per-filament estimates — for non-BL spools without remain data
  73. Returns a list of dicts describing what was logged (for WebSocket broadcast).
  74. """
  75. session = _active_sessions.pop(printer_id, None)
  76. status = data.get("status", "completed")
  77. results = []
  78. handled_trays: set[tuple[int, int]] = set()
  79. # --- Path 1: AMS remain% delta (for spools with valid RFID remain data) ---
  80. if session and session.tray_remain_start:
  81. state = printer_manager.get_status(printer_id)
  82. if state and state.raw_data:
  83. ams_raw = state.raw_data.get("ams", [])
  84. ams_data = (
  85. ams_raw.get("ams", []) if isinstance(ams_raw, dict) else ams_raw if isinstance(ams_raw, list) else []
  86. )
  87. for ams_unit in ams_data:
  88. ams_id = int(ams_unit.get("id", 0))
  89. for tray in ams_unit.get("tray", []):
  90. tray_id = int(tray.get("id", 0))
  91. key = (ams_id, tray_id)
  92. if key not in session.tray_remain_start:
  93. continue
  94. current_remain = tray.get("remain", -1)
  95. if not isinstance(current_remain, int) or current_remain < 0 or current_remain > 100:
  96. continue
  97. start_remain = session.tray_remain_start[key]
  98. delta_pct = start_remain - current_remain
  99. if delta_pct <= 0:
  100. continue # No consumption or tray was refilled
  101. # Look up SpoolAssignment for this slot
  102. result = await db.execute(
  103. select(SpoolAssignment).where(
  104. SpoolAssignment.printer_id == printer_id,
  105. SpoolAssignment.ams_id == ams_id,
  106. SpoolAssignment.tray_id == tray_id,
  107. )
  108. )
  109. assignment = result.scalar_one_or_none()
  110. if not assignment:
  111. continue
  112. # Load spool
  113. spool_result = await db.execute(select(Spool).where(Spool.id == assignment.spool_id))
  114. spool = spool_result.scalar_one_or_none()
  115. if not spool:
  116. continue
  117. # Compute weight consumed
  118. weight_grams = (delta_pct / 100.0) * spool.label_weight
  119. # Update spool
  120. spool.weight_used = (spool.weight_used or 0) + weight_grams
  121. spool.last_used = datetime.now(timezone.utc)
  122. # Insert usage history record
  123. history = SpoolUsageHistory(
  124. spool_id=spool.id,
  125. printer_id=printer_id,
  126. print_name=session.print_name,
  127. weight_used=round(weight_grams, 1),
  128. percent_used=delta_pct,
  129. status=status,
  130. )
  131. db.add(history)
  132. handled_trays.add(key)
  133. results.append(
  134. {
  135. "spool_id": spool.id,
  136. "weight_used": round(weight_grams, 1),
  137. "percent_used": delta_pct,
  138. "ams_id": ams_id,
  139. "tray_id": tray_id,
  140. }
  141. )
  142. logger.info(
  143. "[UsageTracker] Spool %d consumed %.1fg (%d%%) on printer %d AMS%d-T%d (%s)",
  144. spool.id,
  145. weight_grams,
  146. delta_pct,
  147. printer_id,
  148. ams_id,
  149. tray_id,
  150. status,
  151. )
  152. # --- Path 2: 3MF per-filament estimates (for non-BL spools without remain data) ---
  153. if archive_id:
  154. print_name = (
  155. (session.print_name if session else None) or data.get("subtask_name", "") or data.get("filename", "unknown")
  156. )
  157. threemf_results = await _track_from_3mf(
  158. printer_id, archive_id, status, print_name, handled_trays, printer_manager, db
  159. )
  160. results.extend(threemf_results)
  161. if results:
  162. await db.commit()
  163. return results
  164. async def _track_from_3mf(
  165. printer_id: int,
  166. archive_id: int,
  167. status: str,
  168. print_name: str,
  169. handled_trays: set[tuple[int, int]],
  170. printer_manager,
  171. db: AsyncSession,
  172. ) -> list[dict]:
  173. """Track usage from 3MF per-filament data for non-BL spools.
  174. Falls back to slicer-estimated filament weight when AMS remain% is
  175. unavailable (non-RFID spools). For partial prints (failed/aborted),
  176. scales the estimate by print progress.
  177. """
  178. from backend.app.core.config import settings as app_settings
  179. from backend.app.models.archive import PrintArchive
  180. from backend.app.utils.threemf_tools import extract_filament_usage_from_3mf
  181. result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
  182. archive = result.scalar_one_or_none()
  183. if not archive or not archive.file_path:
  184. return []
  185. file_path = app_settings.base_dir / archive.file_path
  186. if not file_path.exists():
  187. return []
  188. filament_usage = extract_filament_usage_from_3mf(file_path)
  189. if not filament_usage:
  190. return []
  191. # Scale factor for partial prints (failed/aborted)
  192. if status == "completed":
  193. scale = 1.0
  194. else:
  195. state = printer_manager.get_status(printer_id)
  196. progress = state.progress if state else 0
  197. scale = max(0.0, min(progress / 100.0, 1.0))
  198. results = []
  199. for usage in filament_usage:
  200. slot_id = usage.get("slot_id", 0)
  201. used_g = usage.get("used_g", 0)
  202. if used_g <= 0:
  203. continue
  204. # Map 3MF slot_id (1-based) to (ams_id, tray_id)
  205. global_tray_id = slot_id - 1
  206. if global_tray_id >= 128:
  207. ams_id = global_tray_id
  208. tray_id = 0
  209. else:
  210. ams_id = global_tray_id // 4
  211. tray_id = global_tray_id % 4
  212. key = (ams_id, tray_id)
  213. if key in handled_trays:
  214. continue # Already tracked via AMS remain% delta
  215. # Find spool assignment for this tray
  216. assign_result = await db.execute(
  217. select(SpoolAssignment).where(
  218. SpoolAssignment.printer_id == printer_id,
  219. SpoolAssignment.ams_id == ams_id,
  220. SpoolAssignment.tray_id == tray_id,
  221. )
  222. )
  223. assignment = assign_result.scalar_one_or_none()
  224. if not assignment:
  225. continue
  226. # Load spool
  227. spool_result = await db.execute(select(Spool).where(Spool.id == assignment.spool_id))
  228. spool = spool_result.scalar_one_or_none()
  229. if not spool:
  230. continue
  231. # Only use 3MF tracking for non-BL spools (BL spools use AMS remain%)
  232. if spool.tag_uid or spool.tray_uuid:
  233. continue
  234. weight_grams = used_g * scale
  235. if weight_grams <= 0:
  236. continue
  237. # Update spool
  238. spool.weight_used = (spool.weight_used or 0) + weight_grams
  239. spool.last_used = datetime.now(timezone.utc)
  240. percent = round(weight_grams / (spool.label_weight or 1000) * 100)
  241. # Insert usage history record
  242. history = SpoolUsageHistory(
  243. spool_id=spool.id,
  244. printer_id=printer_id,
  245. print_name=print_name,
  246. weight_used=round(weight_grams, 1),
  247. percent_used=percent,
  248. status=status,
  249. )
  250. db.add(history)
  251. results.append(
  252. {
  253. "spool_id": spool.id,
  254. "weight_used": round(weight_grams, 1),
  255. "percent_used": percent,
  256. "ams_id": ams_id,
  257. "tray_id": tray_id,
  258. }
  259. )
  260. logger.info(
  261. "[UsageTracker] Spool %d consumed %.1fg (3MF estimate%s) on printer %d AMS%d-T%d (%s)",
  262. spool.id,
  263. weight_grams,
  264. f" scaled to {scale:.0%}" if scale < 1 else "",
  265. printer_id,
  266. ams_id,
  267. tray_id,
  268. status,
  269. )
  270. return results