| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249 |
- """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
- ],
- }
|