plans.py 36 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943
  1. """Simulation plan API router (CRUD + download + upload results + start simulation)."""
  2. import json
  3. import math
  4. import uuid
  5. from datetime import datetime
  6. from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
  7. from sqlalchemy.orm import Session
  8. from ..database import get_db
  9. from ..metrics_constants import METRIC_KEYS
  10. from ..models.project import Project
  11. from ..models.simulation_plan import SimulationPlan
  12. from ..models.simulation_result import SimulationResult
  13. from ..schemas.simulation_plan import (
  14. PlanCreate, PlanUpdate, PlanResponse, PlanListResponse, PlanDownloadResponse,
  15. )
  16. from ..schemas.simulation_result import ResultListResponse
  17. from ..services.task_manager import get_task_manager
  18. router = APIRouter(prefix="/api/plans", tags=["plans"])
  19. @router.get("/variable-catalog")
  20. def get_variable_catalog(topology: str = "SSSR"):
  21. """Return topology-aware variable catalog for frontend scan-variable selectors.
  22. Returns the fixed-parameter template (with motorcad_var resolved) and
  23. the set of known Motor-CAD variable names for the given topology.
  24. Frontend should use this to populate scan-variable dropdowns and prevent
  25. users from entering invalid variable names.
  26. Args:
  27. topology: Motor topology (SSSR/AFIR/RFM). Defaults to SSSR.
  28. """
  29. from ..services.fixed_params_template import FIXED_PARAM_TEMPLATES
  30. from ..services.topology_variable_map import (
  31. get_known_variables,
  32. normalize_topology,
  33. resolve_variable,
  34. )
  35. topo = normalize_topology(topology)
  36. # Build template with resolved motorcad_var for this topology.
  37. # For params where motorcad_var is None but the name is a known alias,
  38. # resolve it. Otherwise keep name as the variable name if known.
  39. template = []
  40. for p in FIXED_PARAM_TEMPLATES:
  41. row = dict(p)
  42. mc_var = row.get("motorcad_var")
  43. if not mc_var:
  44. # Try to resolve from alias map
  45. resolved, was_alias = resolve_variable(row["name"], topo)
  46. if was_alias:
  47. row["motorcad_var"] = resolved
  48. else:
  49. row["motorcad_var"] = row["name"]
  50. template.append(row)
  51. known_vars = sorted(get_known_variables(topo))
  52. return {
  53. "topology": topo,
  54. "template": template,
  55. "known_variables": known_vars,
  56. "total_known": len(known_vars),
  57. "total_template": len(template),
  58. }
  59. def _generate_plan_id() -> str:
  60. """Generate a unique plan ID with timestamp + random suffix to avoid collisions."""
  61. return f"SP-{datetime.now().strftime('%Y%m%d-%H%M%S')}-{uuid.uuid4().hex[:6]}"
  62. def _plan_to_response(db: Session, plan: SimulationPlan) -> PlanResponse:
  63. result_count = db.query(SimulationResult).filter(SimulationResult.plan_id == plan.id).count()
  64. variables_summary = {}
  65. try:
  66. variables_summary = json.loads(plan.variables_summary) if plan.variables_summary else {}
  67. except (json.JSONDecodeError, TypeError):
  68. pass
  69. return PlanResponse(
  70. id=plan.id,
  71. project_id=plan.project_id,
  72. name=plan.name,
  73. plan_id=plan.plan_id,
  74. status=plan.status,
  75. plan_data=plan.get_plan_dict(),
  76. variables_summary=variables_summary,
  77. estimated_points=plan.estimated_points or 0,
  78. estimated_time_min=plan.estimated_time_min or 0,
  79. notes=plan.notes or "",
  80. result_count=result_count,
  81. created_at=plan.created_at,
  82. updated_at=plan.updated_at,
  83. )
  84. @router.get("", response_model=PlanListResponse)
  85. def list_plans(
  86. project_id: int | None = None,
  87. skip: int = 0,
  88. limit: int = 50,
  89. db: Session = Depends(get_db),
  90. ):
  91. """List simulation plans, optionally filtered by project."""
  92. query = db.query(SimulationPlan)
  93. if project_id:
  94. query = query.filter(SimulationPlan.project_id == project_id)
  95. total = query.count()
  96. plans = query.order_by(SimulationPlan.updated_at.desc()).offset(skip).limit(limit).all()
  97. return PlanListResponse(total=total, items=[_plan_to_response(db, p) for p in plans])
  98. @router.post("", response_model=PlanResponse, status_code=201)
  99. def create_plan(data: PlanCreate, db: Session = Depends(get_db)):
  100. """Create a new simulation plan."""
  101. project = db.query(Project).filter(Project.id == data.project_id).first()
  102. if not project:
  103. raise HTTPException(status_code=404, detail="Project not found")
  104. plan_id = _generate_plan_id()
  105. plan_data = data.plan_data
  106. plan_data["plan_id"] = plan_id
  107. # Persist the project boundary conditions (normalized to canonical BC
  108. # keys) when the caller did not supply them, so the plan-detail boundary
  109. # display has data and the plan is traceable to its BC (P1-1).
  110. if not plan_data.get("boundary_conditions"):
  111. from ..services.bc_fields import normalize_bc
  112. plan_data["boundary_conditions"] = normalize_bc(project.get_boundary_conditions())
  113. # Source tracking: manually created plans inherit all BC from the project
  114. # (user-specified). AI-generated plans tag sources in ai_plan.py instead.
  115. if not plan_data.get("bc_meta"):
  116. plan_data["bc_meta"] = {
  117. k: {"source": "user"} for k in (plan_data.get("boundary_conditions") or {})
  118. }
  119. # Auto-fill the base model from the topology registry when neither the
  120. # caller nor the project provided a model path.
  121. if not (plan_data.get("model_path") or "").strip():
  122. from src.afmcore.topology import default_model_for
  123. fallback = default_model_for(plan_data.get("topology") or project.topology or "")
  124. if fallback:
  125. plan_data["model_path"] = fallback
  126. # Validate against the single-source plan schema (draft-stage: model
  127. # path may be configured later).
  128. from src.plan_schema import validate_plan_dict
  129. _ok, _errs = validate_plan_dict(plan_data, require_model_path=False)
  130. if not _ok:
  131. raise HTTPException(
  132. status_code=400,
  133. detail="Invalid plan_data: " + "; ".join(_errs),
  134. )
  135. # Extract variables summary for display
  136. variables_summary = {}
  137. estimated_points = 1
  138. for var in plan_data.get("variables", []):
  139. name = var.get("name", "unknown")
  140. values = var.get("values", [])
  141. variables_summary[name] = {
  142. "unit": var.get("unit", ""),
  143. "values": values,
  144. "count": len(values),
  145. }
  146. estimated_points *= len(values) if values else 1
  147. plan = SimulationPlan(
  148. project_id=data.project_id,
  149. name=data.name,
  150. plan_id=plan_id,
  151. status="draft",
  152. estimated_points=estimated_points,
  153. estimated_time_min=estimated_points * 3, # ~3 min per point estimate
  154. notes=data.notes,
  155. )
  156. plan.set_plan_dict(plan_data)
  157. plan.variables_summary = json.dumps(variables_summary, ensure_ascii=False)
  158. db.add(plan)
  159. db.commit()
  160. db.refresh(plan)
  161. return _plan_to_response(db, plan)
  162. @router.get("/{plan_id}", response_model=PlanResponse)
  163. def get_plan(plan_id: int, db: Session = Depends(get_db)):
  164. """Get a plan by ID."""
  165. plan = db.query(SimulationPlan).filter(SimulationPlan.id == plan_id).first()
  166. if not plan:
  167. raise HTTPException(status_code=404, detail="Plan not found")
  168. return _plan_to_response(db, plan)
  169. @router.put("/{plan_id}", response_model=PlanResponse)
  170. def update_plan(plan_id: int, data: PlanUpdate, db: Session = Depends(get_db)):
  171. """Update a plan."""
  172. plan = db.query(SimulationPlan).filter(SimulationPlan.id == plan_id).first()
  173. if not plan:
  174. raise HTTPException(status_code=404, detail="Plan not found")
  175. update_data = data.model_dump(exclude_unset=True)
  176. if "plan_data" in update_data:
  177. from src.plan_schema import validate_plan_dict
  178. _ok, _errs = validate_plan_dict(
  179. update_data["plan_data"], require_model_path=False
  180. )
  181. if not _ok:
  182. raise HTTPException(
  183. status_code=400,
  184. detail="Invalid plan_data: " + "; ".join(_errs),
  185. )
  186. plan.set_plan_dict(update_data.pop("plan_data"))
  187. for key, value in update_data.items():
  188. setattr(plan, key, value)
  189. db.commit()
  190. db.refresh(plan)
  191. return _plan_to_response(db, plan)
  192. @router.delete("/{plan_id}", status_code=204)
  193. def delete_plan(plan_id: int, db: Session = Depends(get_db)):
  194. """Delete a plan and its results."""
  195. plan = db.query(SimulationPlan).filter(SimulationPlan.id == plan_id).first()
  196. if not plan:
  197. raise HTTPException(status_code=404, detail="Plan not found")
  198. db.delete(plan)
  199. db.commit()
  200. return None
  201. # ---------------------------------------------------------------------------
  202. # API integration endpoints (for local executor)
  203. # ---------------------------------------------------------------------------
  204. @router.get("/{plan_id}/download", response_model=PlanDownloadResponse)
  205. def download_plan(plan_id: int, db: Session = Depends(get_db)):
  206. """Download a plan as simulation_plan.json (read-only, F5 fix).
  207. This is the API endpoint used by the local execution system to fetch plans.
  208. Returns the raw plan JSON in the exact format expected by src/plan_schema.py.
  209. Status changes must go through POST /{plan_id}/start-execution.
  210. """
  211. plan = db.query(SimulationPlan).filter(SimulationPlan.id == plan_id).first()
  212. if not plan:
  213. raise HTTPException(status_code=404, detail="Plan not found")
  214. return PlanDownloadResponse(plan_id=plan.plan_id, plan_data=plan.get_plan_dict())
  215. @router.post("/{plan_id}/start-execution")
  216. def start_execution(plan_id: int, db: Session = Depends(get_db)):
  217. """Mark a plan as executing (explicit state transition, F5 fix)."""
  218. plan = db.query(SimulationPlan).filter(SimulationPlan.id == plan_id).first()
  219. if not plan:
  220. raise HTTPException(status_code=404, detail="Plan not found")
  221. if plan.status in ("draft", "confirmed"):
  222. plan.status = "executing"
  223. db.commit()
  224. return {"plan_id": plan.plan_id, "status": plan.status}
  225. @router.get("/by-plan-id/{plan_uuid}/download", response_model=PlanDownloadResponse)
  226. def download_plan_by_uuid(plan_uuid: str, db: Session = Depends(get_db)):
  227. """Download a plan by its plan_id string (read-only, F5 fix)."""
  228. plan = db.query(SimulationPlan).filter(SimulationPlan.plan_id == plan_uuid).first()
  229. if not plan:
  230. raise HTTPException(status_code=404, detail="Plan not found")
  231. return PlanDownloadResponse(plan_id=plan.plan_id, plan_data=plan.get_plan_dict())
  232. @router.post("/{plan_id}/upload-results", status_code=201)
  233. async def upload_results(
  234. plan_id: int,
  235. file: UploadFile = File(...),
  236. db: Session = Depends(get_db),
  237. ):
  238. """Upload scan_results.csv from local executor.
  239. Parses the CSV and stores each row as a SimulationResult.
  240. This is the API endpoint used by the local execution system to push results back.
  241. """
  242. import csv
  243. import io
  244. plan = db.query(SimulationPlan).filter(SimulationPlan.id == plan_id).first()
  245. if not plan:
  246. raise HTTPException(status_code=404, detail="Plan not found")
  247. # Clear existing results for this plan (re-upload replaces)
  248. db.query(SimulationResult).filter(SimulationResult.plan_id == plan_id).delete()
  249. content = await file.read()
  250. text = content.decode("utf-8-sig")
  251. reader = csv.DictReader(io.StringIO(text))
  252. # A1 fix: use shared metric constants (single source of truth)
  253. metric_keys = METRIC_KEYS
  254. standard_keys = {"run_index", "status", "seconds", "error"}
  255. count = 0
  256. for row in reader:
  257. params = {}
  258. metrics = {}
  259. for key, val in row.items():
  260. if key in standard_keys or val == "" or val is None:
  261. continue
  262. try:
  263. fval = float(val)
  264. except (ValueError, TypeError):
  265. continue
  266. if key in metric_keys:
  267. metrics[key] = fval
  268. else:
  269. params[key] = fval
  270. result = SimulationResult(
  271. plan_id=plan_id,
  272. run_index=int(row.get("run_index", count + 1)),
  273. status=row.get("status", "OK"),
  274. solve_time_s=float(row.get("seconds", 0) or 0),
  275. error_message=row.get("error", ""),
  276. )
  277. result.set_params(params)
  278. result.set_metrics(metrics)
  279. db.add(result)
  280. count += 1
  281. # Update plan status
  282. plan.status = "completed"
  283. db.commit()
  284. return {"message": f"Uploaded {count} results", "count": count, "plan_id": plan.plan_id}
  285. @router.get("/{plan_id}/results", response_model=ResultListResponse)
  286. def get_plan_results(plan_id: int, db: Session = Depends(get_db)):
  287. """Get all results for a plan."""
  288. plan = db.query(SimulationPlan).filter(SimulationPlan.id == plan_id).first()
  289. if not plan:
  290. raise HTTPException(status_code=404, detail="Plan not found")
  291. results = (
  292. db.query(SimulationResult)
  293. .filter(SimulationResult.plan_id == plan_id)
  294. .order_by(SimulationResult.run_index.asc())
  295. .all()
  296. )
  297. from ..schemas.simulation_result import ResultResponse
  298. items = [
  299. ResultResponse(
  300. id=r.id, plan_id=r.plan_id, run_index=r.run_index,
  301. status=r.status, solve_time_s=r.solve_time_s,
  302. params=r.get_params(), metrics=r.get_metrics(),
  303. error_message=r.error_message or "", created_at=r.created_at,
  304. )
  305. for r in results
  306. ]
  307. return ResultListResponse(total=len(items), items=items)
  308. # ---------------------------------------------------------------------------
  309. # One-click simulation start (P0-4)
  310. # ---------------------------------------------------------------------------
  311. def _expand_plan_to_parameters(plan_data: dict) -> list[dict]:
  312. """Expand plan variables into full parameter sets (Cartesian product).
  313. Fixed params are canonicalized against the template: the template is the
  314. source of truth for the real Motor-CAD variable name (motorcad_var),
  315. while the plan's stored value overrides the template default. Only params
  316. with a real motorcad_var are written to the simulation.
  317. Scan variables are resolved through:
  318. 1. The template (if name matches a template param, use its motorcad_var)
  319. 2. The topology-aware alias map (RFM names remapped to AFM names)
  320. 3. Fallback: use the name as-is (caller should validate).
  321. Returns list of param dicts for the task executor.
  322. """
  323. from ..services.fixed_params_template import FIXED_PARAM_TEMPLATES
  324. from ..services.topology_variable_map import (
  325. resolve_variable,
  326. normalize_topology,
  327. )
  328. topology = normalize_topology(plan_data.get("topology"))
  329. template_by_name = {p["name"].lower(): p for p in FIXED_PARAM_TEMPLATES}
  330. plan_fps = plan_data.get("fixed_params", []) or []
  331. seen = set()
  332. merged = []
  333. for fp in plan_fps:
  334. if not (isinstance(fp, dict) and fp.get("name")):
  335. continue
  336. name = fp["name"]
  337. key = name.lower()
  338. if key in seen:
  339. continue
  340. seen.add(key)
  341. tmpl = template_by_name.get(key)
  342. val = fp.get("value")
  343. if tmpl:
  344. row = dict(tmpl) # includes motorcad_var (baseline-tuned default)
  345. # Only user-explicitly-modified values override the baseline
  346. # default. AI-suggested values (or legacy params without source)
  347. # keep the template default so old plans run with valid geometry.
  348. if fp.get("source") == "user" and val is not None and val != "":
  349. row["value"] = val
  350. merged.append(row)
  351. else:
  352. # Param not in template: try topology alias resolution.
  353. # If the alias map resolves it, use the resolved name as
  354. # motorcad_var. Otherwise keep None (will not be written).
  355. row = dict(fp)
  356. resolved, was_alias = resolve_variable(name, topology)
  357. if was_alias:
  358. row["motorcad_var"] = resolved
  359. else:
  360. row["motorcad_var"] = None
  361. merged.append(row)
  362. # Template params missing from the plan (fill with template defaults)
  363. for tmpl in FIXED_PARAM_TEMPLATES:
  364. if tmpl["name"].lower() not in seen:
  365. merged.append(dict(tmpl))
  366. seen.add(tmpl["name"].lower())
  367. fixed = {}
  368. for fp in merged:
  369. var = fp.get("motorcad_var")
  370. val = fp.get("value")
  371. if var and val is not None and val != "":
  372. try:
  373. fixed[var] = float(val)
  374. except (TypeError, ValueError):
  375. fixed[var] = val
  376. variables = plan_data.get("variables", [])
  377. if not variables:
  378. return [dict(fixed)]
  379. # Collect value lists for each variable, resolving the variable name
  380. # through template -> topology alias map -> fallback to raw name.
  381. var_value_lists = []
  382. for v in variables:
  383. if not isinstance(v, dict):
  384. continue
  385. name = v.get("name", "")
  386. if not name:
  387. continue
  388. # Resolve the Motor-CAD variable name for this scan variable.
  389. tmpl = template_by_name.get(name.lower())
  390. if tmpl and tmpl.get("motorcad_var"):
  391. resolved_name = tmpl["motorcad_var"]
  392. else:
  393. resolved_name, _was_alias = resolve_variable(name, topology)
  394. values = v.get("values", [])
  395. if not values and v.get("start") is not None and v.get("stop") is not None and v.get("step"):
  396. start, stop, step = float(v["start"]), float(v["stop"]), float(v["step"])
  397. count = int(math.floor((stop - start) / step + 1e-9)) + 1
  398. values = [round(start + i * step, 6) for i in range(count)]
  399. if values and abs(values[-1] - stop) > 1e-9:
  400. values.append(round(stop, 6))
  401. if values:
  402. var_value_lists.append((resolved_name, values))
  403. if not var_value_lists:
  404. return [dict(fixed)]
  405. # Cartesian product
  406. def _cartesian(idx: int, current: dict) -> list[dict]:
  407. if idx >= len(var_value_lists):
  408. return [dict(current)]
  409. name, vals = var_value_lists[idx]
  410. result = []
  411. for val in vals:
  412. current[name] = val
  413. result.extend(_cartesian(idx + 1, current))
  414. return result
  415. return _cartesian(0, dict(fixed))
  416. @router.get("/{plan_id}/preflight")
  417. def preflight_check(plan_id: int, db: Session = Depends(get_db)):
  418. """Pre-flight checklist before starting a simulation.
  419. Returns machine-readable checks (key/status/data only; the frontend maps
  420. keys to localized labels and messages). status: pass | warn | fail.
  421. Any 'fail' blocks starting; 'warn' is advisory and does not block.
  422. """
  423. import os
  424. from ..config import PROJECT_ROOT
  425. plan = db.query(SimulationPlan).filter(SimulationPlan.id == plan_id).first()
  426. if not plan:
  427. raise HTTPException(status_code=404, detail="Plan not found")
  428. plan_data = plan.get_plan_dict()
  429. checks = []
  430. # 1. Model file must exist. model_path may be repo-relative (e.g.
  431. # "models/xxx.mot") or absolute; resolve relative paths against the repo
  432. # root so the check matches how the executor locates the model.
  433. model_path = (plan_data.get("model_path") or "").strip()
  434. resolved = model_path
  435. if model_path and not os.path.isabs(model_path):
  436. resolved = os.path.join(str(PROJECT_ROOT), model_path)
  437. # Expose the topology's default base model so the UI can offer one-click
  438. # repair when the check fails.
  439. from src.afmcore.topology import default_model_for
  440. default_model = default_model_for(plan_data.get("topology") or "")
  441. if not model_path:
  442. checks.append({
  443. "key": "model_path", "status": "fail", "value": "",
  444. "fixable": bool(default_model), "default_model": default_model,
  445. })
  446. elif not os.path.exists(resolved):
  447. checks.append({
  448. "key": "model_path", "status": "fail", "value": model_path,
  449. "fixable": bool(default_model), "default_model": default_model,
  450. })
  451. else:
  452. checks.append({"key": "model_path", "status": "pass", "value": model_path})
  453. # 2. Fixed params whose Motor-CAD variable name is unverified (no mapping).
  454. unverified = [
  455. fp["name"] for fp in (plan_data.get("fixed_params") or [])
  456. if isinstance(fp, dict) and fp.get("name") and not fp.get("motorcad_var")
  457. ]
  458. checks.append({
  459. "key": "unverified_vars",
  460. "status": "warn" if unverified else "pass",
  461. "items": unverified,
  462. })
  463. # 3. At least one scan variable with values.
  464. variables = plan_data.get("variables") or []
  465. valid_vars = [
  466. v for v in variables
  467. if isinstance(v, dict) and v.get("name") and (v.get("values") or [])
  468. ]
  469. checks.append({
  470. "key": "scan_vars",
  471. "status": "pass" if valid_vars else "fail",
  472. "count": len(valid_vars),
  473. })
  474. # 4. Point-count estimate (advisory when large).
  475. total = 1
  476. for v in valid_vars:
  477. total *= len(v.get("values") or [1])
  478. checks.append({
  479. "key": "point_count",
  480. "status": "warn" if total > 200 else "pass",
  481. "count": total,
  482. })
  483. # 5. Local executor online (advisory: tasks can queue while offline).
  484. try:
  485. exec_status = get_task_manager().get_executor_status()
  486. online = sum(1 for e in exec_status.get("executors", []) if e.get("online"))
  487. except Exception:
  488. online = 0
  489. checks.append({
  490. "key": "executor",
  491. "status": "pass" if online > 0 else "warn",
  492. "online": online,
  493. })
  494. ok = not any(c["status"] == "fail" for c in checks)
  495. return {"ok": ok, "checks": checks}
  496. @router.post("/{plan_id}/start-simulation")
  497. def start_simulation(plan_id: int, db: Session = Depends(get_db)):
  498. """One-click start: expand plan to parameters, create task, dispatch.
  499. Automatically:
  500. 1. Expands variables into Cartesian product parameter sets
  501. 2. Merges fixed_params into each parameter set
  502. 3. Creates a Task linked to this plan
  503. 4. Marks task as dispatched (local executor picks it up)
  504. 5. Updates plan status to 'executing'
  505. Returns the created task info.
  506. """
  507. plan = db.query(SimulationPlan).filter(SimulationPlan.id == plan_id).first()
  508. if not plan:
  509. raise HTTPException(status_code=404, detail="Plan not found")
  510. plan_data = plan.get_plan_dict()
  511. # Runtime fallback: legacy plans may carry an empty model_path (created
  512. # before the topology default-model auto-fill). Auto-fill from the
  513. # topology registry and persist so the plan becomes self-contained.
  514. if not (plan_data.get("model_path") or "").strip():
  515. from src.afmcore.topology import default_model_for
  516. fallback = default_model_for(plan_data.get("topology") or "")
  517. if fallback:
  518. plan_data["model_path"] = fallback
  519. plan.set_plan_dict(plan_data)
  520. db.commit()
  521. parameters = _expand_plan_to_parameters(plan_data)
  522. if not parameters:
  523. raise HTTPException(status_code=400, detail="Plan has no valid parameters to simulate")
  524. # --- Topology-aware variable name validation (P1-bugfix: plan 23) ---
  525. # Reject unknown variable names BEFORE creating the task, with suggested
  526. # alternatives. This prevents silent Motor-CAD "Could not find variable"
  527. # failures that waste 15+ minutes of simulation time.
  528. from ..services.topology_variable_map import (
  529. is_known_variable,
  530. suggest_alternative,
  531. normalize_topology,
  532. )
  533. topo = normalize_topology(plan_data.get("topology"))
  534. if parameters:
  535. all_var_names = list(parameters[0].keys())
  536. unknown = []
  537. for vname in all_var_names:
  538. if not is_known_variable(vname, topo):
  539. suggestion = suggest_alternative(vname, topo)
  540. unknown.append({
  541. "variable": vname,
  542. "suggestion": suggestion,
  543. })
  544. if unknown:
  545. detail_lines = [
  546. f"Unknown Motor-CAD variable(s) for topology {topo}. "
  547. "These will cause 'Could not find variable' errors in Motor-CAD.",
  548. ]
  549. for u in unknown:
  550. if u["suggestion"]:
  551. detail_lines.append(
  552. f" - '{u['variable']}' -> did you mean '{u['suggestion']}'?"
  553. )
  554. else:
  555. detail_lines.append(
  556. f" - '{u['variable']}' (no close match found; verify against .mot model)"
  557. )
  558. raise HTTPException(status_code=400, detail="\n".join(detail_lines))
  559. # --- End variable name validation ---
  560. # Create task via task manager
  561. manager = get_task_manager()
  562. task = manager.create_task(
  563. plan_id=plan_id,
  564. plan_data=plan_data,
  565. parameters=parameters,
  566. task_name=f"{plan.name}_run",
  567. priority=5,
  568. created_by="web",
  569. )
  570. # Dispatch immediately
  571. try:
  572. manager.dispatch_task(task["task_id"])
  573. except ValueError:
  574. pass # Already dispatched or other state issue
  575. # Update plan status
  576. plan.status = "executing"
  577. db.commit()
  578. return {
  579. "task_id": task["task_id"],
  580. "task_name": task["task_name"],
  581. "plan_id": plan.plan_id,
  582. "total_points": len(parameters),
  583. "status": "dispatched",
  584. "message": f"Simulation started with {len(parameters)} points. Local executor will pick it up.",
  585. }
  586. @router.get("/{plan_id}/active-task")
  587. def get_active_task(plan_id: int, db: Session = Depends(get_db)):
  588. """Get the most recent active task for a plan (for progress display)."""
  589. plan = db.query(SimulationPlan).filter(SimulationPlan.id == plan_id).first()
  590. if not plan:
  591. raise HTTPException(status_code=404, detail="Plan not found")
  592. manager = get_task_manager()
  593. tasks = manager.list_tasks(plan_id=plan_id, limit=1)
  594. if tasks.get("tasks"):
  595. return tasks["tasks"][0]
  596. return None
  597. # ---------------------------------------------------------------------------
  598. # AI Analysis & Iteration (P1-8)
  599. # ---------------------------------------------------------------------------
  600. @router.post("/{plan_id}/ai-analyze")
  601. def ai_analyze_results(plan_id: int, db: Session = Depends(get_db)):
  602. """Analyze simulation results using AI and return insights.
  603. Returns key metrics summary, parameter sensitivity, anomaly detection,
  604. and recommendations for next iteration.
  605. """
  606. plan = db.query(SimulationPlan).filter(SimulationPlan.id == plan_id).first()
  607. if not plan:
  608. raise HTTPException(status_code=404, detail="Plan not found")
  609. results = (
  610. db.query(SimulationResult)
  611. .filter(SimulationResult.plan_id == plan_id)
  612. .order_by(SimulationResult.run_index.asc())
  613. .all()
  614. )
  615. if not results:
  616. raise HTTPException(status_code=400, detail="No simulation results to analyze")
  617. # Compute basic statistics
  618. ok_results = [r for r in results if r.status == "OK"]
  619. metrics_list = [r.get_metrics() for r in ok_results]
  620. params_list = [r.get_params() for r in ok_results]
  621. if not metrics_list:
  622. return {"summary": "All points failed", "ok_count": 0, "total": len(results)}
  623. # Compute metric stats
  624. def _stats(key: str) -> dict:
  625. vals = [m.get(key) for m in metrics_list if m.get(key) is not None]
  626. if not vals:
  627. return {}
  628. return {
  629. "min": min(vals), "max": max(vals),
  630. "avg": sum(vals) / len(vals),
  631. "count": len(vals),
  632. }
  633. metric_stats = {
  634. "tavg_nm": _stats("tavg_nm"),
  635. "ripple_pct": _stats("ripple_pct"),
  636. "efficiency_pct": _stats("efficiency_pct"),
  637. "total_losses_w": _stats("total_losses_w"),
  638. }
  639. # Find best point by efficiency
  640. best_idx = -1
  641. best_eff = -1
  642. for i, m in enumerate(metrics_list):
  643. eff = m.get("efficiency_pct", 0)
  644. if eff and eff > best_eff:
  645. best_eff = eff
  646. best_idx = i
  647. best_point = None
  648. if best_idx >= 0:
  649. best_point = {
  650. "run_index": ok_results[best_idx].run_index,
  651. "params": params_list[best_idx],
  652. "metrics": metrics_list[best_idx],
  653. }
  654. # Simple parameter sensitivity (correlation-like)
  655. sensitivity = {}
  656. param_keys = set()
  657. for p in params_list:
  658. param_keys.update(p.keys())
  659. for pk in param_keys:
  660. vals = [p.get(pk) for p in params_list if p.get(pk) is not None]
  661. if len(vals) < 2:
  662. continue
  663. effs = [metrics_list[i].get("efficiency_pct", 0) for i, p in enumerate(params_list) if p.get(pk) is not None]
  664. if len(effs) < 2:
  665. continue
  666. # Simple: range of efficiency vs range of param
  667. p_range = max(vals) - min(vals)
  668. e_range = max(effs) - min(effs)
  669. if p_range > 0:
  670. sensitivity[pk] = round(e_range / p_range, 4)
  671. # Boundary condition check
  672. plan_data = plan.get_plan_dict()
  673. bc = plan_data.get("acceptance_criteria", {})
  674. constraints = bc.get("hard_constraints", [])
  675. satisfied = []
  676. violated = []
  677. for c in constraints:
  678. # Simple parse: "metric >= value" or "metric <= value"
  679. parts = c.replace(">=", ">=").replace("<=", "<=").split()
  680. if len(parts) >= 3:
  681. metric, op, val = parts[0], parts[1], float(parts[2])
  682. stat = metric_stats.get(metric, {})
  683. if stat:
  684. if op == ">=" and stat.get("max", 0) >= val:
  685. satisfied.append(c)
  686. elif op == "<=" and stat.get("min", 999) <= val:
  687. satisfied.append(c)
  688. else:
  689. violated.append(c)
  690. return {
  691. "total_points": len(results),
  692. "ok_count": len(ok_results),
  693. "failed_count": len(results) - len(ok_results),
  694. "metric_stats": metric_stats,
  695. "best_point": best_point,
  696. "sensitivity": sensitivity,
  697. "constraints_satisfied": satisfied,
  698. "constraints_violated": violated,
  699. "recommendations": _generate_recommendations(metric_stats, sensitivity, best_point, violated),
  700. }
  701. def _generate_recommendations(metric_stats: dict, sensitivity: dict, best_point: dict, violated: list) -> list[str]:
  702. """Generate simple recommendations based on analysis results."""
  703. recs = []
  704. eff = metric_stats.get("efficiency_pct", {})
  705. ripple = metric_stats.get("ripple_pct", {})
  706. if eff and eff.get("max", 0) < 90:
  707. recs.append("\u6700\u9ad8\u6548\u7387\u4f4e\u4e8e90%\uff0c\u5efa\u8bae\u51cf\u5c0f\u6c14\u9699\u6216\u589e\u52a0\u78c1\u94a2\u539a\u5ea6\u4ee5\u63d0\u5347\u8f6c\u77e9\u5bc6\u5ea6")
  708. if ripple and ripple.get("min", 100) > 5:
  709. recs.append("\u8f6c\u77e9\u8109\u52a8\u504f\u9ad8(>5%)\uff0c\u5efa\u8bae\u626b\u63cf\u78c1\u94a2\u6781\u5f27\u89d2(Magnet_Arc)\u4f18\u5316\u8109\u52a8")
  710. if sensitivity:
  711. top_sens = sorted(sensitivity.items(), key=lambda x: abs(x[1]), reverse=True)[:3]
  712. for pk, sv in top_sens:
  713. direction = "\u589e\u5927" if sv > 0 else "\u51cf\u5c0f"
  714. recs.append(f"{pk}\u5bf9\u6548\u7387\u5f71\u54cd\u663e\u8457(\u7075\u654f\u5ea6={sv})\uff0c\u5efa\u8bae\u4e0b\u4e00\u8f6e{direction}\u8be5\u53c2\u6570\u8303\u56f4")
  715. if best_point:
  716. recs.append(f"\u5f53\u524d\u6700\u4f18\u70b9: \u6548\u7387{best_point['metrics'].get('efficiency_pct', '?')}%, \u5efa\u8bae\u4ee5\u8be5\u70b9\u53c2\u6570\u4e3a\u4e2d\u5fc3\u7f29\u5c0f\u641c\u7d22\u8303\u56f4")
  717. if violated:
  718. recs.append(f"\u6709{len(violated)}\u9879\u7ea6\u675f\u672a\u6ee1\u8db3\uff0c\u5efa\u8bae\u8c03\u6574\u626b\u63cf\u8303\u56f4\u6216\u56fa\u5b9a\u53c2\u6570")
  719. if not recs:
  720. recs.append("\u7ed3\u679c\u826f\u597d\uff0c\u5efa\u8bae\u4ee5\u5f53\u524d\u6700\u4f18\u70b9\u4e3a\u4e2d\u5fc3\u8fdb\u884c\u7cbe\u7ec6\u5316\u626b\u63cf")
  721. return recs
  722. @router.post("/{plan_id}/generate-iteration")
  723. def generate_iteration_plan(plan_id: int, db: Session = Depends(get_db)):
  724. """Generate next iteration plan based on current results.
  725. Uses AI analysis to adjust scan ranges and creates a new plan
  726. with parent_plan_id linking to the current plan.
  727. """
  728. plan = db.query(SimulationPlan).filter(SimulationPlan.id == plan_id).first()
  729. if not plan:
  730. raise HTTPException(status_code=404, detail="Plan not found")
  731. results = (
  732. db.query(SimulationResult)
  733. .filter(SimulationResult.plan_id == plan_id)
  734. .order_by(SimulationResult.run_index.asc())
  735. .all()
  736. )
  737. if not results:
  738. raise HTTPException(status_code=400, detail="No simulation results for iteration")
  739. plan_data = plan.get_plan_dict()
  740. ok_results = [r for r in results if r.status == "OK"]
  741. if not ok_results:
  742. raise HTTPException(status_code=400, detail="No successful results for iteration")
  743. # Find best point
  744. best = max(ok_results, key=lambda r: r.get_metrics().get("efficiency_pct", 0))
  745. best_params = best.get_params()
  746. best_metrics = best.get_metrics()
  747. # Generate new variables: narrow ranges around best point
  748. new_variables = []
  749. for v in plan_data.get("variables", []):
  750. name = v.get("name", "")
  751. if name in best_params:
  752. best_val = best_params[name]
  753. step = v.get("step", 0.1)
  754. # Narrow to +/- 2 steps around best
  755. new_start = round(best_val - 2 * step, 6)
  756. new_stop = round(best_val + 2 * step, 6)
  757. # Ensure within physical bounds
  758. new_start = max(new_start, v.get("start", new_start))
  759. new_stop = min(new_stop, v.get("stop", new_stop))
  760. values = []
  761. if new_stop > new_start and step > 0:
  762. count = int((new_stop - new_start) / step) + 1
  763. values = [round(new_start + i * step, 6) for i in range(count)]
  764. new_variables.append({
  765. **v,
  766. "start": new_start,
  767. "stop": new_stop,
  768. "values": values,
  769. })
  770. else:
  771. new_variables.append(v)
  772. # Create new plan
  773. import uuid as _uuid
  774. new_plan_id = f"SP-{datetime.now().strftime('%Y%m%d-%H%M%S')}-{_uuid.uuid4().hex[:6]}"
  775. iteration = (plan_data.get("iteration", 1) or 1) + 1
  776. new_plan_data = {
  777. **plan_data,
  778. "plan_id": new_plan_id,
  779. "iteration": iteration,
  780. "parent_plan_id": plan.plan_id,
  781. "variables": new_variables,
  782. "ai_reasoning": f"Iteration #{iteration}: Narrowed search around best point "
  783. f"(eff={best_metrics.get('efficiency_pct', '?')}%, "
  784. f"torque={best_metrics.get('tavg_nm', '?')}Nm). "
  785. f"Previous best params: {best_params}",
  786. }
  787. estimated_points = 1
  788. for v in new_variables:
  789. estimated_points *= len(v.get("values", [])) if v.get("values") else 1
  790. variables_summary = {}
  791. for v in new_variables:
  792. variables_summary[v["name"]] = {
  793. "unit": v.get("unit", ""),
  794. "values": v.get("values", []),
  795. "count": len(v.get("values", [])),
  796. }
  797. new_plan = SimulationPlan(
  798. project_id=plan.project_id,
  799. name=f"{plan.name}_iter{iteration}",
  800. plan_id=new_plan_id,
  801. status="draft",
  802. estimated_points=estimated_points,
  803. estimated_time_min=estimated_points * 3,
  804. notes=f"Iteration #{iteration} from plan {plan.plan_id}. Best eff={best_metrics.get('efficiency_pct', '?')}%",
  805. )
  806. new_plan.set_plan_dict(new_plan_data)
  807. new_plan.variables_summary = json.dumps(variables_summary, ensure_ascii=False)
  808. db.add(new_plan)
  809. db.commit()
  810. db.refresh(new_plan)
  811. return {
  812. "id": new_plan.id,
  813. "plan_id": new_plan.plan_id,
  814. "name": new_plan.name,
  815. "iteration": iteration,
  816. "parent_plan_id": plan.plan_id,
  817. "estimated_points": estimated_points,
  818. "best_point": {
  819. "run_index": best.run_index,
  820. "params": best_params,
  821. "metrics": best_metrics,
  822. },
  823. "message": f"\u8fed\u4ee3\u65b9\u6848\u5df2\u751f\u6210\uff0c\u56f4\u7ed5\u6700\u4f18\u70b9\u7f29\u5c0f\u641c\u7d22\u8303\u56f4\uff0c\u5171{estimated_points}\u4e2a\u6570\u636e\u70b9",
  824. }