usage_tracker.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411
  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. Primary tracking uses 3MF slicer estimates (precise per-filament data).
  5. AMS remain% delta is the fallback for trays not covered by 3MF data.
  6. """
  7. import json
  8. import logging
  9. from dataclasses import dataclass, field
  10. from datetime import datetime, timezone
  11. from sqlalchemy import select
  12. from sqlalchemy.ext.asyncio import AsyncSession
  13. from backend.app.models.spool import Spool
  14. from backend.app.models.spool_assignment import SpoolAssignment
  15. from backend.app.models.spool_usage_history import SpoolUsageHistory
  16. logger = logging.getLogger(__name__)
  17. @dataclass
  18. class PrintSession:
  19. printer_id: int
  20. print_name: str
  21. started_at: datetime
  22. tray_remain_start: dict[tuple[int, int], int] = field(default_factory=dict)
  23. # Module-level storage, keyed by printer_id
  24. _active_sessions: dict[int, PrintSession] = {}
  25. async def on_print_start(printer_id: int, data: dict, printer_manager) -> None:
  26. """Capture AMS tray remain% at print start."""
  27. state = printer_manager.get_status(printer_id)
  28. if not state or not state.raw_data:
  29. logger.debug("[UsageTracker] No state for printer %d, skipping", printer_id)
  30. return
  31. ams_raw = state.raw_data.get("ams", [])
  32. ams_data = ams_raw.get("ams", []) if isinstance(ams_raw, dict) else ams_raw if isinstance(ams_raw, list) else []
  33. if not ams_data:
  34. logger.debug("[UsageTracker] No AMS data for printer %d, skipping", printer_id)
  35. return
  36. tray_remain_start: dict[tuple[int, int], int] = {}
  37. for ams_unit in ams_data:
  38. ams_id = int(ams_unit.get("id", 0))
  39. for tray in ams_unit.get("tray", []):
  40. tray_id = int(tray.get("id", 0))
  41. remain = tray.get("remain", -1)
  42. if isinstance(remain, int) and 0 <= remain <= 100:
  43. tray_remain_start[(ams_id, tray_id)] = remain
  44. print_name = data.get("subtask_name", "") or data.get("filename", "unknown")
  45. # Always create session (even without valid remain data) so print_name
  46. # is available at completion for 3MF-based tracking
  47. session = PrintSession(
  48. printer_id=printer_id,
  49. print_name=print_name,
  50. started_at=datetime.now(timezone.utc),
  51. tray_remain_start=tray_remain_start,
  52. )
  53. _active_sessions[printer_id] = session
  54. if tray_remain_start:
  55. logger.info(
  56. "[UsageTracker] Captured start remain%% for printer %d (%d trays): %s",
  57. printer_id,
  58. len(tray_remain_start),
  59. {f"{k[0]}-{k[1]}": v for k, v in tray_remain_start.items()},
  60. )
  61. else:
  62. logger.debug("[UsageTracker] No valid remain%% for printer %d, 3MF fallback available", printer_id)
  63. async def on_print_complete(
  64. printer_id: int,
  65. data: dict,
  66. printer_manager,
  67. db: AsyncSession,
  68. archive_id: int | None = None,
  69. ) -> list[dict]:
  70. """Compute consumption deltas and update spool weight_used/last_used.
  71. Uses two tracking strategies in priority order:
  72. 1. 3MF per-filament estimates (primary) — precise slicer data for all spools
  73. 2. AMS remain% delta (fallback) — only for trays not already handled by 3MF
  74. Returns a list of dicts describing what was logged (for WebSocket broadcast).
  75. """
  76. session = _active_sessions.pop(printer_id, None)
  77. status = data.get("status", "completed")
  78. results = []
  79. handled_trays: set[tuple[int, int]] = set()
  80. # --- Path 1 (PRIMARY): 3MF per-filament estimates ---
  81. if archive_id:
  82. print_name = (
  83. (session.print_name if session else None) or data.get("subtask_name", "") or data.get("filename", "unknown")
  84. )
  85. threemf_results = await _track_from_3mf(
  86. printer_id, archive_id, status, print_name, handled_trays, printer_manager, db
  87. )
  88. results.extend(threemf_results)
  89. # --- Path 2 (FALLBACK): AMS remain% delta (only for trays not handled by 3MF) ---
  90. if session and session.tray_remain_start:
  91. state = printer_manager.get_status(printer_id)
  92. if state and state.raw_data:
  93. ams_raw = state.raw_data.get("ams", [])
  94. ams_data = (
  95. ams_raw.get("ams", []) if isinstance(ams_raw, dict) else ams_raw if isinstance(ams_raw, list) else []
  96. )
  97. for ams_unit in ams_data:
  98. ams_id = int(ams_unit.get("id", 0))
  99. for tray in ams_unit.get("tray", []):
  100. tray_id = int(tray.get("id", 0))
  101. key = (ams_id, tray_id)
  102. if key in handled_trays:
  103. continue # Already tracked via 3MF
  104. if key not in session.tray_remain_start:
  105. continue
  106. current_remain = tray.get("remain", -1)
  107. if not isinstance(current_remain, int) or current_remain < 0 or current_remain > 100:
  108. continue
  109. start_remain = session.tray_remain_start[key]
  110. delta_pct = start_remain - current_remain
  111. if delta_pct <= 0:
  112. continue # No consumption or tray was refilled
  113. # Look up SpoolAssignment for this slot
  114. result = await db.execute(
  115. select(SpoolAssignment).where(
  116. SpoolAssignment.printer_id == printer_id,
  117. SpoolAssignment.ams_id == ams_id,
  118. SpoolAssignment.tray_id == tray_id,
  119. )
  120. )
  121. assignment = result.scalar_one_or_none()
  122. if not assignment:
  123. continue
  124. # Load spool
  125. spool_result = await db.execute(select(Spool).where(Spool.id == assignment.spool_id))
  126. spool = spool_result.scalar_one_or_none()
  127. if not spool:
  128. continue
  129. # Compute weight consumed
  130. weight_grams = (delta_pct / 100.0) * spool.label_weight
  131. # Update spool
  132. spool.weight_used = (spool.weight_used or 0) + weight_grams
  133. spool.last_used = datetime.now(timezone.utc)
  134. # Insert usage history record
  135. history = SpoolUsageHistory(
  136. spool_id=spool.id,
  137. printer_id=printer_id,
  138. print_name=session.print_name,
  139. weight_used=round(weight_grams, 1),
  140. percent_used=delta_pct,
  141. status=status,
  142. )
  143. db.add(history)
  144. handled_trays.add(key)
  145. results.append(
  146. {
  147. "spool_id": spool.id,
  148. "weight_used": round(weight_grams, 1),
  149. "percent_used": delta_pct,
  150. "ams_id": ams_id,
  151. "tray_id": tray_id,
  152. "material": spool.material,
  153. }
  154. )
  155. logger.info(
  156. "[UsageTracker] Spool %d consumed %.1fg (%d%%) on printer %d AMS%d-T%d (AMS fallback, %s)",
  157. spool.id,
  158. weight_grams,
  159. delta_pct,
  160. printer_id,
  161. ams_id,
  162. tray_id,
  163. status,
  164. )
  165. if results:
  166. await db.commit()
  167. return results
  168. async def _track_from_3mf(
  169. printer_id: int,
  170. archive_id: int,
  171. status: str,
  172. print_name: str,
  173. handled_trays: set[tuple[int, int]],
  174. printer_manager,
  175. db: AsyncSession,
  176. ) -> list[dict]:
  177. """Track usage from 3MF per-filament slicer data (primary path).
  178. Uses slicer-estimated filament weight for all spools (BL and non-BL).
  179. For partial prints (failed/aborted), tries per-layer gcode data first,
  180. then falls back to linear scaling by progress.
  181. Slot-to-tray mapping priority:
  182. 1. Queue item ams_mapping (for queue-initiated prints)
  183. 2. tray_now from printer state (for single-filament non-queue prints)
  184. 3. Default mapping: slot_id - 1 = global_tray_id (last resort)
  185. """
  186. from backend.app.core.config import settings as app_settings
  187. from backend.app.models.archive import PrintArchive
  188. from backend.app.models.print_queue import PrintQueueItem
  189. from backend.app.utils.threemf_tools import extract_filament_usage_from_3mf
  190. result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
  191. archive = result.scalar_one_or_none()
  192. if not archive or not archive.file_path:
  193. return []
  194. file_path = app_settings.base_dir / archive.file_path
  195. if not file_path.exists():
  196. return []
  197. filament_usage = extract_filament_usage_from_3mf(file_path)
  198. if not filament_usage:
  199. return []
  200. # --- Resolve slot-to-tray mapping ---
  201. # 1. Try queue item ams_mapping (queue-initiated prints store the exact mapping)
  202. slot_to_tray = None
  203. queue_result = await db.execute(
  204. select(PrintQueueItem)
  205. .where(PrintQueueItem.archive_id == archive_id)
  206. .where(PrintQueueItem.status.in_(["printing", "completed", "failed"]))
  207. )
  208. queue_item = queue_result.scalar_one_or_none()
  209. if queue_item and queue_item.ams_mapping:
  210. try:
  211. slot_to_tray = json.loads(queue_item.ams_mapping)
  212. except (json.JSONDecodeError, TypeError):
  213. pass
  214. # 2. For single-filament non-queue prints, use tray_now from printer state
  215. nonzero_slots = [u for u in filament_usage if u.get("used_g", 0) > 0]
  216. tray_now_override: int | None = None
  217. if not slot_to_tray and len(nonzero_slots) == 1:
  218. state = printer_manager.get_status(printer_id)
  219. if state and state.tray_now < 255:
  220. tray_now_override = state.tray_now
  221. # Scale factor for partial prints (failed/aborted)
  222. if status == "completed":
  223. scale = 1.0
  224. else:
  225. state = printer_manager.get_status(printer_id)
  226. progress = state.progress if state else 0
  227. scale = max(0.0, min(progress / 100.0, 1.0))
  228. # Per-layer gcode accuracy for partial prints
  229. layer_grams: dict[int, float] | None = None
  230. if status != "completed":
  231. state = printer_manager.get_status(printer_id)
  232. current_layer = state.layer_num if state else 0
  233. if current_layer > 0:
  234. try:
  235. from backend.app.utils.threemf_tools import (
  236. extract_filament_properties_from_3mf,
  237. extract_layer_filament_usage_from_3mf,
  238. get_cumulative_usage_at_layer,
  239. mm_to_grams,
  240. )
  241. layer_usage = extract_layer_filament_usage_from_3mf(file_path)
  242. if layer_usage:
  243. cumulative_mm = get_cumulative_usage_at_layer(layer_usage, current_layer)
  244. filament_props = extract_filament_properties_from_3mf(file_path)
  245. layer_grams = {}
  246. for filament_id, mm_used in cumulative_mm.items():
  247. slot_id = filament_id + 1 # 0-based to 1-based
  248. props = filament_props.get(slot_id, {})
  249. density = props.get("density", 1.24)
  250. diameter = props.get("diameter", 1.75)
  251. layer_grams[slot_id] = mm_to_grams(mm_used, diameter, density)
  252. except Exception:
  253. pass # Fall back to linear scaling
  254. results = []
  255. for usage in filament_usage:
  256. slot_id = usage.get("slot_id", 0)
  257. used_g = usage.get("used_g", 0)
  258. if used_g <= 0:
  259. continue
  260. # Map 3MF slot_id to physical (ams_id, tray_id) using resolved mapping
  261. if tray_now_override is not None:
  262. # Single-filament non-queue print: use actual tray from printer state
  263. global_tray_id = tray_now_override
  264. else:
  265. # Queue mapping or default: slot_id - 1, overridden by ams_mapping
  266. global_tray_id = slot_id - 1
  267. if slot_to_tray and slot_id <= len(slot_to_tray):
  268. mapped = slot_to_tray[slot_id - 1]
  269. if isinstance(mapped, int) and mapped >= 0:
  270. global_tray_id = mapped
  271. if global_tray_id >= 128:
  272. ams_id = global_tray_id
  273. tray_id = 0
  274. else:
  275. ams_id = global_tray_id // 4
  276. tray_id = global_tray_id % 4
  277. key = (ams_id, tray_id)
  278. if key in handled_trays:
  279. continue
  280. # Find spool assignment for this tray
  281. assign_result = await db.execute(
  282. select(SpoolAssignment).where(
  283. SpoolAssignment.printer_id == printer_id,
  284. SpoolAssignment.ams_id == ams_id,
  285. SpoolAssignment.tray_id == tray_id,
  286. )
  287. )
  288. assignment = assign_result.scalar_one_or_none()
  289. if not assignment:
  290. continue
  291. # Load spool
  292. spool_result = await db.execute(select(Spool).where(Spool.id == assignment.spool_id))
  293. spool = spool_result.scalar_one_or_none()
  294. if not spool:
  295. continue
  296. # Use per-layer grams if available, otherwise linear scale
  297. if layer_grams and slot_id in layer_grams:
  298. weight_grams = layer_grams[slot_id]
  299. else:
  300. weight_grams = used_g * scale
  301. if weight_grams <= 0:
  302. continue
  303. # Update spool
  304. spool.weight_used = (spool.weight_used or 0) + weight_grams
  305. spool.last_used = datetime.now(timezone.utc)
  306. percent = round(weight_grams / (spool.label_weight or 1000) * 100)
  307. # Insert usage history record
  308. history = SpoolUsageHistory(
  309. spool_id=spool.id,
  310. printer_id=printer_id,
  311. print_name=print_name,
  312. weight_used=round(weight_grams, 1),
  313. percent_used=percent,
  314. status=status,
  315. )
  316. db.add(history)
  317. handled_trays.add(key)
  318. results.append(
  319. {
  320. "spool_id": spool.id,
  321. "weight_used": round(weight_grams, 1),
  322. "percent_used": percent,
  323. "ams_id": ams_id,
  324. "tray_id": tray_id,
  325. "material": spool.material,
  326. }
  327. )
  328. # Determine mapping source for debug logging
  329. if tray_now_override is not None:
  330. map_src = ", tray_now"
  331. elif slot_to_tray:
  332. map_src = ", queue_map"
  333. else:
  334. map_src = ""
  335. logger.info(
  336. "[UsageTracker] Spool %d consumed %.1fg (3MF%s%s) on printer %d AMS%d-T%d (%s)",
  337. spool.id,
  338. weight_grams,
  339. " per-layer" if (layer_grams and slot_id in layer_grams) else (f" scaled {scale:.0%}" if scale < 1 else ""),
  340. map_src,
  341. printer_id,
  342. ams_id,
  343. tray_id,
  344. status,
  345. )
  346. return results