Cycle diurne de la température d'été à Lyon-Bron, par décennie
Météo-France publie ses données climatologiques de base horaires en open data sur data.gouv.fr. On les utilise ici pour tracer la température moyenne d’été (juin–juillet–août) à chaque heure de la journée à la station Lyon-Bron (NUM_POSTE 69029001), une courbe par décennie de 1971 à 2025, en heure locale d’été (CEST, UTC+2). Le notebook est autonome et reproductible : les données sont téléchargées directement via l’API data.gouv.fr puis mises en cache localement, aucun fichier local n’est requis.
La fréquence d’échantillonnage change sur la période :
- avant 1991 : ~8 relevés/jour aux heures synoptiques 3-horaires (0,3,…,21 UTC → 2,5,…,23 en heure locale).
Ces décennies n’ont que 8 points/jour : on les rend en pointillé, points relevés reliés par une
spline cubique périodique (
CubicSplinede SciPy) ; - à partir de 1991 : vraies données horaires (24 points) → trait plein entre les points.
La palette rainbow_PuRd de Paul Tol (paquet tol-colors) ordonne les décennies des teintes froides vers les teintes chaudes et reste lisible pour tous les types de daltonisme.
1. Installation des dépendances
À exécuter une seule fois (versions figées pour la reproductibilité).
# %pip install "pandas==3.0.3" "numpy==2.4.6" "matplotlib==3.11.0" "scipy==1.18.0" "tol-colors==2.2.0"
import io
import json
import re
import urllib.request
from pathlib import Path
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
import tol_colors as tc
from matplotlib.lines import Line2D
from scipy.interpolate import CubicSpline
2. Télécharger la température horaire de Lyon-Bron
On interroge l’API data.gouv.fr du jeu « Données climatologiques de base – horaires », on ne garde que
les fichiers du département 69 (Rhône) couvrant 1971+, et on en extrait la station Lyon-Bron
(NUM_POSTE = 69029001) : deux colonnes suffisent, l’horodatage (AAAAMMJJHH, UTC) et la température
T (°C). Le résultat est mis en cache localement (lyon_bron_hourly.parquet) pour que les ré-exécutions
soient instantanées.
DATASET_API = (
"https://www.data.gouv.fr/api/1/datasets/"
"donnees-climatologiques-de-base-horaires/"
)
STATION_ID = "69029001" # Lyon-Bron
STATION_NAME = "Lyon-Bron"
FIRST_YEAR = 1971 # 1re décennie tracée
LAST_FULL_YEAR = 2025 # 2026 partielle exclue
CACHE = Path("lyon_bron_hourly.parquet")
UA = {"User-Agent": "Mozilla/5.0 (lyon-diurnal-notebook)"}
def hourly_file_urls():
"""URLs des fichiers horaires dept-69 dont la période atteint FIRST_YEAR."""
req = urllib.request.Request(DATASET_API, headers=UA)
with urllib.request.urlopen(req, timeout=60) as resp:
meta = json.load(resp)
urls = [
r["url"]
for r in meta.get("resources", [])
if "H_69_" in (r.get("url") or "") and (r.get("url") or "").endswith(".csv.gz")
]
keep = []
for u in urls:
years = [int(y) for y in re.findall(r"(\d{4})", u.rsplit("/", 1)[-1])]
if years and max(years) >= FIRST_YEAR: # ex. H_69_1970-1979 -> 1979 >= 1971
keep.append(u)
return sorted(keep)
def load_lyon_bron():
"""DataFrame [datetime (UTC), T (°C)] pour Lyon-Bron, depuis l'open data (avec cache)."""
if CACHE.exists():
print(f"Cache trouvé : {CACHE}")
return pd.read_parquet(CACHE)
frames = []
for url in hourly_file_urls():
name = url.rsplit("/", 1)[-1]
print(f"téléchargement {name} …", flush=True)
req = urllib.request.Request(url, headers=UA)
with urllib.request.urlopen(req, timeout=300) as resp:
blob = resp.read()
df = pd.read_csv(
io.BytesIO(blob),
sep=";",
compression="gzip",
usecols=["NUM_POSTE", "AAAAMMJJHH", "T"],
dtype={"NUM_POSTE": "string", "AAAAMMJJHH": "string", "T": "string"},
)
df = df[df["NUM_POSTE"] == STATION_ID]
if not df.empty:
frames.append(df[["AAAAMMJJHH", "T"]])
raw = pd.concat(frames, ignore_index=True)
out = (
pd.DataFrame(
{
"datetime": pd.to_datetime(raw["AAAAMMJJHH"], format="%Y%m%d%H"),
"T": pd.to_numeric(raw["T"], errors="coerce"),
}
)
.dropna(subset=["datetime"])
.sort_values("datetime")
.reset_index(drop=True)
)
out.to_parquet(CACHE)
print(f"{len(out):,} lignes mises en cache -> {CACHE}")
return out
df = load_lyon_bron()
print(
f"{len(df):,} lignes | {df['datetime'].min():%Y-%m-%d} \u2192 {df['datetime'].max():%Y-%m-%d}"
)
df.head()
Cache trouvé : lyon_bron_hourly.parquet
374,127 lignes | 1970-01-01 → 2026-07-04
| datetime | T | |
|---|---|---|
| 0 | 1970-01-01 00:00:00 | -2.7 |
| 1 | 1970-01-01 03:00:00 | -2.4 |
| 2 | 1970-01-01 06:00:00 | -2.4 |
| 3 | 1970-01-01 09:00:00 | -2.0 |
| 4 | 1970-01-01 12:00:00 | -1.8 |
3. Découpage en décennies
De 1971 à la dernière année complète. La dernière décennie peut être partielle (ex. 2021-2025).
def decade_bounds(first=FIRST_YEAR, last=LAST_FULL_YEAR):
"""Liste de (début, fin) par tranche de 10 ans."""
out, start = [], first
while start <= last:
out.append((start, min(start + 9, last)))
start += 10
return out
decades = decade_bounds()
decades
[(1971, 1980),
(1981, 1990),
(1991, 2000),
(2001, 2010),
(2011, 2020),
(2021, 2025)]
4. Profil diurne moyen par (décennie, heure locale)
On filtre l’été (JJA), on convertit en heure locale ((heure_UTC + 2) % 24), puis on moyenne la
température pour chaque couple (décennie, heure).
lo = decades[0][0]
h = (
df[
(df["datetime"].dt.year >= lo)
& (df["datetime"].dt.year <= LAST_FULL_YEAR)
& (df["datetime"].dt.month.isin([6, 7, 8]))
]
.dropna(subset=["T"])
.copy()
)
# Heure locale d'été à Lyon = UTC+2. Les relevés 3-horaires tombent aux heures 2,5,8,11,14,17,20,23.
h["hour"] = (h["datetime"].dt.hour + 2) % 24
bins = [decades[0][0] - 1] + [e for _, e in decades]
labels = [f"{a}\u2013{b}" for a, b in decades]
h["period"] = pd.cut(h["datetime"].dt.year, bins=bins, labels=labels)
prof = h.groupby(["period", "hour"], observed=True)["T"].mean().unstack("period")
prof.round(1)
| period | 1971–1980 | 1981–1990 | 1991–2000 | 2001–2010 | 2011–2020 | 2021–2025 |
|---|---|---|---|---|---|---|
| hour | ||||||
| 0 | <NA> | <NA> | 19.5 | 20.3 | 20.6 | 20.8 |
| 1 | <NA> | <NA> | 18.8 | 19.7 | 20.0 | 20.1 |
| 2 | 16.4 | 17.4 | 18.2 | 19.0 | 19.3 | 19.5 |
| 3 | <NA> | <NA> | 17.5 | 18.4 | 18.7 | 18.9 |
| 4 | <NA> | <NA> | 17.0 | 17.9 | 18.2 | 18.3 |
| 5 | 14.9 | 15.8 | 16.5 | 17.4 | 17.6 | 17.8 |
| 6 | <NA> | <NA> | 16.1 | 17.0 | 17.2 | 17.5 |
| 7 | <NA> | <NA> | 16.2 | 17.0 | 17.2 | 17.8 |
| 8 | 15.7 | 16.5 | 17.4 | 18.2 | 18.6 | 19.5 |
| 9 | <NA> | <NA> | 18.7 | 19.7 | 20.1 | 21.0 |
| 10 | <NA> | <NA> | 20.1 | 21.0 | 21.5 | 22.4 |
| 11 | 19.8 | 20.7 | 21.4 | 22.3 | 22.8 | 23.7 |
| 12 | <NA> | <NA> | 22.7 | 23.5 | 24.0 | 25.0 |
| 13 | <NA> | <NA> | 23.7 | 24.5 | 25.0 | 26.0 |
| 14 | 22.9 | 23.8 | 24.5 | 25.2 | 25.9 | 27.1 |
| 15 | <NA> | <NA> | 25.1 | 25.8 | 26.5 | 27.7 |
| 16 | <NA> | <NA> | 25.4 | 26.1 | 26.9 | 28.0 |
| 17 | 23.9 | 24.9 | 25.5 | 26.3 | 26.9 | 28.0 |
| 18 | <NA> | <NA> | 25.2 | 26.0 | 26.7 | 27.7 |
| 19 | <NA> | <NA> | 24.6 | 25.5 | 26.1 | 27.0 |
| 20 | 22.2 | 23.2 | 23.7 | 24.6 | 25.2 | 26.0 |
| 21 | <NA> | <NA> | 22.4 | 23.4 | 23.8 | 24.5 |
| 22 | <NA> | <NA> | 21.1 | 22.1 | 22.4 | 22.8 |
| 23 | 18.5 | 19.6 | 20.3 | 21.1 | 21.4 | 21.7 |
Avant 1991, seules les 8 heures synoptiques locales (2, 5, 8, 11, 14, 17, 20, 23) ont une valeur, d’où les <NA> ; 17 h, l’heure du pic, fait partie des heures réellement observées sur toute la période.
5. Figure
- décennies 3-horaires (≤ 8 heures disponibles) : points relevés + spline cubique périodique (pointillé) ;
- décennies horaires : trait plein, bouclé (T à 24 h = T à 0 h).
cmap = tc.rainbow_PuRd
fig, ax = plt.subplots(figsize=(11, 6.0))
handles, leg_labels = [], []
n = len(labels)
for i, period in enumerate(labels):
if period not in prof.columns:
continue
color = cmap(i / (n - 1))
col = prof[period].dropna()
if len(col) <= 8:
# Décennie 3-horaire : spline cubique périodique sur les 8 points synoptiques.
xh = col.index.to_numpy(dtype=float) # heures locales, ex. 2..23
yh = col.to_numpy(dtype=float)
xp = np.append(xh, xh[0] + 24.0) # une période de 24 h
yp = np.append(yh, yh[0])
cs = CubicSpline(xp, yp, bc_type="periodic") # C2 + raccord continu à la couture 2 h/26 h
xx = np.linspace(0.0, 24.0, 289)
xq = np.where(xx < xh[0], xx + 24.0, xx) # replie les heures avant le 1er point
ax.plot(xx, cs(xq), color=color, lw=2.2, ls=":")
ax.plot(xh, yh, ls="none", marker="o", ms=5, color=color)
handles.append(Line2D([], [], color=color, lw=2.2, ls=":", marker="o", ms=5))
leg_labels.append(f"{period} (3-h, interpolé)")
else:
# Décennie horaire : trait plein, bouclé.
xx = np.append(col.index.to_numpy(dtype=float), 24.0)
yy = np.append(col.to_numpy(dtype=float), col.loc[0])
(line,) = ax.plot(xx, yy, marker="o", ms=4, lw=2.2, color=color)
handles.append(line)
leg_labels.append(period)
ax.set_xlabel("Heure locale (CEST, UTC+2)")
ax.set_ylabel("Température moyenne (°C)")
ax.set_xticks(range(0, 25, 3))
ax.set_xlim(-0.4, 24.4)
ax.set_title(
f"Cycle diurne moyen de la température en été (juin-juillet-août) par décennie à {STATION_NAME}",
fontsize=13,
color="0.25",
pad=12,
)
for spine in ("top", "right"):
ax.spines[spine].set_visible(False)
ax.grid(color="0.92", lw=0.8)
ax.legend(
handles,
leg_labels,
loc="upper left",
frameon=False,
fontsize=9,
title="Décennie",
)
fig.tight_layout()
fig.savefig("lyon_diurnal_cycle_by_decade.png", dpi=150)
L’écart entre décennies est présent à toute heure et maximal en fin d’après-midi : au pic de 17 h, la température moyenne passe de 23,9 °C (1971–1980) à 28,0 °C (2021–2025).
La série est brute et la dernière tranche ne couvre que 5 ans ; la décennie complète 2016–2025 donne 27,9 °C au même pic. La croissance urbaine autour de Bron contribue peut-être un peu.