archive_cost_estimate.py 14 KB

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