| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479 |
- """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
|