Spaces:
Running
Running
| """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"] | |
| 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() | |
| 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", | |
| ) | |
| 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()) | |
| 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) | |
| 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, | |
| ) | |