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