Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 76 additions & 0 deletions sim/scenarios.py
Original file line number Diff line number Diff line change
@@ -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))
91 changes: 91 additions & 0 deletions tests/scenarios_test.py
Original file line number Diff line number Diff line change
@@ -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()
Loading