archive_comparison.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287
  1. from sqlalchemy import select
  2. from sqlalchemy.ext.asyncio import AsyncSession
  3. from sqlalchemy.orm import selectinload
  4. from backend.app.models.archive import PrintArchive
  5. class ArchiveComparisonService:
  6. """Service for comparing print archives."""
  7. # Fields to compare
  8. COMPARABLE_FIELDS = [
  9. ("layer_height", "Layer Height", "mm"),
  10. ("nozzle_diameter", "Nozzle Diameter", "mm"),
  11. ("bed_temperature", "Bed Temperature", "°C"),
  12. ("nozzle_temperature", "Nozzle Temperature", "°C"),
  13. ("filament_type", "Filament Type", None),
  14. ("filament_used_grams", "Filament Used", "g"),
  15. ("print_time_seconds", "Print Time", "s"),
  16. ("total_layers", "Total Layers", None),
  17. ("status", "Status", None),
  18. ]
  19. def __init__(self, db: AsyncSession):
  20. self.db = db
  21. async def compare_archives(self, archive_ids: list[int]) -> dict:
  22. """Compare multiple archives side by side.
  23. Args:
  24. archive_ids: List of 2-5 archive IDs to compare
  25. Returns:
  26. Dictionary with comparison results
  27. """
  28. if len(archive_ids) < 2:
  29. raise ValueError("At least 2 archives required for comparison")
  30. if len(archive_ids) > 5:
  31. raise ValueError("Maximum 5 archives can be compared at once")
  32. # Fetch archives
  33. result = await self.db.execute(
  34. select(PrintArchive).options(selectinload(PrintArchive.project)).where(PrintArchive.id.in_(archive_ids))
  35. )
  36. archives = {a.id: a for a in result.scalars().all()}
  37. if len(archives) != len(archive_ids):
  38. missing = set(archive_ids) - set(archives.keys())
  39. raise ValueError(f"Archives not found: {missing}")
  40. # Preserve order from input
  41. ordered_archives = [archives[id] for id in archive_ids]
  42. # Build basic info for each archive
  43. archive_info = [
  44. {
  45. "id": a.id,
  46. "print_name": a.print_name or a.filename,
  47. "status": a.status,
  48. "created_at": a.created_at.isoformat() if a.created_at else None,
  49. "printer_id": a.printer_id,
  50. "project_name": a.project.name if a.project else None,
  51. }
  52. for a in ordered_archives
  53. ]
  54. # Build field comparison
  55. comparison = []
  56. differences = []
  57. for field_name, display_name, unit in self.COMPARABLE_FIELDS:
  58. values = [getattr(a, field_name) for a in ordered_archives]
  59. # Format values for display
  60. formatted_values = []
  61. for v in values:
  62. if v is None:
  63. formatted_values.append(None)
  64. elif field_name == "print_time_seconds":
  65. # Format as human-readable time
  66. hours = int(v) // 3600
  67. minutes = (int(v) % 3600) // 60
  68. formatted_values.append(f"{hours}h {minutes}m" if hours else f"{minutes}m")
  69. elif isinstance(v, float):
  70. formatted_values.append(round(v, 2))
  71. else:
  72. formatted_values.append(v)
  73. # Check if values differ
  74. non_none_values = [v for v in values if v is not None]
  75. has_difference = len({str(v) for v in non_none_values}) > 1 if non_none_values else False
  76. field_data = {
  77. "field": field_name,
  78. "label": display_name,
  79. "unit": unit,
  80. "values": formatted_values,
  81. "raw_values": values,
  82. "has_difference": has_difference,
  83. }
  84. comparison.append(field_data)
  85. if has_difference:
  86. differences.append(field_data)
  87. # Analyze success/failure correlation
  88. success_correlation = self._analyze_success_correlation(ordered_archives)
  89. return {
  90. "archives": archive_info,
  91. "comparison": comparison,
  92. "differences": differences,
  93. "success_correlation": success_correlation,
  94. }
  95. def _analyze_success_correlation(self, archives: list[PrintArchive]) -> dict:
  96. """Analyze what settings correlate with success/failure."""
  97. successful = [a for a in archives if a.status == "completed"]
  98. failed = [a for a in archives if a.status == "failed"]
  99. if not successful or not failed:
  100. return {
  101. "has_both_outcomes": False,
  102. "message": "Need both successful and failed prints to analyze correlation",
  103. }
  104. # Find settings that differ between successful and failed
  105. insights = []
  106. for field_name, display_name, _unit in self.COMPARABLE_FIELDS:
  107. if field_name == "status":
  108. continue
  109. success_values = [getattr(a, field_name) for a in successful if getattr(a, field_name) is not None]
  110. failed_values = [getattr(a, field_name) for a in failed if getattr(a, field_name) is not None]
  111. if not success_values or not failed_values:
  112. continue
  113. # For numeric fields, compare averages
  114. if isinstance(success_values[0], (int, float)):
  115. success_avg = sum(success_values) / len(success_values)
  116. failed_avg = sum(failed_values) / len(failed_values)
  117. if abs(success_avg - failed_avg) > 0.1 * max(abs(success_avg), abs(failed_avg), 0.01):
  118. direction = "higher" if success_avg > failed_avg else "lower"
  119. insights.append(
  120. {
  121. "field": field_name,
  122. "label": display_name,
  123. "success_avg": round(success_avg, 2),
  124. "failed_avg": round(failed_avg, 2),
  125. "insight": f"Successful prints had {direction} {display_name}",
  126. }
  127. )
  128. else:
  129. # For categorical fields, check if success uses different values
  130. success_set = {str(v) for v in success_values}
  131. failed_set = {str(v) for v in failed_values}
  132. if success_set != failed_set:
  133. insights.append(
  134. {
  135. "field": field_name,
  136. "label": display_name,
  137. "success_values": list(success_set),
  138. "failed_values": list(failed_set),
  139. "insight": f"Different {display_name} used in successful vs failed prints",
  140. }
  141. )
  142. return {
  143. "has_both_outcomes": True,
  144. "successful_count": len(successful),
  145. "failed_count": len(failed),
  146. "insights": insights,
  147. }
  148. async def find_similar_archives(
  149. self,
  150. archive_id: int,
  151. limit: int = 10,
  152. ) -> list[dict]:
  153. """Find archives with similar settings for comparison.
  154. Args:
  155. archive_id: The archive to find similar ones for
  156. limit: Maximum number of results
  157. Returns:
  158. List of similar archives with match reasons
  159. """
  160. # Get the reference archive
  161. result = await self.db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
  162. reference = result.scalar_one_or_none()
  163. if not reference:
  164. raise ValueError("Archive not found")
  165. # Find similar archives
  166. similar = []
  167. # By same print name (soft-deleted archives are hidden from the UI
  168. # per #1343 so they must not surface here as "similar" either).
  169. if reference.print_name:
  170. result = await self.db.execute(
  171. select(PrintArchive)
  172. .where(
  173. PrintArchive.id != archive_id,
  174. PrintArchive.print_name == reference.print_name,
  175. PrintArchive.deleted_at.is_(None),
  176. )
  177. .order_by(PrintArchive.created_at.desc())
  178. .limit(limit)
  179. )
  180. for a in result.scalars().all():
  181. similar.append(
  182. {
  183. "archive": {
  184. "id": a.id,
  185. "print_name": a.print_name or a.filename,
  186. "status": a.status,
  187. "created_at": a.created_at.isoformat() if a.created_at else None,
  188. },
  189. "match_reason": "Same print name",
  190. "match_score": 100,
  191. }
  192. )
  193. # By content hash
  194. if reference.content_hash and len(similar) < limit:
  195. result = await self.db.execute(
  196. select(PrintArchive)
  197. .where(
  198. PrintArchive.id != archive_id,
  199. PrintArchive.content_hash == reference.content_hash,
  200. PrintArchive.deleted_at.is_(None),
  201. )
  202. .order_by(PrintArchive.created_at.desc())
  203. .limit(limit - len(similar))
  204. )
  205. for a in result.scalars().all():
  206. if not any(s["archive"]["id"] == a.id for s in similar):
  207. similar.append(
  208. {
  209. "archive": {
  210. "id": a.id,
  211. "print_name": a.print_name or a.filename,
  212. "status": a.status,
  213. "created_at": a.created_at.isoformat() if a.created_at else None,
  214. },
  215. "match_reason": "Same file content",
  216. "match_score": 95,
  217. }
  218. )
  219. # By same filament type
  220. if reference.filament_type and len(similar) < limit:
  221. result = await self.db.execute(
  222. select(PrintArchive)
  223. .where(
  224. PrintArchive.id != archive_id,
  225. PrintArchive.filament_type == reference.filament_type,
  226. )
  227. .order_by(PrintArchive.created_at.desc())
  228. .limit(limit - len(similar))
  229. )
  230. for a in result.scalars().all():
  231. if not any(s["archive"]["id"] == a.id for s in similar):
  232. similar.append(
  233. {
  234. "archive": {
  235. "id": a.id,
  236. "print_name": a.print_name or a.filename,
  237. "status": a.status,
  238. "created_at": a.created_at.isoformat() if a.created_at else None,
  239. },
  240. "match_reason": f"Same filament type ({reference.filament_type})",
  241. "match_score": 50,
  242. }
  243. )
  244. # Sort by match score
  245. similar.sort(key=lambda x: x["match_score"], reverse=True)
  246. return similar[:limit]