"""Experience case API router (CRUD + import from results for Phase 2).""" import json 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 router = APIRouter(prefix="/api/experience", tags=["experience"]) def _case_to_dict(case: ExperienceCase) -> dict: return { "id": case.id, "source_plan_id": case.source_plan_id or "", "topology": case.topology or "SSSR", "model_path": case.model_path or "", "params": case.get_params(), "metrics": case.get_metrics(), "conclusion": case.conclusion or "", "tags": [t.strip() for t in (case.tags or "").split(",") if t.strip()], "rating": case.rating or 0, "created_at": case.created_at, } def _generate_conclusion(params: dict, metrics: dict) -> str: """Auto-generate a simple conclusion from metrics values. Uses ASCII-only text. Describes key performance indicators. """ parts = [] tavg = metrics.get("tavg_nm") if tavg is not None: parts.append(f"avg torque {tavg:.3f} Nm") eff = metrics.get("efficiency_pct") if eff is not None: parts.append(f"efficiency {eff:.1f}%") ripple = metrics.get("ripple_pct") if ripple is not None: parts.append(f"ripple {ripple:.2f}%") losses = metrics.get("total_losses_w") if losses is not None: parts.append(f"total losses {losses:.1f} W") if not parts: return "Imported from simulation result" # Add quality assessment if eff is not None and tavg is not None: if eff >= 85 and tavg >= 1.0: parts.append("good overall performance") elif eff < 80: parts.append("efficiency below target") return "; ".join(parts) # --------------------------------------------------------------------------- # CRUD # --------------------------------------------------------------------------- @router.get("") def list_experience( topology: str | None = None, tag: str | None = None, skip: int = Query(0, ge=0), limit: int = Query(50, ge=1, le=200), db: Session = Depends(get_db), ): """List experience cases with filters.""" query = db.query(ExperienceCase) if topology: query = query.filter(ExperienceCase.topology == topology) if tag: query = query.filter(ExperienceCase.tags.contains(tag)) total = query.count() cases = query.order_by(ExperienceCase.created_at.desc()).offset(skip).limit(limit).all() return {"total": total, "items": [_case_to_dict(c) for c in cases]} @router.post("", status_code=201) def create_experience( data: dict, db: Session = Depends(get_db), ): """Create an experience case from a dict. Dedup by source_plan_id + params hash.""" params_json = json.dumps(data.get("params", {}), ensure_ascii=False, sort_keys=True) source_plan_id = data.get("source_plan_id", "") # Dedup check: if same plan_id and params already exist, return existing if source_plan_id: existing = db.query(ExperienceCase).filter( ExperienceCase.source_plan_id == source_plan_id, ExperienceCase.params_json == params_json, ).first() if existing: return _case_to_dict(existing) case = ExperienceCase( source_plan_id=source_plan_id, topology=data.get("topology", "SSSR"), model_path=data.get("model_path", ""), conclusion=data.get("conclusion", ""), tags=",".join(data.get("tags", [])), rating=data.get("rating", 0), ) case.params_json = params_json case.metrics_json = json.dumps(data.get("metrics", {}), ensure_ascii=False) db.add(case) db.commit() db.refresh(case) return _case_to_dict(case) @router.get("/{case_id}") def get_experience(case_id: int, db: Session = Depends(get_db)): """Get an experience case by ID.""" case = db.query(ExperienceCase).filter(ExperienceCase.id == case_id).first() if not case: raise HTTPException(status_code=404, detail="Experience case not found") return _case_to_dict(case) @router.put("/{case_id}") def update_experience( case_id: int, data: dict, db: Session = Depends(get_db), ): """Update an experience case (conclusion, tags, rating, params, metrics).""" case = db.query(ExperienceCase).filter(ExperienceCase.id == case_id).first() if not case: raise HTTPException(status_code=404, detail="Experience case not found") if "conclusion" in data: case.conclusion = data["conclusion"] if "tags" in data: case.tags = ",".join(data["tags"]) if "rating" in data: case.rating = int(data["rating"]) if "params" in data: case.params_json = json.dumps(data["params"], ensure_ascii=False) if "metrics" in data: case.metrics_json = json.dumps(data["metrics"], ensure_ascii=False) if "topology" in data: case.topology = data["topology"] if "model_path" in data: case.model_path = data["model_path"] db.commit() db.refresh(case) return _case_to_dict(case) @router.delete("/{case_id}", status_code=204) def delete_experience(case_id: int, db: Session = Depends(get_db)): """Delete an experience case.""" case = db.query(ExperienceCase).filter(ExperienceCase.id == case_id).first() if not case: raise HTTPException(status_code=404, detail="Experience case not found") db.delete(case) db.commit() return None # --------------------------------------------------------------------------- # Import from simulation results # --------------------------------------------------------------------------- @router.post("/from-plan/{plan_id}") def import_from_plan( plan_id: int, data: dict | None = None, db: Session = Depends(get_db), ): """Import all OK results from a plan into the experience library. Creates one experience case per OK simulation result. Auto-generates conclusion if not provided. Request body (optional): tags: list of tags to apply to all imported cases rating: default rating (0-5) auto_conclusion: bool (default True) - generate conclusion from metrics """ plan = db.query(SimulationPlan).filter(SimulationPlan.id == plan_id).first() if not plan: raise HTTPException(status_code=404, detail="Plan not found") data = data or {} tags = data.get("tags", ["auto-imported"]) rating = int(data.get("rating", 0)) auto_conclusion = data.get("auto_conclusion", True) results = db.query(SimulationResult).filter( SimulationResult.plan_id == plan_id, SimulationResult.status == "OK", ).all() if not results: return {"imported": 0, "skipped": 0, "message": "No OK results found in plan"} # Get topology and model_path from plan_json if available plan_dict = plan.get_plan_dict() if hasattr(plan, "get_plan_dict") else {} topology = plan_dict.get("topology") or getattr(plan, "topology", None) or "SSSR" model_path = plan_dict.get("model_path") or getattr(plan, "model_path", "") or "" imported = 0 skipped = 0 for r in results: params = r.get_params() metrics = r.get_metrics() if not params or not metrics: skipped += 1 continue conclusion = "" if auto_conclusion: conclusion = _generate_conclusion(params, metrics) case = ExperienceCase( source_plan_id=plan.plan_id or str(plan.id), topology=topology, model_path=model_path, conclusion=conclusion, tags=",".join(tags), rating=rating, ) case.params_json = json.dumps(params, ensure_ascii=False) case.metrics_json = json.dumps(metrics, ensure_ascii=False) db.add(case) imported += 1 db.commit() return { "imported": imported, "skipped": skipped, "plan_id": plan.id, "plan_uuid": plan.plan_id, "message": f"Imported {imported} cases from plan", } @router.post("/from-result/{result_id}") def import_from_result( result_id: int, data: dict | None = None, db: Session = Depends(get_db), ): """Import a single simulation result into the experience library.""" result = db.query(SimulationResult).filter(SimulationResult.id == result_id).first() if not result: raise HTTPException(status_code=404, detail="Result not found") if result.status != "OK": raise HTTPException(status_code=400, detail=f"Cannot import non-OK result (status={result.status})") data = data or {} plan = db.query(SimulationPlan).filter(SimulationPlan.id == result.plan_id).first() params = result.get_params() metrics = result.get_metrics() conclusion = data.get("conclusion") or _generate_conclusion(params, metrics) tags = data.get("tags", ["auto-imported"]) rating = int(data.get("rating", 0)) # Get topology and model_path from plan_json if available plan_dict = plan.get_plan_dict() if plan and hasattr(plan, "get_plan_dict") else {} topology = plan_dict.get("topology") or (getattr(plan, "topology", None) if plan else None) or "SSSR" model_path = plan_dict.get("model_path") or (getattr(plan, "model_path", "") if plan else "") or "" case = ExperienceCase( source_plan_id=plan.plan_id if plan else str(result.plan_id), topology=topology, model_path=model_path, conclusion=conclusion, tags=",".join(tags), rating=rating, ) case.params_json = json.dumps(params, ensure_ascii=False) case.metrics_json = json.dumps(metrics, ensure_ascii=False) db.add(case) db.commit() db.refresh(case) return _case_to_dict(case) # --------------------------------------------------------------------------- # Smart experience extraction (P2-10) # --------------------------------------------------------------------------- @router.post("/from-plan/{plan_id}/smart-extract") def smart_extract_experience( plan_id: int, data: dict | None = None, db: Session = Depends(get_db), ): """Smart extract: only import Pareto-optimal points that satisfy constraints. Unlike import_from_plan (which imports ALL OK results), this endpoint: 1. Filters results by acceptance criteria (hard constraints) 2. Finds Pareto-optimal points (max efficiency, min losses, min ripple) 3. Generates AI-style conclusions with parameter-performance insights 4. Tags them as 'pareto-optimal' and 'constraint-satisfied' Returns the imported cases and a summary of extracted design rules. """ plan = db.query(SimulationPlan).filter(SimulationPlan.id == plan_id).first() if not plan: raise HTTPException(status_code=404, detail="Plan not found") data = data or {} plan_data = plan.get_plan_dict() if hasattr(plan, "get_plan_dict") else {} topology = plan_data.get("topology", "SSSR") model_path = plan_data.get("model_path", "") # Get acceptance criteria ac = plan_data.get("acceptance_criteria", {}) hard_constraints = ac.get("hard_constraints", []) results = db.query(SimulationResult).filter( SimulationResult.plan_id == plan_id, SimulationResult.status == "OK", ).all() if not results: return {"imported": 0, "message": "No OK results found"} # Parse and filter by hard constraints def _check_constraint(metrics: dict, constraint: str) -> bool: parts = constraint.replace(">=", " >= ").replace("<=", " <= ").split() if len(parts) < 3: return True metric, op, val = parts[0], parts[1], float(parts[2]) mv = metrics.get(metric) if mv is None: return True if op == ">=": return mv >= val elif op == "<=": return mv <= val elif op == ">": return mv > val elif op == "<": return mv < val return True satisfying = [] for r in results: metrics = r.get_metrics() ok = all(_check_constraint(metrics, c) for c in hard_constraints) if ok: satisfying.append(r) if not satisfying: return { "imported": 0, "total_ok": len(results), "satisfying_constraints": 0, "message": "No results satisfy all hard constraints", } # Find Pareto-optimal points (max efficiency, min total_losses, min ripple) def _is_pareto(candidate: dict, others: list) -> bool: for o in others: if (o.get("efficiency_pct", 0) >= candidate.get("efficiency_pct", 0) and o.get("total_losses_w", 9999) <= candidate.get("total_losses_w", 9999) and o.get("ripple_pct", 9999) <= candidate.get("ripple_pct", 9999) and (o.get("efficiency_pct", 0) > candidate.get("efficiency_pct", 0) or o.get("total_losses_w", 9999) < candidate.get("total_losses_w", 9999) or o.get("ripple_pct", 9999) < candidate.get("ripple_pct", 9999))): return False return True satisfying_metrics = [r.get_metrics() for r in satisfying] pareto_indices = [i for i, m in enumerate(satisfying_metrics) if _is_pareto(m, satisfying_metrics)] pareto_results = [satisfying[i] for i in pareto_indices] # Import Pareto-optimal points imported = 0 imported_cases = [] for r in pareto_results: params = r.get_params() metrics = r.get_metrics() conclusion = _generate_smart_conclusion(params, metrics, topology, hard_constraints) tags = ["pareto-optimal", "constraint-satisfied", topology.lower()] case = ExperienceCase( source_plan_id=plan.plan_id or str(plan.id), topology=topology, model_path=model_path, conclusion=conclusion, tags=",".join(tags), rating=5, ) case.params_json = json.dumps(params, ensure_ascii=False) case.metrics_json = json.dumps(metrics, ensure_ascii=False) db.add(case) imported += 1 imported_cases.append({ "params": params, "metrics": metrics, "conclusion": conclusion, }) db.commit() # Generate design rules summary rules = _extract_design_rules(satisfying_metrics, plan_data.get("variables", [])) return { "imported": imported, "total_ok": len(results), "satisfying_constraints": len(satisfying), "pareto_count": len(pareto_results), "cases": imported_cases, "design_rules": rules, "message": f"Imported {imported} Pareto-optimal cases (from {len(satisfying)} satisfying constraints)", } def _generate_smart_conclusion(params: dict, metrics: dict, topology: str, constraints: list) -> str: """Generate a smart conclusion with parameter-performance insights.""" parts = [] eff = metrics.get("efficiency_pct") tavg = metrics.get("tavg_nm") ripple = metrics.get("ripple_pct") losses = metrics.get("total_losses_w") if eff is not None: parts.append(f"efficiency {eff:.1f}%") if tavg is not None: parts.append(f"torque {tavg:.3f} Nm") if ripple is not None: parts.append(f"ripple {ripple:.2f}%") if losses is not None: parts.append(f"losses {losses:.1f} W") # Add parameter highlights highlights = [] if "Airgap" in params: highlights.append(f"airgap={params['Airgap']}mm") if "Magnet_Length" in params: highlights.append(f"magnet_len={params['Magnet_Length']}mm") if "RMSCurrent" in params: highlights.append(f"I={params['RMSCurrent']}A") if highlights: parts.append("params: " + ", ".join(highlights)) if constraints: parts.append("meets all hard constraints") return "; ".join(parts) def _extract_design_rules(metrics_list: list, variables: list) -> list: """Extract simple design rules from satisfying results.""" rules = [] if not metrics_list: return rules # Find best efficiency point and its characteristics best = max(metrics_list, key=lambda m: m.get("efficiency_pct", 0)) if best.get("efficiency_pct"): rules.append(f"Best efficiency {best['efficiency_pct']:.1f}% achieved with " f"torque {best.get('tavg_nm', '?')}Nm, ripple {best.get('ripple_pct', '?')}%") # Efficiency range effs = [m.get("efficiency_pct") for m in metrics_list if m.get("efficiency_pct")] if effs: rules.append(f"Efficiency range: {min(effs):.1f}% - {max(effs):.1f}% across scanned parameters") # Ripple range ripples = [m.get("ripple_pct") for m in metrics_list if m.get("ripple_pct")] if ripples: rules.append(f"Torque ripple range: {min(ripples):.2f}% - {max(ripples):.2f}%") return rules