| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269 |
- """Simulation plan API router (CRUD + download + upload results)."""
- import json
- import uuid
- 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:
- """Generate a unique plan ID with timestamp + random suffix to avoid collisions."""
- return f"SP-{datetime.now().strftime('%Y%m%d-%H%M%S')}-{uuid.uuid4().hex[:6]}"
- 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)
|