roverdevkit / webapp /backend /routes /optimize.py
jjreif's picture
Deploy roverdevkit @ 2676a67
b3d14e3
Raw
History Blame Contribute Delete
9.53 kB
"""NSGA-II optimization job routes.
``POST /optimize`` queues a background NSGA-II run, ``/stream`` exposes
per-generation checkpoints as server-sent events, ``/result`` returns
the final Pareto front, and ``/cancel`` requests cooperative
termination. Jobs are intentionally process-local: this is a local-first
tool, and a future deployed queue can preserve the HTTP contract.
"""
from __future__ import annotations
import asyncio
import json
import time
import uuid
from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass, field
from threading import Lock
from typing import Literal
from fastapi import APIRouter, HTTPException
from sse_starlette.sse import EventSourceResponse
from roverdevkit.tradespace.optimizer import (
NSGA2Runner,
OptimizationCheckpoint,
OptimizationConstraint,
OptimizationObjective,
OptimizationResult,
)
from webapp.backend.loaders import (
get_canonical_scenarios,
get_quantile_bundles,
get_soil_for_simulant,
)
from webapp.backend.services import apply_scenario_overrides
from webapp.backend.schemas import (
OptimizeCancelResponse,
OptimizeCheckpointOut,
OptimizeJobResponse,
OptimizeParetoPoint,
OptimizeRequest,
OptimizeResultResponse,
)
router = APIRouter(tags=["optimize"])
JOB_TTL_SECONDS = 30 * 60
_EXECUTOR = ThreadPoolExecutor(max_workers=2, thread_name_prefix="rdk-optimize")
JobStatus = Literal["queued", "running", "completed", "cancelled", "failed"]
@dataclass
class _OptimizeJob:
job_id: str
status: JobStatus = "queued"
checkpoints: list[OptimizationCheckpoint] = field(default_factory=list)
result: OptimizationResult | None = None
error: str | None = None
cancel_requested: bool = False
created_at: float = field(default_factory=time.monotonic)
updated_at: float = field(default_factory=time.monotonic)
lock: Lock = field(default_factory=Lock)
class _JobStore:
def __init__(self) -> None:
self._jobs: dict[str, _OptimizeJob] = {}
self._lock = Lock()
def create(self) -> _OptimizeJob:
self.prune()
job = _OptimizeJob(job_id=uuid.uuid4().hex)
with self._lock:
self._jobs[job.job_id] = job
return job
def get(self, job_id: str) -> _OptimizeJob:
self.prune()
with self._lock:
job = self._jobs.get(job_id)
if job is None:
raise KeyError(job_id)
return job
def prune(self) -> None:
now = time.monotonic()
with self._lock:
expired = [
job_id
for job_id, job in self._jobs.items()
if now - job.updated_at > JOB_TTL_SECONDS
]
for job_id in expired:
self._jobs.pop(job_id, None)
_STORE = _JobStore()
@router.post("/optimize", response_model=OptimizeJobResponse)
def optimize(req: OptimizeRequest) -> OptimizeJobResponse:
"""Queue an NSGA-II optimization job and return its job URLs."""
scenarios = get_canonical_scenarios()
if req.scenario_name not in scenarios:
raise HTTPException(
status_code=404,
detail=(
f"unknown scenario {req.scenario_name!r}. "
f"Pick one of {sorted(scenarios.keys())}."
),
)
scenario = apply_scenario_overrides(
scenarios[req.scenario_name],
operational_duty_cycle=req.operational_duty_cycle,
payload_mass_kg=req.payload_mass_kg,
payload_power_w=req.payload_power_w,
mission_duration_earth_days=req.mission_duration_earth_days,
required_obstacle_height_m=req.required_obstacle_height_m,
)
soil = get_soil_for_simulant(scenario.soil_simulant)
bundles = None
if req.backend == "surrogate":
try:
bundles = get_quantile_bundles()
except FileNotFoundError as exc:
raise HTTPException(
status_code=503,
detail="surrogate artifact not loaded; run scripts/calibrate_intervals.py first.",
) from exc
objectives = tuple(
OptimizationObjective(item.target, item.direction) for item in req.objectives
)
constraints = tuple(
OptimizationConstraint(item.target, item.sense, item.value)
for item in req.constraints
)
try:
runner = NSGA2Runner(
scenario,
soil,
bundles=bundles,
backend=req.backend,
objectives=objectives,
constraints=constraints,
population_size=req.population_size,
n_generations=req.n_generations,
seed=req.seed,
# Live evaluator-backed jobs are interactive: cap chosen so a
# worst-case run finishes inside ~2 min wall clock at the
# analytical evaluator's ~22 ms/call. Surrogate-backed jobs
# are not bound by this cap (the optimizer's own check is
# gated on backend == "evaluator").
evaluator_eval_cap=5000,
)
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
job = _STORE.create()
_EXECUTOR.submit(_run_job, job, runner)
return OptimizeJobResponse(
job_id=job.job_id,
status=job.status,
stream_url=f"/optimize/{job.job_id}/stream",
result_url=f"/optimize/{job.job_id}/result",
cancel_url=f"/optimize/{job.job_id}/cancel",
)
@router.get("/optimize/{job_id}/stream")
async def stream(job_id: str) -> EventSourceResponse:
"""Stream checkpoints for an optimization job as SSE events."""
job = _lookup_job(job_id)
async def events():
sent = 0
while True:
with job.lock:
checkpoints = list(job.checkpoints)
status = job.status
error = job.error
job.updated_at = time.monotonic()
for checkpoint in checkpoints[sent:]:
sent += 1
yield {
"event": "checkpoint",
"data": _checkpoint_out(checkpoint).model_dump_json(),
}
if status in {"completed", "cancelled", "failed"}:
yield {
"event": status,
"data": json.dumps({"job_id": job.job_id, "status": status, "error": error}),
}
break
await asyncio.sleep(0.2)
return EventSourceResponse(events())
@router.get("/optimize/{job_id}/result", response_model=OptimizeResultResponse)
def result(job_id: str) -> OptimizeResultResponse:
"""Return job state and the final Pareto front when available."""
job = _lookup_job(job_id)
with job.lock:
return _result_response(job)
@router.post("/optimize/{job_id}/cancel", response_model=OptimizeCancelResponse)
def cancel(job_id: str) -> OptimizeCancelResponse:
"""Request cooperative cancellation of a queued or running job."""
job = _lookup_job(job_id)
with job.lock:
if job.status in {"queued", "running"}:
job.cancel_requested = True
job.updated_at = time.monotonic()
return OptimizeCancelResponse(job_id=job.job_id, status=job.status)
def _run_job(job: _OptimizeJob, runner: NSGA2Runner) -> None:
def on_checkpoint(checkpoint: OptimizationCheckpoint) -> None:
with job.lock:
job.checkpoints.append(checkpoint)
job.updated_at = time.monotonic()
def should_cancel() -> bool:
with job.lock:
return job.cancel_requested
with job.lock:
job.status = "running"
job.updated_at = time.monotonic()
try:
result = runner.run(on_checkpoint=on_checkpoint, should_cancel=should_cancel)
except Exception as exc: # pragma: no cover - surfaced via API
with job.lock:
job.status = "failed"
job.error = str(exc)
job.updated_at = time.monotonic()
return
with job.lock:
job.result = result
job.status = "cancelled" if job.cancel_requested else "completed"
job.updated_at = time.monotonic()
def _lookup_job(job_id: str) -> _OptimizeJob:
try:
return _STORE.get(job_id)
except KeyError as exc:
raise HTTPException(status_code=404, detail=f"unknown optimization job {job_id!r}") from exc
def _checkpoint_out(checkpoint: OptimizationCheckpoint) -> OptimizeCheckpointOut:
return OptimizeCheckpointOut(
gen=checkpoint.gen,
hypervolume=checkpoint.hypervolume,
pareto_size=checkpoint.pareto_size,
best_per_objective=checkpoint.best_per_objective,
)
def _result_response(job: _OptimizeJob) -> OptimizeResultResponse:
checkpoints = [_checkpoint_out(checkpoint) for checkpoint in job.checkpoints]
pareto_front: list[OptimizeParetoPoint] = []
backend_used: Literal["surrogate", "evaluator"] | None = None
if job.result is not None:
backend_used = job.result.backend_used
pareto_front = [
OptimizeParetoPoint(design=design, metrics=metrics)
for design, metrics in zip(
job.result.design_vectors,
job.result.metrics,
strict=True,
)
]
return OptimizeResultResponse(
job_id=job.job_id,
status=job.status,
backend_used=backend_used,
checkpoints=checkpoints,
pareto_front=pareto_front,
error=job.error,
)