roverdevkit / webapp /backend /routes /predict.py
jjreif's picture
Deploy roverdevkit @ 2676a67
b3d14e3
Raw
History Blame Contribute Delete
3.95 kB
"""``POST /predict`` — surrogate point prediction with 90 % PI."""
from __future__ import annotations
import logging
from fastapi import APIRouter, HTTPException
from roverdevkit.surrogate.features import PRIMARY_REGRESSION_TARGETS
from webapp.backend.loaders import (
get_canonical_scenarios,
get_quantile_bundles,
get_soil_for_simulant,
)
from webapp.backend.schemas import (
FeatureRow,
PredictRequest,
PredictResponse,
PredictTarget,
)
from webapp.backend.services import apply_scenario_overrides
from webapp.backend.services.predict import build_feature_row, predict_quantiles
logger = logging.getLogger(__name__)
router = APIRouter(tags=["predict"])
@router.post("/predict", response_model=PredictResponse)
def predict(req: PredictRequest) -> PredictResponse:
"""Return median + 90 % prediction intervals for the four primary targets.
Pipeline
--------
1. Resolve the scenario from the canonical four (404 if unknown).
2. Look up nominal Bekker-Wong soil parameters for the scenario's
simulant.
3. Assemble the 27-D feature row in the surrogate's training-time
column order, applying any per-call ``operational_duty_cycle``
and schema-v9 payload (``payload_mass_kg`` / ``payload_power_w``)
mission-requirement overrides before flattening so the surrogate
sees the same scenario inputs the deterministic evaluator would.
4. Dispatch to every primary target's ``QuantileHeads`` head and
collect ``(q05, q50, q95)`` triples.
The surrogate is the quantile-calibration ``quantile_bundles.joblib``;
``q50`` is within R² 0.005 of the tuned-median tuned median (see
``reports/intervals_v4/SUMMARY.md`` for the median sanity
guardrail), so this single artifact powers both point estimates
and PI envelopes.
Schema v7_1 (v7_1 schema follow-on): ``operational_duty_cycle`` is
a true surrogate input feature (LHS-sampled per row over [0, 0.6]),
so any in-bounds δ_ops is in-distribution and the calibrated PIs
apply across the full frontend slider range. The pre-v7_1
"evaluator-only fallback when override differs from default" gate
has been removed; ``mode`` is always ``"surrogate"``.
"""
scenarios = get_canonical_scenarios()
if req.scenario_name not in scenarios:
raise HTTPException(
status_code=404,
detail=(
f"unknown scenario {req.scenario_name!r}. 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)
X = build_feature_row(req.design, scenario, soil)
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
preds = predict_quantiles(bundles, X, repair_crossings=req.repair_crossings)
targets = [
PredictTarget(
target=t, # type: ignore[arg-type]
q05=preds[t]["q05"],
q50=preds[t]["q50"],
q95=preds[t]["q95"],
)
for t in PRIMARY_REGRESSION_TARGETS
]
feature_row = FeatureRow(
columns=list(X.columns),
values=[v.item() if hasattr(v, "item") else v for v in X.iloc[0].tolist()],
)
return PredictResponse(
scenario_name=req.scenario_name,
predictions=targets,
feature_row=feature_row,
mode="surrogate",
)