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
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
13 changes: 9 additions & 4 deletions sim/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()


Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -202,15 +207,15 @@ 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,
car=car,
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,
Expand Down Expand Up @@ -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()

Expand Down
126 changes: 126 additions & 0 deletions tests/test_cli_offline.py
Original file line number Diff line number Diff line change
@@ -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
Loading