experience.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479
  1. """Experience case API router (CRUD + import from results for Phase 2)."""
  2. import json
  3. from fastapi import APIRouter, Depends, HTTPException, Query
  4. from sqlalchemy.orm import Session
  5. from ..database import get_db
  6. from ..models.experience_case import ExperienceCase
  7. from ..models.simulation_plan import SimulationPlan
  8. from ..models.simulation_result import SimulationResult
  9. router = APIRouter(prefix="/api/experience", tags=["experience"])
  10. def _case_to_dict(case: ExperienceCase) -> dict:
  11. return {
  12. "id": case.id,
  13. "source_plan_id": case.source_plan_id or "",
  14. "topology": case.topology or "SSSR",
  15. "model_path": case.model_path or "",
  16. "params": case.get_params(),
  17. "metrics": case.get_metrics(),
  18. "conclusion": case.conclusion or "",
  19. "tags": [t.strip() for t in (case.tags or "").split(",") if t.strip()],
  20. "rating": case.rating or 0,
  21. "created_at": case.created_at,
  22. }
  23. def _generate_conclusion(params: dict, metrics: dict) -> str:
  24. """Auto-generate a simple conclusion from metrics values.
  25. Uses ASCII-only text. Describes key performance indicators.
  26. """
  27. parts = []
  28. tavg = metrics.get("tavg_nm")
  29. if tavg is not None:
  30. parts.append(f"avg torque {tavg:.3f} Nm")
  31. eff = metrics.get("efficiency_pct")
  32. if eff is not None:
  33. parts.append(f"efficiency {eff:.1f}%")
  34. ripple = metrics.get("ripple_pct")
  35. if ripple is not None:
  36. parts.append(f"ripple {ripple:.2f}%")
  37. losses = metrics.get("total_losses_w")
  38. if losses is not None:
  39. parts.append(f"total losses {losses:.1f} W")
  40. if not parts:
  41. return "Imported from simulation result"
  42. # Add quality assessment
  43. if eff is not None and tavg is not None:
  44. if eff >= 85 and tavg >= 1.0:
  45. parts.append("good overall performance")
  46. elif eff < 80:
  47. parts.append("efficiency below target")
  48. return "; ".join(parts)
  49. # ---------------------------------------------------------------------------
  50. # CRUD
  51. # ---------------------------------------------------------------------------
  52. @router.get("")
  53. def list_experience(
  54. topology: str | None = None,
  55. tag: str | None = None,
  56. skip: int = Query(0, ge=0),
  57. limit: int = Query(50, ge=1, le=200),
  58. db: Session = Depends(get_db),
  59. ):
  60. """List experience cases with filters."""
  61. query = db.query(ExperienceCase)
  62. if topology:
  63. query = query.filter(ExperienceCase.topology == topology)
  64. if tag:
  65. query = query.filter(ExperienceCase.tags.contains(tag))
  66. total = query.count()
  67. cases = query.order_by(ExperienceCase.created_at.desc()).offset(skip).limit(limit).all()
  68. return {"total": total, "items": [_case_to_dict(c) for c in cases]}
  69. @router.post("", status_code=201)
  70. def create_experience(
  71. data: dict,
  72. db: Session = Depends(get_db),
  73. ):
  74. """Create an experience case from a dict. Dedup by source_plan_id + params hash."""
  75. params_json = json.dumps(data.get("params", {}), ensure_ascii=False, sort_keys=True)
  76. source_plan_id = data.get("source_plan_id", "")
  77. # Dedup check: if same plan_id and params already exist, return existing
  78. if source_plan_id:
  79. existing = db.query(ExperienceCase).filter(
  80. ExperienceCase.source_plan_id == source_plan_id,
  81. ExperienceCase.params_json == params_json,
  82. ).first()
  83. if existing:
  84. return _case_to_dict(existing)
  85. case = ExperienceCase(
  86. source_plan_id=source_plan_id,
  87. topology=data.get("topology", "SSSR"),
  88. model_path=data.get("model_path", ""),
  89. conclusion=data.get("conclusion", ""),
  90. tags=",".join(data.get("tags", [])),
  91. rating=data.get("rating", 0),
  92. )
  93. case.params_json = params_json
  94. case.metrics_json = json.dumps(data.get("metrics", {}), ensure_ascii=False)
  95. db.add(case)
  96. db.commit()
  97. db.refresh(case)
  98. return _case_to_dict(case)
  99. @router.get("/{case_id}")
  100. def get_experience(case_id: int, db: Session = Depends(get_db)):
  101. """Get an experience case by ID."""
  102. case = db.query(ExperienceCase).filter(ExperienceCase.id == case_id).first()
  103. if not case:
  104. raise HTTPException(status_code=404, detail="Experience case not found")
  105. return _case_to_dict(case)
  106. @router.put("/{case_id}")
  107. def update_experience(
  108. case_id: int,
  109. data: dict,
  110. db: Session = Depends(get_db),
  111. ):
  112. """Update an experience case (conclusion, tags, rating, params, metrics)."""
  113. case = db.query(ExperienceCase).filter(ExperienceCase.id == case_id).first()
  114. if not case:
  115. raise HTTPException(status_code=404, detail="Experience case not found")
  116. if "conclusion" in data:
  117. case.conclusion = data["conclusion"]
  118. if "tags" in data:
  119. case.tags = ",".join(data["tags"])
  120. if "rating" in data:
  121. case.rating = int(data["rating"])
  122. if "params" in data:
  123. case.params_json = json.dumps(data["params"], ensure_ascii=False)
  124. if "metrics" in data:
  125. case.metrics_json = json.dumps(data["metrics"], ensure_ascii=False)
  126. if "topology" in data:
  127. case.topology = data["topology"]
  128. if "model_path" in data:
  129. case.model_path = data["model_path"]
  130. db.commit()
  131. db.refresh(case)
  132. return _case_to_dict(case)
  133. @router.delete("/{case_id}", status_code=204)
  134. def delete_experience(case_id: int, db: Session = Depends(get_db)):
  135. """Delete an experience case."""
  136. case = db.query(ExperienceCase).filter(ExperienceCase.id == case_id).first()
  137. if not case:
  138. raise HTTPException(status_code=404, detail="Experience case not found")
  139. db.delete(case)
  140. db.commit()
  141. return None
  142. # ---------------------------------------------------------------------------
  143. # Import from simulation results
  144. # ---------------------------------------------------------------------------
  145. @router.post("/from-plan/{plan_id}")
  146. def import_from_plan(
  147. plan_id: int,
  148. data: dict | None = None,
  149. db: Session = Depends(get_db),
  150. ):
  151. """Import all OK results from a plan into the experience library.
  152. Creates one experience case per OK simulation result.
  153. Auto-generates conclusion if not provided.
  154. Request body (optional):
  155. tags: list of tags to apply to all imported cases
  156. rating: default rating (0-5)
  157. auto_conclusion: bool (default True) - generate conclusion from metrics
  158. """
  159. plan = db.query(SimulationPlan).filter(SimulationPlan.id == plan_id).first()
  160. if not plan:
  161. raise HTTPException(status_code=404, detail="Plan not found")
  162. data = data or {}
  163. tags = data.get("tags", ["auto-imported"])
  164. rating = int(data.get("rating", 0))
  165. auto_conclusion = data.get("auto_conclusion", True)
  166. results = db.query(SimulationResult).filter(
  167. SimulationResult.plan_id == plan_id,
  168. SimulationResult.status == "OK",
  169. ).all()
  170. if not results:
  171. return {"imported": 0, "skipped": 0, "message": "No OK results found in plan"}
  172. # Get topology and model_path from plan_json if available
  173. plan_dict = plan.get_plan_dict() if hasattr(plan, "get_plan_dict") else {}
  174. topology = plan_dict.get("topology") or getattr(plan, "topology", None) or "SSSR"
  175. model_path = plan_dict.get("model_path") or getattr(plan, "model_path", "") or ""
  176. imported = 0
  177. skipped = 0
  178. for r in results:
  179. params = r.get_params()
  180. metrics = r.get_metrics()
  181. if not params or not metrics:
  182. skipped += 1
  183. continue
  184. conclusion = ""
  185. if auto_conclusion:
  186. conclusion = _generate_conclusion(params, metrics)
  187. case = ExperienceCase(
  188. source_plan_id=plan.plan_id or str(plan.id),
  189. topology=topology,
  190. model_path=model_path,
  191. conclusion=conclusion,
  192. tags=",".join(tags),
  193. rating=rating,
  194. )
  195. case.params_json = json.dumps(params, ensure_ascii=False)
  196. case.metrics_json = json.dumps(metrics, ensure_ascii=False)
  197. db.add(case)
  198. imported += 1
  199. db.commit()
  200. return {
  201. "imported": imported,
  202. "skipped": skipped,
  203. "plan_id": plan.id,
  204. "plan_uuid": plan.plan_id,
  205. "message": f"Imported {imported} cases from plan",
  206. }
  207. @router.post("/from-result/{result_id}")
  208. def import_from_result(
  209. result_id: int,
  210. data: dict | None = None,
  211. db: Session = Depends(get_db),
  212. ):
  213. """Import a single simulation result into the experience library."""
  214. result = db.query(SimulationResult).filter(SimulationResult.id == result_id).first()
  215. if not result:
  216. raise HTTPException(status_code=404, detail="Result not found")
  217. if result.status != "OK":
  218. raise HTTPException(status_code=400, detail=f"Cannot import non-OK result (status={result.status})")
  219. data = data or {}
  220. plan = db.query(SimulationPlan).filter(SimulationPlan.id == result.plan_id).first()
  221. params = result.get_params()
  222. metrics = result.get_metrics()
  223. conclusion = data.get("conclusion") or _generate_conclusion(params, metrics)
  224. tags = data.get("tags", ["auto-imported"])
  225. rating = int(data.get("rating", 0))
  226. # Get topology and model_path from plan_json if available
  227. plan_dict = plan.get_plan_dict() if plan and hasattr(plan, "get_plan_dict") else {}
  228. topology = plan_dict.get("topology") or (getattr(plan, "topology", None) if plan else None) or "SSSR"
  229. model_path = plan_dict.get("model_path") or (getattr(plan, "model_path", "") if plan else "") or ""
  230. case = ExperienceCase(
  231. source_plan_id=plan.plan_id if plan else str(result.plan_id),
  232. topology=topology,
  233. model_path=model_path,
  234. conclusion=conclusion,
  235. tags=",".join(tags),
  236. rating=rating,
  237. )
  238. case.params_json = json.dumps(params, ensure_ascii=False)
  239. case.metrics_json = json.dumps(metrics, ensure_ascii=False)
  240. db.add(case)
  241. db.commit()
  242. db.refresh(case)
  243. return _case_to_dict(case)
  244. # ---------------------------------------------------------------------------
  245. # Smart experience extraction (P2-10)
  246. # ---------------------------------------------------------------------------
  247. @router.post("/from-plan/{plan_id}/smart-extract")
  248. def smart_extract_experience(
  249. plan_id: int,
  250. data: dict | None = None,
  251. db: Session = Depends(get_db),
  252. ):
  253. """Smart extract: only import Pareto-optimal points that satisfy constraints.
  254. Unlike import_from_plan (which imports ALL OK results), this endpoint:
  255. 1. Filters results by acceptance criteria (hard constraints)
  256. 2. Finds Pareto-optimal points (max efficiency, min losses, min ripple)
  257. 3. Generates AI-style conclusions with parameter-performance insights
  258. 4. Tags them as 'pareto-optimal' and 'constraint-satisfied'
  259. Returns the imported cases and a summary of extracted design rules.
  260. """
  261. plan = db.query(SimulationPlan).filter(SimulationPlan.id == plan_id).first()
  262. if not plan:
  263. raise HTTPException(status_code=404, detail="Plan not found")
  264. data = data or {}
  265. plan_data = plan.get_plan_dict() if hasattr(plan, "get_plan_dict") else {}
  266. topology = plan_data.get("topology", "SSSR")
  267. model_path = plan_data.get("model_path", "")
  268. # Get acceptance criteria
  269. ac = plan_data.get("acceptance_criteria", {})
  270. hard_constraints = ac.get("hard_constraints", [])
  271. results = db.query(SimulationResult).filter(
  272. SimulationResult.plan_id == plan_id,
  273. SimulationResult.status == "OK",
  274. ).all()
  275. if not results:
  276. return {"imported": 0, "message": "No OK results found"}
  277. # Parse and filter by hard constraints
  278. def _check_constraint(metrics: dict, constraint: str) -> bool:
  279. parts = constraint.replace(">=", " >= ").replace("<=", " <= ").split()
  280. if len(parts) < 3:
  281. return True
  282. metric, op, val = parts[0], parts[1], float(parts[2])
  283. mv = metrics.get(metric)
  284. if mv is None:
  285. return True
  286. if op == ">=":
  287. return mv >= val
  288. elif op == "<=":
  289. return mv <= val
  290. elif op == ">":
  291. return mv > val
  292. elif op == "<":
  293. return mv < val
  294. return True
  295. satisfying = []
  296. for r in results:
  297. metrics = r.get_metrics()
  298. ok = all(_check_constraint(metrics, c) for c in hard_constraints)
  299. if ok:
  300. satisfying.append(r)
  301. if not satisfying:
  302. return {
  303. "imported": 0,
  304. "total_ok": len(results),
  305. "satisfying_constraints": 0,
  306. "message": "No results satisfy all hard constraints",
  307. }
  308. # Find Pareto-optimal points (max efficiency, min total_losses, min ripple)
  309. def _is_pareto(candidate: dict, others: list) -> bool:
  310. for o in others:
  311. if (o.get("efficiency_pct", 0) >= candidate.get("efficiency_pct", 0) and
  312. o.get("total_losses_w", 9999) <= candidate.get("total_losses_w", 9999) and
  313. o.get("ripple_pct", 9999) <= candidate.get("ripple_pct", 9999) and
  314. (o.get("efficiency_pct", 0) > candidate.get("efficiency_pct", 0) or
  315. o.get("total_losses_w", 9999) < candidate.get("total_losses_w", 9999) or
  316. o.get("ripple_pct", 9999) < candidate.get("ripple_pct", 9999))):
  317. return False
  318. return True
  319. satisfying_metrics = [r.get_metrics() for r in satisfying]
  320. pareto_indices = [i for i, m in enumerate(satisfying_metrics) if _is_pareto(m, satisfying_metrics)]
  321. pareto_results = [satisfying[i] for i in pareto_indices]
  322. # Import Pareto-optimal points
  323. imported = 0
  324. imported_cases = []
  325. for r in pareto_results:
  326. params = r.get_params()
  327. metrics = r.get_metrics()
  328. conclusion = _generate_smart_conclusion(params, metrics, topology, hard_constraints)
  329. tags = ["pareto-optimal", "constraint-satisfied", topology.lower()]
  330. case = ExperienceCase(
  331. source_plan_id=plan.plan_id or str(plan.id),
  332. topology=topology,
  333. model_path=model_path,
  334. conclusion=conclusion,
  335. tags=",".join(tags),
  336. rating=5,
  337. )
  338. case.params_json = json.dumps(params, ensure_ascii=False)
  339. case.metrics_json = json.dumps(metrics, ensure_ascii=False)
  340. db.add(case)
  341. imported += 1
  342. imported_cases.append({
  343. "params": params,
  344. "metrics": metrics,
  345. "conclusion": conclusion,
  346. })
  347. db.commit()
  348. # Generate design rules summary
  349. rules = _extract_design_rules(satisfying_metrics, plan_data.get("variables", []))
  350. return {
  351. "imported": imported,
  352. "total_ok": len(results),
  353. "satisfying_constraints": len(satisfying),
  354. "pareto_count": len(pareto_results),
  355. "cases": imported_cases,
  356. "design_rules": rules,
  357. "message": f"Imported {imported} Pareto-optimal cases (from {len(satisfying)} satisfying constraints)",
  358. }
  359. def _generate_smart_conclusion(params: dict, metrics: dict, topology: str, constraints: list) -> str:
  360. """Generate a smart conclusion with parameter-performance insights."""
  361. parts = []
  362. eff = metrics.get("efficiency_pct")
  363. tavg = metrics.get("tavg_nm")
  364. ripple = metrics.get("ripple_pct")
  365. losses = metrics.get("total_losses_w")
  366. if eff is not None:
  367. parts.append(f"efficiency {eff:.1f}%")
  368. if tavg is not None:
  369. parts.append(f"torque {tavg:.3f} Nm")
  370. if ripple is not None:
  371. parts.append(f"ripple {ripple:.2f}%")
  372. if losses is not None:
  373. parts.append(f"losses {losses:.1f} W")
  374. # Add parameter highlights
  375. highlights = []
  376. if "Airgap" in params:
  377. highlights.append(f"airgap={params['Airgap']}mm")
  378. if "Magnet_Length" in params:
  379. highlights.append(f"magnet_len={params['Magnet_Length']}mm")
  380. if "RMSCurrent" in params:
  381. highlights.append(f"I={params['RMSCurrent']}A")
  382. if highlights:
  383. parts.append("params: " + ", ".join(highlights))
  384. if constraints:
  385. parts.append("meets all hard constraints")
  386. return "; ".join(parts)
  387. def _extract_design_rules(metrics_list: list, variables: list) -> list:
  388. """Extract simple design rules from satisfying results."""
  389. rules = []
  390. if not metrics_list:
  391. return rules
  392. # Find best efficiency point and its characteristics
  393. best = max(metrics_list, key=lambda m: m.get("efficiency_pct", 0))
  394. if best.get("efficiency_pct"):
  395. rules.append(f"Best efficiency {best['efficiency_pct']:.1f}% achieved with "
  396. f"torque {best.get('tavg_nm', '?')}Nm, ripple {best.get('ripple_pct', '?')}%")
  397. # Efficiency range
  398. effs = [m.get("efficiency_pct") for m in metrics_list if m.get("efficiency_pct")]
  399. if effs:
  400. rules.append(f"Efficiency range: {min(effs):.1f}% - {max(effs):.1f}% across scanned parameters")
  401. # Ripple range
  402. ripples = [m.get("ripple_pct") for m in metrics_list if m.get("ripple_pct")]
  403. if ripples:
  404. rules.append(f"Torque ripple range: {min(ripples):.2f}% - {max(ripples):.2f}%")
  405. return rules