archive_cost_estimate.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375
  1. """Price a print from its spools when it starts, not only when it ends (#3261).
  2. ``archive.py`` prices an archive at creation from the built-in Filament catalogue
  3. matched on the primary type, falling back to the global default rate. The
  4. spools that actually feed the print only price it at completion -- the
  5. built-in inventory in ``usage_tracker.on_print_complete``, Spoolman in
  6. ``spoolman_tracking._apply_spool_cost_to_archive`` (#2591). In between, the
  7. archive card showed the placeholder, and anyone writing the cost down while a
  8. long print ran recorded a number that later changed without notice.
  9. This runs the completion-time pricing once at print start, on the 3MF's
  10. per-slot estimates instead of charged grams: each slot is priced at the spool
  11. in the tray it is mapped to, grams no spool could price are covered at the
  12. default rate, and the result replaces the placeholder. The figure is still an
  13. estimate -- a failed or stopped print ends up cheaper -- and the archive card
  14. says so while the print runs.
  15. Only mappings that are trustworthy at print start are used: the print
  16. command's own ``ams_mapping``, the queue item's stored mapping, and a colour
  17. match against the trays loaded right now. The printer's ``mapping`` field is
  18. deliberately not read here: it still describes the previous job until the
  19. printer pushes an update (see ``spoolman_tracking._resolve_slot_to_tray_fallback``).
  20. With no trustworthy mapping, nothing is priced and the placeholder stays.
  21. Strictly best-effort. Nothing here may fail a print start.
  22. """
  23. import asyncio
  24. import contextlib
  25. import json
  26. import logging
  27. from sqlalchemy import func, select
  28. from sqlalchemy.ext.asyncio import AsyncSession
  29. from backend.app.core.config import settings as app_settings
  30. logger = logging.getLogger(__name__)
  31. _EXTERNAL_TRAY_IDS = (254, 255)
  32. def _mapped_tray(slot_id: int, slot_to_tray: list, ams_trays: dict[int, dict]) -> int | None:
  33. """The global tray a 1-based slot is mapped to, or None if the mapping doesn't say.
  34. Unlike ``spoolman_tracking._resolve_global_tray_id`` there is no positional
  35. default: a guess would price the slot at an unrelated spool, and the
  36. default rate is the honest answer for a slot nobody mapped.
  37. """
  38. if slot_id <= 0 or slot_id > len(slot_to_tray):
  39. return None
  40. tray = slot_to_tray[slot_id - 1]
  41. if not isinstance(tray, int) or isinstance(tray, bool):
  42. return None
  43. if tray >= 0:
  44. return tray
  45. if tray == -1:
  46. # The flat ams_mapping writes the external spool as -1 (see
  47. # _resolve_global_tray_id).
  48. for ext_id in _EXTERNAL_TRAY_IDS:
  49. if ext_id in ams_trays:
  50. return ext_id
  51. return None
  52. async def _default_cost_per_kg(db: AsyncSession) -> float:
  53. from backend.app.api.routes.settings import get_setting
  54. try:
  55. value = await get_setting(db, "default_filament_cost")
  56. return float(value) if value else 25.0
  57. except (TypeError, ValueError):
  58. return 25.0
  59. async def _internal_rates(
  60. db: AsyncSession, printer_id: int, trays: set[int], default_cost_per_kg: float
  61. ) -> dict[int, float]:
  62. """Price per gram of the built-in inventory spool in each tray.
  63. Same rule as ``usage_tracker``'s completion writer: the spool's own
  64. ``cost_per_kg``, or the default rate when the spool has none.
  65. """
  66. from backend.app.models.spool import Spool
  67. from backend.app.models.spool_assignment import SpoolAssignment
  68. from backend.app.services.spoolman_tracking import _global_tray_id_to_ams_slot
  69. rates: dict[int, float] = {}
  70. for tray in trays:
  71. ams_id, tray_id = _global_tray_id_to_ams_slot(tray)
  72. spool = (
  73. await db.execute(
  74. select(Spool)
  75. .join(SpoolAssignment, SpoolAssignment.spool_id == Spool.id)
  76. .where(
  77. SpoolAssignment.printer_id == printer_id,
  78. SpoolAssignment.ams_id == ams_id,
  79. SpoolAssignment.tray_id == tray_id,
  80. )
  81. )
  82. ).scalar_one_or_none() # one assignment per tray (UniqueConstraint)
  83. if spool is None:
  84. continue
  85. cost_per_kg = spool.cost_per_kg if spool.cost_per_kg is not None else default_cost_per_kg
  86. rates[tray] = cost_per_kg / 1000.0
  87. return rates
  88. async def _spoolman_rates(printer_id: int, trays: set[int], ams_trays: dict[int, dict]) -> dict[int, float]:
  89. """Price per gram of the Spoolman spool in each tray.
  90. Resolves the spool the way usage reporting does (tag first, then the local
  91. slot assignment, #1459) and prices it with ``_spool_cost_per_gram``, so the
  92. estimate and the completion figure read the same price fields.
  93. """
  94. from backend.app.services.spoolman import get_spoolman_client
  95. from backend.app.services.spoolman_tracking import (
  96. _get_printer_serial,
  97. _global_tray_id_to_ams_slot,
  98. _resolve_spool_id_via_slot_assignment,
  99. _resolve_spool_tag,
  100. _spool_cost_per_gram,
  101. )
  102. # The client the integration already set up. No health check: that's an
  103. # extra request per print, and its failure log says usage reporting is
  104. # being skipped. An unreachable Spoolman fails per tray below instead.
  105. client = await get_spoolman_client()
  106. if client is None:
  107. return {}
  108. serial = await _get_printer_serial(printer_id)
  109. rates: dict[int, float] = {}
  110. for tray in trays:
  111. spool = None
  112. try:
  113. tray_info = ams_trays.get(tray)
  114. if tray_info:
  115. tag = _resolve_spool_tag(tray_info, serial, tray)
  116. if tag:
  117. spool = await client.find_spool_by_tag(tag)
  118. if spool is None:
  119. ams_id, tray_id = _global_tray_id_to_ams_slot(tray)
  120. spool_id = await _resolve_spool_id_via_slot_assignment(printer_id, ams_id, tray_id)
  121. if spool_id is not None:
  122. spool = await client.get_spool(spool_id)
  123. except Exception as exc: # noqa: BLE001 -- one unreachable spool leaves its slot at the default rate
  124. logger.debug("[COST] Tray %s: could not fetch the Spoolman spool: %s", tray, exc)
  125. continue
  126. rate = _spool_cost_per_gram(spool)
  127. if rate is not None:
  128. rates[tray] = rate
  129. return rates
  130. def schedule_archive_cost_estimate(
  131. printer_id: int,
  132. archive_id: int,
  133. printer_manager,
  134. ams_mapping: list | None = None,
  135. plate_id: int | None = None,
  136. session_factory=None,
  137. ) -> asyncio.Task | None:
  138. """Run ``estimate_archive_cost_at_start`` in the background, on its own session.
  139. Print start must not wait for it: in Spoolman mode it makes a request per
  140. tray, and a slow Spoolman would hold up everything after it.
  141. ``session_factory`` is the caller's ``async_session``, so the estimate
  142. opens its session the same way the rest of the caller does.
  143. """
  144. from backend.app.core.tasks import spawn_background_task
  145. if session_factory is None:
  146. from backend.app.core.database import async_session as session_factory
  147. try:
  148. mapping = list(ams_mapping) if ams_mapping else None
  149. except TypeError:
  150. mapping = None
  151. async def _run() -> None:
  152. async with session_factory() as db:
  153. await estimate_archive_cost_at_start(db, printer_id, archive_id, printer_manager, mapping, plate_id)
  154. try:
  155. task = spawn_background_task(_run(), name=f"archive-cost-estimate-{archive_id}")
  156. except Exception: # noqa: BLE001 -- an estimate must never fail a print start
  157. logger.warning("[COST] Archive %s: could not schedule the cost estimate", archive_id, exc_info=True)
  158. return None
  159. _running_estimates[archive_id] = task
  160. def _forget(done: asyncio.Task, archive_id: int = archive_id) -> None:
  161. if _running_estimates.get(archive_id) is done:
  162. del _running_estimates[archive_id]
  163. task.add_done_callback(_forget)
  164. return task
  165. # Estimates still running, by archive: completion stops its archive's before
  166. # writing the real cost (COST-4).
  167. _running_estimates: dict[int, asyncio.Task] = {}
  168. async def settle_archive_cost_estimate(archive_id: int) -> None:
  169. """Stop the archive's start estimate, if still running, before the real cost.
  170. Completion commits the measured cost before it marks the archive ended; an
  171. estimate still waiting on Spoolman then saw a printing archive with no runs
  172. logged, and wrote its figure over the real one on a very short print.
  173. """
  174. task = _running_estimates.pop(archive_id, None)
  175. if task is None or task.done():
  176. return
  177. task.cancel()
  178. with contextlib.suppress(asyncio.CancelledError, Exception):
  179. await task
  180. async def estimate_archive_cost_at_start(
  181. db: AsyncSession,
  182. printer_id: int,
  183. archive_id: int,
  184. printer_manager,
  185. ams_mapping: list | None = None,
  186. plate_id: int | None = None,
  187. ) -> None:
  188. """Replace a just-started archive's placeholder cost with a spool-based estimate."""
  189. try:
  190. await _estimate(db, printer_id, archive_id, printer_manager, ams_mapping, plate_id)
  191. except Exception: # noqa: BLE001 -- an estimate must never fail a print start
  192. logger.warning("[COST] Archive %s: could not estimate the cost at print start", archive_id, exc_info=True)
  193. async def _estimate(
  194. db: AsyncSession,
  195. printer_id: int,
  196. archive_id: int,
  197. printer_manager,
  198. ams_mapping: list | None,
  199. plate_id: int | None,
  200. ) -> None:
  201. from backend.app.api.routes.settings import get_setting
  202. from backend.app.models.archive import PrintArchive
  203. from backend.app.models.print_log import PrintLogEntry
  204. from backend.app.models.print_queue import PrintQueueItem
  205. from backend.app.services.spoolman_tracking import build_ams_tray_lookup
  206. from backend.app.services.usage_tracker import _match_slots_by_color
  207. from backend.app.utils.threemf_tools import extract_filament_usage_from_3mf
  208. archive = (await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))).scalar_one_or_none()
  209. if archive is None or not archive.file_path or not archive.filament_used_grams:
  210. return
  211. # A reprint keeps its first run's cost on the card (#1378); completion
  212. # doesn't overwrite it either.
  213. existing_runs = (
  214. await db.execute(select(func.count(PrintLogEntry.id)).where(PrintLogEntry.archive_id == archive_id))
  215. ).scalar()
  216. if existing_runs:
  217. return
  218. full_path = (
  219. app_settings.base_dir / archive.file_path
  220. ) # SEC-PATH-OK: archive.file_path is DB-stored, internally generated
  221. if not full_path.exists():
  222. return
  223. queue_item = (
  224. (
  225. await db.execute(
  226. select(PrintQueueItem)
  227. .where(PrintQueueItem.archive_id == archive_id)
  228. .where(PrintQueueItem.printer_id == printer_id)
  229. .where(PrintQueueItem.status == "printing")
  230. )
  231. )
  232. .scalars()
  233. .first()
  234. )
  235. if plate_id is None and queue_item is not None:
  236. plate_id = queue_item.plate_id
  237. filament_usage = extract_filament_usage_from_3mf(full_path, plate_id) or []
  238. used = [(u.get("slot_id", 0), u.get("used_g", 0) or 0) for u in filament_usage]
  239. used = [(slot, grams) for slot, grams in used if grams > 0]
  240. if not used:
  241. return
  242. state = printer_manager.get_status(printer_id)
  243. raw_data = getattr(state, "raw_data", None) or {}
  244. ams_trays = build_ams_tray_lookup(raw_data)
  245. slot_to_tray = ams_mapping or None
  246. source = "print_cmd" if slot_to_tray else None
  247. if not slot_to_tray and queue_item is not None and queue_item.ams_mapping:
  248. try:
  249. slot_to_tray = json.loads(queue_item.ams_mapping)
  250. source = "queue"
  251. except json.JSONDecodeError:
  252. slot_to_tray = None
  253. if not slot_to_tray:
  254. slot_to_tray = _match_slots_by_color(filament_usage, raw_data.get("ams"))
  255. source = "color_match" if slot_to_tray else None
  256. if not slot_to_tray:
  257. logger.info("[COST] Archive %s: no slot-to-tray mapping at print start, keeping the placeholder", archive_id)
  258. return
  259. slot_trays = {slot: _mapped_tray(slot, slot_to_tray, ams_trays) for slot, _ in used}
  260. trays = {tray for tray in slot_trays.values() if tray is not None}
  261. if not trays:
  262. return
  263. default_cost_per_kg = await _default_cost_per_kg(db)
  264. spoolman_enabled = (await get_setting(db, "spoolman_enabled") or "").lower() == "true"
  265. if spoolman_enabled:
  266. rates = await _spoolman_rates(printer_id, trays, ams_trays)
  267. else:
  268. rates = await _internal_rates(db, printer_id, trays, default_cost_per_kg)
  269. cost = 0.0
  270. priced_grams = 0.0
  271. for slot, grams in used:
  272. rate = rates.get(slot_trays[slot])
  273. if rate is None:
  274. continue
  275. cost += grams * rate
  276. priced_grams += grams
  277. if priced_grams <= 0:
  278. return
  279. # Grams no spool priced -- unmapped slots, empty trays, unpriced spools, and
  280. # anything the 3MF didn't attribute to a slot -- at the default rate, in
  281. # one subtraction against the archive's own total, as both completion
  282. # writers do.
  283. unpriced_grams = max(0.0, archive.filament_used_grams - priced_grams)
  284. if unpriced_grams > 0 and default_cost_per_kg > 0:
  285. cost += (unpriced_grams / 1000.0) * default_cost_per_kg
  286. if cost <= 0:
  287. return
  288. # The Spoolman lookups above can take a while. If the print already ended
  289. # -- a failure seconds in -- completion has written the real figure, and
  290. # an estimate must not replace it.
  291. await db.refresh(archive)
  292. still_running_first_run = (
  293. archive.status == "printing"
  294. and not (
  295. await db.execute(select(func.count(PrintLogEntry.id)).where(PrintLogEntry.archive_id == archive_id))
  296. ).scalar()
  297. )
  298. if not still_running_first_run:
  299. return
  300. new_cost = round(cost, 2)
  301. if new_cost == archive.cost:
  302. return
  303. logger.info(
  304. "[COST] Archive %s: estimated cost %s -> %s at print start (%.2fg priced from %s spools via %s mapping, "
  305. "%.2fg at the default rate)",
  306. archive_id,
  307. archive.cost,
  308. new_cost,
  309. priced_grams,
  310. "Spoolman" if spoolman_enabled else "inventory",
  311. source,
  312. unpriced_grams,
  313. )
  314. archive.cost = new_cost
  315. await db.commit()
  316. from backend.app.core.websocket import ws_manager
  317. await ws_manager.send_archive_updated({"id": archive_id, "cost": new_cost})