--- 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() ``` [1]: https://aranet.com/en/home/products/aranet4-home "Appareil sur le site du fournisseur"