From a0049bbf2a9377e3bd609660baec8412538d8e57 Mon Sep 17 00:00:00 2001 From: pranavvadd Date: Sun, 18 Oct 2026 00:42:37 -0400 Subject: [PATCH 1/2] Make simulation time step configurable end to end (#8) Weather data was always generated at 1-minute spacing, so any time_step_minutes other than 1 produced a wrong time axis and wrong distance/SoC. Resample weather onto the configured step (averaging for coarser steps, interpolating for finer) and expose --time-step-minutes on the CLI for single runs, --plan and --speed-sweep. Co-Authored-By: Claude Sonnet 5.5 --- README.md | 8 + sim/main.py | 22 ++- sim/weather.py | 37 +++- tests/test_time_step.py | 362 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 424 insertions(+), 5 deletions(-) create mode 100644 tests/test_time_step.py diff --git a/README.md b/README.md index 6ee5021..7090761 100644 --- a/README.md +++ b/README.md @@ -87,6 +87,14 @@ python3 sim/main.py --strategy stepped --aggressiveness 1.0 python3 sim/main.py --strategy fixed ``` +Change the simulation time step (default 1 minute). Weather data is resampled to +match, so results stay consistent across steps while larger steps run faster: + +```bash +python3 sim/main.py --time-step-minutes 5 +python3 sim/main.py --plan --time-step-minutes 2 +``` + ## Core Concepts ### Race Simulation diff --git a/sim/main.py b/sim/main.py index d754564..7042d29 100644 --- a/sim/main.py +++ b/sim/main.py @@ -25,6 +25,16 @@ STRATEGY_OPTIONS = ["pi", "stepped", "interval-hold", "fixed"] +def positive_float(value: str) -> float: + try: + number = float(value) + except ValueError: + raise argparse.ArgumentTypeError(f"invalid number: {value!r}") + if not number > 0 or number == float("inf"): + raise argparse.ArgumentTypeError("must be a finite number greater than 0") + return number + + def parse_args(): parser = argparse.ArgumentParser(description="Modular laps race simulator") try: @@ -62,6 +72,12 @@ def parse_args(): action="store_true", help="Run pre-race planning study with fixed-speed and strategy comparisons", ) + parser.add_argument( + "--time-step-minutes", + type=positive_float, + default=1.0, + help="Simulation time step in minutes (default: 1.0)", + ) return parser.parse_args() @@ -99,7 +115,7 @@ def fmt_elapsed(minute_of_day: float, start_time_hour: float) -> str: start_soc=1.0, target_soc=0.10, aggressiveness=DEFAULT_AGGRESSIVENESS["fixed"], - time_step_minutes=1.0, + time_step_minutes=args.time_step_minutes, strategy="fixed", ) rows = run_fixed_speed_sweep( @@ -194,7 +210,7 @@ def main(): aggressiveness=DEFAULT_AGGRESSIVENESS["interval-hold"], energy_safety_scale=1.0, initial_speed_mps=20.0, - time_step_minutes=1.0, + time_step_minutes=args.time_step_minutes, strategy="interval-hold", ) fixed_speed_rows = run_fixed_speed_sweep( @@ -255,7 +271,7 @@ def main(): aggressiveness=aggressiveness, energy_safety_scale=energy_safety_scale, initial_speed_mps=20.0, - time_step_minutes=1.0, + time_step_minutes=args.time_step_minutes, strategy=selected_strategy, ) diff --git a/sim/weather.py b/sim/weather.py index c1732cd..ba8db5c 100644 --- a/sim/weather.py +++ b/sim/weather.py @@ -6,6 +6,35 @@ import requests_cache +def resample_to_time_step(time_minutes, ghi_data, cloud_data, step_minutes: float): + """Resample 1-minute weather arrays onto the simulator's time step. + + The simulator advances one array element per step, so the weather arrays must + be spaced `step_minutes` apart. Steps of 1 minute return the inputs unchanged. + Coarser steps average the minutes inside each step (preserving solar energy); + finer steps linearly interpolate. + """ + if step_minutes == 1.0: + return time_minutes, ghi_data, cloud_data + + start, end = time_minutes[0], time_minutes[-1] + 1.0 + new_times = np.arange(start, end, step_minutes) + + if step_minutes > 1.0: + bins = np.minimum(np.floor((time_minutes - start) / step_minutes).astype(int), len(new_times) - 1) + counts = np.bincount(bins, minlength=len(new_times)) + ghi = np.bincount(bins, weights=ghi_data, minlength=len(new_times)) + cloud = np.bincount(bins, weights=cloud_data, minlength=len(new_times)) + # A non-integer step can leave an empty bin past the last sample; drop it. + keep = counts > 0 + new_times, ghi, cloud = new_times[keep], ghi[keep] / counts[keep], cloud[keep] / counts[keep] + else: + ghi = np.interp(new_times, time_minutes, ghi_data) + cloud = np.interp(new_times, time_minutes, cloud_data) + + return new_times, ghi, cloud + + class WeatherService: def __init__(self, track): self.track = track @@ -86,7 +115,9 @@ def fetch_weather_data(self, race_config) -> Tuple[np.ndarray, np.ndarray, np.nd ghi_data = np.clip(minute_df["ghi"].to_numpy(), 0, None) cloud_data = minute_df["cloud_cover"].to_numpy() / 100.0 - return time_minutes, ghi_data, cloud_data + return resample_to_time_step( + time_minutes, ghi_data, cloud_data, race_config.time_step_minutes + ) def generate_synthetic_data(self, race_config) -> Tuple[np.ndarray, np.ndarray, np.ndarray]: start_min = int(race_config.start_time_hour * 60) @@ -105,4 +136,6 @@ def generate_synthetic_data(self, race_config) -> Tuple[np.ndarray, np.ndarray, cloud_data = np.clip(0.3 + 0.3 * np.random.randn(len(time_minutes)), 0, 1) cloud_data = np.convolve(cloud_data, np.ones(30) / 30, mode="same") - return time_minutes, ghi_data, cloud_data + return resample_to_time_step( + time_minutes, ghi_data, cloud_data, race_config.time_step_minutes + ) diff --git a/tests/test_time_step.py b/tests/test_time_step.py new file mode 100644 index 0000000..fe08eea --- /dev/null +++ b/tests/test_time_step.py @@ -0,0 +1,362 @@ +"""Tests for the configurable simulation time step (issue #8).""" +import sys +from datetime import datetime +from types import SimpleNamespace + +import numpy as np +import pandas as pd +import pytest + +from sim import main as cli +from sim.config import CarConfig, RaceConfig, get_available_tracks +from sim.controllers import IntervalHoldStrategy +from sim.simulator import LapsRaceSimulator +from sim.weather import WeatherService, resample_to_time_step + +STRATEGIES = ["fixed", "pi", "stepped", "interval-hold"] +WINDOW_MINUTES = 480 # default race window: 10:00-18:00 + + +@pytest.fixture(scope="module") +def track(): + return get_available_tracks()[0] + + +def one_minute_weather(n=60): + t = np.arange(600, 600 + n, dtype=float) + ghi = np.arange(n, dtype=float) + cloud = np.linspace(0.0, 1.0, n) + return t, ghi, cloud + + +def run_sim(track, step, strategy="fixed", car=None, seed=1, **race_kwargs): + np.random.seed(seed) + race = RaceConfig(strategy=strategy, time_step_minutes=step, **race_kwargs) + sim = LapsRaceSimulator(track, car or CarConfig(), race, use_api_weather=False) + return sim.run() + + +# A battery big enough that the car never hits min SoC, so the whole window runs. +BIG_BATTERY = CarConfig(battery_capacity=1_000_000) + + +class TestResample: + def test_one_minute_step_is_unchanged(self): + t, g, c = one_minute_weather() + t2, g2, c2 = resample_to_time_step(t, g, c, 1.0) + np.testing.assert_array_equal(t, t2) + np.testing.assert_array_equal(g, g2) + np.testing.assert_array_equal(c, c2) + + def test_coarse_step_averages_each_block(self): + t, g, c = one_minute_weather(10) # ghi 0..9 + t2, g2, _ = resample_to_time_step(t, g, c, 5.0) + np.testing.assert_array_equal(t2, [600.0, 605.0]) + np.testing.assert_allclose(g2, [2.0, 7.0]) # mean(0..4), mean(5..9) + + @pytest.mark.parametrize("step", [2, 3, 5, 10, 15, 60]) + def test_coarse_step_preserves_mean_when_divisible(self, step): + t, g, c = one_minute_weather(60) + _, g2, c2 = resample_to_time_step(t, g, c, float(step)) + assert g2.mean() == pytest.approx(g.mean()) + assert c2.mean() == pytest.approx(c.mean()) + + @pytest.mark.parametrize("step", [2.0, 5.0, 7.0, 10.0, 0.5, 0.25]) + def test_spacing_equals_step(self, step): + t, g, c = one_minute_weather(60) + t2, _, _ = resample_to_time_step(t, g, c, step) + np.testing.assert_allclose(np.diff(t2), step) + + @pytest.mark.parametrize("step", [2.0, 5.0, 7.0, 0.5]) + def test_starts_at_first_timestamp(self, step): + t, g, c = one_minute_weather() + t2, _, _ = resample_to_time_step(t, g, c, step) + assert t2[0] == t[0] + + def test_non_divisible_step_keeps_partial_last_block(self): + t, g, c = one_minute_weather(10) + t2, g2, _ = resample_to_time_step(t, g, c, 7.0) + np.testing.assert_array_equal(t2, [600.0, 607.0]) + np.testing.assert_allclose(g2, [3.0, 8.0]) # mean(0..6), mean(7..9) + + def test_fractional_step_never_produces_nan(self): + # step 5.5 over 6 samples would leave an empty trailing bin + t, g, c = one_minute_weather(6) + t2, g2, c2 = resample_to_time_step(t, g, c, 5.5) + assert not np.isnan(g2).any() and not np.isnan(c2).any() + assert len(t2) == len(g2) == len(c2) + + @pytest.mark.parametrize("n", [1, 2, 7, 59, 480]) + @pytest.mark.parametrize("step", [1.5, 2.0, 3.3, 5.0, 9.0, 480.0, 1000.0]) + def test_output_is_finite_and_aligned(self, n, step): + t, g, c = one_minute_weather(n) + t2, g2, c2 = resample_to_time_step(t, g, c, step) + assert len(t2) == len(g2) == len(c2) >= 1 + assert np.isfinite(g2).all() and np.isfinite(c2).all() + + def test_fine_step_interpolates_linearly(self): + t, g, c = one_minute_weather(10) + t2, g2, _ = resample_to_time_step(t, g, c, 0.5) + np.testing.assert_allclose(g2[:5], [0.0, 0.5, 1.0, 1.5, 2.0]) + assert len(t2) == 20 + + def test_constant_weather_stays_constant(self): + t = np.arange(600, 660, dtype=float) + g = np.full(60, 750.0) + c = np.full(60, 0.25) + for step in (0.5, 2.0, 7.0): + _, g2, c2 = resample_to_time_step(t, g, c, step) + np.testing.assert_allclose(g2, 750.0) + np.testing.assert_allclose(c2, 0.25) + + +class TestSyntheticWeatherUsesStep: + @pytest.mark.parametrize("step", [0.5, 1.0, 2.0, 5.0, 10.0, 15.0]) + def test_shape_and_spacing(self, track, step): + np.random.seed(0) + race = RaceConfig(time_step_minutes=step) + t, ghi, cloud = WeatherService(track).generate_synthetic_data(race) + assert len(t) == len(ghi) == len(cloud) + np.testing.assert_allclose(np.diff(t), step) + assert len(t) == pytest.approx(WINDOW_MINUTES / step, abs=1) + assert t[0] == 600 + + def test_default_matches_previous_one_minute_behavior(self, track): + np.random.seed(0) + t, _, _ = WeatherService(track).generate_synthetic_data(RaceConfig()) + np.testing.assert_array_equal(t, np.arange(600, 1080)) + + def test_values_stay_physical(self, track): + for step in (1.0, 5.0, 15.0): + np.random.seed(3) + _, ghi, cloud = WeatherService(track).generate_synthetic_data( + RaceConfig(time_step_minutes=step) + ) + assert (ghi >= 0).all() + assert (cloud >= 0).all() and (cloud <= 1).all() + + +class TestApiWeatherUsesStep: + """Exercise fetch_weather_data with a fake Open-Meteo client.""" + + @staticmethod + def fake_client(track): + today = pd.Timestamp.now(tz=track.timezone).normalize() + start = today.tz_convert("UTC") + hours = 24 * 16 + ghi = np.array([max(0.0, 800 - 40 * abs((h % 24) - 13) ** 1.5) for h in range(hours)], dtype=np.float32) + cloud = np.full(hours, 50.0, dtype=np.float32) + + def variable(values): + return SimpleNamespace(ValuesAsNumpy=lambda: values) + + hourly = SimpleNamespace( + Time=lambda: int(start.timestamp()), + TimeEnd=lambda: int((start + pd.Timedelta(hours=hours)).timestamp()), + Interval=lambda: 3600, + Variables=lambda i: variable([ghi, cloud][i]), + ) + response = SimpleNamespace(Hourly=lambda: hourly) + return SimpleNamespace(weather_api=lambda url, params=None: [response]) + + @pytest.mark.parametrize("step", [1.0, 5.0, 15.0]) + def test_resampled_to_step(self, track, step): + service = WeatherService(track) + service._client = self.fake_client(track) + t, ghi, cloud = service.fetch_weather_data(RaceConfig(time_step_minutes=step)) + np.testing.assert_allclose(np.diff(t), step) + assert len(t) == len(ghi) == len(cloud) == pytest.approx(WINDOW_MINUTES / step, abs=1) + np.testing.assert_allclose(cloud, 0.5) + + def test_energy_is_consistent_across_steps(self, track): + service = WeatherService(track) + service._client = self.fake_client(track) + totals = [] + for step in (1.0, 5.0, 10.0): + t, ghi, _ = service.fetch_weather_data(RaceConfig(time_step_minutes=step)) + totals.append(ghi.sum() * step) # W/m^2 * minutes + assert totals[1] == pytest.approx(totals[0], rel=1e-6) + assert totals[2] == pytest.approx(totals[0], rel=1e-6) + + +class TestRaceConfigValidation: + @pytest.mark.parametrize("bad", [0, -1, -0.5]) + def test_non_positive_rejected(self, bad): + with pytest.raises(ValueError, match="Time step must be greater than 0."): + RaceConfig(time_step_minutes=bad) + + @pytest.mark.parametrize("ok", [0.1, 1, 2.5, 60]) + def test_positive_accepted(self, ok): + assert RaceConfig(time_step_minutes=ok).time_step_minutes == ok + + +class TestSimulatorIsStepIndependent: + """The core bug: changing the step must not change the race being simulated.""" + + STEPS = [0.5, 1.0, 2.0, 5.0, 10.0] + + @pytest.mark.parametrize("step", STEPS) + def test_time_axis_spans_the_window(self, track, step): + r = run_sim(track, step, car=BIG_BATTERY) + span = r.time_minutes[-1] - r.time_minutes[0] + assert span == pytest.approx(WINDOW_MINUTES - step, abs=step) + assert r.completed_full_window + + @pytest.mark.parametrize("step", STEPS) + def test_number_of_steps_scales_inversely(self, track, step): + r = run_sim(track, step, car=BIG_BATTERY) + assert len(r.time_minutes) == pytest.approx(WINDOW_MINUTES / step, abs=1) + + @pytest.mark.parametrize("step", STEPS) + def test_time_axis_spacing_matches_step(self, track, step): + r = run_sim(track, step, car=BIG_BATTERY) + np.testing.assert_allclose(np.diff(r.time_minutes), step) + + @pytest.mark.parametrize("step", [0.5, 2.0, 5.0, 10.0]) + def test_distance_matches_one_minute_baseline(self, track, step): + base = run_sim(track, 1.0, car=BIG_BATTERY) + other = run_sim(track, step, car=BIG_BATTERY) + assert other.total_distance_m == pytest.approx(base.total_distance_m, rel=0.02) + + @pytest.mark.parametrize("step", [0.5, 2.0, 5.0, 10.0]) + def test_distance_matches_baseline_when_battery_limited(self, track, step): + base = run_sim(track, 1.0) # default battery runs out mid-race + other = run_sim(track, step) + assert other.total_distance_m == pytest.approx(base.total_distance_m, rel=0.03) + assert other.final_soc == pytest.approx(base.final_soc, abs=0.01) + + @pytest.mark.parametrize("step", [2.0, 5.0, 10.0]) + def test_time_of_reaching_min_soc_matches_baseline(self, track, step): + base = run_sim(track, 1.0) + other = run_sim(track, step) + assert base.reached_min_soc_time_minutes is not None + assert other.reached_min_soc_time_minutes == pytest.approx( + base.reached_min_soc_time_minutes, abs=2 * step + ) + + def test_default_step_results_unchanged_from_one_minute(self, track): + np.random.seed(1) + implicit = LapsRaceSimulator( + track, CarConfig(), RaceConfig(strategy="fixed"), use_api_weather=False + ).run() + explicit = run_sim(track, 1.0) + np.testing.assert_array_equal(implicit.soc, explicit.soc) + np.testing.assert_array_equal(implicit.time_minutes, explicit.time_minutes) + + +@pytest.mark.parametrize("strategy", STRATEGIES) +@pytest.mark.parametrize("step", [1.0, 2.0, 5.0, 10.0]) +class TestEveryStrategyAtEveryStep: + def test_runs_with_sane_outputs(self, track, strategy, step): + r = run_sim(track, step, strategy) + for arr in (r.soc, r.speed, r.time_minutes, r.ghi, r.bdr): + assert np.isfinite(arr).all() + assert (r.soc >= 0).all() and (r.soc <= 1).all() + assert (r.speed > 0).all() + assert r.total_distance_m > 0 + assert len(r.soc) == len(r.time_minutes) == len(r.speed) + + def test_distance_close_to_one_minute_run(self, track, strategy, step): + base = run_sim(track, 1.0, strategy) + other = run_sim(track, step, strategy) + # Controllers react per step, so allow a looser band than the fixed strategy. + assert other.total_distance_m == pytest.approx(base.total_distance_m, rel=0.10) + + +class TestIntervalHoldAdaptsToStep: + @pytest.mark.parametrize( + "step, expected_steps", [(1.0, 5), (0.5, 10), (2.0, 2), (5.0, 1), (10.0, 1)] + ) + def test_interval_in_steps(self, step, expected_steps): + strat = IntervalHoldStrategy(RaceConfig(time_step_minutes=step)) + assert strat.interval_steps == expected_steps + + +class TestCliTimeStep: + LOCATION = "shenandoah_speedway" + + @staticmethod + def parse(monkeypatch, *argv): + monkeypatch.setattr(sys, "argv", ["main.py", *argv]) + return cli.parse_args() + + def test_default_is_one_minute(self, monkeypatch): + assert self.parse(monkeypatch).time_step_minutes == 1.0 + + @pytest.mark.parametrize("raw, value", [("5", 5.0), ("2.5", 2.5), ("0.5", 0.5)]) + def test_flag_parsed(self, monkeypatch, raw, value): + assert self.parse(monkeypatch, "--time-step-minutes", raw).time_step_minutes == value + + @pytest.mark.parametrize("bad", ["0", "-1", "-0.5", "abc", "inf", "nan", ""]) + def test_invalid_values_rejected_with_usage_error(self, monkeypatch, capsys, bad): + with pytest.raises(SystemExit) as exc: + self.parse(monkeypatch, "--time-step-minutes", bad) + assert exc.value.code == 2 + assert "--time-step-minutes" in capsys.readouterr().err + + @pytest.fixture + def captured_races(self, monkeypatch): + races = {"fixed": [], "strategy": [], "single": []} + + def fixed_sweep(**kw): + races["fixed"].append(kw["base_race"].time_step_minutes) + return [] + + def strategy_sweep(**kw): + races["strategy"].append(kw["base_race"].time_step_minutes) + return [] + + class FakeSimulator: + def __init__(self, track, car, race, use_api_weather=True): + races["single"].append(race.time_step_minutes) + + def run(self): + return SimpleNamespace() + + class Noop: + def __init__(self, *a, **k): + pass + + def print_summary(self): + pass + + monkeypatch.setattr(cli, "run_fixed_speed_sweep", fixed_sweep) + monkeypatch.setattr(cli, "run_strategy_sweep", strategy_sweep) + monkeypatch.setattr(cli, "print_planning_report", lambda **kw: None) + monkeypatch.setattr(cli, "LapsRaceSimulator", FakeSimulator) + monkeypatch.setattr(cli, "ResultsReporter", Noop) + monkeypatch.setattr(cli, "SimulationPlotter", Noop) + monkeypatch.setattr( + cli, "save_all_plots", lambda *a, **k: dict(dashboard="", soc="", speed="", weather="") + ) + monkeypatch.setattr("builtins.input", lambda *a: "") + return races + + @pytest.mark.parametrize("extra, expected", [([], 1.0), (["--time-step-minutes", "5"], 5.0)]) + def test_single_run_receives_step(self, monkeypatch, captured_races, extra, expected): + monkeypatch.setattr(sys, "argv", ["main.py", "--location", self.LOCATION, "--strategy", "fixed", *extra]) + cli.main() + assert captured_races["single"] == [expected] + + @pytest.mark.parametrize("extra, expected", [([], 1.0), (["--time-step-minutes", "5"], 5.0)]) + def test_plan_receives_step(self, monkeypatch, captured_races, extra, expected): + monkeypatch.setattr(sys, "argv", ["main.py", "--location", self.LOCATION, "--plan", *extra]) + cli.main() + assert captured_races["fixed"] == [expected] + assert captured_races["strategy"] == [expected] + + @pytest.mark.parametrize("extra, expected", [([], 1.0), (["--time-step-minutes", "5"], 5.0)]) + def test_speed_sweep_receives_step(self, monkeypatch, captured_races, extra, expected): + monkeypatch.setattr(sys, "argv", ["main.py", "--location", self.LOCATION, "--speed-sweep", *extra]) + cli.main() + assert captured_races["fixed"] == [expected] + + def test_end_to_end_offline_run_with_coarse_step(self, monkeypatch, capsys): + monkeypatch.setattr(cli, "SPEED_SWEEP_MPH", [20]) + monkeypatch.setattr( + sys, "argv", + ["main.py", "--location", self.LOCATION, "--speed-sweep", "--time-step-minutes", "5"], + ) + monkeypatch.setattr(WeatherService, "fetch_weather_data", lambda *a: (_ for _ in ()).throw(ConnectionError("offline"))) + cli.main() + assert "1 speeds were tested" in capsys.readouterr().out From 553a1986c6ae41080f5ef5bf988ff9e6ccb78fce Mon Sep 17 00:00:00 2001 From: pranavvadd Date: Sat, 24 Oct 2026 04:46:56 -0400 Subject: [PATCH 2/2] Shorten the final step when the time step doesn't divide the race window With e.g. a 7-minute step over a 480-minute race, the last step was simulated as a full 7 minutes, so the race ran 3 minutes past its end and overestimated distance and energy use. Use the real remaining time for the final step. Adds tests for non-divisible steps (end time, distance, energy, all strategies). Co-Authored-By: Claude Sonnet 5.5 --- sim/simulator.py | 21 +++++++++++----- tests/test_time_step.py | 56 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+), 6 deletions(-) diff --git a/sim/simulator.py b/sim/simulator.py index d3faa53..d7bffc8 100644 --- a/sim/simulator.py +++ b/sim/simulator.py @@ -34,6 +34,10 @@ def run(self) -> SimulationResults: print(f" Optimal constant speed baseline: {self.strategy.optimal_speed:.2f} m/s ({self.strategy.optimal_speed*2.237:.1f} mph)") n_steps = len(time_minutes) + # A step that doesn't divide the window leaves a shorter final step. + window_end_minutes = getattr(self.race, "end_time_hour", None) + if window_end_minutes is not None: + window_end_minutes *= 60.0 soc = self.race.start_soc speed = self.strategy.optimal_speed total_distance = 0.0 @@ -54,6 +58,11 @@ def run(self) -> SimulationResults: lap_start_time = time_minutes[0] for i in range(n_steps): + step_minutes = dt_minutes + if window_end_minutes is not None: + step_minutes = min(dt_minutes, window_end_minutes - time_minutes[i]) + step_hours = step_minutes / 60.0 + ghi = max(ghi_data[i], 0) cloud = cloud_data[i] @@ -62,7 +71,7 @@ def run(self) -> SimulationResults: bdr = self.physics.battery_drain_rate(speed, ghi) prev_soc = soc - energy_delta = (power_in - power_out) * dt_hours + energy_delta = (power_in - power_out) * step_hours next_soc = prev_soc + energy_delta / self.car.battery_capacity step_fraction = 1.0 @@ -73,13 +82,13 @@ def run(self) -> SimulationResults: step_fraction = float(np.clip((prev_soc - self.race.min_soc) / denom, 0.0, 1.0)) soc = self.race.min_soc completed_full_window = False - reached_min_soc_time_minutes = time_minutes[i] + dt_minutes * step_fraction + reached_min_soc_time_minutes = time_minutes[i] + step_minutes * step_fraction else: soc = float(np.clip(next_soc, self.race.min_soc, 1.0)) - distance_step = speed * (dt_minutes * 60) * step_fraction + distance_step = speed * (step_minutes * 60) * step_fraction step_start = time_minutes[i] - step_duration = dt_minutes * step_fraction + step_duration = step_minutes * step_fraction carry = current_lap_distance # distance already covered on the current lap distance_into_step = 0.0 total_distance += distance_step @@ -98,14 +107,14 @@ def run(self) -> SimulationResults: soc_error = soc - ideal_soc[i] prev_speed = speed if not hit_min_soc: - speed = self.strategy.next_speed(i, speed, soc, soc_error, bdr, self.physics, ghi_data, dt_hours) + speed = self.strategy.next_speed(i, speed, soc, soc_error, bdr, self.physics, ghi_data, step_hours) regen_wh = self.physics.regen_energy(prev_speed, speed) if regen_wh > 0: soc += regen_wh / self.car.battery_capacity soc = min(soc, 1.0) - time_arr.append(time_minutes[i] + dt_minutes * step_fraction) + time_arr.append(time_minutes[i] + step_minutes * step_fraction) soc_arr.append(soc) speed_arr.append(prev_speed) bdr_arr.append(bdr) diff --git a/tests/test_time_step.py b/tests/test_time_step.py index fe08eea..407c93f 100644 --- a/tests/test_time_step.py +++ b/tests/test_time_step.py @@ -360,3 +360,59 @@ def test_end_to_end_offline_run_with_coarse_step(self, monkeypatch, capsys): monkeypatch.setattr(WeatherService, "fetch_weather_data", lambda *a: (_ for _ in ()).throw(ConnectionError("offline"))) cli.main() assert "1 speeds were tested" in capsys.readouterr().out + + +class TestNonDivisibleStepsEndAtWindowEnd: + """Review feedback on #8: a step that doesn't divide the window must not overrun it.""" + + END_MINUTE = 18 * 60 + NON_DIVISIBLE = [7.0, 11.0, 13.0, 0.7, 2.5, 100.0, 479.0] + ALL_STEPS = [1.0, 2.0, 5.0, 10.0] + NON_DIVISIBLE + + @pytest.mark.parametrize("step", ALL_STEPS) + def test_race_ends_exactly_at_window_end(self, track, step): + r = run_sim(track, step, car=BIG_BATTERY) + assert r.completed_full_window + assert r.time_minutes[-1] == pytest.approx(self.END_MINUTE) + + @pytest.mark.parametrize("step", ALL_STEPS) + def test_fixed_speed_distance_is_speed_times_window(self, track, step): + race_kwargs = dict(fixed_speed_mps=15.0) + r = run_sim(track, step, car=BIG_BATTERY, **race_kwargs) + assert r.total_distance_m == pytest.approx(15.0 * WINDOW_MINUTES * 60) + + @pytest.mark.parametrize("step", ALL_STEPS) + def test_energy_used_is_independent_of_step(self, track, step): + # No solar and constant speed: SoC drop is exactly power * duration / capacity. + car = CarConfig(battery_capacity=50_000, solar_panel_area=0) + r = run_sim(track, step, car=car, fixed_speed_mps=15.0) + from sim.physics import PhysicsEngine + power_w = PhysicsEngine(car).power_drained(15.0) + expected_soc = 1.0 - power_w * (WINDOW_MINUTES / 60.0) / car.battery_capacity + assert r.completed_full_window + assert r.final_soc == pytest.approx(expected_soc, abs=1e-9) + + @pytest.mark.parametrize("strategy", STRATEGIES) + @pytest.mark.parametrize("step", [7.0, 13.0]) + def test_every_strategy_ends_at_window_end(self, track, strategy, step): + r = run_sim(track, step, strategy, car=BIG_BATTERY) + assert r.time_minutes[-1] == pytest.approx(self.END_MINUTE) + + def test_divisible_steps_are_unaffected(self, track): + for step in (1.0, 2.0, 5.0, 10.0): + r = run_sim(track, step, car=BIG_BATTERY) + np.testing.assert_allclose(np.diff(r.time_minutes), step) + + def test_only_the_last_step_is_shortened(self, track): + r = run_sim(track, 7.0, car=BIG_BATTERY) + diffs = np.diff(r.time_minutes) + np.testing.assert_allclose(diffs[:-1], 7.0) + assert diffs[-1] == pytest.approx(WINDOW_MINUTES - 68 * 7.0) # 4 min + + def test_stopping_early_still_reports_correct_time(self, track): + base = run_sim(track, 1.0) # default battery runs out before the window ends + other = run_sim(track, 7.0) + assert not other.completed_full_window + assert other.reached_min_soc_time_minutes == pytest.approx( + base.reached_min_soc_time_minutes, abs=14.0 + )