@ -0,0 +1,418 @@
---
title: Juin 2026
subtitle: Visualisation des données
lang: fr
creation_date: 2026-06-27T08:20:03+0200
date: today
format:
html:
code-fold: true
code-summary: "Afficher le code"
execute:
echo: true
warning: false
id: 20260627082003
tags: ["canicule", "température"]
---
```{python}
#| label: setup
#| include: false
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import matplotlib.dates as mdates
import matplotlib.patches as mpatches
from pathlib import Path
APPART_DATA_PIECE_PRINCIPALE = Path("data/2026-06-appart-data-piece-principale.csv")
APPART_DATA_CHAMBRE = Path("data/2026-06-appart-data-chambre.csv")
GVE_LOCAL = Path("data/gve_h_recent.csv")
def load_aranet(path):
"""Charge un CSV Aranet4 (2 lignes de header, decimal=',', champs vides tolérés)."""
raw = pd.read_csv(
path,
skiprows=2,
header=None,
names=["time", "co2", "temperature", "humidity", "pressure"],
quotechar='"',
dtype=str,
)
raw = raw.dropna(how="all")
raw["time"] = pd.to_datetime(raw["time"], format="%d/%m/%Y %H:%M:%S")
for col in ["co2", "temperature", "humidity", "pressure"]:
raw[col] = pd.to_numeric(
raw[col].str.replace(",", ".", regex=False),
errors="coerce", # champs vides → NaN, pas d'erreur
)
return raw.sort_values("time").reset_index(drop=True)
df = load_aranet(APPART_DATA_PIECE_PRINCIPALE)
df_c = load_aranet(APPART_DATA_CHAMBRE)
# Filtrage sur la période commune
fig_start = max(df["time"].min(), df_c["time"].min())
fig_end = min(df["time"].max(), df_c["time"].max())
# Dates en fr sans la locale
MOIS_FR = {
1: "janvier", 2: "février", 3: "mars", 4: "avril",
5: "mai", 6: "juin", 7: "juillet", 8: "août",
9: "septembre", 10: "octobre", 11: "novembre", 12: "décembre",
}
def fmt_datetime_fr(ts):
return f"{ts.day:02d} {MOIS_FR[ts.month]} {ts.year}, {ts.strftime('%H:%M')}"
# Période commune aux deux capteurs
## Période en chaîne de caractères pour le texte en Markdown.
def fmt_date_fr(ts):
"""Retourne '3 juin 2026' (sans zéro initial, sans heure)."""
return f"{ts.day} {MOIS_FR[ts.month]} {ts.year}"
period_start_str = fmt_date_fr(fig_start)
period_end_str = fmt_date_fr(fig_end)
## Période dans des variables pour les figures
period_start = max(df["time"].min(), df_c["time"].min())
period_end = min(df["time"].max(), df_c["time"].max())
ext = pd.read_csv(
GVE_LOCAL,
sep=";",
encoding="windows-1252",
dtype=str,
)
ext = ext.rename(columns={"reference_timestamp": "time"})
ext["time"] = (
pd.to_datetime(ext["time"], format="%d.%m.%Y %H:%M", utc=True)
.dt.tz_convert("Europe/Zurich")
.dt.tz_localize(None)
)
for col in ["tre200h0", "ure200h0"]:
if col in ext.columns:
ext[col] = pd.to_numeric(ext[col], errors="coerce")
rename_map = {"tre200h0": "temp_ext", "ure200h0": "rh_ext"}
ext = ext[["time"] + [c for c in rename_map if c in ext.columns]].rename(columns=rename_map)
# Filtre sur la période couverte par au moins df (pièce principale)
ext = ext[
(ext["time"] >= df["time"].min()) &
(ext["time"] <= df["time"].max())
].reset_index(drop=True)
print(f"Pièce principale : {len(df)} mesures — {df['time'].min().date()} → {df['time'].max().date()}")
print(f"Chambre : {len(df_c)} mesures — {df_c['time'].min().date()} → {df_c['time'].max().date()}")
print(f"Période commune : {period_start.date()} → {period_end.date()}")
print(f"MétéoSuisse GVE : {len(ext)} mesures — {ext['time'].min().date()} → {ext['time'].max().date()}")
```
## Données
Les mesures de la température intérieure sont prise avec un appareil
[Aranet4][1], pour la pièce principale et la chambre. L'appareil est posé
environ à 1.20 m de hauteur, sur une étagère, plutôt à l'opposé de la fenêtre,
et sans exposition directe au soleil. Intervalle de mesure : 5 minutes.
Période : `{python} period_start_str`– `{python} period_end_str`.
Il s'agit d'un appartement de 45 m^2^ au 8^e^ étage, exposition
Sud-Sud-Est, avec de larges fenêtres sur la façade.
```{python}
#| label: stats
#| echo: false
from IPython.display import Markdown
# ── Pièce principale ─────────────────────────────────────────────────────────
tmax_val = df["temperature"].max()
tmax_time = fmt_datetime_fr(df.loc[df["temperature"].idxmax(), "time"])
tmin_val = df["temperature"].min()
tmin_time = fmt_datetime_fr(df.loc[df["temperature"].idxmin(), "time"])
jours_26 = df.groupby(df["time"].dt.date)["temperature"].max().ge(26).sum()
jours_28 = df.groupby(df["time"].dt.date)["temperature"].max().ge(28).sum()
# ── Chambre ──────────────────────────────────────────────────────────────────
c_tmax_val = df_c["temperature"].max()
c_tmax_time = fmt_datetime_fr(df_c.loc[df_c["temperature"].idxmax(), "time"])
c_tmin_val = df_c["temperature"].min()
c_tmin_time = fmt_datetime_fr(df_c.loc[df_c["temperature"].idxmin(), "time"])
c_jours_26 = df_c.groupby(df_c["time"].dt.date)["temperature"].max().ge(26).sum()
c_jours_28 = df_c.groupby(df_c["time"].dt.date)["temperature"].max().ge(28).sum()
# ── Extérieur MétéoSuisse ────────────────────────────────────────────────────
ext_tmax_val = ext["temp_ext"].max()
ext_tmax_time = fmt_datetime_fr(ext.loc[ext["temp_ext"].idxmax(), "time"])
ext_tmin_val = ext["temp_ext"].min()
nuits_trop = (
ext.groupby(ext["time"].dt.date)["temp_ext"]
.min()
.ge(20)
.sum()
)
Markdown(f"""
| Indicateur | Pièce principale | Chambre |
|---|---|---|
| **T maximale** | **{tmax_val:.1f} °C** — {tmax_time} | **{c_tmax_val:.1f} °C** — {c_tmax_time} |
| T minimale | {tmin_val:.1f} °C — {tmin_time} | {c_tmin_val:.1f} °C — {tmin_time} |
| Jours T max ≥ 26 °C | {jours_26} jours | {c_jours_26} jours |
| Jours T max ≥ 28 °C | {jours_28} jours | {c_jours_28} jours |
| **T extérieure max** (GVE) | **{ext_tmax_val:.1f} °C** — {ext_tmax_time} | — |
| T extérieure min (GVE) | {ext_tmin_val:.1f} °C | — |
| Nuits tropicales ext. (T min ≥ 20 °C) | {nuits_trop} nuits | — |
| Canicule officielle | 18 au 29 juin 2026 | — |
| CO₂ maximal mesuré | {df['co2'].max():.0f} ppm | {df_c['co2'].max():.0f} ppm |
""")
```
## Comparaison température intérieure et extérieure
```{python}
#| label: fig-int-vs-ext
#| fig-cap: "Comparaison des températures intérieure (Aranet4, pièce principale et chambre) et extérieure (MétéoSuisse, station GVE — Genève-Cointrin, données horaires). Période : 3– 28 juin 2026. Zone orange : période de canicule officielle (plan cantonal GE, dès le 18 juin). Bandes violettes : nuits tropicales (T ext min ≥ 20 °C, 20h– 6h). Source données météo : MétéoSuisse OGD."
RED_DARK = "#c62828" # pièce principale
BROWN = "#e65100" # chambre
BLUE = "#1565c0" # extérieur
CANICULE = "#ffe0b2"
NUIT_TROP = "#ede7f6"
df_fig = df[(df["time"] >= fig_start) & (df["time"] <= fig_end)]
dfc_fig = df_c[(df_c["time"] >= fig_start) & (df_c["time"] <= fig_end)]
ext_fig = ext[(ext["time"] >= fig_start) & (ext["time"] <= fig_end)]
fig, ax = plt.subplots(figsize=(14, 5))
fig.patch.set_facecolor("white")
# ── 1. Période de canicule officielle ────────────────────────────────────────
canicule_start = pd.Timestamp("2026-06-18 00:00")
canicule_end = max(df_fig["time"].max(), ext_fig["time"].max())
ax.axvspan(canicule_start, canicule_end,
color=CANICULE, alpha=0.55, zorder=0,
label="Période de canicule (plan cantonal GE, dès le 18 juin)")
# ── 2. Nuits tropicales ──────────────────────────────────────────────────────
ext_tmp = ext_fig.copy()
ext_tmp["night_date"] = ext_tmp["time"].dt.date
mask_ev = ext_tmp["time"].dt.hour >= 20
ext_tmp.loc[mask_ev, "night_date"] = (
ext_tmp.loc[mask_ev, "time"] + pd.Timedelta(days=1)
).dt.date
mask_n = (ext_tmp["time"].dt.hour >= 20) | (ext_tmp["time"].dt.hour < 6)
nuits_dates = (
ext_tmp[mask_n]
.groupby("night_date")["temp_ext"]
.min()
.pipe(lambda s: s[s >= 20])
.index
)
first_nuit = True
for nuit in nuits_dates:
nuit_ts = pd.Timestamp(nuit)
span_start = nuit_ts - pd.Timedelta(hours=4)
span_end = nuit_ts + pd.Timedelta(hours=6)
label = "Nuit tropicale (T ext min ≥ 20 °C)" if first_nuit else ""
ax.axvspan(span_start, span_end,
color=NUIT_TROP, alpha=0.85, zorder=1, label=label)
first_nuit = False
# ── 3. Courbes de température ────────────────────────────────────────────────
ax.plot(dfc_fig["time"], dfc_fig["temperature"],
color=BROWN, linewidth=0.8, alpha=0.75, zorder=3,
label="T intérieure chambre (Aranet4, 5 min)")
ax.plot(df_fig["time"], df_fig["temperature"],
color=RED_DARK, linewidth=0.8, alpha=0.9, zorder=4,
label="T intérieure pièce principale (Aranet4, 5 min)")
ax.plot(ext_fig["time"], ext_fig["temp_ext"],
color=BLUE, linewidth=1.3, zorder=5,
marker="o", markersize=1.8, alpha=0.85,
label="T extérieure (MétéoSuisse GVE, horaire)")
# ── 4. Seuil nuit tropicale ──────────────────────────────────────────────────
ax.axhline(20, color="#7b1fa2", lw=0.8, ls=":", alpha=0.7,
label="Seuil nuit tropicale (20 °C)")
# ── 5. Habillage ─────────────────────────────────────────────────────────────
ax.yaxis.set_ticks_position("both")
ax.tick_params(axis="y", right=True, labelright=True, labelsize=9)
ax.set_ylabel("Température (°C)", fontsize=10)
ax.set_ylim(10, 38)
ax.legend(loc="upper left", fontsize=8, framealpha=0.9)
ax.set_title(
"T intérieure (8e étage, exp. SSE) vs T extérieure (GVE – Cointrin)\n"
"Source : Aranet4 / MétéoSuisse OGD — 3– 28 juin 2026",
fontsize=10
)
ax.grid(axis="y", ls=":", alpha=0.4)
ax.xaxis.set_major_locator(mdates.DayLocator(interval=2))
ax.xaxis.set_minor_locator(mdates.DayLocator(interval=1))
ax.xaxis.set_major_formatter(mdates.DateFormatter("%-d %b"))
plt.setp(ax.xaxis.get_majorticklabels(), rotation=0, ha="center", fontsize=9)
fig.tight_layout()
plt.show()
```
## Pratique d'aération et limites physiques
La stratégie appliquée est optimale dans les contraintes du logement :
les fenêtres sont ouvertes **le soir dès que la température extérieure
rejoint la température intérieure** (croisement des courbes), et maintenues
ouvertes pendant la nuit.
Le matin, dès que le rayonnement solaire direct atteint les vitrages (vers
6h– 7h en juin pour une exposition Sud-Sud-Est), fenêtres et stores sont
fermés. Cette fermeture est visible sur le graphique : la courbe intérieure
repart à la hausse alors que la courbe extérieure est encore basse ou en
descente.
**Les stores ne constituent pas une protection suffisante.** De couleur
sombre et en métal, ils absorbent et accumulent le rayonnement solaire.
Cette chaleur est ensuite diffusée vers l'intérieur par conduction à travers
les cadres de fenêtres et les vitrages, dont l'isolation thermique est
insuffisante. La fermeture des stores ralentit la montée en température
mais ne l'empêche pas.
```{python}
#| label: fig-zoom-croisements
#| fig-cap: "Zoom sur la fin de la canicule, 25– 28 juin 2026. Croisement vespéral : la température extérieure (en baisse) rejoint la température intérieure → ouverture des fenêtres et stores. Divergence matinale : la température intérieure repart à la hausse sous l'effet du rayonnement solaire sur les vitrages et stores métalliques, alors que la température extérieure est encore basse. Zones grises : tranches nocturnes (20h– 6h)."
import numpy as np
from matplotlib.ticker import FuncFormatter
JOURS_FR = {0: "Lun", 1: "Mar", 2: "Mer", 3: "Jeu",
4: "Ven", 5: "Sam", 6: "Dim"}
def fmt_date_fr_court(ts):
return f"{JOURS_FR[ts.weekday()]} {ts.day} {MOIS_FR[ts.month]}"
zoom_start = pd.Timestamp("2026-06-25 00:00")
zoom_end = pd.Timestamp("2026-06-28 23:59")
dz = df[(df["time"] >= zoom_start) & (df["time"] <= zoom_end)].copy().reset_index(drop=True)
dz_c = df_c[(df_c["time"] >= zoom_start) & (df_c["time"] <= zoom_end)].copy()
ez = ext[(ext["time"] >= zoom_start) & (ext["time"] <= zoom_end)].copy()
# Interpolation T extérieure sur la grille 5 min de l'Aranet
temp_ext_interp = (
ez.set_index("time")["temp_ext"]
.reindex(ez.set_index("time")["temp_ext"].index.union(dz["time"]))
.interpolate("index")
.reindex(dz["time"])
.values
)
diff = dz["temperature"].values - temp_ext_interp
sign_changes = np.where(np.diff(np.sign(diff)))[0]
# Détection des fermetures : minimum local de T intérieure entre 5h et 10h
# (on cherche le moment où la courbe intérieure cesse de descendre et repart)
dz["hour"] = dz["time"].dt.hour
dz["diff_temp"] = dz["temperature"].diff() # dérivée discrète
fig, ax = plt.subplots(figsize=(14, 5))
fig.patch.set_facecolor("white")
# Fond canicule
ax.axvspan(zoom_start, zoom_end, color="#ffe0b2", alpha=0.35, zorder=0)
# Zones nocturnes (20h– 6h)
for day_offset in range(4):
night_s = zoom_start + pd.Timedelta(days=day_offset, hours=20)
night_e = night_s + pd.Timedelta(hours=10)
ax.axvspan(night_s, night_e, color="#eceff1", alpha=0.6, zorder=0)
# Courbes
ax.plot(dz_c["time"], dz_c["temperature"],
color=BROWN, linewidth=0.8, alpha=0.75, zorder=3,
linestyle="--", label="T intérieure chambre (Aranet4)")
ax.plot(dz["time"], dz["temperature"],
color=RED_DARK, linewidth=1.3, zorder=4,
label="T intérieure pièce principale (Aranet4)")
ax.plot(ez["time"], ez["temp_ext"],
color=BLUE, linewidth=1.5, zorder=5,
marker="o", markersize=2.5, label="T extérieure (MétéoSuisse GVE)")
# ── Annotations ouvertures (croisement ext descend sous int, soirée) ─────────
annotated_open = 0
for idx in sign_changes:
if idx + 1 >= len(dz):
continue
s_before = np.sign(diff[idx])
s_after = np.sign(diff[idx + 1])
hour = dz["time"].iloc[idx].hour
if s_before < 0 and s_after > 0 and 17 <= hour <= 23 and annotated_open < 4:
ax.annotate(
"ouverture",
xy=(dz["time"].iloc[idx], dz["temperature"].iloc[idx]),
xytext=(0, 25), textcoords="offset points",
fontsize=7.5, color=BLUE, ha="center",
arrowprops=dict(arrowstyle="->", color=BLUE, lw=0.8),
)
annotated_open += 1
# ── Annotations fermetures : minimum local de T int entre 5h et 10h ──────────
# Par jour, on cherche l'indice du minimum de T intérieure dans la fenêtre 5h– 10h
annotated_solar = 0
for day_offset in range(4):
day = zoom_start.date() + pd.Timedelta(days=day_offset)
mask = (
(dz["time"].dt.date == day) &
(dz["hour"] >= 5) &
(dz["hour"] <= 10)
)
subset = dz[mask]
if subset.empty:
continue
# minimum de T intérieure dans la fenêtre → point de divergence
idx_min = subset["temperature"].idxmin()
t_close = dz.loc[idx_min, "time"]
y_close = dz.loc[idx_min, "temperature"]
if annotated_solar < 4:
ax.annotate(
"fermeture",
xy=(t_close, y_close),
xytext=(0, -32), textcoords="offset points",
fontsize=7.5, color=RED_DARK, ha="center",
arrowprops=dict(arrowstyle="->", color=RED_DARK, lw=0.8),
)
annotated_solar += 1
# ── Habillage ─────────────────────────────────────────────────────────────────
ax.yaxis.set_ticks_position("both")
ax.tick_params(axis="y", right=True, labelright=True, labelsize=9)
ax.set_ylabel("Température (°C)", fontsize=10)
ax.set_ylim(19, 37)
ax.legend(loc="upper left", fontsize=8, framealpha=0.9)
ax.set_title(
"Zoom canicule 25– 28 juin 2026 — croisements T intérieure / T extérieure\n"
"Zones grises : tranches nocturnes (20h– 6h)",
fontsize=10
)
ax.grid(axis="y", ls=":", alpha=0.4)
# Axe X : jours en français, heures intermédiaires avec label
ax.xaxis.set_major_locator(mdates.DayLocator(interval=1))
ax.xaxis.set_major_formatter(FuncFormatter(
lambda x, _: fmt_date_fr_court(mdates.num2date(x))
))
ax.xaxis.set_minor_locator(mdates.HourLocator(byhour=[6, 12, 18]))
ax.xaxis.set_minor_formatter(mdates.DateFormatter("%Hh"))
plt.setp(ax.xaxis.get_minorticklabels(), fontsize=7, color="#888888")
plt.setp(ax.xaxis.get_majorticklabels(), rotation=0, ha="center", fontsize=9)
fig.tight_layout()
plt.show()
```
<!-- références -->
[1]: https://aranet.com/en/home/products/aranet4-home "Appareil sur le site du fournisseur"