appartemp/2026-06.qmd

419 lines
17 KiB
Plaintext
Raw Permalink Blame History

This file contains invisible Unicode characters!

This file contains invisible Unicode characters that may be processed differently from what appears below. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to reveal hidden characters.

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

---
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 : 328 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, 20h6h). 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 — 328 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
6h7h 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, 2528 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 (20h6h)."
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 (20h6h)
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 5h10h
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 2528 juin 2026 — croisements T intérieure / T extérieure\n"
"Zones grises : tranches nocturnes (20h6h)",
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"