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
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```

Run offline (no weather API calls, uses synthetic weather). Works with the
single run, `--plan`, and `--speed-sweep`:

Expand Down
22 changes: 19 additions & 3 deletions sim/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,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:
Expand Down Expand Up @@ -69,6 +79,12 @@ def parse_args():
action="store_true",
help="Use synthetic weather instead of calling the Open-Meteo API (offline mode)",
)
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()


Expand Down Expand Up @@ -106,7 +122,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(
Expand Down Expand Up @@ -205,7 +221,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(
Expand Down Expand Up @@ -272,7 +288,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,
)

Expand Down
21 changes: 15 additions & 6 deletions sim/simulator.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,10 @@ def run(self) -> SimulationResults:
print(f" Optimal constant speed baseline: {self.strategy.optimal_speed:.2f} m/s ({mps_to_mph(self.strategy.optimal_speed):.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
Expand All @@ -55,6 +59,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 = minutes_to_hours(step_minutes)

ghi = max(ghi_data[i], 0)
cloud = cloud_data[i]

Expand All @@ -63,7 +72,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
Expand All @@ -74,13 +83,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 * minutes_to_seconds(dt_minutes) * step_fraction
distance_step = speed * minutes_to_seconds(step_minutes) * 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
Expand All @@ -99,14 +108,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)
Expand Down
37 changes: 35 additions & 2 deletions sim/weather.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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
)
Loading