"""Simulation plan API router (CRUD + download + upload results).""" import json from datetime import datetime from fastapi import APIRouter, Depends, HTTPException, UploadFile, File from sqlalchemy.orm import Session from ..database import get_db from ..metrics_constants import METRIC_KEYS from ..models.project import Project from ..models.simulation_plan import SimulationPlan from ..models.simulation_result import SimulationResult from ..schemas.simulation_plan import ( PlanCreate, PlanUpdate, PlanResponse, PlanListResponse, PlanDownloadResponse, ) from ..schemas.simulation_result import ResultListResponse router = APIRouter(prefix="/api/plans", tags=["plans"]) def _generate_plan_id() -> str: return f"SP-{datetime.now().strftime('%Y%m%d-%H%M%S')}" def _plan_to_response(db: Session, plan: SimulationPlan) -> PlanResponse: result_count = db.query(SimulationResult).filter(SimulationResult.plan_id == plan.id).count() variables_summary = {} try: variables_summary = json.loads(plan.variables_summary) if plan.variables_summary else {} except (json.JSONDecodeError, TypeError): pass return PlanResponse( id=plan.id, project_id=plan.project_id, name=plan.name, plan_id=plan.plan_id, status=plan.status, plan_data=plan.get_plan_dict(), variables_summary=variables_summary, estimated_points=plan.estimated_points or 0, estimated_time_min=plan.estimated_time_min or 0, notes=plan.notes or "", result_count=result_count, created_at=plan.created_at, updated_at=plan.updated_at, ) @router.get("", response_model=PlanListResponse) def list_plans( project_id: int | None = None, skip: int = 0, limit: int = 50, db: Session = Depends(get_db), ): """List simulation plans, optionally filtered by project.""" query = db.query(SimulationPlan) if project_id: query = query.filter(SimulationPlan.project_id == project_id) total = query.count() plans = query.order_by(SimulationPlan.updated_at.desc()).offset(skip).limit(limit).all() return PlanListResponse(total=total, items=[_plan_to_response(db, p) for p in plans]) @router.post("", response_model=PlanResponse, status_code=201) def create_plan(data: PlanCreate, db: Session = Depends(get_db)): """Create a new simulation plan.""" project = db.query(Project).filter(Project.id == data.project_id).first() if not project: raise HTTPException(status_code=404, detail="Project not found") plan_id = _generate_plan_id() plan_data = data.plan_data plan_data["plan_id"] = plan_id # Extract variables summary for display variables_summary = {} estimated_points = 1 for var in plan_data.get("variables", []): name = var.get("name", "unknown") values = var.get("values", []) variables_summary[name] = { "unit": var.get("unit", ""), "values": values, "count": len(values), } estimated_points *= len(values) if values else 1 plan = SimulationPlan( project_id=data.project_id, name=data.name, plan_id=plan_id, status="draft", estimated_points=estimated_points, estimated_time_min=estimated_points * 3, # ~3 min per point estimate notes=data.notes, ) plan.set_plan_dict(plan_data) plan.variables_summary = json.dumps(variables_summary, ensure_ascii=False) db.add(plan) db.commit() db.refresh(plan) return _plan_to_response(db, plan) @router.get("/{plan_id}", response_model=PlanResponse) def get_plan(plan_id: int, db: Session = Depends(get_db)): """Get a plan by ID.""" plan = db.query(SimulationPlan).filter(SimulationPlan.id == plan_id).first() if not plan: raise HTTPException(status_code=404, detail="Plan not found") return _plan_to_response(db, plan) @router.put("/{plan_id}", response_model=PlanResponse) def update_plan(plan_id: int, data: PlanUpdate, db: Session = Depends(get_db)): """Update a plan.""" plan = db.query(SimulationPlan).filter(SimulationPlan.id == plan_id).first() if not plan: raise HTTPException(status_code=404, detail="Plan not found") update_data = data.model_dump(exclude_unset=True) if "plan_data" in update_data: plan.set_plan_dict(update_data.pop("plan_data")) for key, value in update_data.items(): setattr(plan, key, value) db.commit() db.refresh(plan) return _plan_to_response(db, plan) @router.delete("/{plan_id}", status_code=204) def delete_plan(plan_id: int, db: Session = Depends(get_db)): """Delete a plan and its results.""" plan = db.query(SimulationPlan).filter(SimulationPlan.id == plan_id).first() if not plan: raise HTTPException(status_code=404, detail="Plan not found") db.delete(plan) db.commit() return None # --------------------------------------------------------------------------- # API integration endpoints (for local executor) # --------------------------------------------------------------------------- @router.get("/{plan_id}/download", response_model=PlanDownloadResponse) def download_plan(plan_id: int, db: Session = Depends(get_db)): """Download a plan as simulation_plan.json (read-only, F5 fix). This is the API endpoint used by the local execution system to fetch plans. Returns the raw plan JSON in the exact format expected by src/plan_schema.py. Status changes must go through POST /{plan_id}/start-execution. """ plan = db.query(SimulationPlan).filter(SimulationPlan.id == plan_id).first() if not plan: raise HTTPException(status_code=404, detail="Plan not found") return PlanDownloadResponse(plan_id=plan.plan_id, plan_data=plan.get_plan_dict()) @router.post("/{plan_id}/start-execution") def start_execution(plan_id: int, db: Session = Depends(get_db)): """Mark a plan as executing (explicit state transition, F5 fix).""" plan = db.query(SimulationPlan).filter(SimulationPlan.id == plan_id).first() if not plan: raise HTTPException(status_code=404, detail="Plan not found") if plan.status in ("draft", "confirmed"): plan.status = "executing" db.commit() return {"plan_id": plan.plan_id, "status": plan.status} @router.get("/by-plan-id/{plan_uuid}/download", response_model=PlanDownloadResponse) def download_plan_by_uuid(plan_uuid: str, db: Session = Depends(get_db)): """Download a plan by its plan_id string (read-only, F5 fix).""" plan = db.query(SimulationPlan).filter(SimulationPlan.plan_id == plan_uuid).first() if not plan: raise HTTPException(status_code=404, detail="Plan not found") return PlanDownloadResponse(plan_id=plan.plan_id, plan_data=plan.get_plan_dict()) @router.post("/{plan_id}/upload-results", status_code=201) async def upload_results( plan_id: int, file: UploadFile = File(...), db: Session = Depends(get_db), ): """Upload scan_results.csv from local executor. Parses the CSV and stores each row as a SimulationResult. This is the API endpoint used by the local execution system to push results back. """ import csv import io plan = db.query(SimulationPlan).filter(SimulationPlan.id == plan_id).first() if not plan: raise HTTPException(status_code=404, detail="Plan not found") # Clear existing results for this plan (re-upload replaces) db.query(SimulationResult).filter(SimulationResult.plan_id == plan_id).delete() content = await file.read() text = content.decode("utf-8-sig") reader = csv.DictReader(io.StringIO(text)) # A1 fix: use shared metric constants (single source of truth) metric_keys = METRIC_KEYS standard_keys = {"run_index", "status", "seconds", "error"} count = 0 for row in reader: params = {} metrics = {} for key, val in row.items(): if key in standard_keys or val == "" or val is None: continue try: fval = float(val) except (ValueError, TypeError): continue if key in metric_keys: metrics[key] = fval else: params[key] = fval result = SimulationResult( plan_id=plan_id, run_index=int(row.get("run_index", count + 1)), status=row.get("status", "OK"), solve_time_s=float(row.get("seconds", 0) or 0), error_message=row.get("error", ""), ) result.set_params(params) result.set_metrics(metrics) db.add(result) count += 1 # Update plan status plan.status = "completed" db.commit() return {"message": f"Uploaded {count} results", "count": count, "plan_id": plan.plan_id} @router.get("/{plan_id}/results", response_model=ResultListResponse) def get_plan_results(plan_id: int, db: Session = Depends(get_db)): """Get all results for a plan.""" plan = db.query(SimulationPlan).filter(SimulationPlan.id == plan_id).first() if not plan: raise HTTPException(status_code=404, detail="Plan not found") results = ( db.query(SimulationResult) .filter(SimulationResult.plan_id == plan_id) .order_by(SimulationResult.run_index.asc()) .all() ) from ..schemas.simulation_result import ResultResponse items = [ ResultResponse( id=r.id, plan_id=r.plan_id, run_index=r.run_index, status=r.status, solve_time_s=r.solve_time_s, params=r.get_params(), metrics=r.get_metrics(), error_message=r.error_message or "", created_at=r.created_at, ) for r in results ] return ResultListResponse(total=len(items), items=items)