[data] weather as training data: Retrieving real conditions with ERA5-Land

“I was too hot on the second half. But was it really the temperature, or was it fatigue doing all the talking?”

On any race, thermal conditions directly affect physiology. Cardiac drift can result from heat just as much as from muscular fatigue. But to tell which one, you need the actual weather, not your watch’s estimate.

On the topic of heat acclimatization, I recommend the article and podcast on Courir-Mieux: Heat and trail: how to acclimatize?


The problem with wrist temperature

The temperature column in a FIT file comes from a sensor embedded in the watch case. On the wrist, that sensor picks up both ambient heat and body heat conducted through the skin. The data is systematically biased.

At rest, the watch reads close to ambient temperature. During effort, the bias climbs to +2 to +5 degrees C depending on the model, bracelet position, and exercise intensity. The bias is higher during climbs (cutaneous vasodilation) than during descents or at aid stations.

The watch is therefore useful for observing variations in temperature along the course, but its absolute values are not reliable for comparison with physiological thresholds.


The solution: ERA5-Land via Open-Meteo

Open-Meteo is a free weather API for research and non-commercial use.

We use the ERA5-Land model, a climate reanalysis that assimilates millions of in-situ measurements (weather stations, radiosondes, satellites, buoys) into a physical model. It is recomputed a posteriori, which improves data homogeneity.

Its characteristics for our purpose:

Parameter Value
Spatial resolution 0.1 x 0.1 degrees, roughly 9 km
Temporal resolution 1 hour
Availability 2001 to present
Coverage Global
Access Free, no registration

At 9 km resolution, ERA5-Land does not capture micro-local effects (cold valley bottoms, wind on ridgelines), but it provides a reference far superior to the watch sensor.


Fetching the data

The API endpoint for historical data is https://archive-api.open-meteo.com/v1/archive. Here is the retrieval function:

import openmeteo_requests

_ARCHIVE_URL = "https://archive-api.open-meteo.com/v1/archive"
_HOURLY_VARS = [
    "temperature_2m", "relative_humidity_2m",
    "apparent_temperature", "precipitation",
    "wind_speed_10m", "shortwave_radiation",
]


def fetch_weather_hourly(lat, lon, date_str, timezone="Europe/Paris",
                         client=None, model="era5_land", date_end=None):
    """Fetch hourly weather data from Open-Meteo ERA5-Land."""
    if client is None:
        client = openmeteo_requests.Client()
    end_str = date_end if date_end is not None else date_str

    params = {
        "latitude": lat, "longitude": lon,
        "start_date": date_str, "end_date": end_str,
        "hourly": _HOURLY_VARS,
        "wind_speed_unit": "kmh",
        "timezone": timezone,
        "models": model,
    }

    responses = client.weather_api(_ARCHIVE_URL, params=params)
    r = responses[0]
    hourly = r.Hourly()

    times = pd.date_range(
        start=pd.to_datetime(hourly.Time(), unit="s", utc=True),
        end=pd.to_datetime(hourly.TimeEnd(), unit="s", utc=True),
        freq=pd.Timedelta(seconds=hourly.Interval()),
        inclusive="left",
    )

    def safe_var(i):
        """Extract variable i; return NaN array if unavailable."""
        try:
            arr = hourly.Variables(i).ValuesAsNumpy()
            return arr if arr is not None else np.full(len(times), np.nan)
        except Exception:
            return np.full(len(times), np.nan)

    df_w = pd.DataFrame({
        "time": times,
        "temperature_2m": safe_var(0),
        "relative_humidity_2m": safe_var(1),
        "apparent_temperature": safe_var(2),
        "precipitation": safe_var(3),
        "wind_speed_10m": safe_var(4),
        "shortwave_radiation": safe_var(5),
    })

    for col in df_w.columns:
        if col == "time":
            continue
        df_w[col] = pd.to_numeric(df_w[col], errors="coerce")
        df_w.loc[df_w[col] < -999, col] = np.nan

    return df_w

The call takes less than a second. The response contains 24 hourly values (one per hour of the day). If the race spans multiple days, set date_end accordingly.


