print_cost_estimate.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333
  1. """Trusted server-side cost estimates for queued prints."""
  2. import asyncio
  3. import json
  4. import logging
  5. from dataclasses import dataclass
  6. from decimal import ROUND_HALF_UP, Decimal
  7. from pathlib import Path
  8. from typing import Any
  9. from sqlalchemy import select, update
  10. from sqlalchemy.ext.asyncio import AsyncSession
  11. from sqlalchemy.orm import selectinload
  12. from backend.app.core.config import settings
  13. from backend.app.models.archive import PrintArchive
  14. from backend.app.models.library import LibraryFile
  15. from backend.app.models.spool_assignment import SpoolAssignment
  16. from backend.app.services.inventory_mode import spoolman_owns_assignments
  17. from backend.app.utils import threemf_tools
  18. from backend.app.utils.safe_path import safe_join_under
  19. logger = logging.getLogger(__name__)
  20. @dataclass(frozen=True)
  21. class RunEstimate:
  22. """The slicer's estimate for one print of an archive (#3375).
  23. ``per_plate`` is False when the archive's plates are not known (the file
  24. not read, not sliced, or gone): the figures are then the whole file's,
  25. which describe a print only of a single-plate file.
  26. """
  27. seconds: int | None
  28. grams: float | None
  29. per_plate: bool
  30. def run_estimate(archive: Any, plate_id: int | None) -> RunEstimate | None:
  31. """What one print of plate ``plate_id`` of ``archive`` was estimated at:
  32. that plate's figures, or the only plate's. None for a print of a
  33. multi-plate file whose plate is not known. ``archive`` is an archive, or
  34. a row with its ``plate_estimates``, ``print_time_seconds`` and
  35. ``filament_used_grams``."""
  36. plates = archive.plate_estimates
  37. if not plates:
  38. return RunEstimate(archive.print_time_seconds, archive.filament_used_grams, per_plate=False)
  39. if plate_id is not None and str(plate_id) in plates:
  40. plate = plates[str(plate_id)]
  41. elif len(plates) == 1:
  42. plate = next(iter(plates.values()))
  43. else:
  44. return None
  45. return RunEstimate(plate.get("seconds") or None, plate.get("grams") or None, per_plate=True)
  46. def read_plate_estimates(archive: Any) -> dict | None:
  47. """The plate estimates of the archive's 3MF on disk. ``{}`` for a file that
  48. is not a 3MF; None when there is no file to read or it cannot be read."""
  49. if not archive.file_path:
  50. return None
  51. path = settings.base_dir / archive.file_path # SEC-PATH-OK: archive.file_path is DB-stored, internally generated
  52. if not path.is_file():
  53. return None
  54. if path.suffix.lower() != ".3mf":
  55. return {}
  56. return threemf_tools.extract_plate_estimates_from_3mf(path)
  57. def refresh_plate_estimates(archive: PrintArchive) -> None:
  58. """Read the plate estimates of the archive's file into it: when the
  59. archive is made, or given a file. Does not commit."""
  60. estimates = read_plate_estimates(archive)
  61. if estimates is not None:
  62. archive.plate_estimates = estimates
  63. async def fill_plate_estimates(session_factory, batch: int = 100) -> int:
  64. """Give every archive not yet read its plate estimates, from its 3MF.
  65. An archive whose file is missing or unreadable gets ``{}``: it is read
  66. once, its figures stay the whole file's. Returns how many were read.
  67. Each is written only while it is still unread: an archive given its 3MF
  68. meanwhile (``refresh_plate_estimates``) keeps what that read, and one
  69. deleted meanwhile is skipped."""
  70. filled = 0
  71. last_id = 0
  72. while True:
  73. async with session_factory() as db:
  74. rows = (
  75. await db.execute(
  76. select(PrintArchive.id, PrintArchive.file_path)
  77. .where(PrintArchive.plate_estimates.is_(None), PrintArchive.id > last_id)
  78. .order_by(PrintArchive.id)
  79. .limit(batch)
  80. )
  81. ).all()
  82. if not rows:
  83. return filled
  84. for row in rows:
  85. estimates = await asyncio.to_thread(read_plate_estimates, row)
  86. written = await db.execute(
  87. update(PrintArchive)
  88. .where(PrintArchive.id == row.id, PrintArchive.plate_estimates.is_(None))
  89. .values(plate_estimates=estimates if estimates is not None else {})
  90. .execution_options(synchronize_session=False)
  91. )
  92. filled += written.rowcount or 0
  93. last_id = rows[-1].id
  94. await db.commit()
  95. def plate_scoped_run_estimate(
  96. archive: PrintArchive,
  97. full_path: Path | None,
  98. plate_id: int | None = None,
  99. ) -> tuple[float | None, float | None]:
  100. """Return trusted ``(grams, cost)`` for one run of an archive plate."""
  101. whole_grams = archive.filament_used_grams
  102. selected_plate = archive.plate_id if plate_id is None else plate_id
  103. if selected_plate is None or full_path is None or not full_path.exists():
  104. return whole_grams, archive.cost
  105. try:
  106. plate_grams = threemf_tools.extract_plate_metadata_from_3mf(full_path, selected_plate).filament_used_grams
  107. except Exception as exc:
  108. logger.debug(
  109. "Plate-scoped estimate failed for archive %s (plate %s): %s",
  110. archive.id,
  111. selected_plate,
  112. exc,
  113. )
  114. return whole_grams, archive.cost
  115. if not plate_grams or plate_grams <= 0:
  116. return whole_grams, archive.cost
  117. plate_cost = archive.cost
  118. if archive.cost and whole_grams and whole_grams > 0:
  119. plate_cost = round(archive.cost * (plate_grams / whole_grams), 2)
  120. return round(plate_grams, 2), plate_cost
  121. def _source_path(library_file: LibraryFile) -> Path:
  122. path = Path(library_file.file_path)
  123. if path.is_absolute():
  124. # SEC-PATH-OK: absolute paths are persisted LibraryFile locations for
  125. # configured external libraries; this branch performs no path join.
  126. return path
  127. return safe_join_under(settings.base_dir, library_file.file_path, http=False)
  128. def _parse_mapping(mapping: list[int] | str | None) -> list[int] | None:
  129. if isinstance(mapping, list):
  130. return mapping
  131. if isinstance(mapping, str):
  132. try:
  133. parsed = json.loads(mapping)
  134. except (TypeError, json.JSONDecodeError):
  135. return None
  136. return parsed if isinstance(parsed, list) else None
  137. return None
  138. def _global_tray_id(assignment) -> int:
  139. if assignment.ams_id == 255:
  140. return 254 + assignment.tray_id
  141. if assignment.ams_id >= 128:
  142. return assignment.ams_id
  143. return assignment.ams_id * 4 + assignment.tray_id
  144. async def _spoolman_cost_by_tray(db: AsyncSession, printer_id: int, trays: set[int]) -> dict[int, float | None]:
  145. """Per-kg price of the Spoolman spool in each of ``trays``, from the spool's
  146. own price or its filament's. Only those spools are fetched, after a health
  147. check: this runs on queue add and at dispatch. Empty when Spoolman cannot
  148. be asked, and the estimate uses the default rate, as for an unpriced spool.
  149. """
  150. from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
  151. from backend.app.services.spoolman_tracking import _get_spoolman_client_with_fallback, _spool_cost_per_gram
  152. rows = [
  153. row
  154. for row in (
  155. await db.execute(select(SpoolmanSlotAssignment).where(SpoolmanSlotAssignment.printer_id == printer_id))
  156. )
  157. .scalars()
  158. .all()
  159. if _global_tray_id(row) in trays
  160. ]
  161. if not rows:
  162. return {}
  163. by_tray: dict[int, float | None] = {}
  164. try:
  165. client = await _get_spoolman_client_with_fallback()
  166. if client is None:
  167. return {}
  168. for row in rows:
  169. per_gram = _spool_cost_per_gram(await client.get_spool(row.spoolman_spool_id))
  170. by_tray[_global_tray_id(row)] = per_gram * 1000.0 if per_gram is not None else None
  171. except Exception as exc: # noqa: BLE001 — an estimate falls back to the default rate
  172. logger.debug("Spoolman prices unavailable for the queue estimate: %s", exc)
  173. return by_tray
  174. async def default_cost_per_kg(db: AsyncSession) -> float:
  175. """The default filament price per kg, as the Settings page shows it: 25
  176. when it was never saved."""
  177. from backend.app.api.routes.settings import get_setting
  178. raw = await get_setting(db, "default_filament_cost")
  179. try:
  180. return float(raw) if raw not in (None, "") else 25.0
  181. except (TypeError, ValueError):
  182. return 25.0
  183. def price_one_print(
  184. grams: float | None,
  185. usage_cost: float | None,
  186. tracked_grams: float,
  187. printed_grams: float,
  188. runs: int,
  189. fallback_cost_per_kg: float,
  190. default_cost_per_kg: float,
  191. ) -> float | None:
  192. """Cost of one print of ``grams``, the figure an archive carries (#1378).
  193. ``usage_cost`` / ``tracked_grams`` are the priced spool usage recorded
  194. against the archive, over every run of it; ``printed_grams`` / ``runs`` are
  195. those runs' grams and count from Print History. The spools tracked
  196. ``tracked_grams`` of ``printed_grams``: one print gets that share at their
  197. average price per gram and the rest at the default rate (#1344). Without
  198. priced usage, everything is priced at ``fallback_cost_per_kg``.
  199. """
  200. if usage_cost and usage_cost > 0 and tracked_grams > 0:
  201. if not grams:
  202. # No figure for one print: an average run's usage is the best one.
  203. cost = usage_cost / max(1, runs)
  204. else:
  205. expected = printed_grams if printed_grams > 0 else grams
  206. covered = grams * min(1.0, tracked_grams / expected)
  207. cost = covered * (usage_cost / tracked_grams) + (grams - covered) / 1000.0 * default_cost_per_kg
  208. elif grams:
  209. cost = grams / 1000.0 * fallback_cost_per_kg
  210. else:
  211. return None
  212. return float(Decimal(str(cost)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP))
  213. async def estimate_queue_source_cost(
  214. db: AsyncSession,
  215. *,
  216. archive: PrintArchive | None = None,
  217. library_file: LibraryFile | None = None,
  218. plate_id: int | None = None,
  219. ams_mapping: list[int] | str | None = None,
  220. printer_id: int | None = None,
  221. ) -> float | None:
  222. """Compute a queue cost without trusting the request's display hint."""
  223. if archive is not None:
  224. archive_path = settings.base_dir / archive.file_path
  225. grams, cost = plate_scoped_run_estimate(archive, archive_path, plate_id)
  226. if cost is not None and cost > 0:
  227. return float(cost)
  228. # Older archives and imports can have trustworthy filament usage but
  229. # no stored cost. Model-based and multi-printer jobs have no single
  230. # spool mapping at enqueue time, so use the server setting rather than
  231. # requiring the browser to provide an estimate.
  232. if grams is None or grams <= 0:
  233. return None
  234. default_cost = await default_cost_per_kg(db)
  235. estimated_cost = (grams / 1000.0) * default_cost
  236. return max(0.01, round(estimated_cost, 2)) if estimated_cost > 0 else None
  237. if library_file is None:
  238. return None
  239. path = _source_path(library_file)
  240. usage: list[dict] = []
  241. if path.exists():
  242. usage = threemf_tools.extract_plate_metadata_from_3mf(path, plate_id).filament_usage
  243. metadata = library_file.file_metadata or {}
  244. if not usage:
  245. try:
  246. grams = float(metadata.get("filament_used_grams") or 0)
  247. except (TypeError, ValueError):
  248. grams = 0
  249. if grams > 0:
  250. usage = [{"slot_id": 1, "used_g": grams}]
  251. if not usage:
  252. return None
  253. default_cost = await default_cost_per_kg(db)
  254. cost_by_tray: dict[int, float | None] = {}
  255. mapping = _parse_mapping(ams_mapping)
  256. # The prices of the spools the mode's own table puts in those trays. The
  257. # built-in table may still hold rows from before a switch to Spoolman --
  258. # nothing clears it since #2812 -- and pricing from a spool the printer is
  259. # not drawing on would be worse than the default rate this falls back to.
  260. spoolman_mode = await spoolman_owns_assignments(db)
  261. if printer_id is not None and mapping and spoolman_mode:
  262. cost_by_tray = await _spoolman_cost_by_tray(db, printer_id, {t for t in mapping if t >= 0})
  263. if printer_id is not None and mapping and not spoolman_mode:
  264. assignments = (
  265. (
  266. await db.execute(
  267. select(SpoolAssignment)
  268. .options(selectinload(SpoolAssignment.spool))
  269. .where(SpoolAssignment.printer_id == printer_id)
  270. )
  271. )
  272. .scalars()
  273. .all()
  274. )
  275. cost_by_tray = {_global_tray_id(a): a.spool.cost_per_kg for a in assignments}
  276. total = 0.0
  277. for filament in usage:
  278. try:
  279. slot_id = int(filament.get("slot_id") or 0)
  280. grams = float(filament.get("used_g") or 0)
  281. except (TypeError, ValueError):
  282. continue
  283. tray_id = mapping[slot_id - 1] if mapping and 0 < slot_id <= len(mapping) else None
  284. cost_per_kg = cost_by_tray.get(tray_id) if tray_id is not None else None
  285. if cost_per_kg is None or cost_per_kg <= 0:
  286. cost_per_kg = default_cost
  287. total += (grams / 1000.0) * cost_per_kg
  288. return round(total, 2) if total > 0 else None