diff --git a/README.md b/README.md index 6ee5021..60734e6 100644 --- a/README.md +++ b/README.md @@ -87,6 +87,15 @@ python3 sim/main.py --strategy stepped --aggressiveness 1.0 python3 sim/main.py --strategy fixed ``` +Run offline (no weather API calls, uses synthetic weather). Works with the +single run, `--plan`, and `--speed-sweep`: + +```bash +python3 sim/main.py --no-api-weather +python3 sim/main.py --plan --no-api-weather +python3 sim/main.py --speed-sweep --no-api-weather +``` + ## Core Concepts ### Race Simulation @@ -136,6 +145,9 @@ assumptions. The simulator tries to use forecast data from Open-Meteo. If weather data cannot be fetched, it falls back to generated synthetic weather. +Pass `--no-api-weather` to skip the API entirely and always use synthetic +weather. + This fallback is useful for development and offline testing, but real planning should use the best available forecast and should eventually include uncertainty checks. diff --git a/sim/main.py b/sim/main.py index d754564..ee34c21 100644 --- a/sim/main.py +++ b/sim/main.py @@ -62,6 +62,11 @@ def parse_args(): action="store_true", help="Run pre-race planning study with fixed-speed and strategy comparisons", ) + parser.add_argument( + "--no-api-weather", + action="store_true", + help="Use synthetic weather instead of calling the Open-Meteo API (offline mode)", + ) return parser.parse_args() @@ -107,7 +112,7 @@ def fmt_elapsed(minute_of_day: float, start_time_hour: float) -> str: car=car, base_race=base_race, speeds_mph=SPEED_SWEEP_MPH, - use_api_weather=True, + use_api_weather=not args.no_api_weather, ) print_speed_sweep_summary(rows) @@ -202,7 +207,7 @@ def main(): car=car, base_race=base_race, speeds_mph=SPEED_SWEEP_MPH, - use_api_weather=True, + use_api_weather=not args.no_api_weather, ) strategy_rows = run_strategy_sweep( track=track, @@ -210,7 +215,7 @@ def main(): base_race=base_race, strategies=STRATEGY_OPTIONS, aggressiveness_by_strategy=DEFAULT_AGGRESSIVENESS, - use_api_weather=True, + use_api_weather=not args.no_api_weather, ) print_planning_report( track=track, @@ -269,7 +274,7 @@ def main(): track=track, car=car, race=race, - use_api_weather=True, + use_api_weather=not args.no_api_weather, ) results = simulator.run() diff --git a/tests/test_cli_offline.py b/tests/test_cli_offline.py new file mode 100644 index 0000000..a9e13a6 --- /dev/null +++ b/tests/test_cli_offline.py @@ -0,0 +1,126 @@ +"""Tests for the --no-api-weather CLI flag (offline mode).""" +import sys +from types import SimpleNamespace + +import pytest + +from sim import main as cli +from sim.weather import WeatherService + +LOCATION = "shenandoah_speedway" + + +def run_cli(monkeypatch, *argv): + monkeypatch.setattr(sys, "argv", ["main.py", *argv]) + cli.main() + + +@pytest.fixture +def recorded(monkeypatch): + """Replace the heavy sim entry points in sim.main and record use_api_weather.""" + calls = {"fixed": [], "strategy": [], "simulator": []} + + def fake_fixed_sweep(**kwargs): + calls["fixed"].append(kwargs["use_api_weather"]) + return [] + + def fake_strategy_sweep(**kwargs): + calls["strategy"].append(kwargs["use_api_weather"]) + return [] + + class FakeSimulator: + def __init__(self, track, car, race, use_api_weather=True): + calls["simulator"].append(use_api_weather) + + def run(self): + return SimpleNamespace() + + class FakeReporter: + def __init__(self, *args, **kwargs): + pass + + def print_summary(self): + pass + + monkeypatch.setattr(cli, "run_fixed_speed_sweep", fake_fixed_sweep) + monkeypatch.setattr(cli, "run_strategy_sweep", fake_strategy_sweep) + monkeypatch.setattr(cli, "print_planning_report", lambda **kwargs: None) + monkeypatch.setattr(cli, "LapsRaceSimulator", FakeSimulator) + monkeypatch.setattr(cli, "ResultsReporter", FakeReporter) + monkeypatch.setattr(cli, "SimulationPlotter", lambda *a, **k: None) + monkeypatch.setattr( + cli, + "save_all_plots", + lambda *a, **k: {"dashboard": "", "soc": "", "speed": "", "weather": ""}, + ) + monkeypatch.setattr("builtins.input", lambda *a: "") # accept default safety scale + return calls + + +class TestParseArgs: + def test_defaults_to_api_weather(self, monkeypatch): + monkeypatch.setattr(sys, "argv", ["main.py"]) + assert cli.parse_args().no_api_weather is False + + def test_flag_enables_offline_mode(self, monkeypatch): + monkeypatch.setattr(sys, "argv", ["main.py", "--no-api-weather"]) + assert cli.parse_args().no_api_weather is True + + def test_flag_combines_with_other_modes(self, monkeypatch): + monkeypatch.setattr(sys, "argv", ["main.py", "--plan", "--no-api-weather"]) + args = cli.parse_args() + assert args.plan is True and args.no_api_weather is True + + +@pytest.mark.parametrize( + "extra, expected_use_api", + [([], True), (["--no-api-weather"], False)], + ids=["default-uses-api", "flag-goes-offline"], +) +class TestFlagIsPassedThrough: + def test_single_run(self, monkeypatch, recorded, extra, expected_use_api): + run_cli(monkeypatch, "--location", LOCATION, "--strategy", "fixed", *extra) + assert recorded["simulator"] == [expected_use_api] + + def test_plan_mode(self, monkeypatch, recorded, extra, expected_use_api): + run_cli(monkeypatch, "--location", LOCATION, "--plan", *extra) + assert recorded["fixed"] == [expected_use_api] + assert recorded["strategy"] == [expected_use_api] + + def test_speed_sweep_mode(self, monkeypatch, recorded, extra, expected_use_api): + run_cli(monkeypatch, "--location", LOCATION, "--speed-sweep", *extra) + assert recorded["fixed"] == [expected_use_api] + + +class TestRealWeatherPath: + """Run the real simulator (small sweep) and watch the weather API boundary.""" + + @pytest.fixture(autouse=True) + def small_sweep(self, monkeypatch): + monkeypatch.setattr(cli, "SPEED_SWEEP_MPH", [20]) + + def test_offline_mode_never_calls_the_api(self, monkeypatch, capsys): + def fail(self, race): + raise AssertionError("weather API was called in offline mode") + + monkeypatch.setattr(WeatherService, "fetch_weather_data", fail) + run_cli(monkeypatch, "--location", LOCATION, "--speed-sweep", "--no-api-weather") + + out = capsys.readouterr().out + assert "1 speeds were tested" in out + assert "API fetch failed" not in out + + def test_default_tries_api_then_falls_back_to_synthetic(self, monkeypatch, capsys): + attempts = [] + + def unreachable(self, race): + attempts.append(race) + raise ConnectionError("no network") + + monkeypatch.setattr(WeatherService, "fetch_weather_data", unreachable) + run_cli(monkeypatch, "--location", LOCATION, "--speed-sweep") + + out = capsys.readouterr().out + assert attempts, "default mode should attempt the API first" + assert "API fetch failed" in out + assert "1 speeds were tested" in out