archive_cost_estimate.py 15 KB

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