Interpolating to 1-second resolution

The GPS DataFrame has one point per second. The weather data has 24 points per day. We interpolate linearly by aligning on timestamps:

def make_utc_naive(ts):
    """Force any timestamp Series to UTC-naive for safe comparison."""
    if ts.dt.tz is not None:
        return ts.dt.tz_convert("UTC").dt.tz_localize(None)
    return ts


def enrich_df_with_weather(df, df_weather):
    """Interpolate hourly weather onto the GPS DataFrame."""
    # Normalize both timestamp columns to UTC-naive
    t_gps_naive = make_utc_naive(df["timestamp"])
    t_w_naive = make_utc_naive(df_weather["time"])

    # Debug
    print(f"  GPS range    : {t_gps_naive.iloc[0]} -> {t_gps_naive.iloc[-1]}")
    print(f"  Weather range: {t_w_naive.iloc[0]} -> {t_w_naive.iloc[-1]}")

    # Convert to float seconds for np.interp
    ref = pd.Timestamp("2000-01-01")
    t_gps_s = (t_gps_naive - ref).dt.total_seconds().to_numpy()
    t_w_s = (t_w_naive - ref).dt.total_seconds().to_numpy()

    print(f"  Epoch GPS    : {t_gps_s[0]:.0f} -> {t_gps_s[-1]:.0f}")
    print(f"  Epoch weather: {t_w_s[0]:.0f} -> {t_w_s[-1]:.0f}")

    if t_gps_s[0] > t_w_s[-1] or t_gps_s[-1] < t_w_s[0]:
        print("  ERROR: no overlap between GPS and weather timestamps!")
        return df

    for src_col, dst_col in [
        ("temperature_2m", "temp_api"),
        ("relative_humidity_2m", "humidity_api"),
        ("wind_speed_10m", "wind_kmh_api"),
        ("apparent_temperature", "apparent_temp_api"),
    ]:
        if src_col in df_weather.columns:
            vals = df_weather[src_col].to_numpy(dtype=float)
            if np.all(np.isnan(vals)):
                df[dst_col] = np.nan
                print(f"  {dst_col}: all NaN, skipped")
            else:
                df[dst_col] = np.interp(t_gps_s, t_w_s, vals)
                print(f"  {dst_col}: {df[dst_col].min():.2f} -> {df[dst_col].max():.2f}")
        else:
            df[dst_col] = np.nan

    return df

df = enrich_df_with_weather(df, df_weather)

Linear interpolation is an acceptable approximation for temperature and humidity, which evolve slowly. Precipitation, on the other hand, does not interpolate well and should be kept at hourly resolution.


What the figure reveals

The plot produced by the notebook has two panels:

Panel 1: Temperatures. The watch curve (smoothed over 3 km) and the ERA5-Land interpolated curve side by side. Expect the watch to read 2-5 degrees higher than ERA5 during effort, with the gap narrowing at aid stations and during descents.

Panel 2: Humidity and wind. Humidity is the key variable for analyzing physiology in nocturnal conditions. Strong wind can partially compensate for high humidity. But on a forested course, ERA5’s surface wind (measured in open terrain) overestimates what is actually felt under canopy.


A critical look

9 km spatial resolution. ERA5-Land does not see local effects: a valley bottom 3 degrees colder, katabatic wind on a descent, urban heat islands. On a high-alpine course (UTMB, Ventoux, TGVF…), the gap between ERA5 and actual ground-level weather can reach 3-5 degrees on ridgelines.

Linear interpolation. Weather does not vary linearly, especially wind. Hourly interpolation is acceptable for temperature and humidity, less so for gusts or convective events (thunderstorms).

ERA5-Land vs. local weather stations. For a scientifically rigorous analysis, cross-referencing ERA5-Land with nearby weather station data (via the French national weather API or equivalent) would be more robust. But for field use and coaching, ERA5-Land is sufficient.


The notebook for this article is available on GitHub. The only cell you need to change is the one at the top.

References


Disclaimer: I am a research engineer, not a sports physiologist. What you read here is the notebook of a curious trail runner who likes understanding his data. Sources are provided so you can verify for yourself.