print_log.py 8.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221
  1. """Service for writing independent print log entries.
  2. Log entries are written to a separate table and never touch archives or queue items.
  3. """
  4. import logging
  5. from collections.abc import Set as AbstractSet
  6. from datetime import datetime, timezone
  7. from sqlalchemy import func, or_, select
  8. from sqlalchemy.ext.asyncio import AsyncSession
  9. from backend.app.models.print_log import PrintLogEntry
  10. logger = logging.getLogger(__name__)
  11. def wear_cost_for_run(duration_seconds: int | None, wear_cost_per_hour: float | None) -> float | None:
  12. """Printer wear for one run: its duration at the printer's hourly rate (#694).
  13. None when the printer has no rate or the run has no measured duration (a
  14. reconciled run stores 0 because its real end time is unknown).
  15. """
  16. if not isinstance(wear_cost_per_hour, int | float) or not isinstance(duration_seconds, int):
  17. return None
  18. if wear_cost_per_hour <= 0 or duration_seconds <= 0:
  19. return None
  20. return round(duration_seconds / 3600 * wear_cost_per_hour, 3)
  21. async def write_log_entry(
  22. db: AsyncSession,
  23. *,
  24. status: str,
  25. archive_id: int | None = None,
  26. queue_item_id: int | None = None,
  27. print_name: str | None = None,
  28. plate_id: int | None = None,
  29. printer_name: str | None = None,
  30. printer_id: int | None = None,
  31. started_at: datetime | None = None,
  32. completed_at: datetime | None = None,
  33. filament_type: str | None = None,
  34. filament_color: str | None = None,
  35. filament_used_grams: float | None = None,
  36. cost: float | None = None,
  37. energy_kwh: float | None = None,
  38. energy_cost: float | None = None,
  39. wear_cost_per_hour: float | None = None,
  40. failure_reason: str | None = None,
  41. thumbnail_path: str | None = None,
  42. created_by_id: int | None = None,
  43. created_by_username: str | None = None,
  44. reconciled: bool = False,
  45. duration_unmeasured: bool = False,
  46. ) -> PrintLogEntry:
  47. """Write a print log entry.
  48. ``reconciled`` marks a synthetic completion written when a stale
  49. ``status="printing"`` archive is closed out at reconnect. Its real end time
  50. is unknown — the print stopped somewhere during the disconnect and
  51. ``completed_at`` is only the reconnect moment — so ``completed_at -
  52. started_at`` would bank the entire disconnect gap as print time, adding
  53. hundreds of fictitious hours across a farm of stale rows (#2592). For those
  54. entries we store an explicit ``0`` ("no measured runtime") rather than a
  55. fabricated duration; the stats total trusts a stored 0 instead of
  56. recomputing from the stale timestamps.
  57. ``duration_unmeasured`` marks a run closed at reconnect, whose times were
  58. not measured (#3310): ``completed_at`` is its start plus the slicer's
  59. estimate, or the reconnect. The time accuracy leaves it out.
  60. """
  61. if reconciled:
  62. duration: int | None = 0
  63. elif started_at and completed_at:
  64. duration = int((_utc(completed_at) - _utc(started_at)).total_seconds())
  65. else:
  66. duration = None
  67. fields = {
  68. "archive_id": archive_id,
  69. "queue_item_id": queue_item_id,
  70. "print_name": print_name,
  71. "plate_id": plate_id,
  72. "printer_name": printer_name,
  73. "printer_id": printer_id,
  74. "status": status,
  75. "started_at": started_at,
  76. "completed_at": completed_at,
  77. "duration_seconds": duration,
  78. "filament_type": filament_type,
  79. "filament_color": filament_color,
  80. "filament_used_grams": filament_used_grams,
  81. "cost": cost,
  82. "energy_kwh": energy_kwh,
  83. "energy_cost": energy_cost,
  84. "wear_cost": wear_cost_for_run(duration, wear_cost_per_hour),
  85. "duration_unmeasured": True if duration_unmeasured else None,
  86. "failure_reason": failure_reason,
  87. "thumbnail_path": thumbnail_path,
  88. "created_by_id": created_by_id,
  89. "created_by_username": created_by_username,
  90. }
  91. entry = await _entry_of_this_run(db, archive_id, printer_id, started_at)
  92. if entry is None:
  93. entry = PrintLogEntry(**fields)
  94. db.add(entry)
  95. elif entry.status != status:
  96. # The run ended differently from how it was first logged: a print
  97. # stopped while its printer was offline that finished after all. The
  98. # real outcome replaces the stop rather than counting the run twice.
  99. for name, value in fields.items():
  100. if value is None and name in _KEPT_WHEN_NOT_REPORTED:
  101. continue
  102. setattr(entry, name, value)
  103. # The same outcome reported again (a repeated completion after a reconnect)
  104. # changes nothing: the repeat no longer has the run's tracked usage, and the
  105. # entry may have been edited or given its energy since.
  106. await db.flush()
  107. return entry
  108. async def mirror_archive_cost(
  109. db: AsyncSession, archive, previous_cost: float | None, *, skip_runs: AbstractSet[int] = frozenset()
  110. ) -> None:
  111. """Give the archive's newest run the archive's new cost, if it had the old one.
  112. Statistics, project totals and batch sums read a run's cost. A completed
  113. run without its own spool price took the archive's (or none), so a new
  114. archive cost belongs to it too. A run its spools priced keeps that price,
  115. and a failed or cancelled run keeps its own: it printed only part of it.
  116. A run in ``skip_runs`` was given its own new cost already.
  117. """
  118. entry = await db.scalar(
  119. select(PrintLogEntry).where(PrintLogEntry.archive_id == archive.id).order_by(PrintLogEntry.id.desc()).limit(1)
  120. )
  121. if entry is None or entry.id in skip_runs:
  122. return
  123. if entry.status == "completed" and entry.cost in (None, previous_cost):
  124. entry.cost = archive.cost
  125. async def other_runs_of(db: AsyncSession, archive) -> int:
  126. """How many log entries *archive* has from runs other than its current one.
  127. The archive card keeps its first run's figures; a run whose own entry is
  128. already written (its stop, logged while the printer was offline) is still
  129. the first run.
  130. """
  131. # Without a start time or printer on the archive, ``!= None`` reads as
  132. # ``IS NOT NULL``: every entry is another run's.
  133. query = select(func.count(PrintLogEntry.id)).where(
  134. PrintLogEntry.archive_id == archive.id,
  135. or_(
  136. PrintLogEntry.started_at.is_(None),
  137. PrintLogEntry.started_at != archive.started_at,
  138. PrintLogEntry.printer_id.is_(None),
  139. PrintLogEntry.printer_id != archive.printer_id,
  140. ),
  141. )
  142. return await db.scalar(query) or 0
  143. # Who and what the run was, which a repeated report may no longer know: a
  144. # completion that finds the queue item already closed reports none. Its
  145. # outcome (status, reason, grams, cost) is replaced as reported.
  146. _KEPT_WHEN_NOT_REPORTED = frozenset(
  147. {
  148. "queue_item_id",
  149. "print_name",
  150. "plate_id",
  151. "printer_name",
  152. "thumbnail_path",
  153. "created_by_id",
  154. "created_by_username",
  155. }
  156. )
  157. def _utc(moment: datetime) -> datetime:
  158. """Naive UTC, as the columns store it; an aware time is converted first."""
  159. return moment.astimezone(timezone.utc).replace(tzinfo=None) if moment.tzinfo else moment
  160. async def _entry_of_this_run(
  161. db: AsyncSession,
  162. archive_id: int | None,
  163. printer_id: int | None,
  164. started_at: datetime | None,
  165. ) -> PrintLogEntry | None:
  166. """The entry already written for this run, if any.
  167. A run is its archive, printer and start time together: plates of one
  168. archive printing on two printers share the archive row (and can share its
  169. start time), and a reprint starts at a new time. The queue item is not
  170. part of it: a repeated completion finds the item already closed and
  171. reports none. Without an archive, printer or start time a run can't be
  172. told apart, so nothing is matched.
  173. """
  174. if archive_id is None or printer_id is None or started_at is None:
  175. return None
  176. query = select(PrintLogEntry).where(
  177. PrintLogEntry.archive_id == archive_id,
  178. PrintLogEntry.printer_id == printer_id,
  179. PrintLogEntry.started_at == started_at,
  180. )
  181. return (await db.scalars(query.order_by(PrintLogEntry.id.desc()).limit(1))).first()
  182. async def record_archive_wear(db: AsyncSession, archive, entry: PrintLogEntry) -> None:
  183. """Copy a run's wear cost onto its archive when it is the archive's first run.
  184. Same rule as energy (#1378): the archive shows its first print, and a
  185. reprint's wear stays on its own log entry (#694). ``entry`` must already be
  186. flushed, so it counts as one of the archive's runs.
  187. """
  188. if entry.wear_cost is None:
  189. return
  190. runs = await db.scalar(select(func.count(PrintLogEntry.id)).where(PrintLogEntry.archive_id == archive.id))
  191. if (runs or 0) <= 1:
  192. archive.wear_cost = entry.wear_cost