archive_comparison.py 11 KB

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