419 lines
17 KiB
Plaintext
419 lines
17 KiB
Plaintext
---
|
||
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"
|