plans.py 44 KB

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