print_cost_estimate.py 7.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206
  1. """Trusted server-side cost estimates for queued prints."""
  2. import json
  3. import logging
  4. from decimal import ROUND_HALF_UP, Decimal
  5. from pathlib import Path
  6. from sqlalchemy import select
  7. from sqlalchemy.ext.asyncio import AsyncSession
  8. from sqlalchemy.orm import selectinload
  9. from backend.app.core.config import settings
  10. from backend.app.models.archive import PrintArchive
  11. from backend.app.models.library import LibraryFile
  12. from backend.app.models.spool_assignment import SpoolAssignment
  13. from backend.app.services.inventory_mode import spoolman_owns_assignments
  14. from backend.app.utils import threemf_tools
  15. from backend.app.utils.safe_path import safe_join_under
  16. logger = logging.getLogger(__name__)
  17. def plate_scoped_run_estimate(
  18. archive: PrintArchive,
  19. full_path: Path | None,
  20. plate_id: int | None = None,
  21. ) -> tuple[float | None, float | None]:
  22. """Return trusted ``(grams, cost)`` for one run of an archive plate."""
  23. whole_grams = archive.filament_used_grams
  24. selected_plate = archive.plate_id if plate_id is None else plate_id
  25. if selected_plate is None or full_path is None or not full_path.exists():
  26. return whole_grams, archive.cost
  27. try:
  28. plate_grams = threemf_tools.extract_plate_metadata_from_3mf(full_path, selected_plate).filament_used_grams
  29. except Exception as exc:
  30. logger.debug(
  31. "Plate-scoped estimate failed for archive %s (plate %s): %s",
  32. archive.id,
  33. selected_plate,
  34. exc,
  35. )
  36. return whole_grams, archive.cost
  37. if not plate_grams or plate_grams <= 0:
  38. return whole_grams, archive.cost
  39. plate_cost = archive.cost
  40. if archive.cost and whole_grams and whole_grams > 0:
  41. plate_cost = round(archive.cost * (plate_grams / whole_grams), 2)
  42. return round(plate_grams, 2), plate_cost
  43. def _source_path(library_file: LibraryFile) -> Path:
  44. path = Path(library_file.file_path)
  45. if path.is_absolute():
  46. # SEC-PATH-OK: absolute paths are persisted LibraryFile locations for
  47. # configured external libraries; this branch performs no path join.
  48. return path
  49. return safe_join_under(settings.base_dir, library_file.file_path, http=False)
  50. def _parse_mapping(mapping: list[int] | str | None) -> list[int] | None:
  51. if isinstance(mapping, list):
  52. return mapping
  53. if isinstance(mapping, str):
  54. try:
  55. parsed = json.loads(mapping)
  56. except (TypeError, json.JSONDecodeError):
  57. return None
  58. return parsed if isinstance(parsed, list) else None
  59. return None
  60. def _global_tray_id(assignment: SpoolAssignment) -> int:
  61. if assignment.ams_id == 255:
  62. return 254 + assignment.tray_id
  63. if assignment.ams_id >= 128:
  64. return assignment.ams_id
  65. return assignment.ams_id * 4 + assignment.tray_id
  66. async def default_cost_per_kg(db: AsyncSession) -> float:
  67. """The default filament price per kg, as the Settings page shows it: 25
  68. when it was never saved."""
  69. from backend.app.api.routes.settings import get_setting
  70. raw = await get_setting(db, "default_filament_cost")
  71. try:
  72. return float(raw) if raw not in (None, "") else 25.0
  73. except (TypeError, ValueError):
  74. return 25.0
  75. def price_one_print(
  76. grams: float | None,
  77. usage_cost: float | None,
  78. tracked_grams: float,
  79. printed_grams: float,
  80. runs: int,
  81. fallback_cost_per_kg: float,
  82. default_cost_per_kg: float,
  83. ) -> float | None:
  84. """Cost of one print of ``grams``, the figure an archive carries (#1378).
  85. ``usage_cost`` / ``tracked_grams`` are the priced spool usage recorded
  86. against the archive, over every run of it; ``printed_grams`` / ``runs`` are
  87. those runs' grams and count from Print History. The spools tracked
  88. ``tracked_grams`` of ``printed_grams``: one print gets that share at their
  89. average price per gram and the rest at the default rate (#1344). Without
  90. priced usage, everything is priced at ``fallback_cost_per_kg``.
  91. """
  92. if usage_cost and usage_cost > 0 and tracked_grams > 0:
  93. if not grams:
  94. # No figure for one print: an average run's usage is the best one.
  95. cost = usage_cost / max(1, runs)
  96. else:
  97. expected = printed_grams if printed_grams > 0 else grams
  98. covered = grams * min(1.0, tracked_grams / expected)
  99. cost = covered * (usage_cost / tracked_grams) + (grams - covered) / 1000.0 * default_cost_per_kg
  100. elif grams:
  101. cost = grams / 1000.0 * fallback_cost_per_kg
  102. else:
  103. return None
  104. return float(Decimal(str(cost)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP))
  105. async def estimate_queue_source_cost(
  106. db: AsyncSession,
  107. *,
  108. archive: PrintArchive | None = None,
  109. library_file: LibraryFile | None = None,
  110. plate_id: int | None = None,
  111. ams_mapping: list[int] | str | None = None,
  112. printer_id: int | None = None,
  113. ) -> float | None:
  114. """Compute a queue cost without trusting the request's display hint."""
  115. if archive is not None:
  116. archive_path = settings.base_dir / archive.file_path
  117. grams, cost = plate_scoped_run_estimate(archive, archive_path, plate_id)
  118. if cost is not None and cost > 0:
  119. return float(cost)
  120. # Older archives and imports can have trustworthy filament usage but
  121. # no stored cost. Model-based and multi-printer jobs have no single
  122. # spool mapping at enqueue time, so use the server setting rather than
  123. # requiring the browser to provide an estimate.
  124. if grams is None or grams <= 0:
  125. return None
  126. default_cost = await default_cost_per_kg(db)
  127. estimated_cost = (grams / 1000.0) * default_cost
  128. return max(0.01, round(estimated_cost, 2)) if estimated_cost > 0 else None
  129. if library_file is None:
  130. return None
  131. path = _source_path(library_file)
  132. usage: list[dict] = []
  133. if path.exists():
  134. usage = threemf_tools.extract_plate_metadata_from_3mf(path, plate_id).filament_usage
  135. metadata = library_file.file_metadata or {}
  136. if not usage:
  137. try:
  138. grams = float(metadata.get("filament_used_grams") or 0)
  139. except (TypeError, ValueError):
  140. grams = 0
  141. if grams > 0:
  142. usage = [{"slot_id": 1, "used_g": grams}]
  143. if not usage:
  144. return None
  145. default_cost = await default_cost_per_kg(db)
  146. cost_by_tray: dict[int, float | None] = {}
  147. mapping = _parse_mapping(ams_mapping)
  148. # Built-in spool prices only. In Spoolman mode the built-in table may still
  149. # hold rows from before the user switched -- nothing clears it since #2812 --
  150. # and pricing an estimate from a spool the printer is not drawing on would
  151. # be worse than the default rate this falls back to.
  152. if printer_id is not None and mapping and not await spoolman_owns_assignments(db):
  153. assignments = (
  154. (
  155. await db.execute(
  156. select(SpoolAssignment)
  157. .options(selectinload(SpoolAssignment.spool))
  158. .where(SpoolAssignment.printer_id == printer_id)
  159. )
  160. )
  161. .scalars()
  162. .all()
  163. )
  164. cost_by_tray = {_global_tray_id(a): a.spool.cost_per_kg for a in assignments}
  165. total = 0.0
  166. for filament in usage:
  167. try:
  168. slot_id = int(filament.get("slot_id") or 0)
  169. grams = float(filament.get("used_g") or 0)
  170. except (TypeError, ValueError):
  171. continue
  172. tray_id = mapping[slot_id - 1] if mapping and 0 < slot_id <= len(mapping) else None
  173. cost_per_kg = cost_by_tray.get(tray_id) if tray_id is not None else None
  174. if cost_per_kg is None or cost_per_kg <= 0:
  175. cost_per_kg = default_cost
  176. total += (grams / 1000.0) * cost_per_kg
  177. return round(total, 2) if total > 0 else None