"""Analytics API router (experience stats, result trends, Pareto, sensitivity).""" from fastapi import APIRouter, Depends, HTTPException, Query from sqlalchemy.orm import Session from ..database import get_db from ..models.experience_case import ExperienceCase from ..models.simulation_plan import SimulationPlan from ..models.simulation_result import SimulationResult from ..services.analytics import ( compute_experience_stats, find_similar_cases, compute_trend_data, compute_pareto_frontier, compute_sensitivity, get_metric_defs, ) router = APIRouter(prefix="/api/analytics", tags=["analytics"]) def _result_to_dict(r: SimulationResult) -> dict: return { "id": r.id, "plan_id": r.plan_id, "run_index": r.run_index, "status": r.status, "solve_time_s": r.solve_time_s, "params": r.get_params(), "metrics": r.get_metrics(), "error_message": r.error_message or "", } @router.get("/solve-time-stats") def solve_time_stats(db: Session = Depends(get_db)): """Measured per-point solve time statistics from completed results. Used by the frontend to calibrate the estimated simulation duration from real solve times instead of a hardcoded per-point guess (P2-1). Returns avg/min/max/median over results that recorded a positive solve_time_s. """ rows = ( db.query(SimulationResult.solve_time_s) .filter(SimulationResult.solve_time_s.isnot(None)) .filter(SimulationResult.solve_time_s > 0) .all() ) vals = sorted(float(r[0]) for r in rows) if not vals: return {"count": 0, "avg_s": None, "min_s": None, "max_s": None, "median_s": None} n = len(vals) avg = sum(vals) / n median = vals[n // 2] if n % 2 else (vals[n // 2 - 1] + vals[n // 2]) / 2 return { "count": n, "avg_s": round(avg, 1), "min_s": round(vals[0], 1), "max_s": round(vals[-1], 1), "median_s": round(median, 1), } def _case_to_dict(c: ExperienceCase) -> dict: return { "id": c.id, "source_plan_id": c.source_plan_id or "", "topology": c.topology or "SSSR", "model_path": c.model_path or "", "params": c.get_params(), "metrics": c.get_metrics(), "conclusion": c.conclusion or "", "tags": [t.strip() for t in (c.tags or "").split(",") if t.strip()], "rating": c.rating or 0, "created_at": c.created_at, } # --------------------------------------------------------------------------- # Metric definitions # --------------------------------------------------------------------------- @router.get("/metrics") def list_metrics(): """List all available metric definitions for frontend charts.""" return {"metrics": get_metric_defs()} # --------------------------------------------------------------------------- # Experience library analytics # --------------------------------------------------------------------------- @router.get("/experience/stats") def experience_stats( topology: str | None = None, db: Session = Depends(get_db), ): """Compute aggregate statistics for the experience library.""" query = db.query(ExperienceCase) if topology: query = query.filter(ExperienceCase.topology == topology) cases = [_case_to_dict(c) for c in query.all()] return compute_experience_stats(cases) @router.post("/experience/similar") def experience_similar( data: dict, topology: str | None = None, top_k: int = Query(5, ge=1, le=50), tolerance: float = Query(0.3, ge=0.0, le=1.0), db: Session = Depends(get_db), ): """Find experience cases similar to target parameters. Request body: {"params": {"Airgap": 1.0, "RMSCurrent": 21, ...}} """ target_params = data.get("params", {}) if not target_params: raise HTTPException(status_code=400, detail="params field is required") query = db.query(ExperienceCase) if topology: query = query.filter(ExperienceCase.topology == topology) cases = [_case_to_dict(c) for c in query.all()] similar = find_similar_cases(target_params, cases, top_k=top_k, tolerance=tolerance) return {"total": len(similar), "items": similar} # --------------------------------------------------------------------------- # Simulation result analytics # --------------------------------------------------------------------------- @router.get("/plans/{plan_id}/trend") def plan_trend( plan_id: int, param_key: str = Query(..., description="Parameter name for X axis"), metric_key: str = Query(..., description="Metric name for Y axis"), db: Session = Depends(get_db), ): """Compute trend data for a parameter vs metric scatter plot.""" plan = db.query(SimulationPlan).filter(SimulationPlan.id == plan_id).first() if not plan: raise HTTPException(status_code=404, detail="Plan not found") results = [_result_to_dict(r) for r in db.query(SimulationResult).filter( SimulationResult.plan_id == plan_id ).order_by(SimulationResult.run_index.asc()).all()] return compute_trend_data(results, param_key, metric_key) @router.get("/plans/{plan_id}/pareto") def plan_pareto( plan_id: int, x_metric: str = Query("total_losses_w", description="X axis metric"), y_metric: str = Query("efficiency_pct", description="Y axis metric"), db: Session = Depends(get_db), ): """Compute Pareto frontier for two metrics in a plan.""" plan = db.query(SimulationPlan).filter(SimulationPlan.id == plan_id).first() if not plan: raise HTTPException(status_code=404, detail="Plan not found") results = [_result_to_dict(r) for r in db.query(SimulationResult).filter( SimulationResult.plan_id == plan_id ).all()] return compute_pareto_frontier(results, x_metric=x_metric, y_metric=y_metric) @router.get("/plans/{plan_id}/sensitivity") def plan_sensitivity( plan_id: int, metric_key: str = Query(..., description="Target metric for sensitivity analysis"), db: Session = Depends(get_db), ): """Compute parameter sensitivity ranking for a target metric.""" plan = db.query(SimulationPlan).filter(SimulationPlan.id == plan_id).first() if not plan: raise HTTPException(status_code=404, detail="Plan not found") results = [_result_to_dict(r) for r in db.query(SimulationResult).filter( SimulationResult.plan_id == plan_id ).all()] sensitivities = compute_sensitivity(results, metric_key) return {"metric": metric_key, "total_params": len(sensitivities), "items": sensitivities} # --------------------------------------------------------------------------- # Project-level analytics (aggregate all plans in a project) # --------------------------------------------------------------------------- @router.get("/projects/{project_id}/overview") def project_overview( project_id: int, db: Session = Depends(get_db), ): """Get overview analytics for a project (all plans and results).""" from ..models.project import Project project = db.query(Project).filter(Project.id == project_id).first() if not project: raise HTTPException(status_code=404, detail="Project not found") plans = db.query(SimulationPlan).filter(SimulationPlan.project_id == project_id).all() plan_ids = [p.id for p in plans] total_results = 0 ok_results = 0 failed_results = 0 all_results = [] if plan_ids: results = db.query(SimulationResult).filter( SimulationResult.plan_id.in_(plan_ids) ).all() total_results = len(results) ok_results = sum(1 for r in results if r.status == "OK") failed_results = sum(1 for r in results if r.status != "OK") all_results = [_result_to_dict(r) for r in results] # Best efficiency and torque from OK results best_eff = 0.0 best_torque = 0.0 for r in all_results: if r["status"] == "OK": eff = r["metrics"].get("efficiency_pct", 0) torque = r["metrics"].get("tavg_nm", 0) if isinstance(eff, (int, float)): best_eff = max(best_eff, eff) if isinstance(torque, (int, float)): best_torque = max(best_torque, torque) return { "project_id": project_id, "project_name": project.name, "total_plans": len(plans), "total_results": total_results, "ok_results": ok_results, "failed_results": failed_results, "best_efficiency_pct": round(best_eff, 2), "best_torque_nm": round(best_torque, 4), "plans": [ { "id": p.id, "name": p.name, "plan_id": p.plan_id, "status": p.status, "estimated_points": p.estimated_points, "result_count": sum(1 for r in all_results if r["plan_id"] == p.id), } for p in plans ], }