failure_analysis.py 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247
  1. from collections import defaultdict
  2. from datetime import date, datetime, time, timedelta, timezone
  3. from sqlalchemy import and_, func, select
  4. from sqlalchemy.ext.asyncio import AsyncSession
  5. from backend.app.models.print_log import PrintLogEntry
  6. from backend.app.models.printer import Printer
  7. class FailureAnalysisService:
  8. """Service for analyzing print failure patterns.
  9. Reads from print_log_entries (per-event data) rather than print_archives
  10. so reprints contribute each run and orphan events (archive deleted, log
  11. row survived via ON DELETE SET NULL) still count consistently with
  12. Quick Stats. The archive-based predecessor diverged from Quick Stats
  13. after #1378 moved the rest of the page to per-event aggregation.
  14. """
  15. def __init__(self, db: AsyncSession):
  16. self.db = db
  17. async def analyze_failures(
  18. self,
  19. days: int | None = None,
  20. date_from: date | None = None,
  21. date_to: date | None = None,
  22. printer_id: int | None = None,
  23. project_id: int | None = None,
  24. created_by_id: int | None = None,
  25. ) -> dict:
  26. """Analyze failure patterns across logged print events."""
  27. # Build base query — separate date vs non-date filters for trend reuse
  28. base_filter = []
  29. non_date_filter = []
  30. if date_from or date_to:
  31. if date_from:
  32. dt_from = datetime.combine(date_from, time.min, tzinfo=timezone.utc)
  33. base_filter.append(PrintLogEntry.created_at >= dt_from)
  34. if date_to:
  35. dt_to = datetime.combine(date_to, time.max, tzinfo=timezone.utc)
  36. base_filter.append(PrintLogEntry.created_at <= dt_to)
  37. range_start = dt_from if date_from else datetime.now(timezone.utc) - timedelta(days=365)
  38. range_end = dt_to if date_to else datetime.now(timezone.utc)
  39. effective_days = max((range_end - range_start).days, 1)
  40. else:
  41. effective_days = days if days is not None else 30
  42. cutoff_date = datetime.now(timezone.utc) - timedelta(days=effective_days)
  43. base_filter.append(PrintLogEntry.created_at >= cutoff_date)
  44. if printer_id:
  45. non_date_filter.append(PrintLogEntry.printer_id == printer_id)
  46. # project_id is an archive-level concept; PrintLogEntry has no project
  47. # link, so we resolve it by archive_id where present.
  48. if project_id:
  49. from backend.app.models.archive import PrintArchive
  50. # Soft-deleted archives (#1343) keep their project_id, so without
  51. # this the failure rate for a project still counts prints the user
  52. # deleted from it — and disagrees with the project's own numbers,
  53. # which now exclude them (#2731).
  54. project_archive_ids = await self.db.execute(
  55. select(PrintArchive.id).where(
  56. PrintArchive.project_id == project_id,
  57. PrintArchive.deleted_at.is_(None),
  58. )
  59. )
  60. archive_ids = [row[0] for row in project_archive_ids.fetchall()]
  61. if archive_ids:
  62. non_date_filter.append(PrintLogEntry.archive_id.in_(archive_ids))
  63. else:
  64. # No archives in this project → nothing to count
  65. non_date_filter.append(PrintLogEntry.id.is_(None))
  66. if created_by_id is not None:
  67. if created_by_id == -1:
  68. non_date_filter.append(PrintLogEntry.created_by_id.is_(None))
  69. else:
  70. non_date_filter.append(PrintLogEntry.created_by_id == created_by_id)
  71. base_filter.extend(non_date_filter)
  72. # Total counts
  73. total_result = await self.db.execute(select(func.count(PrintLogEntry.id)).where(and_(*base_filter)))
  74. total_prints = total_result.scalar() or 0
  75. successful_result = await self.db.execute(
  76. select(func.count(PrintLogEntry.id)).where(and_(*base_filter, PrintLogEntry.status == "completed"))
  77. )
  78. successful_prints = successful_result.scalar() or 0
  79. failed_result = await self.db.execute(
  80. select(func.count(PrintLogEntry.id)).where(
  81. and_(*base_filter, PrintLogEntry.status.in_(["failed", "aborted"]))
  82. )
  83. )
  84. failed_prints = failed_result.scalar() or 0
  85. # Failure rate divides by quality-outcome prints only — a cancelled or
  86. # skipped print is neither a success nor a failure of the printer, so
  87. # including it in the denominator silently lowered the displayed rate
  88. # whenever the user stopped jobs (#1390). Total Prints (the absolute
  89. # count incl. cancelled) is still returned separately for the "X / Y
  90. # prints failed" caption.
  91. outcome_prints = successful_prints + failed_prints
  92. failure_rate = (failed_prints / outcome_prints * 100) if outcome_prints > 0 else 0
  93. # Failures by reason
  94. reason_result = await self.db.execute(
  95. select(
  96. PrintLogEntry.failure_reason,
  97. func.count(PrintLogEntry.id).label("count"),
  98. )
  99. .where(and_(*base_filter, PrintLogEntry.status.in_(["failed", "aborted"])))
  100. .group_by(PrintLogEntry.failure_reason)
  101. .order_by(func.count(PrintLogEntry.id).desc())
  102. )
  103. failures_by_reason = {(row[0] or "Unknown"): row[1] for row in reason_result.fetchall()}
  104. # Failures by filament type
  105. filament_result = await self.db.execute(
  106. select(
  107. PrintLogEntry.filament_type,
  108. func.count(PrintLogEntry.id).label("count"),
  109. )
  110. .where(and_(*base_filter, PrintLogEntry.status.in_(["failed", "aborted"])))
  111. .group_by(PrintLogEntry.filament_type)
  112. .order_by(func.count(PrintLogEntry.id).desc())
  113. )
  114. failures_by_filament = {(row[0] or "Unknown"): row[1] for row in filament_result.fetchall()}
  115. # Failures by printer
  116. printer_result = await self.db.execute(
  117. select(
  118. PrintLogEntry.printer_id,
  119. func.count(PrintLogEntry.id).label("count"),
  120. )
  121. .where(
  122. and_(
  123. *base_filter,
  124. PrintLogEntry.status.in_(["failed", "aborted"]),
  125. PrintLogEntry.printer_id.isnot(None),
  126. )
  127. )
  128. .group_by(PrintLogEntry.printer_id)
  129. .order_by(func.count(PrintLogEntry.id).desc())
  130. )
  131. failures_by_printer_id = {row[0]: row[1] for row in printer_result.fetchall()}
  132. # Get printer names
  133. if failures_by_printer_id:
  134. printers_result = await self.db.execute(
  135. select(Printer.id, Printer.name).where(Printer.id.in_(failures_by_printer_id.keys()))
  136. )
  137. printer_names = {row[0]: row[1] for row in printers_result.fetchall()}
  138. failures_by_printer = {
  139. printer_names.get(pid, f"Printer {pid}"): count for pid, count in failures_by_printer_id.items()
  140. }
  141. else:
  142. failures_by_printer = {}
  143. # Failures by hour of day
  144. failed_events_result = await self.db.execute(
  145. select(PrintLogEntry.started_at).where(
  146. and_(
  147. *base_filter,
  148. PrintLogEntry.status.in_(["failed", "aborted"]),
  149. PrintLogEntry.started_at.isnot(None),
  150. )
  151. )
  152. )
  153. failures_by_hour = defaultdict(int)
  154. for (started_at,) in failed_events_result.fetchall():
  155. if started_at:
  156. hour = started_at.hour
  157. failures_by_hour[hour] += 1
  158. failures_by_hour_complete = {h: failures_by_hour.get(h, 0) for h in range(24)}
  159. # Recent failures
  160. recent_result = await self.db.execute(
  161. select(PrintLogEntry)
  162. .where(and_(*base_filter, PrintLogEntry.status.in_(["failed", "aborted"])))
  163. .order_by(PrintLogEntry.created_at.desc())
  164. .limit(10)
  165. )
  166. recent_failures = [
  167. {
  168. "id": e.archive_id,
  169. "print_name": e.print_name,
  170. "failure_reason": e.failure_reason,
  171. "filament_type": e.filament_type,
  172. "printer_id": e.printer_id,
  173. "created_at": e.created_at.isoformat() if e.created_at else None,
  174. }
  175. for e in recent_result.scalars().all()
  176. ]
  177. # Failure rate trend (by week)
  178. trend_data = []
  179. num_weeks = max(effective_days // 7, 1)
  180. for i in range(num_weeks):
  181. week_end = datetime.now(timezone.utc) - timedelta(weeks=i)
  182. week_start = week_end - timedelta(weeks=1)
  183. week_filter = [
  184. PrintLogEntry.created_at >= week_start,
  185. PrintLogEntry.created_at < week_end,
  186. *non_date_filter,
  187. ]
  188. week_total = await self.db.execute(select(func.count(PrintLogEntry.id)).where(and_(*week_filter)))
  189. week_successful = await self.db.execute(
  190. select(func.count(PrintLogEntry.id)).where(and_(*week_filter, PrintLogEntry.status == "completed"))
  191. )
  192. week_failed = await self.db.execute(
  193. select(func.count(PrintLogEntry.id)).where(
  194. and_(*week_filter, PrintLogEntry.status.in_(["failed", "aborted"]))
  195. )
  196. )
  197. total = week_total.scalar() or 0
  198. successful = week_successful.scalar() or 0
  199. failed = week_failed.scalar() or 0
  200. week_outcome = successful + failed
  201. rate = (failed / week_outcome * 100) if week_outcome > 0 else 0
  202. trend_data.append(
  203. {
  204. "week_start": week_start.date().isoformat(),
  205. "total_prints": total,
  206. "failed_prints": failed,
  207. "failure_rate": round(rate, 1),
  208. }
  209. )
  210. trend_data.reverse() # Oldest first
  211. return {
  212. "period_days": effective_days,
  213. "total_prints": total_prints,
  214. "failed_prints": failed_prints,
  215. "failure_rate": round(failure_rate, 1),
  216. "failures_by_reason": failures_by_reason,
  217. "failures_by_filament": failures_by_filament,
  218. "failures_by_printer": failures_by_printer,
  219. "failures_by_hour": failures_by_hour_complete,
  220. "recent_failures": recent_failures,
  221. "trend": trend_data,
  222. }