projet: commit initial

- Ajoute les données intérieures (chambre et pièce principale).
- Ajoute les données de MétéoSuisse.
- Crée l'environnement python et ajoute les dépendances.
- Crée le site web avec Quarto.
- Écrit la page qui analyse et présente les données pour le mois de juin
  2026 qui comprend la canicule du 18 au 29 juin.

Co-Authored-by: iGor milhit <igor@milhit.ch>
main
iGor milhit 2026-06-27 22:51:26 +02:00
commit ef79de8c74
Signed by: igor
GPG Key ID: 692D97C3D0228A99
14 changed files with 23033 additions and 0 deletions

14
.gitignore vendored 100644
View File

@ -0,0 +1,14 @@
# Python-generated files
__pycache__/
*.py[oc]
build/
dist/
wheels/
*.egg-info
# Virtual environments
.venv
/.quarto/
**/*.quarto_ipynb
_site

1
.python-version 100644
View File

@ -0,0 +1 @@
3.13

423
2026-06.qmd 100644
View File

@ -0,0 +1,423 @@
---
title: Juin 2026
subtitle: Un épisode caniculaire précoce et intense
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
cache: 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()}")
```
## Contexte
En 2026, il y avait déjà eu un épisode chaud en mai. Celui de juin a été plus
intense et plus long également. Officiellement, il a duré du 18 au 29 juin
2026.
```{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 et extérieure du 3 au 30 juin 2026. Zone orange : période de canicule officielle du 18 au 29 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 = pd.Timestamp("2026-06-29 23:59")
ax.axvspan(canicule_start, canicule_end,
color=CANICULE, alpha=0.55, zorder=0,
label="Période de canicule (plan cantonal GE, du 18 au 29 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)
# Date maximale couverte par les données extérieures
ext_last_date = ext_fig["time"].max().date()
nuits_dates = (
ext_tmp[mask_n]
.groupby("night_date")["temp_ext"]
.min()
.pipe(lambda s: s[s >= 20])
# Exclure les nuits dont la tranche matinale (0h6h) n'est pas couverte :
# night_date correspond au matin de la nuit → doit être ≤ dernière date disponible
.pipe(lambda s: s[pd.to_datetime(s.index) <= pd.Timestamp(ext_last_date)])
.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)")
ax.set_xlim(fig_start, fig_end)
# ── 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 — 330 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, 2628 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-26 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)")
ax.set_xlim(zoom_start, zoom_end)
# ── 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 2628 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"

0
README.md 100644
View File

24
_quarto.yml 100644
View File

@ -0,0 +1,24 @@
project:
type: website
website:
title: "Appartemp"
navbar:
left:
- href: index.qmd
text: Accueil
- href: 2026-06.qmd
text: "Juin 2026"
- href: about.qmd
text: À propos
format:
html:
theme:
- cosmo
- brand
css: styles.css
toc: true

5
about.qmd 100644
View File

@ -0,0 +1,5 @@
---
title: "About"
---
About this site

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

4345
data/gve_h_recent.csv 100644

File diff suppressed because it is too large Load Diff

BIN
img/appart.jpg 100644

Binary file not shown.

After

Width:  |  Height:  |  Size: 115 KiB

64
index.qmd 100644
View File

@ -0,0 +1,64 @@
---
title: Un appartement, des températures
subtitle: Contexte et mesure
lang: fr
creation_date: 2026-06-27T08:20:00+0200
date: today
id: 20260627082000
---
## Appartement
Il s'agit d'un site web qui cherche à documenter comment se comporte la
température dans un appartement. Il s'agit d'un appartement d'environ 45 m^2^,
au 8^e^ étage, exposé approximativement Sud-Sud-Est. Il n'est pas traversant et
la grande partie de la façade est constituée de fenêtres relativement peu
isolées. C'est bien un double vitrage, mais le cadre des fenêtre laisse passer
l'air et le bruit.
Le plan de l'appartement:
![Plan de l'appartement][2]
## Mesures
Les mesures intérieures sont prises par deux appareils [Aranet4][3], chaque 5
minutes, un appareil par pièce. Ils sont posés sur des étagères, environ à
1.20 m du sol, plutôt vers le mur intérieur de l'appartement, et non exposé au
soleil direct. J'utilise la fonction d'export au format CSV des données,
lorsque je veux traiter celles-ci. Ce n'est pas fait automatiquement.
Ces appareils mesurent:
- La teneur en CO^2^.
- La température.
- L'humidité relative.
- La pression atmosphérique.
Lorsque j'utilise des données de température extérieure, je me base sur celles
mises à disposition par MétéoSuisse, pour la station de Genève, à savoir
l'aéroport Cornavin. Ce sont des données officielles, fiables. Mais c'est assez
éloigné de l'appartement. Mon hypothèse non vérifiée est que la température
autour de l'appartement est vraisemblablement un peu moins élevée lors des
grandes chaleurs. Je n'ai aucune idée s'il y a vraiment une différence, et de
son importance éventuelle.
Mais les données de MétéoSuisse me semblent une bonne base. Je les récupère
avec la commande suivante, `gve` étant le code de la station:
```bash
curl -o gve_h_recent.csv https://data.geo.admin.ch/ch.meteoschweiz.ogd-smn/gve/ogd-smn_gve_h_recent.csv
```
## Périodes traitées
Pour le moment, il n'y a qu'un seul épisode qui est documenté:
- [Juin 2026][1].
<!-- références -->
[1]: 2026-06.qmd
[2]: ./img/appart.jpg
[3]: https://aranet.com/en/home/products/aranet4-home "Appareil sur le site du fournisseur"

14
pyproject.toml 100644
View File

@ -0,0 +1,14 @@
[project]
name = "appartemp"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
requires-python = ">=3.13"
dependencies = [
"jupyter>=1.1.1",
"jupyter-cache>=1.0.1",
"matplotlib>=3.11.0",
"numpy>=2.5.0",
"pandas>=3.0.3",
"scipy>=1.18.0",
]

1
styles.css 100644
View File

@ -0,0 +1 @@
/* css styles */

2103
uv.lock 100644

File diff suppressed because it is too large Load Diff