plans.py 42 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121
  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. @router.get("/{plan_id}/export-xlsx")
  311. def export_plan_results_xlsx(plan_id: int, db: Session = Depends(get_db)):
  312. """Export plan simulation results as a formatted .xlsx workbook.
  313. Format follows the torqrippswap scan_results.xlsx convention: two-row
  314. header (merged category groups + column headers), freeze panes,
  315. autofilter. Columns cover scan info, scan variables, electromagnetic
  316. metrics, and thermal metrics (only columns present in the data are
  317. exported, so thermal columns appear only for thermal_mode != off).
  318. """
  319. import tempfile
  320. from urllib.parse import quote
  321. from fastapi.responses import FileResponse
  322. from starlette.background import BackgroundTask
  323. from ..metrics_constants import METRIC_KEYS as _METRIC_KEYS # noqa: F401
  324. from afmcore.xlsx_report import write_summary_xlsx
  325. plan = db.query(SimulationPlan).filter(SimulationPlan.id == plan_id).first()
  326. if not plan:
  327. raise HTTPException(status_code=404, detail="Plan not found")
  328. results = (
  329. db.query(SimulationResult)
  330. .filter(SimulationResult.plan_id == plan_id)
  331. .order_by(SimulationResult.run_index.asc())
  332. .all()
  333. )
  334. if not results:
  335. raise HTTPException(status_code=400, detail="No simulation results to export")
  336. col_keys, col_categories, col_headers, data_rows = _collect_export_rows(
  337. plan, results
  338. )
  339. ts = datetime.now().strftime("%Y%m%d_%H%M%S")
  340. tmp = tempfile.NamedTemporaryFile(
  341. prefix=f"scan_results_{ts}_", suffix=".xlsx", delete=False
  342. )
  343. tmp.close()
  344. write_summary_xlsx(tmp.name, col_keys, col_categories, col_headers, data_rows)
  345. plan_name = (plan.name or f"plan_{plan_id}").strip() or f"plan_{plan_id}"
  346. # RFC 5987: ASCII fallback + UTF-8 filename* for Chinese plan names.
  347. ascii_fallback = f"scan_results_{plan_id}_{ts}.xlsx"
  348. utf8_name = f"scan_results_{plan_name}_{ts}.xlsx"
  349. disposition = (
  350. f"attachment; filename=\"{ascii_fallback}\"; "
  351. f"filename*=UTF-8''{quote(utf8_name)}"
  352. )
  353. return FileResponse(
  354. tmp.name,
  355. media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
  356. headers={"Content-Disposition": disposition},
  357. background=BackgroundTask(os_remove_quiet, tmp.name),
  358. )
  359. def os_remove_quiet(path: str) -> None:
  360. """Best-effort temp-file cleanup after FileResponse streaming."""
  361. import os
  362. try:
  363. os.remove(path)
  364. except OSError:
  365. pass
  366. def _collect_export_rows(plan, results):
  367. """Shared row/column assembly for xlsx and csv exports.
  368. Returns (col_keys, col_categories, col_headers, data_rows).
  369. """
  370. from afmcore.xlsx_report import build_export_columns
  371. param_labels: dict = {}
  372. try:
  373. plan_dict = plan.get_plan_dict()
  374. for var in plan_dict.get("variables", []) or []:
  375. name = var.get("name")
  376. if not name:
  377. continue
  378. cn = var.get("name_cn") or name
  379. unit = var.get("unit") or ""
  380. param_labels[name] = f"{cn}[{unit}]" if unit else cn
  381. except Exception:
  382. param_labels = {}
  383. param_names: list = []
  384. present_metrics: set = set()
  385. data_rows: list = []
  386. for r in results:
  387. params = r.get_params()
  388. metrics = r.get_metrics()
  389. for name in params:
  390. if name not in param_names:
  391. param_names.append(name)
  392. present_metrics.update(metrics.keys())
  393. row = {
  394. "run_index": r.run_index,
  395. "status": r.status,
  396. "solve_time_s": r.solve_time_s,
  397. "error": r.error_message or "",
  398. }
  399. row.update(params)
  400. row.update(metrics)
  401. data_rows.append(row)
  402. present_metrics = {k for k in present_metrics if isinstance(k, str)}
  403. col_keys, col_categories, col_headers = build_export_columns(
  404. param_names, sorted(present_metrics), param_labels
  405. )
  406. return col_keys, col_categories, col_headers, data_rows
  407. @router.get("/{plan_id}/export-csv")
  408. def export_plan_results_csv(plan_id: int, db: Session = Depends(get_db)):
  409. """Export plan simulation results as CSV (same columns as the xlsx export).
  410. Single header row (Chinese labels with units); utf-8-sig BOM so Excel
  411. opens the file with correct encoding.
  412. """
  413. import csv
  414. import io
  415. from urllib.parse import quote
  416. from fastapi.responses import Response
  417. plan = db.query(SimulationPlan).filter(SimulationPlan.id == plan_id).first()
  418. if not plan:
  419. raise HTTPException(status_code=404, detail="Plan not found")
  420. results = (
  421. db.query(SimulationResult)
  422. .filter(SimulationResult.plan_id == plan_id)
  423. .order_by(SimulationResult.run_index.asc())
  424. .all()
  425. )
  426. if not results:
  427. raise HTTPException(status_code=400, detail="No simulation results to export")
  428. col_keys, _cats, col_headers, data_rows = _collect_export_rows(plan, results)
  429. buf = io.StringIO()
  430. writer = csv.writer(buf)
  431. writer.writerow(col_headers)
  432. for row in data_rows:
  433. writer.writerow([row.get(k, "") for k in col_keys])
  434. ts = datetime.now().strftime("%Y%m%d_%H%M%S")
  435. plan_name = (plan.name or f"plan_{plan_id}").strip() or f"plan_{plan_id}"
  436. ascii_fallback = f"scan_results_{plan_id}_{ts}.csv"
  437. utf8_name = f"scan_results_{plan_name}_{ts}.csv"
  438. disposition = (
  439. f"attachment; filename=\"{ascii_fallback}\"; "
  440. f"filename*=UTF-8''{quote(utf8_name)}"
  441. )
  442. return Response(
  443. content=buf.getvalue().encode("utf-8-sig"),
  444. media_type="text/csv",
  445. headers={"Content-Disposition": disposition},
  446. )
  447. # ---------------------------------------------------------------------------
  448. # One-click simulation start (P0-4)
  449. # ---------------------------------------------------------------------------
  450. def _expand_plan_to_parameters(plan_data: dict) -> list[dict]:
  451. """Expand plan variables into full parameter sets (Cartesian product).
  452. Fixed params are canonicalized against the template: the template is the
  453. source of truth for the real Motor-CAD variable name (motorcad_var),
  454. while the plan's stored value overrides the template default. Only params
  455. with a real motorcad_var are written to the simulation.
  456. Scan variables are resolved through:
  457. 1. The template (if name matches a template param, use its motorcad_var)
  458. 2. The topology-aware alias map (RFM names remapped to AFM names)
  459. 3. Fallback: use the name as-is (caller should validate).
  460. Returns list of param dicts for the task executor.
  461. """
  462. from ..services.fixed_params_template import FIXED_PARAM_TEMPLATES
  463. from ..services.topology_variable_map import (
  464. resolve_variable,
  465. normalize_topology,
  466. )
  467. topology = normalize_topology(plan_data.get("topology"))
  468. template_by_name = {p["name"].lower(): p for p in FIXED_PARAM_TEMPLATES}
  469. plan_fps = plan_data.get("fixed_params", []) or []
  470. seen = set()
  471. merged = []
  472. for fp in plan_fps:
  473. if not (isinstance(fp, dict) and fp.get("name")):
  474. continue
  475. name = fp["name"]
  476. key = name.lower()
  477. if key in seen:
  478. continue
  479. seen.add(key)
  480. tmpl = template_by_name.get(key)
  481. val = fp.get("value")
  482. if tmpl:
  483. row = dict(tmpl) # includes motorcad_var (baseline-tuned default)
  484. # Only user-explicitly-modified values override the baseline
  485. # default. AI-suggested values (or legacy params without source)
  486. # keep the template default so old plans run with valid geometry.
  487. if fp.get("source") == "user" and val is not None and val != "":
  488. row["value"] = val
  489. merged.append(row)
  490. else:
  491. # Param not in template: try topology alias resolution.
  492. # If the alias map resolves it, use the resolved name as
  493. # motorcad_var. Otherwise keep None (will not be written).
  494. row = dict(fp)
  495. resolved, was_alias = resolve_variable(name, topology)
  496. if was_alias:
  497. row["motorcad_var"] = resolved
  498. else:
  499. row["motorcad_var"] = None
  500. merged.append(row)
  501. # Template params missing from the plan (fill with template defaults)
  502. for tmpl in FIXED_PARAM_TEMPLATES:
  503. if tmpl["name"].lower() not in seen:
  504. merged.append(dict(tmpl))
  505. seen.add(tmpl["name"].lower())
  506. fixed = {}
  507. for fp in merged:
  508. var = fp.get("motorcad_var")
  509. val = fp.get("value")
  510. if var and val is not None and val != "":
  511. try:
  512. fixed[var] = float(val)
  513. except (TypeError, ValueError):
  514. fixed[var] = val
  515. variables = plan_data.get("variables", [])
  516. if not variables:
  517. return [dict(fixed)]
  518. # Collect value lists for each variable, resolving the variable name
  519. # through template -> topology alias map -> fallback to raw name.
  520. var_value_lists = []
  521. for v in variables:
  522. if not isinstance(v, dict):
  523. continue
  524. name = v.get("name", "")
  525. if not name:
  526. continue
  527. # Resolve the Motor-CAD variable name for this scan variable.
  528. tmpl = template_by_name.get(name.lower())
  529. if tmpl and tmpl.get("motorcad_var"):
  530. resolved_name = tmpl["motorcad_var"]
  531. else:
  532. resolved_name, _was_alias = resolve_variable(name, topology)
  533. values = v.get("values", [])
  534. if not values and v.get("start") is not None and v.get("stop") is not None and v.get("step"):
  535. start, stop, step = float(v["start"]), float(v["stop"]), float(v["step"])
  536. count = int(math.floor((stop - start) / step + 1e-9)) + 1
  537. values = [round(start + i * step, 6) for i in range(count)]
  538. if values and abs(values[-1] - stop) > 1e-9:
  539. values.append(round(stop, 6))
  540. if values:
  541. var_value_lists.append((resolved_name, values))
  542. if not var_value_lists:
  543. return [dict(fixed)]
  544. # Cartesian product
  545. def _cartesian(idx: int, current: dict) -> list[dict]:
  546. if idx >= len(var_value_lists):
  547. return [dict(current)]
  548. name, vals = var_value_lists[idx]
  549. result = []
  550. for val in vals:
  551. current[name] = val
  552. result.extend(_cartesian(idx + 1, current))
  553. return result
  554. return _cartesian(0, dict(fixed))
  555. @router.get("/{plan_id}/preflight")
  556. def preflight_check(plan_id: int, db: Session = Depends(get_db)):
  557. """Pre-flight checklist before starting a simulation.
  558. Returns machine-readable checks (key/status/data only; the frontend maps
  559. keys to localized labels and messages). status: pass | warn | fail.
  560. Any 'fail' blocks starting; 'warn' is advisory and does not block.
  561. """
  562. import os
  563. from ..config import PROJECT_ROOT
  564. plan = db.query(SimulationPlan).filter(SimulationPlan.id == plan_id).first()
  565. if not plan:
  566. raise HTTPException(status_code=404, detail="Plan not found")
  567. plan_data = plan.get_plan_dict()
  568. checks = []
  569. # 1. Model file must exist. model_path may be repo-relative (e.g.
  570. # "models/xxx.mot") or absolute; resolve relative paths against the repo
  571. # root so the check matches how the executor locates the model.
  572. model_path = (plan_data.get("model_path") or "").strip()
  573. resolved = model_path
  574. if model_path and not os.path.isabs(model_path):
  575. resolved = os.path.join(str(PROJECT_ROOT), model_path)
  576. # Expose the topology's default base model so the UI can offer one-click
  577. # repair when the check fails.
  578. from src.afmcore.topology import default_model_for
  579. default_model = default_model_for(plan_data.get("topology") or "")
  580. if not model_path:
  581. checks.append({
  582. "key": "model_path", "status": "fail", "value": "",
  583. "fixable": bool(default_model), "default_model": default_model,
  584. })
  585. elif not os.path.exists(resolved):
  586. checks.append({
  587. "key": "model_path", "status": "fail", "value": model_path,
  588. "fixable": bool(default_model), "default_model": default_model,
  589. })
  590. else:
  591. checks.append({"key": "model_path", "status": "pass", "value": model_path})
  592. # 2. Fixed params whose Motor-CAD variable name is unverified (no mapping).
  593. unverified = [
  594. fp["name"] for fp in (plan_data.get("fixed_params") or [])
  595. if isinstance(fp, dict) and fp.get("name") and not fp.get("motorcad_var")
  596. ]
  597. checks.append({
  598. "key": "unverified_vars",
  599. "status": "warn" if unverified else "pass",
  600. "items": unverified,
  601. })
  602. # 3. At least one scan variable with values.
  603. variables = plan_data.get("variables") or []
  604. valid_vars = [
  605. v for v in variables
  606. if isinstance(v, dict) and v.get("name") and (v.get("values") or [])
  607. ]
  608. checks.append({
  609. "key": "scan_vars",
  610. "status": "pass" if valid_vars else "fail",
  611. "count": len(valid_vars),
  612. })
  613. # 4. Point-count estimate (advisory when large).
  614. total = 1
  615. for v in valid_vars:
  616. total *= len(v.get("values") or [1])
  617. checks.append({
  618. "key": "point_count",
  619. "status": "warn" if total > 200 else "pass",
  620. "count": total,
  621. })
  622. # 5. Local executor online (advisory: tasks can queue while offline).
  623. try:
  624. exec_status = get_task_manager().get_executor_status()
  625. online = sum(1 for e in exec_status.get("executors", []) if e.get("online"))
  626. except Exception:
  627. online = 0
  628. checks.append({
  629. "key": "executor",
  630. "status": "pass" if online > 0 else "warn",
  631. "online": online,
  632. })
  633. ok = not any(c["status"] == "fail" for c in checks)
  634. return {"ok": ok, "checks": checks}
  635. class StartSimulationRequest(BaseModel):
  636. """Optional one-click start options (task-level thermal switch)."""
  637. thermal_mode: Literal["off", "steady", "coupled"] = "steady"
  638. @router.post("/{plan_id}/start-simulation")
  639. def start_simulation(
  640. plan_id: int,
  641. request: Optional[StartSimulationRequest] = None,
  642. db: Session = Depends(get_db),
  643. ):
  644. """One-click start: expand plan to parameters, create task, dispatch.
  645. Automatically:
  646. 1. Expands variables into Cartesian product parameter sets
  647. 2. Merges fixed_params into each parameter set
  648. 3. Creates a Task linked to this plan
  649. 4. Marks task as dispatched (local executor picks it up)
  650. 5. Updates plan status to 'executing'
  651. Returns the created task info.
  652. """
  653. thermal_mode = request.thermal_mode if request is not None else "steady"
  654. plan = db.query(SimulationPlan).filter(SimulationPlan.id == plan_id).first()
  655. if not plan:
  656. raise HTTPException(status_code=404, detail="Plan not found")
  657. plan_data = plan.get_plan_dict()
  658. # Runtime fallback: legacy plans may carry an empty model_path (created
  659. # before the topology default-model auto-fill). Auto-fill from the
  660. # topology registry and persist so the plan becomes self-contained.
  661. if not (plan_data.get("model_path") or "").strip():
  662. from src.afmcore.topology import default_model_for
  663. fallback = default_model_for(plan_data.get("topology") or "")
  664. if fallback:
  665. plan_data["model_path"] = fallback
  666. plan.set_plan_dict(plan_data)
  667. db.commit()
  668. parameters = _expand_plan_to_parameters(plan_data)
  669. if not parameters:
  670. raise HTTPException(status_code=400, detail="Plan has no valid parameters to simulate")
  671. # --- Topology-aware variable name validation (P1-bugfix: plan 23) ---
  672. # Reject unknown variable names BEFORE creating the task, with suggested
  673. # alternatives. This prevents silent Motor-CAD "Could not find variable"
  674. # failures that waste 15+ minutes of simulation time.
  675. from ..services.topology_variable_map import (
  676. is_known_variable,
  677. suggest_alternative,
  678. normalize_topology,
  679. )
  680. topo = normalize_topology(plan_data.get("topology"))
  681. if parameters:
  682. all_var_names = list(parameters[0].keys())
  683. unknown = []
  684. for vname in all_var_names:
  685. if not is_known_variable(vname, topo):
  686. suggestion = suggest_alternative(vname, topo)
  687. unknown.append({
  688. "variable": vname,
  689. "suggestion": suggestion,
  690. })
  691. if unknown:
  692. detail_lines = [
  693. f"Unknown Motor-CAD variable(s) for topology {topo}. "
  694. "These will cause 'Could not find variable' errors in Motor-CAD.",
  695. ]
  696. for u in unknown:
  697. if u["suggestion"]:
  698. detail_lines.append(
  699. f" - '{u['variable']}' -> did you mean '{u['suggestion']}'?"
  700. )
  701. else:
  702. detail_lines.append(
  703. f" - '{u['variable']}' (no close match found; verify against .mot model)"
  704. )
  705. raise HTTPException(status_code=400, detail="\n".join(detail_lines))
  706. # --- End variable name validation ---
  707. # Create task via task manager
  708. manager = get_task_manager()
  709. task = manager.create_task(
  710. plan_id=plan_id,
  711. plan_data=plan_data,
  712. parameters=parameters,
  713. task_name=f"{plan.name}_run",
  714. priority=5,
  715. created_by="web",
  716. thermal_mode=thermal_mode,
  717. )
  718. # Dispatch immediately
  719. try:
  720. manager.dispatch_task(task["task_id"])
  721. except ValueError:
  722. pass # Already dispatched or other state issue
  723. # Update plan status
  724. plan.status = "executing"
  725. db.commit()
  726. return {
  727. "task_id": task["task_id"],
  728. "task_name": task["task_name"],
  729. "plan_id": plan.plan_id,
  730. "total_points": len(parameters),
  731. "status": "dispatched",
  732. "message": f"Simulation started with {len(parameters)} points. Local executor will pick it up.",
  733. }
  734. @router.get("/{plan_id}/active-task")
  735. def get_active_task(plan_id: int, db: Session = Depends(get_db)):
  736. """Get the most recent active task for a plan (for progress display)."""
  737. plan = db.query(SimulationPlan).filter(SimulationPlan.id == plan_id).first()
  738. if not plan:
  739. raise HTTPException(status_code=404, detail="Plan not found")
  740. manager = get_task_manager()
  741. tasks = manager.list_tasks(plan_id=plan_id, limit=1)
  742. if tasks.get("tasks"):
  743. return tasks["tasks"][0]
  744. return None
  745. # ---------------------------------------------------------------------------
  746. # AI Analysis & Iteration (P1-8)
  747. # ---------------------------------------------------------------------------
  748. @router.post("/{plan_id}/ai-analyze")
  749. def ai_analyze_results(plan_id: int, db: Session = Depends(get_db)):
  750. """Analyze simulation results using AI and return insights.
  751. Returns key metrics summary, parameter sensitivity, anomaly detection,
  752. and recommendations for next iteration.
  753. """
  754. plan = db.query(SimulationPlan).filter(SimulationPlan.id == plan_id).first()
  755. if not plan:
  756. raise HTTPException(status_code=404, detail="Plan not found")
  757. results = (
  758. db.query(SimulationResult)
  759. .filter(SimulationResult.plan_id == plan_id)
  760. .order_by(SimulationResult.run_index.asc())
  761. .all()
  762. )
  763. if not results:
  764. raise HTTPException(status_code=400, detail="No simulation results to analyze")
  765. # Compute basic statistics
  766. ok_results = [r for r in results if r.status == "OK"]
  767. metrics_list = [r.get_metrics() for r in ok_results]
  768. params_list = [r.get_params() for r in ok_results]
  769. if not metrics_list:
  770. return {"summary": "All points failed", "ok_count": 0, "total": len(results)}
  771. # Compute metric stats
  772. def _stats(key: str) -> dict:
  773. vals = [m.get(key) for m in metrics_list if m.get(key) is not None]
  774. if not vals:
  775. return {}
  776. return {
  777. "min": min(vals), "max": max(vals),
  778. "avg": sum(vals) / len(vals),
  779. "count": len(vals),
  780. }
  781. metric_stats = {
  782. "tavg_nm": _stats("tavg_nm"),
  783. "ripple_pct": _stats("ripple_pct"),
  784. "efficiency_pct": _stats("efficiency_pct"),
  785. "total_losses_w": _stats("total_losses_w"),
  786. }
  787. # Find best point by efficiency
  788. best_idx = -1
  789. best_eff = -1
  790. for i, m in enumerate(metrics_list):
  791. eff = m.get("efficiency_pct", 0)
  792. if eff and eff > best_eff:
  793. best_eff = eff
  794. best_idx = i
  795. best_point = None
  796. if best_idx >= 0:
  797. best_point = {
  798. "run_index": ok_results[best_idx].run_index,
  799. "params": params_list[best_idx],
  800. "metrics": metrics_list[best_idx],
  801. }
  802. # Simple parameter sensitivity (correlation-like)
  803. sensitivity = {}
  804. param_keys = set()
  805. for p in params_list:
  806. param_keys.update(p.keys())
  807. for pk in param_keys:
  808. vals = [p.get(pk) for p in params_list if p.get(pk) is not None]
  809. if len(vals) < 2:
  810. continue
  811. effs = [metrics_list[i].get("efficiency_pct", 0) for i, p in enumerate(params_list) if p.get(pk) is not None]
  812. if len(effs) < 2:
  813. continue
  814. # Simple: range of efficiency vs range of param
  815. p_range = max(vals) - min(vals)
  816. e_range = max(effs) - min(effs)
  817. if p_range > 0:
  818. sensitivity[pk] = round(e_range / p_range, 4)
  819. # Boundary condition check
  820. plan_data = plan.get_plan_dict()
  821. bc = plan_data.get("acceptance_criteria", {})
  822. constraints = bc.get("hard_constraints", [])
  823. satisfied = []
  824. violated = []
  825. for c in constraints:
  826. # Simple parse: "metric >= value" or "metric <= value"
  827. parts = c.replace(">=", ">=").replace("<=", "<=").split()
  828. if len(parts) >= 3:
  829. metric, op, val = parts[0], parts[1], float(parts[2])
  830. stat = metric_stats.get(metric, {})
  831. if stat:
  832. if op == ">=" and stat.get("max", 0) >= val:
  833. satisfied.append(c)
  834. elif op == "<=" and stat.get("min", 999) <= val:
  835. satisfied.append(c)
  836. else:
  837. violated.append(c)
  838. return {
  839. "total_points": len(results),
  840. "ok_count": len(ok_results),
  841. "failed_count": len(results) - len(ok_results),
  842. "metric_stats": metric_stats,
  843. "best_point": best_point,
  844. "sensitivity": sensitivity,
  845. "constraints_satisfied": satisfied,
  846. "constraints_violated": violated,
  847. "recommendations": _generate_recommendations(metric_stats, sensitivity, best_point, violated),
  848. }
  849. def _generate_recommendations(metric_stats: dict, sensitivity: dict, best_point: dict, violated: list) -> list[str]:
  850. """Generate simple recommendations based on analysis results."""
  851. recs = []
  852. eff = metric_stats.get("efficiency_pct", {})
  853. ripple = metric_stats.get("ripple_pct", {})
  854. if eff and eff.get("max", 0) < 90:
  855. 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")
  856. if ripple and ripple.get("min", 100) > 5:
  857. 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")
  858. if sensitivity:
  859. top_sens = sorted(sensitivity.items(), key=lambda x: abs(x[1]), reverse=True)[:3]
  860. for pk, sv in top_sens:
  861. direction = "\u589e\u5927" if sv > 0 else "\u51cf\u5c0f"
  862. 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")
  863. if best_point:
  864. 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")
  865. if violated:
  866. 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")
  867. if not recs:
  868. 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")
  869. return recs
  870. @router.post("/{plan_id}/generate-iteration")
  871. def generate_iteration_plan(plan_id: int, db: Session = Depends(get_db)):
  872. """Generate next iteration plan based on current results.
  873. Uses AI analysis to adjust scan ranges and creates a new plan
  874. with parent_plan_id linking to the current plan.
  875. """
  876. plan = db.query(SimulationPlan).filter(SimulationPlan.id == plan_id).first()
  877. if not plan:
  878. raise HTTPException(status_code=404, detail="Plan not found")
  879. results = (
  880. db.query(SimulationResult)
  881. .filter(SimulationResult.plan_id == plan_id)
  882. .order_by(SimulationResult.run_index.asc())
  883. .all()
  884. )
  885. if not results:
  886. raise HTTPException(status_code=400, detail="No simulation results for iteration")
  887. plan_data = plan.get_plan_dict()
  888. ok_results = [r for r in results if r.status == "OK"]
  889. if not ok_results:
  890. raise HTTPException(status_code=400, detail="No successful results for iteration")
  891. # Find best point
  892. best = max(ok_results, key=lambda r: r.get_metrics().get("efficiency_pct", 0))
  893. best_params = best.get_params()
  894. best_metrics = best.get_metrics()
  895. # Generate new variables: narrow ranges around best point
  896. new_variables = []
  897. for v in plan_data.get("variables", []):
  898. name = v.get("name", "")
  899. if name in best_params:
  900. best_val = best_params[name]
  901. step = v.get("step", 0.1)
  902. # Narrow to +/- 2 steps around best
  903. new_start = round(best_val - 2 * step, 6)
  904. new_stop = round(best_val + 2 * step, 6)
  905. # Ensure within physical bounds
  906. new_start = max(new_start, v.get("start", new_start))
  907. new_stop = min(new_stop, v.get("stop", new_stop))
  908. values = []
  909. if new_stop > new_start and step > 0:
  910. count = int((new_stop - new_start) / step) + 1
  911. values = [round(new_start + i * step, 6) for i in range(count)]
  912. new_variables.append({
  913. **v,
  914. "start": new_start,
  915. "stop": new_stop,
  916. "values": values,
  917. })
  918. else:
  919. new_variables.append(v)
  920. # Create new plan
  921. import uuid as _uuid
  922. new_plan_id = f"SP-{datetime.now().strftime('%Y%m%d-%H%M%S')}-{_uuid.uuid4().hex[:6]}"
  923. iteration = (plan_data.get("iteration", 1) or 1) + 1
  924. new_plan_data = {
  925. **plan_data,
  926. "plan_id": new_plan_id,
  927. "iteration": iteration,
  928. "parent_plan_id": plan.plan_id,
  929. "variables": new_variables,
  930. "ai_reasoning": f"Iteration #{iteration}: Narrowed search around best point "
  931. f"(eff={best_metrics.get('efficiency_pct', '?')}%, "
  932. f"torque={best_metrics.get('tavg_nm', '?')}Nm). "
  933. f"Previous best params: {best_params}",
  934. }
  935. estimated_points = 1
  936. for v in new_variables:
  937. estimated_points *= len(v.get("values", [])) if v.get("values") else 1
  938. variables_summary = {}
  939. for v in new_variables:
  940. variables_summary[v["name"]] = {
  941. "unit": v.get("unit", ""),
  942. "values": v.get("values", []),
  943. "count": len(v.get("values", [])),
  944. }
  945. new_plan = SimulationPlan(
  946. project_id=plan.project_id,
  947. name=f"{plan.name}_iter{iteration}",
  948. plan_id=new_plan_id,
  949. status="draft",
  950. estimated_points=estimated_points,
  951. estimated_time_min=estimated_points * 3,
  952. notes=f"Iteration #{iteration} from plan {plan.plan_id}. Best eff={best_metrics.get('efficiency_pct', '?')}%",
  953. )
  954. new_plan.set_plan_dict(new_plan_data)
  955. new_plan.variables_summary = json.dumps(variables_summary, ensure_ascii=False)
  956. db.add(new_plan)
  957. db.commit()
  958. db.refresh(new_plan)
  959. return {
  960. "id": new_plan.id,
  961. "plan_id": new_plan.plan_id,
  962. "name": new_plan.name,
  963. "iteration": iteration,
  964. "parent_plan_id": plan.plan_id,
  965. "estimated_points": estimated_points,
  966. "best_point": {
  967. "run_index": best.run_index,
  968. "params": best_params,
  969. "metrics": best_metrics,
  970. },
  971. "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",
  972. }