"""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 ..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 (compatible with local executor). 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. """ plan = db.query(SimulationPlan).filter(SimulationPlan.id == plan_id).first() if not plan: raise HTTPException(status_code=404, detail="Plan not found") # Mark as executing when downloaded if plan.status == "draft" or plan.status == "confirmed": plan.status = "executing" db.commit() return PlanDownloadResponse(plan_id=plan.plan_id, plan_data=plan.get_plan_dict()) @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 (e.g. SP-20260827-001).""" plan = db.query(SimulationPlan).filter(SimulationPlan.plan_id == plan_uuid).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 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)) # Known metric keys (from solver_core) metric_keys = { "tavg_nm", "tmax_nm", "tmin_nm", "ripple_pct", "ripple_abs_nm", "efficiency_pct", "output_power_w", "input_power_w", "total_losses_w", "copper_loss_w", "iron_loss_w", "magnet_loss_w", "back_emf_v", "no_load_speed_rpm", "power_factor", } 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)