From 366a63d324d6a907905992ea249e0a59e9bb3285 Mon Sep 17 00:00:00 2001 From: pranavvadd Date: Fri, 9 Oct 2026 22:41:25 -0400 Subject: [PATCH] Add conservative/base/optimistic CarConfig scenarios (#55) Generate three CarConfig variants from each parameter's value and uncertainty, with documented conservative directions and tests. Co-Authored-By: Claude Sonnet 5.5 --- sim/scenarios.py | 76 ++++++++++++++++++++++++++++++++++ tests/scenarios_test.py | 91 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 167 insertions(+) create mode 100644 sim/scenarios.py create mode 100644 tests/scenarios_test.py diff --git a/sim/scenarios.py b/sim/scenarios.py new file mode 100644 index 0000000..2c7d9db --- /dev/null +++ b/sim/scenarios.py @@ -0,0 +1,76 @@ +"""Conservative / base / optimistic CarConfig variants from parameter uncertainty. + +Each parameter is shifted by +/- its `uncertainty` in the direction that +worsens (conservative) or improves (optimistic) expected performance. +""" +import json +from dataclasses import fields +from typing import Dict + +from sim.config import CarConfig + +CONSERVATIVE = "conservative" +BASE = "base" +OPTIMISTIC = "optimistic" +SCENARIOS = (CONSERVATIVE, BASE, OPTIMISTIC) + +# +1: conservative = value + uncertainty (bigger is worse for performance) +# -1: conservative = value - uncertainty (bigger is better for performance) +# Optimistic is always the opposite. Parameters not listed (e.g. g) are held fixed. +CONSERVATIVE_DIRECTION: Dict[str, int] = { + "mass": +1, # heavier -> more rolling loss, more energy to accelerate + "C_dA": +1, # more drag + "rho": +1, # denser air -> more drag + "battery_capacity": -1, # less usable energy + "solar_panel_area": -1, # less solar power + "solar_panel_efficiency": -1, # less solar power + "electrical_efficiency": -1, # more losses (see note below) + "tire_pressure": -1, # lower pressure -> higher Crr + "regen_efficiency": -1, # less energy recovered +} +# NOTE: physics.py currently multiplies drain by electrical_efficiency, so a lower +# value reduces drain there. The direction above is the physical one; revisit +# if that formula is corrected to divide. + +FRACTION_PARAMS = {"solar_panel_efficiency", "electrical_efficiency", "regen_efficiency"} + + +def _shift(name: str, value: float, uncertainty: float, sign: int) -> float: + shifted = value + sign * CONSERVATIVE_DIRECTION[name] * uncertainty + if name in FRACTION_PARAMS: + shifted = min(max(shifted, 0.0), 1.0) + return shifted + + +def make_scenario(params: Dict[str, dict], scenario: str) -> CarConfig: + """Build one CarConfig from rich-schema params ({name: {"value", "uncertainty", ...}}). + + Missing parameters or null values fall back to CarConfig defaults; a null + uncertainty is treated as 0. + """ + if scenario not in SCENARIOS: + raise ValueError(f"unknown scenario {scenario!r}; expected one of {SCENARIOS}") + sign = {CONSERVATIVE: +1, BASE: 0, OPTIMISTIC: -1}[scenario] + + kwargs = {} + for f in fields(CarConfig): + entry = params.get(f.name) + if not entry or entry.get("value") is None: + continue + value = entry["value"] + uncertainty = entry.get("uncertainty") or 0.0 + if f.name in CONSERVATIVE_DIRECTION and sign != 0: + value = _shift(f.name, value, uncertainty, sign) + kwargs[f.name] = value + return CarConfig(**kwargs) + + +def make_scenarios(params: Dict[str, dict]) -> Dict[str, CarConfig]: + """Return {"conservative": ..., "base": ..., "optimistic": ...}.""" + return {s: make_scenario(params, s) for s in SCENARIOS} + + +def load_scenarios(filepath: str) -> Dict[str, CarConfig]: + """Load a rich-schema parameter JSON file and build all three scenarios.""" + with open(filepath, "r") as f: + return make_scenarios(json.load(f)) diff --git a/tests/scenarios_test.py b/tests/scenarios_test.py new file mode 100644 index 0000000..0eab772 --- /dev/null +++ b/tests/scenarios_test.py @@ -0,0 +1,91 @@ +import unittest + +from sim.config import CarConfig +from sim.scenarios import CONSERVATIVE_DIRECTION, make_scenario, make_scenarios + + +def _params(): + return { + "mass": {"value": 300, "uncertainty": 10}, + "battery_capacity": {"value": 5000, "uncertainty": 200}, + "solar_panel_area": {"value": 4, "uncertainty": 0.2}, + "solar_panel_efficiency": {"value": 0.23, "uncertainty": 0.01}, + "electrical_efficiency": {"value": 0.99, "uncertainty": 0.02}, + "C_dA": {"value": 0.7, "uncertainty": 0.05}, + "tire_pressure": {"value": 5, "uncertainty": 0.5}, + "regen_efficiency": {"value": 0.5, "uncertainty": 0.1}, + "rho": {"value": 1.2, "uncertainty": 0.05}, + "g": {"value": 9.80665, "uncertainty": None}, + } + + +class TestScenarios(unittest.TestCase): + + def test_returns_three_car_configs(self): + s = make_scenarios(_params()) + self.assertEqual(set(s), {"conservative", "base", "optimistic"}) + for cfg in s.values(): + self.assertIsInstance(cfg, CarConfig) + + def test_base_uses_nominal_values(self): + base = make_scenario(_params(), "base") + self.assertEqual(base.mass, 300) + self.assertEqual(base.battery_capacity, 5000) + + def test_mass_direction(self): + s = make_scenarios(_params()) + self.assertEqual(s["conservative"].mass, 310) + self.assertEqual(s["optimistic"].mass, 290) + + def test_battery_capacity_direction(self): + s = make_scenarios(_params()) + self.assertEqual(s["conservative"].battery_capacity, 4800) + self.assertEqual(s["optimistic"].battery_capacity, 5200) + + def test_every_directional_param_ordered(self): + s = make_scenarios(_params()) + for name, direction in CONSERVATIVE_DIRECTION.items(): + cons = getattr(s["conservative"], name) + base = getattr(s["base"], name) + opt = getattr(s["optimistic"], name) + if direction > 0: + self.assertGreater(cons, base, name) + self.assertLess(opt, base, name) + else: + self.assertLess(cons, base, name) + self.assertGreater(opt, base, name) + + def test_g_is_not_shifted(self): + s = make_scenarios(_params()) + self.assertEqual(s["conservative"].g, s["optimistic"].g) + + def test_null_uncertainty_treated_as_zero(self): + p = _params() + p["mass"]["uncertainty"] = None + s = make_scenarios(p) + self.assertEqual(s["conservative"].mass, 300) + self.assertEqual(s["optimistic"].mass, 300) + + def test_missing_or_null_value_uses_default(self): + p = _params() + del p["mass"] + p["rho"]["value"] = None + cfg = make_scenario(p, "conservative") + self.assertEqual(cfg.mass, CarConfig().mass) + self.assertEqual(cfg.rho, CarConfig().rho) + + def test_fractions_clamped(self): + p = _params() + p["electrical_efficiency"] = {"value": 0.99, "uncertainty": 0.5} + s = make_scenarios(p) + self.assertEqual(s["optimistic"].electrical_efficiency, 1.0) + p["regen_efficiency"] = {"value": 0.1, "uncertainty": 0.5} + self.assertEqual(make_scenario(p, "conservative").regen_efficiency, 0.0) + + def test_unknown_scenario_raises(self): + with self.assertRaises(ValueError): + make_scenario(_params(), "wild") + + +if __name__ == "__main__": + unittest.main()