analytics.py 8.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249
  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. @router.get("/solve-time-stats")
  29. def solve_time_stats(db: Session = Depends(get_db)):
  30. """Measured per-point solve time statistics from completed results.
  31. Used by the frontend to calibrate the estimated simulation duration from
  32. real solve times instead of a hardcoded per-point guess (P2-1). Returns
  33. avg/min/max/median over results that recorded a positive solve_time_s.
  34. """
  35. rows = (
  36. db.query(SimulationResult.solve_time_s)
  37. .filter(SimulationResult.solve_time_s.isnot(None))
  38. .filter(SimulationResult.solve_time_s > 0)
  39. .all()
  40. )
  41. vals = sorted(float(r[0]) for r in rows)
  42. if not vals:
  43. return {"count": 0, "avg_s": None, "min_s": None, "max_s": None, "median_s": None}
  44. n = len(vals)
  45. avg = sum(vals) / n
  46. median = vals[n // 2] if n % 2 else (vals[n // 2 - 1] + vals[n // 2]) / 2
  47. return {
  48. "count": n,
  49. "avg_s": round(avg, 1),
  50. "min_s": round(vals[0], 1),
  51. "max_s": round(vals[-1], 1),
  52. "median_s": round(median, 1),
  53. }
  54. def _case_to_dict(c: ExperienceCase) -> dict:
  55. return {
  56. "id": c.id,
  57. "source_plan_id": c.source_plan_id or "",
  58. "topology": c.topology or "SSSR",
  59. "model_path": c.model_path or "",
  60. "params": c.get_params(),
  61. "metrics": c.get_metrics(),
  62. "conclusion": c.conclusion or "",
  63. "tags": [t.strip() for t in (c.tags or "").split(",") if t.strip()],
  64. "rating": c.rating or 0,
  65. "created_at": c.created_at,
  66. }
  67. # ---------------------------------------------------------------------------
  68. # Metric definitions
  69. # ---------------------------------------------------------------------------
  70. @router.get("/metrics")
  71. def list_metrics():
  72. """List all available metric definitions for frontend charts."""
  73. return {"metrics": get_metric_defs()}
  74. # ---------------------------------------------------------------------------
  75. # Experience library analytics
  76. # ---------------------------------------------------------------------------
  77. @router.get("/experience/stats")
  78. def experience_stats(
  79. topology: str | None = None,
  80. db: Session = Depends(get_db),
  81. ):
  82. """Compute aggregate statistics for the experience library."""
  83. query = db.query(ExperienceCase)
  84. if topology:
  85. query = query.filter(ExperienceCase.topology == topology)
  86. cases = [_case_to_dict(c) for c in query.all()]
  87. return compute_experience_stats(cases)
  88. @router.post("/experience/similar")
  89. def experience_similar(
  90. data: dict,
  91. topology: str | None = None,
  92. top_k: int = Query(5, ge=1, le=50),
  93. tolerance: float = Query(0.3, ge=0.0, le=1.0),
  94. db: Session = Depends(get_db),
  95. ):
  96. """Find experience cases similar to target parameters.
  97. Request body: {"params": {"Airgap": 1.0, "RMSCurrent": 21, ...}}
  98. """
  99. target_params = data.get("params", {})
  100. if not target_params:
  101. raise HTTPException(status_code=400, detail="params field is required")
  102. query = db.query(ExperienceCase)
  103. if topology:
  104. query = query.filter(ExperienceCase.topology == topology)
  105. cases = [_case_to_dict(c) for c in query.all()]
  106. similar = find_similar_cases(target_params, cases, top_k=top_k, tolerance=tolerance)
  107. return {"total": len(similar), "items": similar}
  108. # ---------------------------------------------------------------------------
  109. # Simulation result analytics
  110. # ---------------------------------------------------------------------------
  111. @router.get("/plans/{plan_id}/trend")
  112. def plan_trend(
  113. plan_id: int,
  114. param_key: str = Query(..., description="Parameter name for X axis"),
  115. metric_key: str = Query(..., description="Metric name for Y axis"),
  116. db: Session = Depends(get_db),
  117. ):
  118. """Compute trend data for a parameter vs metric scatter plot."""
  119. plan = db.query(SimulationPlan).filter(SimulationPlan.id == plan_id).first()
  120. if not plan:
  121. raise HTTPException(status_code=404, detail="Plan not found")
  122. results = [_result_to_dict(r) for r in db.query(SimulationResult).filter(
  123. SimulationResult.plan_id == plan_id
  124. ).order_by(SimulationResult.run_index.asc()).all()]
  125. return compute_trend_data(results, param_key, metric_key)
  126. @router.get("/plans/{plan_id}/pareto")
  127. def plan_pareto(
  128. plan_id: int,
  129. x_metric: str = Query("total_losses_w", description="X axis metric"),
  130. y_metric: str = Query("efficiency_pct", description="Y axis metric"),
  131. db: Session = Depends(get_db),
  132. ):
  133. """Compute Pareto frontier for two metrics in a plan."""
  134. plan = db.query(SimulationPlan).filter(SimulationPlan.id == plan_id).first()
  135. if not plan:
  136. raise HTTPException(status_code=404, detail="Plan not found")
  137. results = [_result_to_dict(r) for r in db.query(SimulationResult).filter(
  138. SimulationResult.plan_id == plan_id
  139. ).all()]
  140. return compute_pareto_frontier(results, x_metric=x_metric, y_metric=y_metric)
  141. @router.get("/plans/{plan_id}/sensitivity")
  142. def plan_sensitivity(
  143. plan_id: int,
  144. metric_key: str = Query(..., description="Target metric for sensitivity analysis"),
  145. db: Session = Depends(get_db),
  146. ):
  147. """Compute parameter sensitivity ranking for a target metric."""
  148. plan = db.query(SimulationPlan).filter(SimulationPlan.id == plan_id).first()
  149. if not plan:
  150. raise HTTPException(status_code=404, detail="Plan not found")
  151. results = [_result_to_dict(r) for r in db.query(SimulationResult).filter(
  152. SimulationResult.plan_id == plan_id
  153. ).all()]
  154. sensitivities = compute_sensitivity(results, metric_key)
  155. return {"metric": metric_key, "total_params": len(sensitivities), "items": sensitivities}
  156. # ---------------------------------------------------------------------------
  157. # Project-level analytics (aggregate all plans in a project)
  158. # ---------------------------------------------------------------------------
  159. @router.get("/projects/{project_id}/overview")
  160. def project_overview(
  161. project_id: int,
  162. db: Session = Depends(get_db),
  163. ):
  164. """Get overview analytics for a project (all plans and results)."""
  165. from ..models.project import Project
  166. project = db.query(Project).filter(Project.id == project_id).first()
  167. if not project:
  168. raise HTTPException(status_code=404, detail="Project not found")
  169. plans = db.query(SimulationPlan).filter(SimulationPlan.project_id == project_id).all()
  170. plan_ids = [p.id for p in plans]
  171. total_results = 0
  172. ok_results = 0
  173. failed_results = 0
  174. all_results = []
  175. if plan_ids:
  176. results = db.query(SimulationResult).filter(
  177. SimulationResult.plan_id.in_(plan_ids)
  178. ).all()
  179. total_results = len(results)
  180. ok_results = sum(1 for r in results if r.status == "OK")
  181. failed_results = sum(1 for r in results if r.status != "OK")
  182. all_results = [_result_to_dict(r) for r in results]
  183. # Best efficiency and torque from OK results
  184. best_eff = 0.0
  185. best_torque = 0.0
  186. for r in all_results:
  187. if r["status"] == "OK":
  188. eff = r["metrics"].get("efficiency_pct", 0)
  189. torque = r["metrics"].get("tavg_nm", 0)
  190. if isinstance(eff, (int, float)):
  191. best_eff = max(best_eff, eff)
  192. if isinstance(torque, (int, float)):
  193. best_torque = max(best_torque, torque)
  194. return {
  195. "project_id": project_id,
  196. "project_name": project.name,
  197. "total_plans": len(plans),
  198. "total_results": total_results,
  199. "ok_results": ok_results,
  200. "failed_results": failed_results,
  201. "best_efficiency_pct": round(best_eff, 2),
  202. "best_torque_nm": round(best_torque, 4),
  203. "plans": [
  204. {
  205. "id": p.id,
  206. "name": p.name,
  207. "plan_id": p.plan_id,
  208. "status": p.status,
  209. "estimated_points": p.estimated_points,
  210. "result_count": sum(1 for r in all_results if r["plan_id"] == p.id),
  211. }
  212. for p in plans
  213. ],
  214. }