Should I stay or should I go?#

When you plan a bike ride, you probably check the weather forecast.

While it is fairly easy to assess temperature and rain risk, interpreting the wind is often more difficult.

This notebook aims to determine the best wind conditions over the coming days for a favorite bike ride (and, above all, to avoid the worst conditions).

[20]:
# Language is forced to English for consistent output in notebooks and tests, regardless of the user's locale.
import os
os.environ["OUTPUT_LANG"] = "en"
print("OUTPUT_LANG forced to en")
# home is expanded to the user's home directory for consistent file path handling across different environments.
home = os.path.expanduser("~")
OUTPUT_LANG forced to en
[21]:
from datetime import timedelta, datetime
from zoneinfo import ZoneInfo
import sys
import os
import logging

from biwipy.core import Simulator
from biwipy.core.cyclist_params import CyclistBehavior

from biwipy.weather import WeatherProvider
from biwipy.weather.grib_finder import build_grib_list

from biwipy.analysis import RouteAnalyzer


from biwipy.analysis.anareswind import (
    print_summary_statistics
)

1. Building the wind model#

1.1. Selecting the GRIB files for the wind#

First, we focus on wind data.

The idea is to select the best days from all possible departure times.

Weather forecasts are available for 384 hours (at 0h, 6h, 12h, and 18h), but forecasts are generally reliable only for the first 5 days.

So we consider departure times for the next 5 days at 10h and 15h. The estimated duration of our ride is ~2h (conservative estimate with headwind).

We build the list of all GRIB files required to simulate the different rides. The function build_grib_list:

  • automatically adds margins to include all files required by temporal interpolation for each departure time,

  • downloads the required GRIB files if needed (about 30 files; this may take a few minutes),

  • returns the list of files after download.

[22]:
# Use local timezone for departure times (will be converted to UTC by GRIB functions)
local_tz = ZoneInfo("Europe/Paris")  # Change to your local timezone if needed
current_dateTime = datetime.now(local_tz).replace(hour=0, minute=0, second=0, microsecond=0)

# Lists for departure times and GRIB files
list_gribs_all = []
list_departure_times = []

# Very conservative estimate (headwind scenario)
estimated_ride_duration_h = 3

# Define the directory used to store GRIB files
hdir = home+"/data"  # GRIB files stored in home/data by default

# Fetch GRIB files (can take a few minutes depending on internet connection)
print("Collecting GRIB files", end="")
for i in range(0, 5):  # Next 3 days
    print(".", end="")
    departure_time = current_dateTime + timedelta(days=i, hours=10)  # departure: 10h
    list_departure_times.append(departure_time)
    for eachgrib in build_grib_list(hdir, departure_time, pas=3, intervalle_h=estimated_ride_duration_h):
        list_gribs_all.append(eachgrib)
    departure_time = current_dateTime + timedelta(days=i, hours=15)  # departure: 15h
    list_departure_times.append(departure_time)
    for eachgrib in build_grib_list(hdir, departure_time, pas=3, intervalle_h=estimated_ride_duration_h):
        list_gribs_all.append(eachgrib)

# Note: grib_manager automatically removes duplicates, so no deduplication is needed here
print(f"Total GRIB files collected: {len(list_gribs_all)} ({len(list_gribs_all)-len(set(list_gribs_all))} duplicate files will be handled by grib_manager)")
Collecting GRIB files.....Total GRIB files collected: 30 (5 duplicate files will be handled by grib_manager)

1.2. Initializing the weather provider#

With all required GRIB files for the different departure times, we can initialize the weather provider (the GRIB content is loaded into memory).

[23]:
print("Initializing weather model")
weather = WeatherProvider(list_gribs_all)
mygrib = weather.grib
print("--> Weather model initialization completed.")
Initializing weather model
--> Weather model initialization completed.

2. Building the simulator#

2.1. Defining parameters#

We define the required parameters:

  • CdA: 0.53 for a gravel bike with a relaxed position (hands on top of the bars),

  • Cr: 0.0055 (Tufo Thundero 38 mm tires),

  • Weight: 87 (rider) + 10 (steel gravel bike) + 1 (clothes…),

  • Intermediate behavior profile (realistic).

2.2. Initializing the Simulator and baseline power#

