usage_tracker.py 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542
  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. # tray_now at print start (correct value, unlike at completion where it's 255)
  24. tray_now_at_start: int = -1
  25. # Module-level storage, keyed by printer_id
  26. _active_sessions: dict[int, PrintSession] = {}
  27. async def on_print_start(printer_id: int, data: dict, printer_manager) -> None:
  28. """Capture AMS tray remain% at print start."""
  29. state = printer_manager.get_status(printer_id)
  30. if not state or not state.raw_data:
  31. logger.debug("[UsageTracker] No state for printer %d, skipping", printer_id)
  32. return
  33. ams_raw = state.raw_data.get("ams", [])
  34. ams_data = ams_raw.get("ams", []) if isinstance(ams_raw, dict) else ams_raw if isinstance(ams_raw, list) else []
  35. if not ams_data:
  36. logger.debug("[UsageTracker] No AMS data for printer %d, skipping", printer_id)
  37. return
  38. tray_remain_start: dict[tuple[int, int], int] = {}
  39. for ams_unit in ams_data:
  40. ams_id = int(ams_unit.get("id", 0))
  41. for tray in ams_unit.get("tray", []):
  42. tray_id = int(tray.get("id", 0))
  43. remain = tray.get("remain", -1)
  44. if isinstance(remain, int) and 0 <= remain <= 100:
  45. tray_remain_start[(ams_id, tray_id)] = remain
  46. print_name = data.get("subtask_name", "") or data.get("filename", "unknown")
  47. # Capture tray_now at print start (reliable, unlike at completion where it's 255)
  48. tray_now_at_start = state.tray_now if state else -1
  49. # --- Diagnostic logging: dump mapping-related MQTT fields at print start ---
  50. # This helps us understand what each printer model reports for slot-to-tray mapping.
  51. mapping_field = state.raw_data.get("mapping")
  52. logger.info(
  53. "[UsageTracker] PRINT START printer %d: mapping=%s, tray_now=%d, last_loaded_tray=%s",
  54. printer_id,
  55. mapping_field,
  56. tray_now_at_start,
  57. getattr(state, "last_loaded_tray", "N/A"),
  58. )
  59. # Log all raw_data keys containing "map" or "ams" for discovery
  60. map_keys = {k: state.raw_data[k] for k in state.raw_data if "map" in k.lower()}
  61. if map_keys:
  62. logger.info("[UsageTracker] PRINT START printer %d: mapping-related keys: %s", printer_id, map_keys)
  63. # Log per-tray summary: tray_now, tray_tar, tray_type, tray_color for each slot
  64. for ams_unit in ams_data:
  65. ams_id = int(ams_unit.get("id", 0))
  66. tray_summary = []
  67. for tray in ams_unit.get("tray", []):
  68. tray_summary.append(
  69. f"T{tray.get('id', '?')}(type={tray.get('tray_type', '')}, "
  70. f"color={tray.get('tray_color', '')}, "
  71. f"now={ams_raw.get('tray_now', '?') if isinstance(ams_raw, dict) else '?'}, "
  72. f"tar={ams_raw.get('tray_tar', '?') if isinstance(ams_raw, dict) else '?'})"
  73. )
  74. logger.info("[UsageTracker] PRINT START printer %d AMS %d: %s", printer_id, ams_id, ", ".join(tray_summary))
  75. # Always create session (even without valid remain data) so print_name
  76. # is available at completion for 3MF-based tracking
  77. session = PrintSession(
  78. printer_id=printer_id,
  79. print_name=print_name,
  80. started_at=datetime.now(timezone.utc),
  81. tray_remain_start=tray_remain_start,
  82. tray_now_at_start=tray_now_at_start,
  83. )
  84. _active_sessions[printer_id] = session
  85. if tray_remain_start:
  86. logger.info(
  87. "[UsageTracker] Captured start remain%% for printer %d (%d trays): %s",
  88. printer_id,
  89. len(tray_remain_start),
  90. {f"{k[0]}-{k[1]}": v for k, v in tray_remain_start.items()},
  91. )
  92. else:
  93. logger.debug("[UsageTracker] No valid remain%% for printer %d, 3MF fallback available", printer_id)
  94. async def on_print_complete(
  95. printer_id: int,
  96. data: dict,
  97. printer_manager,
  98. db: AsyncSession,
  99. archive_id: int | None = None,
  100. ams_mapping: list[int] | None = None,
  101. ) -> list[dict]:
  102. """Compute consumption deltas and update spool weight_used/last_used.
  103. Uses two tracking strategies in priority order:
  104. 1. 3MF per-filament estimates (primary) — precise slicer data for all spools
  105. 2. AMS remain% delta (fallback) — only for trays not already handled by 3MF
  106. Returns a list of dicts describing what was logged (for WebSocket broadcast).
  107. """
  108. session = _active_sessions.pop(printer_id, None)
  109. status = data.get("status", "completed")
  110. results = []
  111. handled_trays: set[tuple[int, int]] = set()
  112. logger.info(
  113. "[UsageTracker] on_print_complete: printer=%d, archive=%s, session=%s, ams_mapping=%s",
  114. printer_id,
  115. archive_id,
  116. "yes" if session else "no",
  117. ams_mapping,
  118. )
  119. # --- Diagnostic logging: dump mapping-related MQTT fields at print completion ---
  120. state = printer_manager.get_status(printer_id)
  121. if state and state.raw_data:
  122. logger.info(
  123. "[UsageTracker] PRINT COMPLETE printer %d: mapping=%s, tray_now=%s, last_loaded_tray=%s",
  124. printer_id,
  125. state.raw_data.get("mapping"),
  126. state.tray_now,
  127. getattr(state, "last_loaded_tray", "N/A"),
  128. )
  129. # --- Path 1 (PRIMARY): 3MF per-filament estimates ---
  130. if archive_id:
  131. print_name = (
  132. (session.print_name if session else None) or data.get("subtask_name", "") or data.get("filename", "unknown")
  133. )
  134. threemf_results = await _track_from_3mf(
  135. printer_id,
  136. archive_id,
  137. status,
  138. print_name,
  139. handled_trays,
  140. printer_manager,
  141. db,
  142. ams_mapping=ams_mapping,
  143. tray_now_at_start=session.tray_now_at_start if session else -1,
  144. last_progress=data.get("last_progress", 0.0),
  145. last_layer_num=data.get("last_layer_num", 0),
  146. )
  147. results.extend(threemf_results)
  148. # --- Path 2 (FALLBACK): AMS remain% delta (only for trays not handled by 3MF) ---
  149. if session and session.tray_remain_start:
  150. state = printer_manager.get_status(printer_id)
  151. if state and state.raw_data:
  152. ams_raw = state.raw_data.get("ams", [])
  153. ams_data = (
  154. ams_raw.get("ams", []) if isinstance(ams_raw, dict) else ams_raw if isinstance(ams_raw, list) else []
  155. )
  156. for ams_unit in ams_data:
  157. ams_id = int(ams_unit.get("id", 0))
  158. for tray in ams_unit.get("tray", []):
  159. tray_id = int(tray.get("id", 0))
  160. key = (ams_id, tray_id)
  161. if key in handled_trays:
  162. continue # Already tracked via 3MF
  163. if key not in session.tray_remain_start:
  164. continue
  165. current_remain = tray.get("remain", -1)
  166. if not isinstance(current_remain, int) or current_remain < 0 or current_remain > 100:
  167. continue
  168. start_remain = session.tray_remain_start[key]
  169. delta_pct = start_remain - current_remain
  170. if delta_pct <= 0:
  171. continue # No consumption or tray was refilled
  172. # Look up SpoolAssignment for this slot
  173. result = await db.execute(
  174. select(SpoolAssignment).where(
  175. SpoolAssignment.printer_id == printer_id,
  176. SpoolAssignment.ams_id == ams_id,
  177. SpoolAssignment.tray_id == tray_id,
  178. )
  179. )
  180. assignment = result.scalar_one_or_none()
  181. if not assignment:
  182. continue
  183. # Load spool
  184. spool_result = await db.execute(select(Spool).where(Spool.id == assignment.spool_id))
  185. spool = spool_result.scalar_one_or_none()
  186. if not spool:
  187. continue
  188. # Compute weight consumed
  189. weight_grams = (delta_pct / 100.0) * spool.label_weight
  190. # Update spool
  191. spool.weight_used = (spool.weight_used or 0) + weight_grams
  192. spool.last_used = datetime.now(timezone.utc)
  193. # Insert usage history record
  194. history = SpoolUsageHistory(
  195. spool_id=spool.id,
  196. printer_id=printer_id,
  197. print_name=session.print_name,
  198. weight_used=round(weight_grams, 1),
  199. percent_used=delta_pct,
  200. status=status,
  201. )
  202. db.add(history)
  203. handled_trays.add(key)
  204. results.append(
  205. {
  206. "spool_id": spool.id,
  207. "weight_used": round(weight_grams, 1),
  208. "percent_used": delta_pct,
  209. "ams_id": ams_id,
  210. "tray_id": tray_id,
  211. "material": spool.material,
  212. }
  213. )
  214. logger.info(
  215. "[UsageTracker] Spool %d consumed %.1fg (%d%%) on printer %d AMS%d-T%d (AMS fallback, %s)",
  216. spool.id,
  217. weight_grams,
  218. delta_pct,
  219. printer_id,
  220. ams_id,
  221. tray_id,
  222. status,
  223. )
  224. if results:
  225. await db.commit()
  226. return results
  227. async def _track_from_3mf(
  228. printer_id: int,
  229. archive_id: int,
  230. status: str,
  231. print_name: str,
  232. handled_trays: set[tuple[int, int]],
  233. printer_manager,
  234. db: AsyncSession,
  235. ams_mapping: list[int] | None = None,
  236. tray_now_at_start: int = -1,
  237. last_progress: float = 0.0,
  238. last_layer_num: int = 0,
  239. ) -> list[dict]:
  240. """Track usage from 3MF per-filament slicer data (primary path).
  241. Uses slicer-estimated filament weight for all spools (BL and non-BL).
  242. For partial prints (failed/aborted), tries per-layer gcode data first,
  243. then falls back to linear scaling by progress.
  244. Slot-to-tray mapping priority:
  245. 1. Stored ams_mapping from print command (reprints/direct prints)
  246. 2. Queue item ams_mapping (for queue-initiated prints)
  247. 3. tray_now from printer state (for single-filament non-queue prints)
  248. 4. Default mapping: slot_id - 1 = global_tray_id (last resort)
  249. """
  250. from backend.app.core.config import settings as app_settings
  251. from backend.app.models.archive import PrintArchive
  252. from backend.app.models.print_queue import PrintQueueItem
  253. from backend.app.utils.threemf_tools import extract_filament_usage_from_3mf
  254. result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
  255. archive = result.scalar_one_or_none()
  256. if not archive or not archive.file_path:
  257. logger.info("[UsageTracker] 3MF: archive %s has no file_path, skipping", archive_id)
  258. return []
  259. file_path = app_settings.base_dir / archive.file_path
  260. if not file_path.exists():
  261. logger.info("[UsageTracker] 3MF: file not found: %s", file_path)
  262. return []
  263. filament_usage = extract_filament_usage_from_3mf(file_path)
  264. if not filament_usage:
  265. logger.info("[UsageTracker] 3MF: no filament usage data in %s", file_path)
  266. return []
  267. logger.info("[UsageTracker] 3MF: archive %s, filament_usage=%s", archive_id, filament_usage)
  268. # --- Resolve slot-to-tray mapping ---
  269. # 1. Use stored ams_mapping from the print command (reprints/direct prints)
  270. slot_to_tray = ams_mapping
  271. # 2. Try queue item ams_mapping (queue-initiated prints store the exact mapping)
  272. if not slot_to_tray:
  273. queue_result = await db.execute(
  274. select(PrintQueueItem)
  275. .where(PrintQueueItem.archive_id == archive_id)
  276. .where(PrintQueueItem.status.in_(["printing", "completed", "failed"]))
  277. )
  278. queue_item = queue_result.scalar_one_or_none()
  279. if queue_item and queue_item.ams_mapping:
  280. try:
  281. slot_to_tray = json.loads(queue_item.ams_mapping)
  282. except (json.JSONDecodeError, TypeError):
  283. pass
  284. logger.info(
  285. "[UsageTracker] 3MF: slot_to_tray=%s (source: %s)",
  286. slot_to_tray,
  287. "print_cmd" if ams_mapping else ("queue" if slot_to_tray else "none"),
  288. )
  289. # 3. For single-filament non-queue prints, use tray_now from printer state
  290. # Priority: tray_now_at_start > current tray_now > last_loaded_tray > vt_tray check
  291. nonzero_slots = [u for u in filament_usage if u.get("used_g", 0) > 0]
  292. tray_now_override: int | None = None
  293. if not slot_to_tray and len(nonzero_slots) == 1:
  294. state = printer_manager.get_status(printer_id)
  295. # Try tray_now_at_start first (captured at print start)
  296. if 0 <= tray_now_at_start <= 254:
  297. tray_now_override = tray_now_at_start
  298. logger.info("[UsageTracker] 3MF: using tray_now_at_start=%d (single-filament fallback)", tray_now_at_start)
  299. elif state and 0 <= state.tray_now <= 254:
  300. # Current state is valid (printer didn't retract yet)
  301. tray_now_override = state.tray_now
  302. logger.info("[UsageTracker] 3MF: using current tray_now=%d", state.tray_now)
  303. elif state and 0 <= state.last_loaded_tray <= 253:
  304. # Last valid tray before retract (H2D retracts before completion callback)
  305. tray_now_override = state.last_loaded_tray
  306. logger.info("[UsageTracker] 3MF: using last_loaded_tray=%d (post-retract fallback)", state.last_loaded_tray)
  307. elif state and state.tray_now == 255:
  308. # 255 = "no filament" on legacy printers, but valid 2nd external spool on H2-series
  309. vt_tray = state.raw_data.get("vt_tray") or []
  310. if any(int(vt.get("id", 0)) == 255 for vt in vt_tray if isinstance(vt, dict)):
  311. tray_now_override = state.tray_now
  312. logger.info("[UsageTracker] 3MF: using tray_now=255 (H2-series external spool)")
  313. if tray_now_override is None:
  314. logger.info(
  315. "[UsageTracker] 3MF: no valid tray_now (at_start=%d, current=%s, last_loaded=%s)",
  316. tray_now_at_start,
  317. state.tray_now if state else "N/A",
  318. state.last_loaded_tray if state else "N/A",
  319. )
  320. # Scale factor for partial prints (failed/aborted)
  321. if status == "completed":
  322. scale = 1.0
  323. else:
  324. state = printer_manager.get_status(printer_id)
  325. progress = state.progress if state else 0
  326. # Firmware resets progress to 0 on cancel — use last valid progress captured during print
  327. if progress <= 0 and last_progress > 0:
  328. progress = last_progress
  329. logger.info("[UsageTracker] 3MF: using last_progress=%.1f (firmware reset current to 0)", last_progress)
  330. scale = max(0.0, min(progress / 100.0, 1.0))
  331. # Per-layer gcode accuracy for partial prints
  332. layer_grams: dict[int, float] | None = None
  333. if status != "completed":
  334. state = printer_manager.get_status(printer_id)
  335. current_layer = state.layer_num if state else 0
  336. # Firmware resets layer_num to 0 on cancel — use last valid layer captured during print
  337. if current_layer <= 0 and last_layer_num > 0:
  338. current_layer = last_layer_num
  339. logger.info("[UsageTracker] 3MF: using last_layer_num=%d (firmware reset current to 0)", last_layer_num)
  340. if current_layer > 0:
  341. try:
  342. from backend.app.utils.threemf_tools import (
  343. extract_filament_properties_from_3mf,
  344. extract_layer_filament_usage_from_3mf,
  345. get_cumulative_usage_at_layer,
  346. mm_to_grams,
  347. )
  348. layer_usage = extract_layer_filament_usage_from_3mf(file_path)
  349. if layer_usage:
  350. cumulative_mm = get_cumulative_usage_at_layer(layer_usage, current_layer)
  351. filament_props = extract_filament_properties_from_3mf(file_path)
  352. layer_grams = {}
  353. for filament_id, mm_used in cumulative_mm.items():
  354. slot_id = filament_id + 1 # 0-based to 1-based
  355. props = filament_props.get(slot_id, {})
  356. density = props.get("density", 1.24)
  357. diameter = props.get("diameter", 1.75)
  358. layer_grams[slot_id] = mm_to_grams(mm_used, diameter, density)
  359. except Exception:
  360. pass # Fall back to linear scaling
  361. results = []
  362. for usage in filament_usage:
  363. slot_id = usage.get("slot_id", 0)
  364. used_g = usage.get("used_g", 0)
  365. if used_g <= 0:
  366. continue
  367. # Map 3MF slot_id to physical (ams_id, tray_id) using resolved mapping
  368. if tray_now_override is not None:
  369. # Single-filament non-queue print: use actual tray from printer state
  370. global_tray_id = tray_now_override
  371. else:
  372. # Queue mapping or default: slot_id - 1, overridden by ams_mapping
  373. global_tray_id = slot_id - 1
  374. if slot_to_tray and slot_id <= len(slot_to_tray):
  375. mapped = slot_to_tray[slot_id - 1]
  376. if isinstance(mapped, int) and mapped >= 0:
  377. global_tray_id = mapped
  378. if global_tray_id >= 254:
  379. # External spool: ams_id=255 (sentinel), tray_id=slot index (0 or 1)
  380. ams_id = 255
  381. tray_id = global_tray_id - 254
  382. elif global_tray_id >= 128:
  383. ams_id = global_tray_id
  384. tray_id = 0
  385. else:
  386. ams_id = global_tray_id // 4
  387. tray_id = global_tray_id % 4
  388. logger.info(
  389. "[UsageTracker] 3MF: slot_id=%d -> global_tray=%d -> AMS%d-T%d (used_g=%.1f, tray_now_override=%s)",
  390. slot_id,
  391. global_tray_id,
  392. ams_id,
  393. tray_id,
  394. used_g,
  395. tray_now_override,
  396. )
  397. key = (ams_id, tray_id)
  398. if key in handled_trays:
  399. continue
  400. # Find spool assignment for this tray
  401. assign_result = await db.execute(
  402. select(SpoolAssignment).where(
  403. SpoolAssignment.printer_id == printer_id,
  404. SpoolAssignment.ams_id == ams_id,
  405. SpoolAssignment.tray_id == tray_id,
  406. )
  407. )
  408. assignment = assign_result.scalar_one_or_none()
  409. if not assignment:
  410. logger.info("[UsageTracker] 3MF: no spool assignment at printer %d AMS%d-T%d", printer_id, ams_id, tray_id)
  411. continue
  412. # Load spool
  413. spool_result = await db.execute(select(Spool).where(Spool.id == assignment.spool_id))
  414. spool = spool_result.scalar_one_or_none()
  415. if not spool:
  416. continue
  417. # Use per-layer grams if available, otherwise linear scale
  418. if layer_grams and slot_id in layer_grams:
  419. weight_grams = layer_grams[slot_id]
  420. else:
  421. weight_grams = used_g * scale
  422. if weight_grams <= 0:
  423. continue
  424. # Update spool
  425. spool.weight_used = (spool.weight_used or 0) + weight_grams
  426. spool.last_used = datetime.now(timezone.utc)
  427. percent = round(weight_grams / (spool.label_weight or 1000) * 100)
  428. # Insert usage history record
  429. history = SpoolUsageHistory(
  430. spool_id=spool.id,
  431. printer_id=printer_id,
  432. print_name=print_name,
  433. weight_used=round(weight_grams, 1),
  434. percent_used=percent,
  435. status=status,
  436. )
  437. db.add(history)
  438. handled_trays.add(key)
  439. results.append(
  440. {
  441. "spool_id": spool.id,
  442. "weight_used": round(weight_grams, 1),
  443. "percent_used": percent,
  444. "ams_id": ams_id,
  445. "tray_id": tray_id,
  446. "material": spool.material,
  447. }
  448. )
  449. # Determine mapping source for debug logging
  450. if tray_now_override is not None:
  451. map_src = ", tray_now"
  452. elif slot_to_tray and ams_mapping:
  453. map_src = ", print_cmd_map"
  454. elif slot_to_tray:
  455. map_src = ", queue_map"
  456. else:
  457. map_src = ""
  458. logger.info(
  459. "[UsageTracker] Spool %d consumed %.1fg (3MF%s%s) on printer %d AMS%d-T%d (%s)",
  460. spool.id,
  461. weight_grams,
  462. " per-layer" if (layer_grams and slot_id in layer_grams) else (f" scaled {scale:.0%}" if scale < 1 else ""),
  463. map_src,
  464. printer_id,
  465. ams_id,
  466. tray_id,
  467. status,
  468. )
  469. return results