plans.py 9.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265
  1. """Simulation plan API router (CRUD + download + upload results)."""
  2. import json
  3. from datetime import datetime
  4. from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
  5. from sqlalchemy.orm import Session
  6. from ..database import get_db
  7. from ..models.project import Project
  8. from ..models.simulation_plan import SimulationPlan
  9. from ..models.simulation_result import SimulationResult
  10. from ..schemas.simulation_plan import (
  11. PlanCreate, PlanUpdate, PlanResponse, PlanListResponse, PlanDownloadResponse,
  12. )
  13. from ..schemas.simulation_result import ResultListResponse
  14. router = APIRouter(prefix="/api/plans", tags=["plans"])
  15. def _generate_plan_id() -> str:
  16. return f"SP-{datetime.now().strftime('%Y%m%d-%H%M%S')}"
  17. def _plan_to_response(db: Session, plan: SimulationPlan) -> PlanResponse:
  18. result_count = db.query(SimulationResult).filter(SimulationResult.plan_id == plan.id).count()
  19. variables_summary = {}
  20. try:
  21. variables_summary = json.loads(plan.variables_summary) if plan.variables_summary else {}
  22. except (json.JSONDecodeError, TypeError):
  23. pass
  24. return PlanResponse(
  25. id=plan.id,
  26. project_id=plan.project_id,
  27. name=plan.name,
  28. plan_id=plan.plan_id,
  29. status=plan.status,
  30. plan_data=plan.get_plan_dict(),
  31. variables_summary=variables_summary,
  32. estimated_points=plan.estimated_points or 0,
  33. estimated_time_min=plan.estimated_time_min or 0,
  34. notes=plan.notes or "",
  35. result_count=result_count,
  36. created_at=plan.created_at,
  37. updated_at=plan.updated_at,
  38. )
  39. @router.get("", response_model=PlanListResponse)
  40. def list_plans(
  41. project_id: int | None = None,
  42. skip: int = 0,
  43. limit: int = 50,
  44. db: Session = Depends(get_db),
  45. ):
  46. """List simulation plans, optionally filtered by project."""
  47. query = db.query(SimulationPlan)
  48. if project_id:
  49. query = query.filter(SimulationPlan.project_id == project_id)
  50. total = query.count()
  51. plans = query.order_by(SimulationPlan.updated_at.desc()).offset(skip).limit(limit).all()
  52. return PlanListResponse(total=total, items=[_plan_to_response(db, p) for p in plans])
  53. @router.post("", response_model=PlanResponse, status_code=201)
  54. def create_plan(data: PlanCreate, db: Session = Depends(get_db)):
  55. """Create a new simulation plan."""
  56. project = db.query(Project).filter(Project.id == data.project_id).first()
  57. if not project:
  58. raise HTTPException(status_code=404, detail="Project not found")
  59. plan_id = _generate_plan_id()
  60. plan_data = data.plan_data
  61. plan_data["plan_id"] = plan_id
  62. # Extract variables summary for display
  63. variables_summary = {}
  64. estimated_points = 1
  65. for var in plan_data.get("variables", []):
  66. name = var.get("name", "unknown")
  67. values = var.get("values", [])
  68. variables_summary[name] = {
  69. "unit": var.get("unit", ""),
  70. "values": values,
  71. "count": len(values),
  72. }
  73. estimated_points *= len(values) if values else 1
  74. plan = SimulationPlan(
  75. project_id=data.project_id,
  76. name=data.name,
  77. plan_id=plan_id,
  78. status="draft",
  79. estimated_points=estimated_points,
  80. estimated_time_min=estimated_points * 3, # ~3 min per point estimate
  81. notes=data.notes,
  82. )
  83. plan.set_plan_dict(plan_data)
  84. plan.variables_summary = json.dumps(variables_summary, ensure_ascii=False)
  85. db.add(plan)
  86. db.commit()
  87. db.refresh(plan)
  88. return _plan_to_response(db, plan)
  89. @router.get("/{plan_id}", response_model=PlanResponse)
  90. def get_plan(plan_id: int, db: Session = Depends(get_db)):
  91. """Get a plan by ID."""
  92. plan = db.query(SimulationPlan).filter(SimulationPlan.id == plan_id).first()
  93. if not plan:
  94. raise HTTPException(status_code=404, detail="Plan not found")
  95. return _plan_to_response(db, plan)
  96. @router.put("/{plan_id}", response_model=PlanResponse)
  97. def update_plan(plan_id: int, data: PlanUpdate, db: Session = Depends(get_db)):
  98. """Update a plan."""
  99. plan = db.query(SimulationPlan).filter(SimulationPlan.id == plan_id).first()
  100. if not plan:
  101. raise HTTPException(status_code=404, detail="Plan not found")
  102. update_data = data.model_dump(exclude_unset=True)
  103. if "plan_data" in update_data:
  104. plan.set_plan_dict(update_data.pop("plan_data"))
  105. for key, value in update_data.items():
  106. setattr(plan, key, value)
  107. db.commit()
  108. db.refresh(plan)
  109. return _plan_to_response(db, plan)
  110. @router.delete("/{plan_id}", status_code=204)
  111. def delete_plan(plan_id: int, db: Session = Depends(get_db)):
  112. """Delete a plan and its results."""
  113. plan = db.query(SimulationPlan).filter(SimulationPlan.id == plan_id).first()
  114. if not plan:
  115. raise HTTPException(status_code=404, detail="Plan not found")
  116. db.delete(plan)
  117. db.commit()
  118. return None
  119. # ---------------------------------------------------------------------------
  120. # API integration endpoints (for local executor)
  121. # ---------------------------------------------------------------------------
  122. @router.get("/{plan_id}/download", response_model=PlanDownloadResponse)
  123. def download_plan(plan_id: int, db: Session = Depends(get_db)):
  124. """Download a plan as simulation_plan.json (compatible with local executor).
  125. This is the API endpoint used by the local execution system to fetch plans.
  126. Returns the raw plan JSON in the exact format expected by src/plan_schema.py.
  127. """
  128. plan = db.query(SimulationPlan).filter(SimulationPlan.id == plan_id).first()
  129. if not plan:
  130. raise HTTPException(status_code=404, detail="Plan not found")
  131. # Mark as executing when downloaded
  132. if plan.status == "draft" or plan.status == "confirmed":
  133. plan.status = "executing"
  134. db.commit()
  135. return PlanDownloadResponse(plan_id=plan.plan_id, plan_data=plan.get_plan_dict())
  136. @router.get("/by-plan-id/{plan_uuid}/download", response_model=PlanDownloadResponse)
  137. def download_plan_by_uuid(plan_uuid: str, db: Session = Depends(get_db)):
  138. """Download a plan by its plan_id string (e.g. SP-20260827-001)."""
  139. plan = db.query(SimulationPlan).filter(SimulationPlan.plan_id == plan_uuid).first()
  140. if not plan:
  141. raise HTTPException(status_code=404, detail="Plan not found")
  142. if plan.status in ("draft", "confirmed"):
  143. plan.status = "executing"
  144. db.commit()
  145. return PlanDownloadResponse(plan_id=plan.plan_id, plan_data=plan.get_plan_dict())
  146. @router.post("/{plan_id}/upload-results", status_code=201)
  147. async def upload_results(
  148. plan_id: int,
  149. file: UploadFile = File(...),
  150. db: Session = Depends(get_db),
  151. ):
  152. """Upload scan_results.csv from local executor.
  153. Parses the CSV and stores each row as a SimulationResult.
  154. This is the API endpoint used by the local execution system to push results back.
  155. """
  156. import csv
  157. import io
  158. plan = db.query(SimulationPlan).filter(SimulationPlan.id == plan_id).first()
  159. if not plan:
  160. raise HTTPException(status_code=404, detail="Plan not found")
  161. # Clear existing results for this plan (re-upload replaces)
  162. db.query(SimulationResult).filter(SimulationResult.plan_id == plan_id).delete()
  163. content = await file.read()
  164. text = content.decode("utf-8-sig")
  165. reader = csv.DictReader(io.StringIO(text))
  166. # Known metric keys (from solver_core)
  167. metric_keys = {
  168. "tavg_nm", "tmax_nm", "tmin_nm", "ripple_pct", "ripple_abs_nm",
  169. "efficiency_pct", "output_power_w", "input_power_w", "total_losses_w",
  170. "copper_loss_w", "iron_loss_w", "magnet_loss_w", "back_emf_v",
  171. "no_load_speed_rpm", "power_factor",
  172. }
  173. standard_keys = {"run_index", "status", "seconds", "error"}
  174. count = 0
  175. for row in reader:
  176. params = {}
  177. metrics = {}
  178. for key, val in row.items():
  179. if key in standard_keys or val == "" or val is None:
  180. continue
  181. try:
  182. fval = float(val)
  183. except (ValueError, TypeError):
  184. continue
  185. if key in metric_keys:
  186. metrics[key] = fval
  187. else:
  188. params[key] = fval
  189. result = SimulationResult(
  190. plan_id=plan_id,
  191. run_index=int(row.get("run_index", count + 1)),
  192. status=row.get("status", "OK"),
  193. solve_time_s=float(row.get("seconds", 0) or 0),
  194. error_message=row.get("error", ""),
  195. )
  196. result.set_params(params)
  197. result.set_metrics(metrics)
  198. db.add(result)
  199. count += 1
  200. # Update plan status
  201. plan.status = "completed"
  202. db.commit()
  203. return {"message": f"Uploaded {count} results", "count": count, "plan_id": plan.plan_id}
  204. @router.get("/{plan_id}/results", response_model=ResultListResponse)
  205. def get_plan_results(plan_id: int, db: Session = Depends(get_db)):
  206. """Get all results for a plan."""
  207. plan = db.query(SimulationPlan).filter(SimulationPlan.id == plan_id).first()
  208. if not plan:
  209. raise HTTPException(status_code=404, detail="Plan not found")
  210. results = (
  211. db.query(SimulationResult)
  212. .filter(SimulationResult.plan_id == plan_id)
  213. .order_by(SimulationResult.run_index.asc())
  214. .all()
  215. )
  216. from ..schemas.simulation_result import ResultResponse
  217. items = [
  218. ResultResponse(
  219. id=r.id, plan_id=r.plan_id, run_index=r.run_index,
  220. status=r.status, solve_time_s=r.solve_time_s,
  221. params=r.get_params(), metrics=r.get_metrics(),
  222. error_message=r.error_message or "", created_at=r.created_at,
  223. )
  224. for r in results
  225. ]
  226. return ResultListResponse(total=len(items), items=items)