failure_analysis.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303
  1. from collections import defaultdict
  2. from datetime import date, datetime, timedelta, timezone, tzinfo
  3. from sqlalchemy import and_, func, select
  4. from sqlalchemy.ext.asyncio import AsyncSession
  5. from backend.app.core.printer_scope import PrinterScope
  6. from backend.app.models.print_log import PrintLogEntry
  7. from backend.app.models.printer import Printer
  8. from backend.app.utils.local_time import local_date_end, local_date_start
  9. class FailureAnalysisService:
  10. """Service for analyzing print failure patterns.
  11. Reads from print_log_entries (per-event data) rather than print_archives
  12. so reprints contribute each run and orphan events (archive deleted, log
  13. row survived via ON DELETE SET NULL) still count consistently with
  14. Quick Stats. The archive-based predecessor diverged from Quick Stats
  15. after #1378 moved the rest of the page to per-event aggregation.
  16. """
  17. def __init__(self, db: AsyncSession):
  18. self.db = db
  19. async def analyze_failures(
  20. self,
  21. days: int | None = None,
  22. date_from: date | None = None,
  23. date_to: date | None = None,
  24. printer_id: int | None = None,
  25. project_id: int | None = None,
  26. created_by_id: int | None = None,
  27. printer_scope: PrinterScope | None = None,
  28. day_zone: tzinfo | None = None,
  29. ) -> dict:
  30. """Analyze failure patterns across logged print events.
  31. ``day_zone``: the zone ``date_from``/``date_to`` are calendar days in
  32. (the server's when None).
  33. """
  34. # Build base query — separate date vs non-date filters for trend reuse
  35. base_filter = []
  36. non_date_filter = []
  37. if date_from or date_to:
  38. if date_from:
  39. dt_from = local_date_start(date_from, day_zone)
  40. base_filter.append(PrintLogEntry.created_at >= dt_from)
  41. if date_to:
  42. dt_to = local_date_end(date_to, day_zone)
  43. base_filter.append(PrintLogEntry.created_at <= dt_to)
  44. range_start = dt_from if date_from else datetime.now(timezone.utc) - timedelta(days=365)
  45. range_end = dt_to if date_to else datetime.now(timezone.utc)
  46. effective_days = max((range_end - range_start).days, 1)
  47. else:
  48. effective_days = days if days is not None else 30
  49. cutoff_date = datetime.now(timezone.utc) - timedelta(days=effective_days)
  50. base_filter.append(PrintLogEntry.created_at >= cutoff_date)
  51. if printer_id:
  52. non_date_filter.append(PrintLogEntry.printer_id == printer_id)
  53. # project_id is an archive-level concept; PrintLogEntry has no project
  54. # link, so we resolve it by archive_id where present.
  55. if project_id:
  56. from backend.app.models.archive import PrintArchive
  57. # Soft-deleted archives (#1343) keep their project_id, so without
  58. # this the failure rate for a project still counts prints the user
  59. # deleted from it — and disagrees with the project's own numbers,
  60. # which now exclude them (#2731).
  61. project_archive_ids = await self.db.execute(
  62. select(PrintArchive.id).where(
  63. PrintArchive.project_id == project_id,
  64. PrintArchive.deleted_at.is_(None),
  65. )
  66. )
  67. archive_ids = [row[0] for row in project_archive_ids.fetchall()]
  68. if archive_ids:
  69. non_date_filter.append(PrintLogEntry.archive_id.in_(archive_ids))
  70. else:
  71. # No archives in this project → nothing to count
  72. non_date_filter.append(PrintLogEntry.id.is_(None))
  73. if created_by_id is not None:
  74. if created_by_id == -1:
  75. non_date_filter.append(PrintLogEntry.created_by_id.is_(None))
  76. else:
  77. non_date_filter.append(PrintLogEntry.created_by_id == created_by_id)
  78. # Only prints on printers the caller may see (#1727)
  79. if printer_scope is not None and (clause := printer_scope.where(PrintLogEntry.printer_id)) is not None:
  80. non_date_filter.append(clause)
  81. base_filter.extend(non_date_filter)
  82. # Total counts
  83. total_result = await self.db.execute(select(func.count(PrintLogEntry.id)).where(and_(*base_filter)))
  84. total_prints = total_result.scalar() or 0
  85. successful_result = await self.db.execute(
  86. select(func.count(PrintLogEntry.id)).where(and_(*base_filter, PrintLogEntry.status == "completed"))
  87. )
  88. successful_prints = successful_result.scalar() or 0
  89. failed_result = await self.db.execute(
  90. select(func.count(PrintLogEntry.id)).where(
  91. and_(*base_filter, PrintLogEntry.status.in_(["failed", "aborted"]))
  92. )
  93. )
  94. failed_prints = failed_result.scalar() or 0
  95. # Failure rate divides by quality-outcome prints only — a cancelled or
  96. # skipped print is neither a success nor a failure of the printer, so
  97. # including it in the denominator silently lowered the displayed rate
  98. # whenever the user stopped jobs (#1390). Total Prints (the absolute
  99. # count incl. cancelled) is still returned separately for the "X / Y
  100. # prints failed" caption.
  101. outcome_prints = successful_prints + failed_prints
  102. failure_rate = (failed_prints / outcome_prints * 100) if outcome_prints > 0 else 0
  103. # Quality dimension (#1898): a completed print the user marked as
  104. # reject is machine-success but scrap. Kept OUT of failure_rate — that
  105. # number stays the machine's — and reported separately, with a yield
  106. # rate that treats rejects as non-good output.
  107. rejected_result = await self.db.execute(
  108. select(func.count(PrintLogEntry.id)).where(
  109. and_(*base_filter, PrintLogEntry.status == "completed", PrintLogEntry.user_verdict == "reject")
  110. )
  111. )
  112. rejected_prints = rejected_result.scalar() or 0
  113. yield_rate = ((successful_prints - rejected_prints) / outcome_prints * 100) if outcome_prints > 0 else 0
  114. rejects_reason_result = await self.db.execute(
  115. select(
  116. PrintLogEntry.failure_reason,
  117. func.count(PrintLogEntry.id).label("count"),
  118. )
  119. .where(and_(*base_filter, PrintLogEntry.status == "completed", PrintLogEntry.user_verdict == "reject"))
  120. .group_by(PrintLogEntry.failure_reason)
  121. .order_by(func.count(PrintLogEntry.id).desc())
  122. )
  123. rejects_by_reason = {(row[0] or "Unknown"): row[1] for row in rejects_reason_result.fetchall()}
  124. # Failures by reason
  125. reason_result = await self.db.execute(
  126. select(
  127. PrintLogEntry.failure_reason,
  128. func.count(PrintLogEntry.id).label("count"),
  129. )
  130. .where(and_(*base_filter, PrintLogEntry.status.in_(["failed", "aborted"])))
  131. .group_by(PrintLogEntry.failure_reason)
  132. .order_by(func.count(PrintLogEntry.id).desc())
  133. )
  134. failures_by_reason = {(row[0] or "Unknown"): row[1] for row in reason_result.fetchall()}
  135. # Failures by filament type
  136. filament_result = await self.db.execute(
  137. select(
  138. PrintLogEntry.filament_type,
  139. func.count(PrintLogEntry.id).label("count"),
  140. )
  141. .where(and_(*base_filter, PrintLogEntry.status.in_(["failed", "aborted"])))
  142. .group_by(PrintLogEntry.filament_type)
  143. .order_by(func.count(PrintLogEntry.id).desc())
  144. )
  145. failures_by_filament = {(row[0] or "Unknown"): row[1] for row in filament_result.fetchall()}
  146. # Failures by printer
  147. printer_result = await self.db.execute(
  148. select(
  149. PrintLogEntry.printer_id,
  150. func.count(PrintLogEntry.id).label("count"),
  151. )
  152. .where(
  153. and_(
  154. *base_filter,
  155. PrintLogEntry.status.in_(["failed", "aborted"]),
  156. PrintLogEntry.printer_id.isnot(None),
  157. )
  158. )
  159. .group_by(PrintLogEntry.printer_id)
  160. .order_by(func.count(PrintLogEntry.id).desc())
  161. )
  162. failures_by_printer_id = {row[0]: row[1] for row in printer_result.fetchall()}
  163. # Get printer names
  164. if failures_by_printer_id:
  165. printers_result = await self.db.execute(
  166. select(Printer.id, Printer.name).where(Printer.id.in_(failures_by_printer_id.keys()))
  167. )
  168. printer_names = {row[0]: row[1] for row in printers_result.fetchall()}
  169. # A printer deleted with its history kept has no row left to read a
  170. # name from, and "Printer 3" tells nobody which machine kept failing
  171. # (#2873). Each run recorded the name it printed on, so fall back to
  172. # the last one that id was known by.
  173. missing = [pid for pid in failures_by_printer_id if pid not in printer_names]
  174. if missing:
  175. last_named_run = (
  176. select(func.max(PrintLogEntry.id).label("entry_id"))
  177. .where(PrintLogEntry.printer_id.in_(missing), PrintLogEntry.printer_name.isnot(None))
  178. .group_by(PrintLogEntry.printer_id)
  179. .subquery()
  180. )
  181. historic_result = await self.db.execute(
  182. select(PrintLogEntry.printer_id, PrintLogEntry.printer_name).join(
  183. last_named_run, PrintLogEntry.id == last_named_run.c.entry_id
  184. )
  185. )
  186. for pid, name in historic_result.fetchall():
  187. printer_names[pid] = name
  188. failures_by_printer = {
  189. printer_names.get(pid, f"Printer {pid}"): count for pid, count in failures_by_printer_id.items()
  190. }
  191. else:
  192. failures_by_printer = {}
  193. # Failures by hour of day
  194. failed_events_result = await self.db.execute(
  195. select(PrintLogEntry.started_at).where(
  196. and_(
  197. *base_filter,
  198. PrintLogEntry.status.in_(["failed", "aborted"]),
  199. PrintLogEntry.started_at.isnot(None),
  200. )
  201. )
  202. )
  203. failures_by_hour = defaultdict(int)
  204. for (started_at,) in failed_events_result.fetchall():
  205. if started_at:
  206. hour = started_at.hour
  207. failures_by_hour[hour] += 1
  208. failures_by_hour_complete = {h: failures_by_hour.get(h, 0) for h in range(24)}
  209. # Recent failures
  210. recent_result = await self.db.execute(
  211. select(PrintLogEntry)
  212. .where(and_(*base_filter, PrintLogEntry.status.in_(["failed", "aborted"])))
  213. .order_by(PrintLogEntry.created_at.desc())
  214. .limit(10)
  215. )
  216. recent_failures = [
  217. {
  218. "id": e.archive_id,
  219. "print_name": e.print_name,
  220. "failure_reason": e.failure_reason,
  221. "filament_type": e.filament_type,
  222. "printer_id": e.printer_id,
  223. "created_at": e.created_at.isoformat() if e.created_at else None,
  224. }
  225. for e in recent_result.scalars().all()
  226. ]
  227. # Failure rate trend (by week)
  228. trend_data = []
  229. num_weeks = max(effective_days // 7, 1)
  230. for i in range(num_weeks):
  231. week_end = datetime.now(timezone.utc) - timedelta(weeks=i)
  232. week_start = week_end - timedelta(weeks=1)
  233. week_filter = [
  234. PrintLogEntry.created_at >= week_start,
  235. PrintLogEntry.created_at < week_end,
  236. *non_date_filter,
  237. ]
  238. week_total = await self.db.execute(select(func.count(PrintLogEntry.id)).where(and_(*week_filter)))
  239. week_successful = await self.db.execute(
  240. select(func.count(PrintLogEntry.id)).where(and_(*week_filter, PrintLogEntry.status == "completed"))
  241. )
  242. week_failed = await self.db.execute(
  243. select(func.count(PrintLogEntry.id)).where(
  244. and_(*week_filter, PrintLogEntry.status.in_(["failed", "aborted"]))
  245. )
  246. )
  247. total = week_total.scalar() or 0
  248. successful = week_successful.scalar() or 0
  249. failed = week_failed.scalar() or 0
  250. week_outcome = successful + failed
  251. rate = (failed / week_outcome * 100) if week_outcome > 0 else 0
  252. trend_data.append(
  253. {
  254. "week_start": week_start.date().isoformat(),
  255. "total_prints": total,
  256. "failed_prints": failed,
  257. "failure_rate": round(rate, 1),
  258. }
  259. )
  260. trend_data.reverse() # Oldest first
  261. return {
  262. "period_days": effective_days,
  263. "total_prints": total_prints,
  264. "failed_prints": failed_prints,
  265. "failure_rate": round(failure_rate, 1),
  266. "rejected_prints": rejected_prints,
  267. "yield_rate": round(yield_rate, 1),
  268. "rejects_by_reason": rejects_by_reason,
  269. "failures_by_reason": failures_by_reason,
  270. "failures_by_filament": failures_by_filament,
  271. "failures_by_printer": failures_by_printer,
  272. "failures_by_hour": failures_by_hour_complete,
  273. "recent_failures": recent_failures,
  274. "trend": trend_data,
  275. }