{ "cells": [ { "cell_type": "markdown", "id": "b871d339", "metadata": {}, "source": [ "\n", "# Hellish Conditions for Seixas?\n", "\n", "In February 2026, the Decathlon CMA CGM cycling team was at a training camp in southern Spain.\n", "\n", "On February 14, after returning from a training session, rising French cycling star Paul Seixas posted the ride on his public Strava account with the comment: \"A bit dangerous this wind 😬\"\n", "\n", "![Strava Paul Seixas](/_static/Stravaseixas.png)\n", "\n", "I immediately downloaded the GPX and measured the wind conditions along his ride..." ] }, { "cell_type": "code", "execution_count": 21, "id": "75735225", "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": 16, "id": "a494e3f9", "metadata": {}, "outputs": [], "source": [ "# required imports for the notebook, including core simulation components, weather data handling, analysis tools, and visualization functions.\n", "from datetime import timedelta, datetime\n", "from zoneinfo import ZoneInfo\n", "\n", "from biwipy.core import Simulator\n", "from biwipy.core.cyclist_params import create_pro_profile\n", "\n", "from biwipy.weather import WeatherProvider\n", "from biwipy.weather.grib_finder import build_grib_list\n", "from biwipy.analysis import RouteAnalyzer\n", "from biwipy.analysis.anareswind import print_summary_statistics" ] }, { "cell_type": "markdown", "id": "8ce82a46", "metadata": {}, "source": [ "## 1. Building the wind model\n", "\n", "\n", "We cannot use the time provided by the GPX file because it does not contain any timestamp. GPX files downloaded from Strava do not include timestamps unless it is a file associated with your account.\n", "\n", "Since the GPX file does not contain timestamps, a replay simulation is not possible.\n", "\n", "However, it is still possible to run a simulation in \"future mode\" by choosing the actual date and time of the ride.\n", "\n", "This will allow us to assess the actual wind conditions encountered by Paul Seixas.\n", "\n", "Therefore, we will use the date displayed on the Strava webpage." ] }, { "cell_type": "code", "execution_count": 22, "id": "995ce33f", "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "Grib loaded\n" ] } ], "source": [ "# Use the Strava start time\n", "local_tz = ZoneInfo(\"Europe/Madrid\")\n", "departure_time = '2026-02-14 10:25:00'\n", "t_start = datetime.fromisoformat(departure_time).replace(tzinfo=local_tz)\n", "\n", "# Ride duration\n", "estimated_ride_duration_h = 4\n", "\n", "# Define the directory used to store GRIB files\n", "hdir = home+\"/data\" # GRIB files stored in ~/data by default\n", "\n", "gribs_list = build_grib_list(hdir, t_start, 1, estimated_ride_duration_h)\n", "weather = WeatherProvider(gribs_list)\n", "mygrib = weather.grib\n", "print(\"Grib loaded\")" ] }, { "cell_type": "markdown", "id": "784f980e", "metadata": {}, "source": [ "## 2. Parameters and simulator initialization\n", "\n", "We use typical pro-level parameters for this simulation. Paul Seixas's weight is easy to find online.\n", "\n", "We previously ran several simulations to tune a base power that gives realistic results (time and average speed). For this tuning, we selected `v0` values consistent with the average speed reported by Strava and the elevation profile.\n", "\n", "Remember that `v0` is the speed corresponding to power `P0` on flat terrain and without wind. Here, the route has significant elevation gain (> 2000 m) and strong wind. A `v0` of 39 km/h is a realistic choice to obtain an average speed close to 32 km/h.\n" ] }, { "cell_type": "code", "execution_count": 18, "id": "4120c69b", "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "Input parameters for the simulation: v0=39.00 km/h, P0=244.4 W, CdA=0.28, Cr=0.0035, m=71\n" ] } ], "source": [ "pro = create_pro_profile()\n", "procda = 0.28\n", "procr = 0.0035\n", "promass = 71\n", "\n", "sim = Simulator(mygrib, behavior=pro, CdA=procda, Cr=procr, m=promass)\n", "\n", "v0 = 39 / 3.6 # m/s\n", "P0 = sim.P0_from_v0(v0)\n", "\n", "print(f\"Input parameters for the simulation: v0={v0*3.6:.2f} km/h, P0={P0:.1f} W, CdA={procda}, Cr={procr}, m={promass}\")" ] }, { "cell_type": "markdown", "id": "d269cbec", "metadata": {}, "source": [ "## 3. Loading the GPX\n", "\n", "GPX preprocessing. Stop filtering is not relevant here because this GPX has no timestamps." ] }, { "cell_type": "code", "execution_count": 25, "id": "8b1487a4", "metadata": {}, "outputs": [ { "name": "stderr", "output_type": "stream", "text": [ "WARNING:biwipy.analysis.route_analyzer: 6 abnormal segments detected\n" ] } ], "source": [ "\n", "filegpx=home+\"/data/gpx/P20260214-103K-1025_Seixas.gpx\"\n", "analyzer = RouteAnalyzer()\n", "segments, stats = analyzer.process_gpx(filegpx, verbose=True)\n", "\n" ] }, { "cell_type": "markdown", "id": "5948061c", "metadata": {}, "source": [ "## 4. Simulation launch\n", "\n", "Before running the simulation, we recall the Strava ride summary:\n", "\n", "**Distance**: 103.20 km **Time**: 3:13:54 **Elevation gain**: 2416 m\n", "\n", "**Average**: 31.9 km/h **Max**: 87.3 km/h\n", "\n", "The simulation results are very close.\n", "\n", "I cannot guarantee that the estimated power exactly matches the value measured by Paul Seixas's power meter, but it seems realistic for a training ride.\n" ] }, { "cell_type": "code", "execution_count": 26, "id": "044bde57", "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "\n", "--- Simulation du parcours avec le vent le 2026-02-14 10:25:00+01:00 ---\n", "\n", "Average speed: 31.81 km/h - Moving Average speed: 31.81 km/h - Max speed: 88.05 km/h\n", "Average power: 270.0 W\n", "temps= 3:14:15 , longueur= 102.97 km\n" ] } ], "source": [ "\n", "\n", "results = sim.simulate_future(segments, t_start, P0=P0)\n", "print(\"\\n--- Simulation du parcours avec le vent le \" + str(t_start) + \" ---\")\n", "print(f\"\\nAverage speed: {results.speed.avg:.2f} km/h - Moving Average speed: {results.speed.moving_avg:.2f} km/h - Max speed: {results.speed.max:.2f} km/h\")\n", "print(f\"Average power: {results.power.avg:.1f} W\" if results.power else \"Average power: n/a\")\n", "print(f\"temps= {str(timedelta(seconds=int(results.time.total_seconds)))} , longueur= {results.distance.total_km:.2f} km\")" ] }, { "cell_type": "markdown", "id": "2e1238cb", "metadata": {}, "source": [ "## 4. Wind analysis\n", "\n", "The ride summary gives a WindScore grade of `F`.\n", "\n", "This grade is driven by both safety and performance criteria." ] }, { "cell_type": "code", "execution_count": 27, "id": "e3bae65b", "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "\n", "============================================================\n", " STATISTICS - Parcours de Seixas du 14 février 2026 avec vent\n", "============================================================\n", "\n", "\n", "📏 Total distance: 102.97 km\n", "⏱️ Total time: 03:14:15\n", "🚴 Average speed: 31.81 km/h (max=88.05 km/h)\n", "⚡ Average power: 270.0 W\n", "\n", "💨 Wind (TWS and TWD):\n", " Mean: 21.79 km/h - Direction: 327° (NNW)\n", " Min: 15.12 km/h\n", " Max: 25.81 km/h\n", "\n", "💨 Gusts:\n", " Mean: 32.19 km/h\n", " Min: 26.86 km/h (at km 83.00)\n", " Max: 38.20 km/h (at km 0.23)\n", "\n", "⛰️ Terrain slope:\n", " Mean: 2.24 %\n", " Min (smoothed 100m): -16.28% (at km 71.97)\n", " Max (smoothed 100m): 14.98% (at km 65.41)\n", " Elevation gain: 2306 m\n", " Elevation loss: -2346 m\n", "\n", "🌬️ Virtual slope (wind):\n", " Mean: 0.26%\n", " Min (smoothed 100m): -8.69% (at km 100.77)\n", " Max (smoothed 100m): 6.86% (at km 15.60)\n", " Positive virtual elevation: 1222 m\n", " Negative virtual elevation: -1550 m\n", "\n", "📊 Effective slope (terrain + wind):\n", " Mean: 2.49%\n", " Min (smoothed 100m): -20.19% (at km 95.03)\n", " Max (smoothed 100m): 15.77% (at km 65.42)\n", " Positive effective elevation: 3528 m\n", " Negative effective elevation: -3896 m\n", "\n", "🎯 Wind along trajectory:\n", " Headwind: 49.8% (51.24 km) - Mean: 15.63 km/h\n", " Tailwind: 50.2% (51.71 km) - Mean: -14.75 km/h\n", " Headwind min: 0.04 km/h (at km 92.82)\n", " Headwind max: 28.58 km/h (at km 0.54)\n", " Tailwind min: -0.04 km/h (at km 31.71)\n", " Tailwind max: -27.79 km/h (at km 2.43)\n", "\n", "🏁 WindScore:\n", " Final grade: F\n", " Reason: safety+performance\n", " Performance: F (score=-7.762)\n", " Safety: F (danger=7)\n", "\n", "============================================================\n", "============================================================\n", "\n" ] } ], "source": [ "print_summary_statistics(results, \"Parcours de Seixas du 14 février 2026 avec vent\")\n", "\n" ] }, { "cell_type": "markdown", "id": "eecb4f1d", "metadata": {}, "source": [ "## 5. Conclusion\n", "\n", "The main reasons for this poor safety rating are:\n", "\n", "- strong gusts,\n", "\n", "- high average wind speed (TWS),\n", "\n", "- high lateral instability (crosswind).\n", "\n", "Paul Seixas was right to highlight how dangerous the wind was. Beyond his impressive performance for his age, this also shows strong maturity and risk awareness.\n", "\n" ] }, { "cell_type": "code", "execution_count": 9, "id": "c87883e6", "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "Safety danger score of 7 out of a maximum of 7\n", "Reasons:\n", " Gust max: 38.20 km/h\n", " Crosswind average: 14.07 km/h\n", " TWS average: 21.79 km/h\n" ] } ], "source": [ "print(f\"Safety danger score of {results.wind_score.safety_danger_score} out of a maximum of 7\\nReasons:\")\n", "\n", "gust_max_kmh = results.gusts.max_kmh\n", "print(f\" Gust max: {gust_max_kmh:.2f} km/h\")\n", "\n", "crosswind_avg_kmh = results.crosswind.avg_kmh\n", "print(f\" Crosswind average: {crosswind_avg_kmh:.2f} km/h\")\n", "\n", "wind_avg_kmh = results.wind.tws.avg_kmh\n", "print(f\" TWS average: {wind_avg_kmh:.2f} km/h\")" ] } ], "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 }