analytics.py 7.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220
  1. """Analytics API router (experience stats, result trends, Pareto, sensitivity)."""
  2. from fastapi import APIRouter, Depends, HTTPException, Query
  3. from sqlalchemy.orm import Session
  4. from ..database import get_db
  5. from ..models.experience_case import ExperienceCase
  6. from ..models.simulation_plan import SimulationPlan
  7. from ..models.simulation_result import SimulationResult
  8. from ..services.analytics import (
  9. compute_experience_stats,
  10. find_similar_cases,
  11. compute_trend_data,
  12. compute_pareto_frontier,
  13. compute_sensitivity,
  14. get_metric_defs,
  15. )
  16. router = APIRouter(prefix="/api/analytics", tags=["analytics"])
  17. def _result_to_dict(r: SimulationResult) -> dict:
  18. return {
  19. "id": r.id,
  20. "plan_id": r.plan_id,
  21. "run_index": r.run_index,
  22. "status": r.status,
  23. "solve_time_s": r.solve_time_s,
  24. "params": r.get_params(),
  25. "metrics": r.get_metrics(),
  26. "error_message": r.error_message or "",
  27. }
  28. def _case_to_dict(c: ExperienceCase) -> dict:
  29. return {
  30. "id": c.id,
  31. "source_plan_id": c.source_plan_id or "",
  32. "topology": c.topology or "SSSR",
  33. "model_path": c.model_path or "",
  34. "params": c.get_params(),
  35. "metrics": c.get_metrics(),
  36. "conclusion": c.conclusion or "",
  37. "tags": [t.strip() for t in (c.tags or "").split(",") if t.strip()],
  38. "rating": c.rating or 0,
  39. "created_at": c.created_at,
  40. }
  41. # ---------------------------------------------------------------------------
  42. # Metric definitions
  43. # ---------------------------------------------------------------------------
  44. @router.get("/metrics")
  45. def list_metrics():
  46. """List all available metric definitions for frontend charts."""
  47. return {"metrics": get_metric_defs()}
  48. # ---------------------------------------------------------------------------
  49. # Experience library analytics
  50. # ---------------------------------------------------------------------------
  51. @router.get("/experience/stats")
  52. def experience_stats(
  53. topology: str | None = None,
  54. db: Session = Depends(get_db),
  55. ):
  56. """Compute aggregate statistics for the experience library."""
  57. query = db.query(ExperienceCase)
  58. if topology:
  59. query = query.filter(ExperienceCase.topology == topology)
  60. cases = [_case_to_dict(c) for c in query.all()]
  61. return compute_experience_stats(cases)
  62. @router.post("/experience/similar")
  63. def experience_similar(
  64. data: dict,
  65. topology: str | None = None,
  66. top_k: int = Query(5, ge=1, le=50),
  67. tolerance: float = Query(0.3, ge=0.0, le=1.0),
  68. db: Session = Depends(get_db),
  69. ):
  70. """Find experience cases similar to target parameters.
  71. Request body: {"params": {"Airgap": 1.0, "RMSCurrent": 21, ...}}
  72. """
  73. target_params = data.get("params", {})
  74. if not target_params:
  75. raise HTTPException(status_code=400, detail="params field is required")
  76. query = db.query(ExperienceCase)
  77. if topology:
  78. query = query.filter(ExperienceCase.topology == topology)
  79. cases = [_case_to_dict(c) for c in query.all()]
  80. similar = find_similar_cases(target_params, cases, top_k=top_k, tolerance=tolerance)
  81. return {"total": len(similar), "items": similar}
  82. # ---------------------------------------------------------------------------
  83. # Simulation result analytics
  84. # ---------------------------------------------------------------------------
  85. @router.get("/plans/{plan_id}/trend")
  86. def plan_trend(
  87. plan_id: int,
  88. param_key: str = Query(..., description="Parameter name for X axis"),
  89. metric_key: str = Query(..., description="Metric name for Y axis"),
  90. db: Session = Depends(get_db),
  91. ):
  92. """Compute trend data for a parameter vs metric scatter plot."""
  93. plan = db.query(SimulationPlan).filter(SimulationPlan.id == plan_id).first()
  94. if not plan:
  95. raise HTTPException(status_code=404, detail="Plan not found")
  96. results = [_result_to_dict(r) for r in db.query(SimulationResult).filter(
  97. SimulationResult.plan_id == plan_id
  98. ).order_by(SimulationResult.run_index.asc()).all()]
  99. return compute_trend_data(results, param_key, metric_key)
  100. @router.get("/plans/{plan_id}/pareto")
  101. def plan_pareto(
  102. plan_id: int,
  103. x_metric: str = Query("total_losses_w", description="X axis metric"),
  104. y_metric: str = Query("efficiency_pct", description="Y axis metric"),
  105. db: Session = Depends(get_db),
  106. ):
  107. """Compute Pareto frontier for two metrics in a plan."""
  108. plan = db.query(SimulationPlan).filter(SimulationPlan.id == plan_id).first()
  109. if not plan:
  110. raise HTTPException(status_code=404, detail="Plan not found")
  111. results = [_result_to_dict(r) for r in db.query(SimulationResult).filter(
  112. SimulationResult.plan_id == plan_id
  113. ).all()]
  114. return compute_pareto_frontier(results, x_metric=x_metric, y_metric=y_metric)
  115. @router.get("/plans/{plan_id}/sensitivity")
  116. def plan_sensitivity(
  117. plan_id: int,
  118. metric_key: str = Query(..., description="Target metric for sensitivity analysis"),
  119. db: Session = Depends(get_db),
  120. ):
  121. """Compute parameter sensitivity ranking for a target metric."""
  122. plan = db.query(SimulationPlan).filter(SimulationPlan.id == plan_id).first()
  123. if not plan:
  124. raise HTTPException(status_code=404, detail="Plan not found")
  125. results = [_result_to_dict(r) for r in db.query(SimulationResult).filter(
  126. SimulationResult.plan_id == plan_id
  127. ).all()]
  128. sensitivities = compute_sensitivity(results, metric_key)
  129. return {"metric": metric_key, "total_params": len(sensitivities), "items": sensitivities}
  130. # ---------------------------------------------------------------------------
  131. # Project-level analytics (aggregate all plans in a project)
  132. # ---------------------------------------------------------------------------
  133. @router.get("/projects/{project_id}/overview")
  134. def project_overview(
  135. project_id: int,
  136. db: Session = Depends(get_db),
  137. ):
  138. """Get overview analytics for a project (all plans and results)."""
  139. from ..models.project import Project
  140. project = db.query(Project).filter(Project.id == project_id).first()
  141. if not project:
  142. raise HTTPException(status_code=404, detail="Project not found")
  143. plans = db.query(SimulationPlan).filter(SimulationPlan.project_id == project_id).all()
  144. plan_ids = [p.id for p in plans]
  145. total_results = 0
  146. ok_results = 0
  147. failed_results = 0
  148. all_results = []
  149. if plan_ids:
  150. results = db.query(SimulationResult).filter(
  151. SimulationResult.plan_id.in_(plan_ids)
  152. ).all()
  153. total_results = len(results)
  154. ok_results = sum(1 for r in results if r.status == "OK")
  155. failed_results = sum(1 for r in results if r.status != "OK")
  156. all_results = [_result_to_dict(r) for r in results]
  157. # Best efficiency and torque from OK results
  158. best_eff = 0.0
  159. best_torque = 0.0
  160. for r in all_results:
  161. if r["status"] == "OK":
  162. eff = r["metrics"].get("efficiency_pct", 0)
  163. torque = r["metrics"].get("tavg_nm", 0)
  164. if isinstance(eff, (int, float)):
  165. best_eff = max(best_eff, eff)
  166. if isinstance(torque, (int, float)):
  167. best_torque = max(best_torque, torque)
  168. return {
  169. "project_id": project_id,
  170. "project_name": project.name,
  171. "total_plans": len(plans),
  172. "total_results": total_results,
  173. "ok_results": ok_results,
  174. "failed_results": failed_results,
  175. "best_efficiency_pct": round(best_eff, 2),
  176. "best_torque_nm": round(best_torque, 4),
  177. "plans": [
  178. {
  179. "id": p.id,
  180. "name": p.name,
  181. "plan_id": p.plan_id,
  182. "status": p.status,
  183. "estimated_points": p.estimated_points,
  184. "result_count": sum(1 for r in all_results if r["plan_id"] == p.id),
  185. }
  186. for p in plans
  187. ],
  188. }