plans.py 36 KB

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