plans.py 9.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267
  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 ..metrics_constants import METRIC_KEYS
  8. from ..models.project import Project
  9. from ..models.simulation_plan import SimulationPlan
  10. from ..models.simulation_result import SimulationResult
  11. from ..schemas.simulation_plan import (
  12. PlanCreate, PlanUpdate, PlanResponse, PlanListResponse, PlanDownloadResponse,
  13. )
  14. from ..schemas.simulation_result import ResultListResponse
  15. router = APIRouter(prefix="/api/plans", tags=["plans"])
  16. def _generate_plan_id() -> str:
  17. return f"SP-{datetime.now().strftime('%Y%m%d-%H%M%S')}"
  18. def _plan_to_response(db: Session, plan: SimulationPlan) -> PlanResponse:
  19. result_count = db.query(SimulationResult).filter(SimulationResult.plan_id == plan.id).count()
  20. variables_summary = {}
  21. try:
  22. variables_summary = json.loads(plan.variables_summary) if plan.variables_summary else {}
  23. except (json.JSONDecodeError, TypeError):
  24. pass
  25. return PlanResponse(
  26. id=plan.id,
  27. project_id=plan.project_id,
  28. name=plan.name,
  29. plan_id=plan.plan_id,
  30. status=plan.status,
  31. plan_data=plan.get_plan_dict(),
  32. variables_summary=variables_summary,
  33. estimated_points=plan.estimated_points or 0,
  34. estimated_time_min=plan.estimated_time_min or 0,
  35. notes=plan.notes or "",
  36. result_count=result_count,
  37. created_at=plan.created_at,
  38. updated_at=plan.updated_at,
  39. )
  40. @router.get("", response_model=PlanListResponse)
  41. def list_plans(
  42. project_id: int | None = None,
  43. skip: int = 0,
  44. limit: int = 50,
  45. db: Session = Depends(get_db),
  46. ):
  47. """List simulation plans, optionally filtered by project."""
  48. query = db.query(SimulationPlan)
  49. if project_id:
  50. query = query.filter(SimulationPlan.project_id == project_id)
  51. total = query.count()
  52. plans = query.order_by(SimulationPlan.updated_at.desc()).offset(skip).limit(limit).all()
  53. return PlanListResponse(total=total, items=[_plan_to_response(db, p) for p in plans])
  54. @router.post("", response_model=PlanResponse, status_code=201)
  55. def create_plan(data: PlanCreate, db: Session = Depends(get_db)):
  56. """Create a new simulation plan."""
  57. project = db.query(Project).filter(Project.id == data.project_id).first()
  58. if not project:
  59. raise HTTPException(status_code=404, detail="Project not found")
  60. plan_id = _generate_plan_id()
  61. plan_data = data.plan_data
  62. plan_data["plan_id"] = plan_id
  63. # Extract variables summary for display
  64. variables_summary = {}
  65. estimated_points = 1
  66. for var in plan_data.get("variables", []):
  67. name = var.get("name", "unknown")
  68. values = var.get("values", [])
  69. variables_summary[name] = {
  70. "unit": var.get("unit", ""),
  71. "values": values,
  72. "count": len(values),
  73. }
  74. estimated_points *= len(values) if values else 1
  75. plan = SimulationPlan(
  76. project_id=data.project_id,
  77. name=data.name,
  78. plan_id=plan_id,
  79. status="draft",
  80. estimated_points=estimated_points,
  81. estimated_time_min=estimated_points * 3, # ~3 min per point estimate
  82. notes=data.notes,
  83. )
  84. plan.set_plan_dict(plan_data)
  85. plan.variables_summary = json.dumps(variables_summary, ensure_ascii=False)
  86. db.add(plan)
  87. db.commit()
  88. db.refresh(plan)
  89. return _plan_to_response(db, plan)
  90. @router.get("/{plan_id}", response_model=PlanResponse)
  91. def get_plan(plan_id: int, db: Session = Depends(get_db)):
  92. """Get a plan by ID."""
  93. plan = db.query(SimulationPlan).filter(SimulationPlan.id == plan_id).first()
  94. if not plan:
  95. raise HTTPException(status_code=404, detail="Plan not found")
  96. return _plan_to_response(db, plan)
  97. @router.put("/{plan_id}", response_model=PlanResponse)
  98. def update_plan(plan_id: int, data: PlanUpdate, db: Session = Depends(get_db)):
  99. """Update a plan."""
  100. plan = db.query(SimulationPlan).filter(SimulationPlan.id == plan_id).first()
  101. if not plan:
  102. raise HTTPException(status_code=404, detail="Plan not found")
  103. update_data = data.model_dump(exclude_unset=True)
  104. if "plan_data" in update_data:
  105. plan.set_plan_dict(update_data.pop("plan_data"))
  106. for key, value in update_data.items():
  107. setattr(plan, key, value)
  108. db.commit()
  109. db.refresh(plan)
  110. return _plan_to_response(db, plan)
  111. @router.delete("/{plan_id}", status_code=204)
  112. def delete_plan(plan_id: int, db: Session = Depends(get_db)):
  113. """Delete a plan and its results."""
  114. plan = db.query(SimulationPlan).filter(SimulationPlan.id == plan_id).first()
  115. if not plan:
  116. raise HTTPException(status_code=404, detail="Plan not found")
  117. db.delete(plan)
  118. db.commit()
  119. return None
  120. # ---------------------------------------------------------------------------
  121. # API integration endpoints (for local executor)
  122. # ---------------------------------------------------------------------------
  123. @router.get("/{plan_id}/download", response_model=PlanDownloadResponse)
  124. def download_plan(plan_id: int, db: Session = Depends(get_db)):
  125. """Download a plan as simulation_plan.json (read-only, F5 fix).
  126. This is the API endpoint used by the local execution system to fetch plans.
  127. Returns the raw plan JSON in the exact format expected by src/plan_schema.py.
  128. Status changes must go through POST /{plan_id}/start-execution.
  129. """
  130. plan = db.query(SimulationPlan).filter(SimulationPlan.id == plan_id).first()
  131. if not plan:
  132. raise HTTPException(status_code=404, detail="Plan not found")
  133. return PlanDownloadResponse(plan_id=plan.plan_id, plan_data=plan.get_plan_dict())
  134. @router.post("/{plan_id}/start-execution")
  135. def start_execution(plan_id: int, db: Session = Depends(get_db)):
  136. """Mark a plan as executing (explicit state transition, F5 fix)."""
  137. plan = db.query(SimulationPlan).filter(SimulationPlan.id == plan_id).first()
  138. if not plan:
  139. raise HTTPException(status_code=404, detail="Plan not found")
  140. if plan.status in ("draft", "confirmed"):
  141. plan.status = "executing"
  142. db.commit()
  143. return {"plan_id": plan.plan_id, "status": plan.status}
  144. @router.get("/by-plan-id/{plan_uuid}/download", response_model=PlanDownloadResponse)
  145. def download_plan_by_uuid(plan_uuid: str, db: Session = Depends(get_db)):
  146. """Download a plan by its plan_id string (read-only, F5 fix)."""
  147. plan = db.query(SimulationPlan).filter(SimulationPlan.plan_id == plan_uuid).first()
  148. if not plan:
  149. raise HTTPException(status_code=404, detail="Plan not found")
  150. return PlanDownloadResponse(plan_id=plan.plan_id, plan_data=plan.get_plan_dict())
  151. @router.post("/{plan_id}/upload-results", status_code=201)
  152. async def upload_results(
  153. plan_id: int,
  154. file: UploadFile = File(...),
  155. db: Session = Depends(get_db),
  156. ):
  157. """Upload scan_results.csv from local executor.
  158. Parses the CSV and stores each row as a SimulationResult.
  159. This is the API endpoint used by the local execution system to push results back.
  160. """
  161. import csv
  162. import io
  163. plan = db.query(SimulationPlan).filter(SimulationPlan.id == plan_id).first()
  164. if not plan:
  165. raise HTTPException(status_code=404, detail="Plan not found")
  166. # Clear existing results for this plan (re-upload replaces)
  167. db.query(SimulationResult).filter(SimulationResult.plan_id == plan_id).delete()
  168. content = await file.read()
  169. text = content.decode("utf-8-sig")
  170. reader = csv.DictReader(io.StringIO(text))
  171. # A1 fix: use shared metric constants (single source of truth)
  172. metric_keys = METRIC_KEYS
  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)