archive_cost_estimate.py 13 KB

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