roverdevkit / tests /test_tradespace_sweeps.py
jjreif's picture
Deploy roverdevkit @ 2676a67
b3d14e3
Raw
History Blame Contribute Delete
8.34 kB
"""Pure-Python unit tests for :mod:`roverdevkit.tradespace.sweeps`.
These tests exercise the grid expansion and backend-pick logic
without touching joblib / xgboost / FastAPI -- they only need
:mod:`roverdevkit.schema` and numpy.
"""
from __future__ import annotations
import numpy as np
import pytest
from roverdevkit.schema import DesignVector
from roverdevkit.tradespace.sweeps import (
EVALUATOR_AUTO_THRESHOLD,
EVALUATOR_HARD_LIMIT,
SURROGATE_HARD_LIMIT,
SweepAxis,
SweepResult,
SweepSpec,
compute_sensitivity,
expand_grid,
pick_backend,
)
def _base_design() -> DesignVector:
return DesignVector(
wheel_radius_m=0.10,
wheel_width_m=0.10,
grouser_height_m=0.012,
grouser_count=14,
n_wheels=6,
chassis_mass_kg=20.0,
wheelbase_m=0.6,
solar_area_m2=0.5,
battery_capacity_wh=100.0,
avionics_power_w=15.0,
peak_wheel_torque_nm=1.5,
)
def test_sweep_axis_values_endpoints_inclusive() -> None:
axis = SweepAxis(variable="wheel_radius_m", lo=0.08, hi=0.18, n_points=11)
vals = axis.values()
assert vals[0] == pytest.approx(0.08)
assert vals[-1] == pytest.approx(0.18)
assert len(vals) == 11
def test_sweep_axis_rejects_n_points_below_two() -> None:
with pytest.raises(ValueError, match="n_points must be >= 2"):
SweepAxis("wheel_radius_m", 0.08, 0.18, 1).values()
def test_sweep_axis_rejects_inverted_range() -> None:
with pytest.raises(ValueError, match="hi must be > lo"):
SweepAxis("wheel_radius_m", 0.18, 0.08, 5).values()
def test_sweep_spec_rejects_non_primary_target() -> None:
with pytest.raises(ValueError, match="not a primary regression target"):
SweepSpec(
target="not_a_real_metric",
x_axis=SweepAxis("wheel_radius_m", 0.08, 0.18, 5),
y_axis=None,
)
def test_sweep_spec_rejects_duplicate_axis_variables() -> None:
axis = SweepAxis("wheel_radius_m", 0.08, 0.18, 5)
with pytest.raises(ValueError, match="must sweep different variables"):
SweepSpec(target="range_km", x_axis=axis, y_axis=axis)
def test_sweep_spec_rejects_unknown_backend() -> None:
with pytest.raises(ValueError, match="not in"):
SweepSpec(
target="range_km",
x_axis=SweepAxis("wheel_radius_m", 0.08, 0.18, 5),
y_axis=None,
backend="cuda", # type: ignore[arg-type]
)
def test_expand_grid_1d_overrides_just_x() -> None:
spec = SweepSpec(
target="range_km",
x_axis=SweepAxis("wheel_radius_m", 0.08, 0.18, 5),
y_axis=None,
)
designs = expand_grid(spec, _base_design())
assert len(designs) == 5
radii = [d.wheel_radius_m for d in designs]
assert radii[0] == pytest.approx(0.08)
assert radii[-1] == pytest.approx(0.18)
# All other dims unchanged
for d in designs:
assert d.wheel_width_m == pytest.approx(0.10)
assert d.solar_area_m2 == pytest.approx(0.5)
def test_expand_grid_2d_row_major_y_outer_x_inner() -> None:
spec = SweepSpec(
target="range_km",
x_axis=SweepAxis("wheel_radius_m", 0.08, 0.18, 3),
y_axis=SweepAxis("solar_area_m2", 0.4, 0.8, 2),
)
designs = expand_grid(spec, _base_design())
assert len(designs) == 6
# Row-major: first three share y[0], next three share y[1].
ys = [d.solar_area_m2 for d in designs]
assert ys[:3] == pytest.approx([0.4, 0.4, 0.4])
assert ys[3:] == pytest.approx([0.8, 0.8, 0.8])
xs = [d.wheel_radius_m for d in designs]
np.testing.assert_allclose(xs[:3], [0.08, 0.13, 0.18])
np.testing.assert_allclose(xs[3:], [0.08, 0.13, 0.18])
def test_expand_grid_rounds_integer_variable() -> None:
spec = SweepSpec(
target="range_km",
x_axis=SweepAxis("grouser_count", 0.0, 24.0, 5),
y_axis=None,
)
designs = expand_grid(spec, _base_design())
counts = [d.grouser_count for d in designs]
# Linear grid is [0, 6, 12, 18, 24]; all integers already.
assert counts == [0, 6, 12, 18, 24]
def test_pick_backend_auto_uses_evaluator_below_threshold() -> None:
spec = SweepSpec(
target="range_km",
x_axis=SweepAxis("wheel_radius_m", 0.08, 0.18, EVALUATOR_AUTO_THRESHOLD),
y_axis=None,
)
assert pick_backend(spec) == "evaluator"
def test_pick_backend_auto_promotes_to_surrogate_above_threshold() -> None:
n = EVALUATOR_AUTO_THRESHOLD + 1
spec = SweepSpec(
target="range_km",
x_axis=SweepAxis("wheel_radius_m", 0.08, 0.18, n),
y_axis=None,
)
assert pick_backend(spec) == "surrogate"
def test_pick_backend_explicit_evaluator_hard_limit() -> None:
n = EVALUATOR_HARD_LIMIT + 1
spec = SweepSpec(
target="range_km",
x_axis=SweepAxis("wheel_radius_m", 0.08, 0.18, n),
y_axis=None,
backend="evaluator",
)
with pytest.raises(ValueError, match="evaluator hard limit"):
pick_backend(spec)
# ---------------------------------------------------------------------------
# compute_sensitivity
#
# These tests build SweepResult objects directly with synthetic z grids so
# we can pin down the spread numerics without running the evaluator.
# ---------------------------------------------------------------------------
def _make_result(
z: np.ndarray,
*,
x_n: int,
y_n: int | None,
) -> SweepResult:
"""Wrap a precomputed ``z`` array in a SweepResult for sensitivity tests."""
x_axis = SweepAxis("wheel_radius_m", 0.08, 0.18, x_n)
y_axis = (
None if y_n is None else SweepAxis("solar_area_m2", 0.4, 0.8, y_n)
)
spec = SweepSpec(target="range_km", x_axis=x_axis, y_axis=y_axis)
return SweepResult(
spec=spec,
x_values=x_axis.values(),
y_values=None if y_axis is None else y_axis.values(),
z_values=z,
backend_used="evaluator",
elapsed_s=0.0,
)
def test_compute_sensitivity_1d_total_spread_and_relative() -> None:
z = np.array([10.0, 12.0, 15.0, 18.0, 20.0])
sens = compute_sensitivity(_make_result(z, x_n=5, y_n=None))
assert sens.total_spread == pytest.approx(10.0)
assert sens.relative_spread == pytest.approx(10.0 / 20.0)
assert sens.axis_spread_x == pytest.approx(10.0)
assert sens.axis_spread_y is None
def test_compute_sensitivity_constant_grid_returns_zero_relative_spread() -> None:
# All-NaN guard sits on top, but a flat finite grid is the more
# interesting "metric saturated" branch that drives the UI hint.
z = np.full((4, 5), 3.7)
sens = compute_sensitivity(_make_result(z, x_n=5, y_n=4))
assert sens.total_spread == pytest.approx(0.0)
assert sens.relative_spread == pytest.approx(0.0)
assert sens.axis_spread_x == pytest.approx(0.0)
assert sens.axis_spread_y == pytest.approx(0.0)
def test_compute_sensitivity_all_nan_grid_zeroed_safely() -> None:
z = np.full((3, 4), np.nan)
sens = compute_sensitivity(_make_result(z, x_n=4, y_n=3))
assert sens.total_spread == 0.0
assert sens.relative_spread == 0.0
assert sens.axis_spread_x == 0.0
assert sens.axis_spread_y == 0.0
def test_compute_sensitivity_2d_x_dominated_grid() -> None:
# Each row varies strongly with column index (x), but rows differ
# only by a small additive shift (weak y dependence). Sensitivity
# along x should be ~10x sensitivity along y.
base_x = np.array([0.0, 5.0, 10.0]) # spread along x = 10
rows = np.stack([base_x, base_x + 1.0]) # spread along y at fixed x = 1
sens = compute_sensitivity(_make_result(rows, x_n=3, y_n=2))
assert sens.axis_spread_x == pytest.approx(10.0)
assert sens.axis_spread_y == pytest.approx(1.0)
# total spread spans both effects: 0 -> 11
assert sens.total_spread == pytest.approx(11.0)
def test_pick_backend_explicit_surrogate_hard_limit() -> None:
# 200 × 201 = 40_200 > SURROGATE_HARD_LIMIT (40_000).
spec = SweepSpec(
target="range_km",
x_axis=SweepAxis("wheel_radius_m", 0.08, 0.18, 200),
y_axis=SweepAxis("solar_area_m2", 0.4, 0.8, 201),
backend="surrogate",
)
assert spec.n_cells() > SURROGATE_HARD_LIMIT
with pytest.raises(ValueError, match="surrogate hard limit"):
pick_backend(spec)