print_cost_estimate.py 5.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165
  1. """Trusted server-side cost estimates for queued prints."""
  2. import json
  3. import logging
  4. from pathlib import Path
  5. from sqlalchemy import select
  6. from sqlalchemy.ext.asyncio import AsyncSession
  7. from sqlalchemy.orm import selectinload
  8. from backend.app.core.config import settings
  9. from backend.app.models.archive import PrintArchive
  10. from backend.app.models.library import LibraryFile
  11. from backend.app.models.spool_assignment import SpoolAssignment
  12. from backend.app.utils import threemf_tools
  13. from backend.app.utils.safe_path import safe_join_under
  14. logger = logging.getLogger(__name__)
  15. def plate_scoped_run_estimate(
  16. archive: PrintArchive,
  17. full_path: Path | None,
  18. plate_id: int | None = None,
  19. ) -> tuple[float | None, float | None]:
  20. """Return trusted ``(grams, cost)`` for one run of an archive plate."""
  21. whole_grams = archive.filament_used_grams
  22. selected_plate = archive.plate_id if plate_id is None else plate_id
  23. if selected_plate is None or full_path is None or not full_path.exists():
  24. return whole_grams, archive.cost
  25. try:
  26. plate_grams = threemf_tools.extract_plate_metadata_from_3mf(full_path, selected_plate).filament_used_grams
  27. except Exception as exc:
  28. logger.debug(
  29. "Plate-scoped estimate failed for archive %s (plate %s): %s",
  30. archive.id,
  31. selected_plate,
  32. exc,
  33. )
  34. return whole_grams, archive.cost
  35. if not plate_grams or plate_grams <= 0:
  36. return whole_grams, archive.cost
  37. plate_cost = archive.cost
  38. if archive.cost and whole_grams and whole_grams > 0:
  39. plate_cost = round(archive.cost * (plate_grams / whole_grams), 2)
  40. return round(plate_grams, 2), plate_cost
  41. def _source_path(library_file: LibraryFile) -> Path:
  42. path = Path(library_file.file_path)
  43. if path.is_absolute():
  44. # SEC-PATH-OK: absolute paths are persisted LibraryFile locations for
  45. # configured external libraries; this branch performs no path join.
  46. return path
  47. return safe_join_under(settings.base_dir, library_file.file_path, http=False)
  48. def _parse_mapping(mapping: list[int] | str | None) -> list[int] | None:
  49. if isinstance(mapping, list):
  50. return mapping
  51. if isinstance(mapping, str):
  52. try:
  53. parsed = json.loads(mapping)
  54. except (TypeError, json.JSONDecodeError):
  55. return None
  56. return parsed if isinstance(parsed, list) else None
  57. return None
  58. def _global_tray_id(assignment: SpoolAssignment) -> int:
  59. if assignment.ams_id == 255:
  60. return 254 + assignment.tray_id
  61. if assignment.ams_id >= 128:
  62. return assignment.ams_id
  63. return assignment.ams_id * 4 + assignment.tray_id
  64. async def _default_cost_per_kg(db: AsyncSession) -> float:
  65. from backend.app.api.routes.settings import get_setting
  66. raw = await get_setting(db, "default_filament_cost")
  67. try:
  68. return float(raw) if raw is not None else 25.0
  69. except (TypeError, ValueError):
  70. return 25.0
  71. async def estimate_queue_source_cost(
  72. db: AsyncSession,
  73. *,
  74. archive: PrintArchive | None = None,
  75. library_file: LibraryFile | None = None,
  76. plate_id: int | None = None,
  77. ams_mapping: list[int] | str | None = None,
  78. printer_id: int | None = None,
  79. ) -> float | None:
  80. """Compute a queue cost without trusting the request's display hint."""
  81. if archive is not None:
  82. archive_path = settings.base_dir / archive.file_path
  83. grams, cost = plate_scoped_run_estimate(archive, archive_path, plate_id)
  84. if cost is not None and cost > 0:
  85. return float(cost)
  86. # Older archives and imports can have trustworthy filament usage but
  87. # no stored cost. Model-based and multi-printer jobs have no single
  88. # spool mapping at enqueue time, so use the server setting rather than
  89. # requiring the browser to provide an estimate.
  90. if grams is None or grams <= 0:
  91. return None
  92. default_cost = await _default_cost_per_kg(db)
  93. estimated_cost = (grams / 1000.0) * default_cost
  94. return max(0.01, round(estimated_cost, 2)) if estimated_cost > 0 else None
  95. if library_file is None:
  96. return None
  97. path = _source_path(library_file)
  98. usage: list[dict] = []
  99. if path.exists():
  100. usage = threemf_tools.extract_plate_metadata_from_3mf(path, plate_id).filament_usage
  101. metadata = library_file.file_metadata or {}
  102. if not usage:
  103. try:
  104. grams = float(metadata.get("filament_used_grams") or 0)
  105. except (TypeError, ValueError):
  106. grams = 0
  107. if grams > 0:
  108. usage = [{"slot_id": 1, "used_g": grams}]
  109. if not usage:
  110. return None
  111. default_cost = await _default_cost_per_kg(db)
  112. cost_by_tray: dict[int, float | None] = {}
  113. mapping = _parse_mapping(ams_mapping)
  114. if printer_id is not None and mapping:
  115. assignments = (
  116. (
  117. await db.execute(
  118. select(SpoolAssignment)
  119. .options(selectinload(SpoolAssignment.spool))
  120. .where(SpoolAssignment.printer_id == printer_id)
  121. )
  122. )
  123. .scalars()
  124. .all()
  125. )
  126. cost_by_tray = {_global_tray_id(a): a.spool.cost_per_kg for a in assignments}
  127. total = 0.0
  128. for filament in usage:
  129. try:
  130. slot_id = int(filament.get("slot_id") or 0)
  131. grams = float(filament.get("used_g") or 0)
  132. except (TypeError, ValueError):
  133. continue
  134. tray_id = mapping[slot_id - 1] if mapping and 0 < slot_id <= len(mapping) else None
  135. cost_per_kg = cost_by_tray.get(tray_id) if tray_id is not None else None
  136. if cost_per_kg is None or cost_per_kg <= 0:
  137. cost_per_kg = default_cost
  138. total += (grams / 1000.0) * cost_per_kg
  139. return round(total, 2) if total > 0 else None