jjreif's picture
Deploy roverdevkit @ 2676a67
b3d14e3
Raw
History Blame Contribute Delete
6.06 kB
"""Backend dispatcher for parametric sweeps.
Pure-Python core lives in :mod:`roverdevkit.tradespace.sweeps` (axis
definitions, grid expansion, backend picking). This module is the
glue that loads the artifacts (quantile bundles, soil parameters),
runs the chosen backend, and returns a
:class:`~roverdevkit.tradespace.sweeps.SweepResult`.
Two backends, two performance profiles
--------------------------------------
- **Evaluator**: analytical Bekker-Wong pipeline. ~30 ms / cell after
the traverse-loop lift-out. Ground truth.
Used for ≤ ``EVALUATOR_AUTO_THRESHOLD`` cells in auto mode.
- **Surrogate**: τ=0.5 head from the quantile bundles. Vectorised --
one batch ``predict`` over the whole grid. ~5 ms total for any
reasonable resolution. Used for > ``EVALUATOR_AUTO_THRESHOLD``
cells in auto mode.
Both backends emit values for the same primary regression targets so
the response shape is identical.
"""
from __future__ import annotations
import time
import numpy as np
import pandas as pd
from roverdevkit.mission.evaluator import evaluate as evaluator_evaluate
from roverdevkit.schema import DesignVector, MissionScenario
from roverdevkit.surrogate.uncertainty import QuantileHeads
from roverdevkit.terramechanics.bekker_wong import SoilParameters
from roverdevkit.tradespace.sweeps import (
SweepResult,
SweepSpec,
expand_grid,
pick_backend,
)
from webapp.backend.services.predict import build_feature_row
def run_sweep(
spec: SweepSpec,
base_design: DesignVector,
scenario: MissionScenario,
soil: SoilParameters,
*,
bundles: dict[str, QuantileHeads],
) -> SweepResult:
"""Resolve the backend, execute the sweep, return a packed result.
Parameters
----------
spec
Validated sweep specification (axes + target + backend mode).
base_design
The "rest of the design" -- every dimension not on an axis is
held at this value across the whole grid.
scenario, soil
The mission scenario (already resolved to one of the canonical
four) and its nominal soil parameters. Both are constant
across the grid -- a sweep varies design, not scenario.
bundles
Quantile XGBoost bundles for the surrogate path. Required
even when the auto-picker chooses the evaluator -- the route
loads them once per process and passes them through unchanged.
"""
backend = pick_backend(spec)
designs = expand_grid(spec, base_design)
t0 = time.perf_counter()
if backend == "evaluator":
z_flat = _run_evaluator(spec, designs, scenario)
elif backend == "surrogate":
z_flat = _run_surrogate(spec, designs, scenario, soil, bundles=bundles)
else: # pragma: no cover -- pick_backend guards this
raise AssertionError(f"unreachable backend {backend!r}")
elapsed_s = time.perf_counter() - t0
x_values = spec.x_axis.values()
y_values = spec.y_axis.values() if spec.y_axis is not None else None
if y_values is None:
z_values = z_flat
else:
# expand_grid emits row-major (y outer, x inner); reshape so
# the first axis is y to match Plotly heatmap orientation.
z_values = z_flat.reshape(spec.y_axis.n_points, spec.x_axis.n_points)
return SweepResult(
spec=spec,
x_values=x_values,
y_values=y_values,
z_values=z_values,
backend_used=backend,
elapsed_s=elapsed_s,
)
# ---------------------------------------------------------------------------
# Evaluator path
# ---------------------------------------------------------------------------
def _run_evaluator(
spec: SweepSpec,
designs: list[DesignVector],
scenario: MissionScenario,
) -> np.ndarray:
"""Per-cell call to :func:`roverdevkit.mission.evaluator.evaluate`.
Returns a 1-D array of length ``len(designs)`` (already in
row-major order; the caller reshapes for 2-D sweeps).
"""
out = np.empty(len(designs), dtype=float)
for i, d in enumerate(designs):
metrics = evaluator_evaluate(d, scenario)
out[i] = float(getattr(metrics, spec.target))
return out
# ---------------------------------------------------------------------------
# Surrogate path
# ---------------------------------------------------------------------------
def _run_surrogate(
spec: SweepSpec,
designs: list[DesignVector],
scenario: MissionScenario,
soil: SoilParameters,
*,
bundles: dict[str, QuantileHeads],
) -> np.ndarray:
"""Vectorised batch predict on the τ=0.5 head of the chosen target.
Builds one feature DataFrame for the entire grid and runs a
single ``QuantileHeads.predict`` call -- XGBoost's batched
prediction is dramatically faster than per-row calls and dwarfs
the per-row feature-construction time at any reasonable grid size.
"""
if spec.target not in bundles:
raise KeyError(
f"quantile bundle missing target {spec.target!r}; "
f"available: {sorted(bundles.keys())}."
)
feature_rows = [
build_feature_row(d, scenario, soil) for d in designs
]
X = pd.concat(feature_rows, ignore_index=True)
preds = bundles[spec.target].predict(X, repair_crossings=True)
# The "0.50" key is added by QuantileHeads.predict for whichever
# quantile equals 0.5 in the configured triple. Default triple is
# (0.05, 0.5, 0.95), so this lookup matches the quantile-calibration contract.
if "q50" in preds:
return np.asarray(preds["q50"], dtype=float)
# Fallback: pick the entry whose label is closest to 0.5. Defensive
# against future bundle versions that stash predictions under a
# numeric key.
closest = min(preds.keys(), key=lambda k: abs(_quantile_from_key(k) - 0.5))
return np.asarray(preds[closest], dtype=float)
def _quantile_from_key(key: str) -> float:
"""Parse ``"q05"`` -> 0.05, ``"q50"`` -> 0.5, etc.; ``"0.5"`` also works."""
if key.startswith("q"):
return int(key[1:]) / 100.0
return float(key)