plans.py 9.7 KB

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