failure_analysis.py 7.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198
  1. from datetime import datetime, timedelta
  2. from collections import defaultdict
  3. from sqlalchemy.ext.asyncio import AsyncSession
  4. from sqlalchemy import select, func, and_
  5. from backend.app.models.archive import PrintArchive
  6. from backend.app.models.printer import Printer
  7. class FailureAnalysisService:
  8. """Service for analyzing print failure patterns."""
  9. def __init__(self, db: AsyncSession):
  10. self.db = db
  11. async def analyze_failures(
  12. self,
  13. days: int = 30,
  14. printer_id: int | None = None,
  15. project_id: int | None = None,
  16. ) -> dict:
  17. """Analyze failure patterns across archives.
  18. Args:
  19. days: Number of days to analyze
  20. printer_id: Optional filter by printer
  21. project_id: Optional filter by project
  22. Returns:
  23. Dictionary with failure analysis results
  24. """
  25. cutoff_date = datetime.utcnow() - timedelta(days=days)
  26. # Build base query
  27. base_filter = [PrintArchive.created_at >= cutoff_date]
  28. if printer_id:
  29. base_filter.append(PrintArchive.printer_id == printer_id)
  30. if project_id:
  31. base_filter.append(PrintArchive.project_id == project_id)
  32. # Total counts
  33. total_result = await self.db.execute(
  34. select(func.count(PrintArchive.id)).where(and_(*base_filter))
  35. )
  36. total_prints = total_result.scalar() or 0
  37. failed_result = await self.db.execute(
  38. select(func.count(PrintArchive.id)).where(
  39. and_(*base_filter, PrintArchive.status == "failed")
  40. )
  41. )
  42. failed_prints = failed_result.scalar() or 0
  43. failure_rate = (failed_prints / total_prints * 100) if total_prints > 0 else 0
  44. # Failures by reason
  45. reason_result = await self.db.execute(
  46. select(
  47. PrintArchive.failure_reason,
  48. func.count(PrintArchive.id).label("count"),
  49. )
  50. .where(and_(*base_filter, PrintArchive.status == "failed"))
  51. .group_by(PrintArchive.failure_reason)
  52. .order_by(func.count(PrintArchive.id).desc())
  53. )
  54. failures_by_reason = {
  55. (row[0] or "Unknown"): row[1]
  56. for row in reason_result.fetchall()
  57. }
  58. # Failures by filament type
  59. filament_result = await self.db.execute(
  60. select(
  61. PrintArchive.filament_type,
  62. func.count(PrintArchive.id).label("count"),
  63. )
  64. .where(and_(*base_filter, PrintArchive.status == "failed"))
  65. .group_by(PrintArchive.filament_type)
  66. .order_by(func.count(PrintArchive.id).desc())
  67. )
  68. failures_by_filament = {
  69. (row[0] or "Unknown"): row[1]
  70. for row in filament_result.fetchall()
  71. }
  72. # Failures by printer
  73. printer_result = await self.db.execute(
  74. select(
  75. PrintArchive.printer_id,
  76. func.count(PrintArchive.id).label("count"),
  77. )
  78. .where(
  79. and_(*base_filter, PrintArchive.status == "failed", PrintArchive.printer_id.isnot(None))
  80. )
  81. .group_by(PrintArchive.printer_id)
  82. .order_by(func.count(PrintArchive.id).desc())
  83. )
  84. failures_by_printer_id = {row[0]: row[1] for row in printer_result.fetchall()}
  85. # Get printer names
  86. if failures_by_printer_id:
  87. printers_result = await self.db.execute(
  88. select(Printer.id, Printer.name).where(
  89. Printer.id.in_(failures_by_printer_id.keys())
  90. )
  91. )
  92. printer_names = {row[0]: row[1] for row in printers_result.fetchall()}
  93. failures_by_printer = {
  94. printer_names.get(pid, f"Printer {pid}"): count
  95. for pid, count in failures_by_printer_id.items()
  96. }
  97. else:
  98. failures_by_printer = {}
  99. # Failures by hour of day
  100. failed_archives_result = await self.db.execute(
  101. select(PrintArchive.started_at)
  102. .where(
  103. and_(
  104. *base_filter,
  105. PrintArchive.status == "failed",
  106. PrintArchive.started_at.isnot(None),
  107. )
  108. )
  109. )
  110. failures_by_hour = defaultdict(int)
  111. for (started_at,) in failed_archives_result.fetchall():
  112. if started_at:
  113. hour = started_at.hour
  114. failures_by_hour[hour] += 1
  115. # Convert to dict with all 24 hours
  116. failures_by_hour_complete = {h: failures_by_hour.get(h, 0) for h in range(24)}
  117. # Recent failures
  118. recent_result = await self.db.execute(
  119. select(PrintArchive)
  120. .where(and_(*base_filter, PrintArchive.status == "failed"))
  121. .order_by(PrintArchive.created_at.desc())
  122. .limit(10)
  123. )
  124. recent_failures = [
  125. {
  126. "id": a.id,
  127. "print_name": a.print_name or a.filename,
  128. "failure_reason": a.failure_reason,
  129. "filament_type": a.filament_type,
  130. "printer_id": a.printer_id,
  131. "created_at": a.created_at.isoformat() if a.created_at else None,
  132. }
  133. for a in recent_result.scalars().all()
  134. ]
  135. # Failure rate trend (by week)
  136. trend_data = []
  137. for i in range(min(days // 7, 12)): # Up to 12 weeks
  138. week_end = datetime.utcnow() - timedelta(weeks=i)
  139. week_start = week_end - timedelta(weeks=1)
  140. week_filter = base_filter.copy()
  141. week_filter[0] = and_(
  142. PrintArchive.created_at >= week_start,
  143. PrintArchive.created_at < week_end,
  144. )
  145. week_total = await self.db.execute(
  146. select(func.count(PrintArchive.id)).where(and_(*week_filter))
  147. )
  148. week_failed = await self.db.execute(
  149. select(func.count(PrintArchive.id)).where(
  150. and_(*week_filter, PrintArchive.status == "failed")
  151. )
  152. )
  153. total = week_total.scalar() or 0
  154. failed = week_failed.scalar() or 0
  155. rate = (failed / total * 100) if total > 0 else 0
  156. trend_data.append({
  157. "week_start": week_start.date().isoformat(),
  158. "total_prints": total,
  159. "failed_prints": failed,
  160. "failure_rate": round(rate, 1),
  161. })
  162. trend_data.reverse() # Oldest first
  163. return {
  164. "period_days": days,
  165. "total_prints": total_prints,
  166. "failed_prints": failed_prints,
  167. "failure_rate": round(failure_rate, 1),
  168. "failures_by_reason": failures_by_reason,
  169. "failures_by_filament": failures_by_filament,
  170. "failures_by_printer": failures_by_printer,
  171. "failures_by_hour": failures_by_hour_complete,
  172. "recent_failures": recent_failures,
  173. "trend": trend_data,
  174. }