---
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"
