{ "cells": [ { "cell_type": "markdown", "id": "ea260b9f", "metadata": {}, "source": [ "# Should I stay or should I go?\n", "\n", "When you plan a bike ride, you probably check the weather forecast.\n", "\n", "While it is fairly easy to assess temperature and rain risk, interpreting the wind is often more difficult.\n", "\n", "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)." ] }, { "cell_type": "code", "execution_count": 20, "id": "2d932a54", "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "OUTPUT_LANG forced to en\n" ] } ], "source": [ "# Language is forced to English for consistent output in notebooks and tests, regardless of the user's locale.\n", "import os\n", "os.environ[\"OUTPUT_LANG\"] = \"en\"\n", "print(\"OUTPUT_LANG forced to en\")\n", "# home is expanded to the user's home directory for consistent file path handling across different environments.\n", "home = os.path.expanduser(\"~\")" ] }, { "cell_type": "code", "execution_count": 21, "id": "92f0b70a", "metadata": {}, "outputs": [], "source": [ "from datetime import timedelta, datetime\n", "from zoneinfo import ZoneInfo\n", "import sys\n", "import os\n", "import logging\n", "\n", "from biwipy.core import Simulator\n", "from biwipy.core.cyclist_params import CyclistBehavior\n", "\n", "from biwipy.weather import WeatherProvider\n", "from biwipy.weather.grib_finder import build_grib_list\n", "\n", "from biwipy.analysis import RouteAnalyzer\n", "\n", "\n", "from biwipy.analysis.anareswind import (\n", " print_summary_statistics\n", ")" ] }, { "cell_type": "markdown", "id": "c1834557", "metadata": {}, "source": [ "## 1. Building the wind model\n", "\n", "### 1.1. Selecting the GRIB files for the wind\n", "\n", "First, we focus on wind data.\n", "\n", "The idea is to select the best days from all possible departure times.\n", "\n", "Weather forecasts are available for 384 hours (at 0h, 6h, 12h, and 18h), but forecasts are generally reliable only for the first 5 days.\n", "\n", "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).\n", "\n", "We build the list of all GRIB files required to simulate the different rides. The function `build_grib_list`:\n", "\n", "- automatically adds margins to include all files required by temporal interpolation for each departure time,\n", "\n", "- downloads the required GRIB files if needed (about 30 files; this may take a few minutes),\n", "\n", "- returns the list of files after download." ] }, { "cell_type": "code", "execution_count": 22, "id": "e005dfed", "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "Collecting GRIB files.....Total GRIB files collected: 30 (5 duplicate files will be handled by grib_manager)\n" ] } ], "source": [ "# Use local timezone for departure times (will be converted to UTC by GRIB functions)\n", "local_tz = ZoneInfo(\"Europe/Paris\") # Change to your local timezone if needed\n", "current_dateTime = datetime.now(local_tz).replace(hour=0, minute=0, second=0, microsecond=0)\n", "\n", "# Lists for departure times and GRIB files\n", "list_gribs_all = []\n", "list_departure_times = []\n", "\n", "# Very conservative estimate (headwind scenario)\n", "estimated_ride_duration_h = 3\n", "\n", "# Define the directory used to store GRIB files\n", "hdir = home+\"/data\" # GRIB files stored in home/data by default\n", "\n", "# Fetch GRIB files (can take a few minutes depending on internet connection)\n", "print(\"Collecting GRIB files\", end=\"\")\n", "for i in range(0, 5): # Next 3 days\n", " print(\".\", end=\"\")\n", " departure_time = current_dateTime + timedelta(days=i, hours=10) # departure: 10h\n", " list_departure_times.append(departure_time)\n", " for eachgrib in build_grib_list(hdir, departure_time, pas=3, intervalle_h=estimated_ride_duration_h):\n", " list_gribs_all.append(eachgrib)\n", " departure_time = current_dateTime + timedelta(days=i, hours=15) # departure: 15h\n", " list_departure_times.append(departure_time)\n", " for eachgrib in build_grib_list(hdir, departure_time, pas=3, intervalle_h=estimated_ride_duration_h):\n", " list_gribs_all.append(eachgrib)\n", "\n", "# Note: grib_manager automatically removes duplicates, so no deduplication is needed here\n", "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)\")" ] }, { "cell_type": "markdown", "id": "40377aa6", "metadata": {}, "source": [ "### 1.2. Initializing the weather provider\n", "\n", "With all required GRIB files for the different departure times, we can initialize the weather provider (the GRIB content is loaded into memory)." ] }, { "cell_type": "code", "execution_count": 23, "id": "8c6a91a1", "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "Initializing weather model\n", "--> Weather model initialization completed.\n" ] } ], "source": [ "print(\"Initializing weather model\")\n", "weather = WeatherProvider(list_gribs_all)\n", "mygrib = weather.grib\n", "print(\"--> Weather model initialization completed.\")" ] }, { "cell_type": "markdown", "id": "2d41f6e8", "metadata": {}, "source": [ "## 2. Building the simulator\n", "\n", "### 2.1. Defining parameters\n", "\n", "We define the required parameters:\n", "\n", "- CdA: 0.53 for a gravel bike with a relaxed position (hands on top of the bars),\n", "\n", "- Cr: 0.0055 (Tufo Thundero 38 mm tires),\n", "\n", "- Weight: 87 (rider) + 10 (steel gravel bike) + 1 (clothes...),\n", "\n", "- Intermediate behavior profile (`realistic`).\n", "\n", "### 2.2. Initializing the Simulator and baseline power\n", "\n", "We build the simulator using the previously defined parameters: weather component, rider profile, and cyclist parameters.\n", "\n", "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.\n", "\n", "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." ] }, { "cell_type": "code", "execution_count": 24, "id": "9c748687", "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "Input parameters for the simulation: v0=25.00 km/h, P0=145.4 W, CdA=0.53, Cr=0.0055, m=98.0\n", "Behaviour profile: Uphill=REALISTIC - Downhill=REALISTIC - Corner=REALISTIC\n" ] } ], "source": [ "# ==================================\n", "# Define simulation parameters\n", "CdA = 0.530\n", "Cr = 0.0055\n", "M = 98.0\n", "\n", "mon_profil = CyclistBehavior(uphill='realistic', downhill='realistic', corner='realistic')\n", "\n", "# Build the simulator\n", "sim_wind = Simulator(mygrib, behavior=mon_profil, CdA=CdA, Cr=Cr, m=M)\n", "\n", "# To define power, use a baseline flat speed v0 (reference speed)\n", "# Speed is in m/s (convert from km/h by dividing by 3.6). Chosen speed: 25 km/h\n", "v0 = 25 / 3.6 # m/s\n", "\n", "# Compute the power output corresponding to reference speed v0 on flat terrain\n", "P0 = sim_wind.P0_from_v0(v0)\n", "\n", "# Show all parameters used for the simulations\n", "print(f\"Input parameters for the simulation: v0={v0*3.6:.2f} km/h, P0={P0:.1f} W, CdA={CdA}, Cr={Cr}, m={M}\")\n", "mon_profil.display(verbose=False)\n", "# ==================================" ] }, { "cell_type": "markdown", "id": "6c201ab9", "metadata": {}, "source": [ "## 3. Defining the ride with the GPX file\n", "\n", "Then we define the GPX file corresponding to the planned ride.\n", "\n", "The GPX file used for this sample is provided in the sample directory.\n", "\n", "Replace it with your favorite ride!\n", "\n", "The GPX file must be preprocessed before simulation. At the end, we can inspect the distance and elevation gain." ] }, { "cell_type": "code", "execution_count": 25, "id": "dcea6f7b", "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "Loading and processing GPX file\n", "--> GPX processing completed - ride distance: 36.45 km - elevation gain: 475 m -\n" ] } ], "source": [ "# gpxdir points to the directory where GPX files are stored, and filegpx is the GPX file to analyze.\n", "gpxdir = \"./\"\n", "pathbase = gpxdir + \"Favorite\"\n", "filegpx = pathbase + \".gpx\"\n", "filegpx_base = os.path.basename(filegpx)\n", "if not os.path.exists(filegpx):\n", " print(f\"⚠ GPX file missing for {filegpx}\")\n", " sys.exit(1)\n", "\n", "# Load and preprocess GPX\n", "print(\"Loading and processing GPX file\", end=\"\")\n", "analyzer = RouteAnalyzer()\n", "gpx_result = analyzer.process_gpx(filegpx, verbose=False)\n", "segments = gpx_result.segments\n", "stats = gpx_result.stats\n", "print(f\"\\n--> GPX processing completed - ride distance: {stats['distance_km']:.2f} km - elevation gain: {stats['deniv_pos_final']:.0f} m -\")" ] }, { "cell_type": "markdown", "id": "7aaa9b52", "metadata": {}, "source": [ "## 4. Running the simulation\n", "\n", "Now we simulate the ride for each departure time and store results in a list.\n", "\n", "We call `simulate_future` for every departure time.\n", "\n", "We pass preprocessed segments, the departure time, and the previously computed baseline power `P0`." ] }, { "cell_type": "code", "execution_count": 26, "id": "24c7c7b3", "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "Start of simulations...........\n", "--> 10 simulations completed.\n" ] } ], "source": [ "results_wind = []\n", "# Simulate for each departure time\n", "print(\"Start of simulations.\", end=\"\")\n", "\n", "for departure_time in list_departure_times:\n", " results_wind.append(sim_wind.simulate_future(segments, departure_time, P0=P0))\n", " print(\".\", end=\"\")\n", "print(f\"\\n--> {len(results_wind)} simulations completed.\")" ] }, { "cell_type": "markdown", "id": "e1ab3b03", "metadata": {}, "source": [ "## 5. Analyzing results\n", "The `results_wind` list now stores all simulation results.\n", "\n", "We first focus on:\n", "\n", "- the wind score (grade from A to F),\n", "\n", "- the safety and performance components of the wind score,\n", "\n", "- wind direction,\n", "\n", "- estimated travel times.\n", "\n", "Both lists are sorted by wind score grade (primary key) and estimated time (secondary key).\n", "\n", "The results are compiled into a readable table." ] }, { "cell_type": "code", "execution_count": 27, "id": "bc166f17", "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "\n", "Sorted departure list by windscore and estimated time (36.45 km, base power 145.4 W):\n", " # | Departure | Wind | Safety | Perf | TWD | Estimated time\n", "---+-------------------------------+------+--------+------+-----+---------------\n", "01 | Monday 04 May 2026 - 10:00 | B | A | B | WNW | 1:49:43 \n", "02 | Thursday 07 May 2026 - 10:00 | B | A | B | ENE | 1:50:37 \n", "03 | Tuesday 05 May 2026 - 10:00 | B | A | B | NNW | 1:51:14 \n", "04 | Monday 04 May 2026 - 15:00 | C | A | C | E | 1:51:57 \n", "05 | Thursday 07 May 2026 - 15:00 | C | A | C | ENE | 1:52:05 \n", "06 | Tuesday 05 May 2026 - 15:00 | D | A | D | WNW | 1:51:50 \n", "07 | Wednesday 06 May 2026 - 10:00 | D | A | D | WNW | 1:52:01 \n", "08 | Wednesday 06 May 2026 - 15:00 | E | A | E | WNW | 1:52:33 \n", "09 | Friday 08 May 2026 - 15:00 | E | A | E | ESE | 1:55:06 \n", "10 | Friday 08 May 2026 - 10:00 | E | C | E | ESE | 1:57:21 \n" ] } ], "source": [ "# sort both lists by grade (primary key) and estimated time (secondary key)\n", "results_wind, list_departure_times = map(\n", " list,\n", " zip(\n", " *sorted(\n", " zip(results_wind, list_departure_times),\n", " key=lambda x: (x[0].wind_score.grade, x[0].time.total_seconds),\n", " )\n", " ),\n", ")\n", "\n", "print(\n", " f\"\\nSorted departure list by windscore and estimated time \"\n", " f\"({stats['distance_km']:.2f} km, base power {P0:.1f} W):\"\n", ")\n", "\n", "headers = (\"#\", \"Departure\", \"Wind\", \"Safety\", \"Perf\", \"TWD\", \"Estimated time\")\n", "rows = []\n", "for i, res in enumerate(results_wind, start=1):\n", " rows.append(\n", " (\n", " f\"{i:02d}\",\n", " list_departure_times[i - 1].strftime(\"%A %d %B %Y - %H:%M\"),\n", " str(res.wind_score.grade),\n", " str(res.wind_score.safety_grade),\n", " str(res.wind_score.performance_grade),\n", " str(res.wind.twd_compass),\n", " str(timedelta(seconds=int(res.time.total_seconds))),\n", " )\n", " )\n", "\n", "widths = [max(len(h), *(len(r[idx]) for r in rows)) for idx, h in enumerate(headers)]\n", "\n", "def fmt_line(values):\n", " return \" | \".join(\n", " v.rjust(w) if idx == 0 else v.ljust(w)\n", " for idx, (v, w) in enumerate(zip(values, widths))\n", " )\n", "\n", "print(fmt_line(headers))\n", "print(\"-+-\".join(\"-\" * w for w in widths))\n", "for row in rows:\n", " print(fmt_line(row))\n", "\n" ] }, { "cell_type": "code", "execution_count": 11, "id": "394cc9a3", "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "\n", "============================================================\n", " STATISTICS - Best scenario summary statistics:\n", "============================================================\n", "\n", "\n", "📏 Total distance: 36.45 km\n", "⏱️ Total time: 01:50:44\n", "🚴 Average speed: 19.75 km/h (max=45.77 km/h)\n", "⚡ Average power: 146.2 W\n", "\n", "💨 Wind (TWS and TWD):\n", " Mean: 5.70 km/h - Direction: 268° (W)\n", " Min: 4.50 km/h\n", " Max: 7.06 km/h\n", "\n", "💨 Gusts:\n", " Mean: 9.55 km/h\n", " Min: 7.13 km/h (at km 20.82)\n", " Max: 14.00 km/h (at km 19.04)\n", "\n", "⛰️ Terrain slope:\n", " Mean: 0.08 %\n", " Min (smoothed 100m): -10.11% (at km 29.76)\n", " Max (smoothed 100m): 10.54% (at km 27.23)\n", " Elevation gain: 475 m\n", " Elevation loss: -476 m\n", "\n", "🌬️ Virtual slope (wind):\n", " Mean: -0.01%\n", " Min (smoothed 100m): -2.26% (at km 16.64)\n", " Max (smoothed 100m): 1.53% (at km 32.84)\n", " Positive virtual elevation: 126 m\n", " Negative virtual elevation: -113 m\n", "\n", "📊 Effective slope (terrain + wind):\n", " Mean: 0.07%\n", " Min (smoothed 100m): -10.23% (at km 16.49)\n", " Max (smoothed 100m): 10.86% (at km 27.23)\n", " Positive effective elevation: 602 m\n", " Negative effective elevation: -589 m\n", "\n", "🎯 Wind along trajectory:\n", " Headwind: 52.2% (19.01 km) - Mean: 4.44 km/h\n", " Tailwind: 47.8% (17.44 km) - Mean: -5.21 km/h\n", " Headwind min: 0.07 km/h (at km 24.77)\n", " Headwind max: 7.09 km/h (at km 34.42)\n", " Tailwind min: -0.07 km/h (at km 7.12)\n", " Tailwind max: -8.53 km/h (at km 18.99)\n", "\n", "🏁 WindScore:\n", " Final grade: D\n", " Reason: performance\n", " Performance: D (score=-2.445)\n", " Safety: A (danger=0)\n", "\n", "============================================================\n", "============================================================\n", "\n" ] } ], "source": [ "# we could print summary statistics for the best scenario if needed\n", "\n", "print_summary_statistics(results_wind[0], \"Best scenario summary statistics:\")" ] } ], "metadata": { "kernelspec": { "display_name": "bikewind", "language": "python", "name": "python3" }, "language_info": { "codemirror_mode": { "name": "ipython", "version": 3 }, "file_extension": ".py", "mimetype": "text/x-python", "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", "version": "3.10.19" } }, "nbformat": 4, "nbformat_minor": 5 }