We build the simulator using the previously defined parameters: weather component, rider profile, and cyclist parameters.

To define the power used by the simulation, we choose a baseline flat speed v0 in m/s (convert from km/h by dividing by 3.6). The chosen speed is 25 km/h.

We can then compute the equivalent baseline power P0 in watts. With these parameters, the corresponding power at 25 km/h is about 154.4 W.

[24]:
# ==================================
# Define simulation parameters
CdA = 0.530
Cr = 0.0055
M = 98.0

mon_profil = CyclistBehavior(uphill='realistic', downhill='realistic', corner='realistic')

# Build the simulator
sim_wind = Simulator(mygrib, behavior=mon_profil, CdA=CdA, Cr=Cr, m=M)

# To define power, use a baseline flat speed v0 (reference speed)
# Speed is in m/s (convert from km/h by dividing by 3.6). Chosen speed: 25 km/h
v0 = 25 / 3.6  # m/s

# Compute the power output corresponding to reference speed v0 on flat terrain
P0 = sim_wind.P0_from_v0(v0)

# Show all parameters used for the simulations
print(f"Input parameters for the simulation: v0={v0*3.6:.2f} km/h, P0={P0:.1f} W, CdA={CdA}, Cr={Cr}, m={M}")
mon_profil.display(verbose=False)
# ==================================
Input parameters for the simulation: v0=25.00 km/h, P0=145.4 W, CdA=0.53, Cr=0.0055, m=98.0
Behaviour profile: Uphill=REALISTIC - Downhill=REALISTIC - Corner=REALISTIC

3. Defining the ride with the GPX file#

Then we define the GPX file corresponding to the planned ride.

The GPX file used for this sample is provided in the sample directory.

Replace it with your favorite ride!

The GPX file must be preprocessed before simulation. At the end, we can inspect the distance and elevation gain.

[25]:
# gpxdir points to the directory where GPX files are stored, and filegpx is the GPX file to analyze.
gpxdir = "./"
pathbase = gpxdir + "Favorite"
filegpx = pathbase + ".gpx"
filegpx_base = os.path.basename(filegpx)
if not os.path.exists(filegpx):
    print(f"⚠ GPX file missing for {filegpx}")
    sys.exit(1)

# Load and preprocess GPX
print("Loading and processing GPX file", end="")
analyzer = RouteAnalyzer()
gpx_result = analyzer.process_gpx(filegpx, verbose=False)
segments = gpx_result.segments
stats = gpx_result.stats
print(f"\n--> GPX processing completed - ride distance: {stats['distance_km']:.2f} km - elevation gain: {stats['deniv_pos_final']:.0f} m -")
Loading and processing GPX file
--> GPX processing completed - ride distance: 36.45 km - elevation gain: 475 m -

4. Running the simulation#

Now we simulate the ride for each departure time and store results in a list.

We call simulate_future for every departure time.

We pass preprocessed segments, the departure time, and the previously computed baseline power P0.

[26]:
results_wind = []
# Simulate for each departure time
print("Start of simulations.", end="")

for departure_time in list_departure_times:
    results_wind.append(sim_wind.simulate_future(segments, departure_time, P0=P0))
    print(".", end="")
print(f"\n--> {len(results_wind)} simulations completed.")
Start of simulations...........
--> 10 simulations completed.

5. Analyzing results#

The results_wind list now stores all simulation results.

We first focus on:

  • the wind score (grade from A to F),

  • the safety and performance components of the wind score,

  • wind direction,

  • estimated travel times.

Both lists are sorted by wind score grade (primary key) and estimated time (secondary key).

The results are compiled into a readable table.

[27]:
# sort both lists by grade (primary key) and estimated time (secondary key)
results_wind, list_departure_times = map(
    list,
    zip(
        *sorted(
            zip(results_wind, list_departure_times),
            key=lambda x: (x[0].wind_score.grade, x[0].time.total_seconds),
        )
    ),
)

print(
    f"\nSorted departure list by windscore and estimated time "
    f"({stats['distance_km']:.2f} km, base power {P0:.1f} W):"
)

headers = ("#", "Departure", "Wind", "Safety", "Perf", "TWD", "Estimated time")
rows = []
for i, res in enumerate(results_wind, start=1):
    rows.append(
        (
            f"{i:02d}",
            list_departure_times[i - 1].strftime("%A %d %B %Y - %H:%M"),
            str(res.wind_score.grade),
            str(res.wind_score.safety_grade),
            str(res.wind_score.performance_grade),
            str(res.wind.twd_compass),
            str(timedelta(seconds=int(res.time.total_seconds))),
        )
    )

widths = [max(len(h), *(len(r[idx]) for r in rows)) for idx, h in enumerate(headers)]

def fmt_line(values):
    return " | ".join(
        v.rjust(w) if idx == 0 else v.ljust(w)
        for idx, (v, w) in enumerate(zip(values, widths))
    )

print(fmt_line(headers))
print("-+-".join("-" * w for w in widths))
for row in rows:
    print(fmt_line(row))



Sorted departure list by windscore and estimated time (36.45 km, base power 145.4 W):
 # | Departure                     | Wind | Safety | Perf | TWD | Estimated time
---+-------------------------------+------+--------+------+-----+---------------
01 | Monday 04 May 2026 - 10:00    | B    | A      | B    | WNW | 1:49:43
02 | Thursday 07 May 2026 - 10:00  | B    | A      | B    | ENE | 1:50:37
03 | Tuesday 05 May 2026 - 10:00   | B    | A      | B    | NNW | 1:51:14
04 | Monday 04 May 2026 - 15:00    | C    | A      | C    | E   | 1:51:57
05 | Thursday 07 May 2026 - 15:00  | C    | A      | C    | ENE | 1:52:05
06 | Tuesday 05 May 2026 - 15:00   | D    | A      | D    | WNW | 1:51:50
07 | Wednesday 06 May 2026 - 10:00 | D    | A      | D    | WNW | 1:52:01
08 | Wednesday 06 May 2026 - 15:00 | E    | A      | E    | WNW | 1:52:33
09 | Friday 08 May 2026 - 15:00    | E    | A      | E    | ESE | 1:55:06
10 | Friday 08 May 2026 - 10:00    | E    | C      | E    | ESE | 1:57:21
[11]:
# we could print summary statistics for the best scenario if needed

print_summary_statistics(results_wind[0], "Best scenario summary statistics:")

============================================================
  STATISTICS - Best scenario summary statistics:
============================================================


📏 Total distance: 36.45 km
⏱️  Total time: 01:50:44
🚴  Average speed: 19.75 km/h (max=45.77 km/h)
⚡  Average power: 146.2 W

💨 Wind (TWS and TWD):
   Mean: 5.70 km/h - Direction: 268° (W)
   Min: 4.50 km/h
   Max: 7.06 km/h

💨 Gusts:
   Mean: 9.55 km/h
   Min: 7.13 km/h (at km 20.82)
   Max: 14.00 km/h (at km 19.04)

⛰️ Terrain slope:
   Mean: 0.08 %
   Min (smoothed 100m): -10.11% (at km 29.76)
   Max (smoothed 100m): 10.54% (at km 27.23)
   Elevation gain: 475 m
   Elevation loss: -476 m

🌬️ Virtual slope (wind):
   Mean: -0.01%
   Min (smoothed 100m): -2.26% (at km 16.64)
   Max (smoothed 100m): 1.53% (at km 32.84)
   Positive virtual elevation: 126 m
   Negative virtual elevation: -113 m

📊 Effective slope (terrain + wind):
   Mean: 0.07%
   Min (smoothed 100m): -10.23% (at km 16.49)
   Max (smoothed 100m): 10.86% (at km 27.23)
   Positive effective elevation: 602 m
   Negative effective elevation: -589 m

🎯 Wind along trajectory:
   Headwind: 52.2% (19.01 km) - Mean: 4.44 km/h
   Tailwind: 47.8% (17.44 km) - Mean: -5.21 km/h
   Headwind min: 0.07 km/h (at km 24.77)
   Headwind max: 7.09 km/h (at km 34.42)
   Tailwind min: -0.07 km/h (at km 7.12)
   Tailwind max: -8.53 km/h (at km 18.99)

🏁 WindScore:
   Final grade: D
   Reason: performance
   Performance: D (score=-2.445)
   Safety: A (danger=0)

============================================================
============================================================