1

Commit initial

This commit is contained in:
Richard Dern 2025-11-17 02:00:28 +01:00
commit 62a928ec85
34 changed files with 1886 additions and 0 deletions

4
.env.example Normal file
View File

@ -0,0 +1,4 @@
INFLUXDB_URL=http://
INFLUXDB_TOKEN=
INFLUXDB_ORG=
INFLUXDB_BUCKET=weather

5
.gitignore vendored Normal file
View File

@ -0,0 +1,5 @@
.venv
.env
data
scripts/__pycache__
meteo/__pycache__

View File

@ -0,0 +1,17 @@
# Installation de l'environnement de base
Après avoir cloné le dépôt :
```shell
python3 -m venv .venv
source .venv/bin/activate
python -m pip install --upgrade pip
pip install -r requirements.txt
python -c "import pandas, influxdb_client, sklearn; print('OK')"
```
- On installe l'environnement virtuel de python
- On entre dans cet environnement
- On met à jour le gestionnaire de paquets pip
- On installe les dépendances définies dans `requirements.txt`
- On vérifie que les dépendances sont correctement installées

View File

@ -0,0 +1,12 @@
# Configuration
```shell
cp .env.example .env
```
On copie le fichier de configuration d'exemple, puis on l'ouvre pour l'adapter à notre cas.
- `INFLUXDB_URL` : URL de l'api du serveur InfluxDB2 (cela inclue probablement le port 8086)
- `INFLUXDB_TOKEN` : le jeton d'authentification à créer dans votre compte InfluxDB2
- `INFLUXDB_ORG` : l'organisation à laquelle le token est rattaché
- `INFLUXDB_BUCKET` : le nom du bucket dans lequel les données sont stockées

View File

@ -0,0 +1,169 @@
# Test de l'environnement de travail
```shell
python -m scripts.test_influx_connection
```
```output
Configuration InfluxDB chargée :
URL : http://10.0.3.2:8086
Org : Dern
Bucket : weather
→ Ping du serveur InfluxDB…
✔ Ping OK
→ Requête de test sur le bucket…
✔ Requête de test réussie : 18 table(s), 58 enregistrement(s) trouvés.
Exemple de point :
time : 2025-11-16 22:30:50.263360+00:00
measurement : %
field : device_class_str
value : humidity
```
Ensuite, on peut demander à InfluxDB de nous détailler ce qu'il stocke :
```shell
python -m scripts.test_influx_schema
```
```output
Bucket InfluxDB : weather
Measurements disponibles :
- %
- hPa
- km/h
- lx
- mm/h
- °
- °C
Champs pour measurement « % » :
- device_class_str (type: unknown)
- friendly_name_str (type: unknown)
- state_class_str (type: unknown)
- value (type: unknown)
Champs pour measurement « hPa » :
- device_class_str (type: unknown)
- friendly_name_str (type: unknown)
- state_class_str (type: unknown)
- value (type: unknown)
Champs pour measurement « km/h » :
- device_class_str (type: unknown)
- friendly_name_str (type: unknown)
- state_class_str (type: unknown)
- value (type: unknown)
Champs pour measurement « lx » :
- device_class_str (type: unknown)
- friendly_name_str (type: unknown)
- value (type: unknown)
Champs pour measurement « mm/h » :
- device_class_str (type: unknown)
- friendly_name_str (type: unknown)
- state_class_str (type: unknown)
- value (type: unknown)
Champs pour measurement « ° » :
- friendly_name_str (type: unknown)
- value (type: unknown)
Champs pour measurement « °C » :
- device_class_str (type: unknown)
- friendly_name_str (type: unknown)
- state_class_str (type: unknown)
- value (type: unknown)
```
Mais pour obtenir les données dont on a besoin, il faut aussi connaitre les entités manipulées par Influx :
```shell
python -m scripts.test_influx_entities
```
```output
Bucket InfluxDB : weather
Measurement « % »
Tag keys :
- _field
- _measurement
- _start
- _stop
- domain
- entity_id
entity_id possibles :
- station_meteo_bresser_exterieur_humidite_relative
Measurement « hPa »
Tag keys :
- _field
- _measurement
- _start
- _stop
- domain
- entity_id
entity_id possibles :
- station_meteo_bresser_exterieur_pression_atmospherique
Measurement « km/h »
Tag keys :
- _field
- _measurement
- _start
- _stop
- domain
- entity_id
entity_id possibles :
- station_meteo_bresser_exterieur_vitesse_du_vent
Measurement « lx »
Tag keys :
- _field
- _measurement
- _start
- _stop
- domain
- entity_id
entity_id possibles :
- station_meteo_bresser_exterieur_luminance
Measurement « mm/h »
Tag keys :
- _field
- _measurement
- _start
- _stop
- domain
- entity_id
entity_id possibles :
- station_meteo_bresser_exterieur_precipitations
Measurement « ° »
Tag keys :
- _field
- _measurement
- _start
- _stop
- domain
- entity_id
entity_id possibles :
- station_meteo_bresser_exterieur_direction_du_vent
Measurement « °C »
Tag keys :
- _field
- _measurement
- _start
- _stop
- domain
- entity_id
entity_id possibles :
- station_meteo_bresser_exterieur_temperature
```
Ces informations combinées se retrouvent dans le fichier `meteo/station_config.py`.

View File

@ -0,0 +1,19 @@
# Export des données
```shell
python -m scripts.export_station_data
```
La sortie est assez longue, et inclut un certain nombre d'avertissements qui peuvent être ignorés.
L'important est que le script se termine sur :
```output
✔ Export terminé : /Users/richard/Documents/donnees_meteo/data/weather_raw_7d.csv
```
(Le chemin changera sur votre propre machine)
Vérifiez que le fichier est bien créé et qu'il contient des données.
À la place de `scripts.export_station_data`, on pourrait aussi lancer `scripts.export_station_data_full`.
Au lieu de télécharger les données des 7 derniers jours, l'ensemble des données stockées sur le serveur pour ce bucket seront téléchargées, ce qui, selon la granularité et l'ancienneté des données peut prendre un certain temps et occuper un espace disque conséquent.

70
docs/05 - Ajustements.md Normal file
View File

@ -0,0 +1,70 @@
# Ajustements
Le fichier peut être rapidement inspecté avec la commande `head` :
```shell
head data/weather_raw_full.csv
```
```output
time,temperature,humidity,pressure,illuminance,wind_speed,wind_direction,rain_rate
2025-03-10 09:35:23.156646+00:00,,,996.95,,,,
2025-03-10 09:35:23.158538+00:00,10.6,,,,,,
2025-03-10 09:35:23.162398+00:00,,83.0,,,,,
2025-03-10 09:35:23.164634+00:00,,,,,7.4,,
2025-03-10 09:35:23.170122+00:00,,,,,,256.0,
2025-03-10 09:35:23.183555+00:00,,,,,,,0.0
2025-03-10 09:35:41.211148+00:00,,,,20551.2,,,
2025-03-10 09:36:22.638255+00:00,,,,,12.2,,
2025-03-10 09:36:22.640356+00:00,,,,,,306.0,
```
On peut voir que HomeAssistant écrit une nouvelle entrée pour chaque capteur, alors qu'on aurait pu s'attendre à une ligne unique pour l'ensemble des capteurs.
Le script suivant s'occupe de regrouper les données de capteurs dont l'enregistrement est proche :
```shell
python -m scripts.format_raw_csv
```
```output
Fichier brut chargé : data/weather_raw_full.csv
Lignes : 1570931, colonnes : ['temperature', 'humidity', 'pressure', 'illuminance', 'wind_speed', 'wind_direction', 'rain_rate']
Type d'index : <class 'pandas.core.indexes.datetimes.DatetimeIndex'>
Après combinaison (1s) : 630171 lignes
✔ Fichier formaté écrit dans : /Users/richard/Documents/donnees_meteo/data/weather_formatted_1s.csv
```
Un nouveau document CSV intermédiaire est donc créé.
```shell
head data/weather_formatted_1s.csv
```
```output
time,temperature,humidity,pressure,illuminance,wind_speed,wind_direction,rain_rate
2025-03-10 09:35:23+00:00,10.6,83.0,996.95,,7.4,256.0,0.0
2025-03-10 09:35:41+00:00,,,,20551.2,,,
2025-03-10 09:36:22+00:00,,,,20247.6,12.2,306.0,
2025-03-10 09:36:52+00:00,,,,20199.6,9.3,246.0,
2025-03-10 09:37:22+00:00,,,,20034.0,7.9,,
2025-03-10 09:37:52+00:00,,,,20124.0,7.4,284.0,
2025-03-10 09:38:22+00:00,,,,19860.0,9.7,215.0,
2025-03-10 09:39:22+00:00,,,,19722.0,11.4,203.0,
2025-03-10 09:40:22+00:00,,,,19720.8,10.0,209.0,
```
Il reste des cellules vides : en effet, HA n'enregistre pas la valeur d'un capteur si elle n'a pas changé depuis la dernière fois.
On fait donc :
```shell
python -m scripts.fill_formatted_1s
```
```output
Fichier 1s formaté chargé : data/weather_formatted_1s.csv
Lignes : 630171, colonnes : ['temperature', 'humidity', 'pressure', 'illuminance', 'wind_speed', 'wi
nd_direction', 'rain_rate'] Après propagation des dernières valeurs connues : 630171 lignes
✔ Fichier 1s 'complet' écrit dans : /Users/richard/Documents/donnees_meteo/data/weather_filled_1s.csv
```

132
docs/06 - Downsampling.md Normal file
View File

@ -0,0 +1,132 @@
# Downsampling
On peut maintenant s'assurer d'avoir une seule ligne par minute, avec toutes les valeurs de capteurs :
```shell
python -m scripts.make_minutely_dataset
```
Ce qui va produire le fichier `data/weather_minutely.csv`.
On peut s'assurer que plus aucune information n'est manquante :
```shell
python -m scripts.check_missing_values
```
```output
Dataset chargé : data/weather_minutely.csv
Lignes : 321881
Colonnes : ['temperature', 'humidity', 'pressure', 'illuminance', 'wind_speed', 'wind_direction', 'r
ain_rate']
=== Synthèse des valeurs manquantes ===
Total de cellules : 2253167
Cellules manquantes : 0
Fraction manquante : 0.000000
Lignes complètes : 321881
Lignes avec des trous : 0
Fraction lignes complètes : 1.000000
Valeurs manquantes par colonne :
- temperature : 0
- humidity : 0
- pressure : 0
- illuminance : 0
- wind_speed : 0
- wind_direction : 0
- rain_rate : 0
✔ Aucune valeur manquante dans le dataset minuté.
```
Le script suivant nous permet de vérifier rapidement si des problèmes majeurs peuvent être découverts :
```shell
python -m scripts.describe_minutely_dataset
```
```output
Dataset minuté chargé : data/weather_minutely.csv
Lignes : 321881
Colonnes : ['temperature', 'humidity', 'pressure', 'illuminance', 'wind_speed', 'wind_direction', 'r
ain_rate'] Période : 2025-03-10 09:35:00+00:00 → 2025-11-17 00:41:00+00:00
=== describe() ===
temperature humidity pressure ... wind_speed wind_direction rain_rate
count 321881.000000 321881.000000 321881.000000 ... 321881.000000 321881.000000 321881.000000
mean 15.004488 74.131993 1010.683189 ... 2.877190 181.977411 0.108216
std 6.349077 18.885843 8.210283 ... 3.151080 88.089334 0.820691
min -2.200000 20.000000 976.973123 ... 0.000000 0.000000 0.000000
25% 10.277778 59.000000 1005.420000 ... 0.000000 96.000000 0.000000
50% 14.600000 77.666667 1011.514287 ... 2.333549 210.000000 0.000000
75% 19.000000 91.000000 1015.900000 ... 4.650000 247.666196 0.000000
max 34.888889 99.000000 1033.187174 ... 26.554176 360.000000 42.672000
[8 rows x 7 columns]
=== Min / max avec dates ===
- temperature:
min = -2.2 à 2025-03-17 05:16:00+00:00
max = 34.8888888888889 à 2025-07-02 15:59:00+00:00
- humidity:
min = 20.0 à 2025-04-30 15:22:00+00:00
max = 99.0 à 2025-03-11 06:29:00+00:00
- pressure:
min = 976.973122738378 à 2025-10-23 05:06:00+00:00
max = 1033.18717416804 à 2025-10-10 17:12:00+00:00
- illuminance:
min = 0.0 à 2025-03-10 17:44:00+00:00
max = 133520.394 à 2025-07-29 11:48:00+00:00
- wind_speed:
min = 0.0 à 2025-03-10 14:31:00+00:00
max = 26.554176 à 2025-06-26 00:10:00+00:00
- wind_direction:
min = 0.0 à 2025-03-12 04:57:00+00:00
max = 360.0 à 2025-03-12 07:33:00+00:00
- rain_rate:
min = 0.0 à 2025-03-10 09:35:00+00:00
max = 42.672 à 2025-06-15 03:10:00+00:00
=== Vérification de la continuité temporelle ===
Différences d'intervalle (top 5):
time
0 days 00:01:00 304291
0 days 00:02:00 9426
0 days 00:03:00 3562
0 days 00:04:00 1740
0 days 00:05:00 1142
Name: count, dtype: int64
Nombre d'intervalles ≠ 60s : 17589
```
Il y a donc des trous entre certains jeux de données.
Ces écarts peuvent être identifiés avec le script suivant :
```shell
python -m scripts.list_time_gaps
```
```
Dataset minuté chargé : data/weather_minutely.csv
Lignes : 321881
=== Gaps temporels détectés ===
Nombre de gaps : 17589
Total minutes manquantes (théoriques) : 40466
Top 10 des gaps les plus longs :
- De 2025-06-21 19:09:00+00:00 à 2025-06-21 20:10:00+00:00 (durée: 0 days 01:01:00, manquants: 60, de
2025-06-21 19:10:00+00:00 à 2025-06-21 20:09:00+00:00) - De 2025-08-10 22:17:00+00:00 à 2025-08-10 23:15:00+00:00 (durée: 0 days 00:58:00, manquants: 57, de
2025-08-10 22:18:00+00:00 à 2025-08-10 23:14:00+00:00) - De 2025-09-24 20:34:00+00:00 à 2025-09-24 21:32:00+00:00 (durée: 0 days 00:58:00, manquants: 57, de
2025-09-24 20:35:00+00:00 à 2025-09-24 21:31:00+00:00) - De 2025-06-21 10:58:00+00:00 à 2025-06-21 11:55:00+00:00 (durée: 0 days 00:57:00, manquants: 56, de
2025-06-21 10:59:00+00:00 à 2025-06-21 11:54:00+00:00) - De 2025-07-10 07:17:00+00:00 à 2025-07-10 08:14:00+00:00 (durée: 0 days 00:57:00, manquants: 56, de
2025-07-10 07:18:00+00:00 à 2025-07-10 08:13:00+00:00) - De 2025-07-24 03:52:00+00:00 à 2025-07-24 04:46:00+00:00 (durée: 0 days 00:54:00, manquants: 53, de
2025-07-24 03:53:00+00:00 à 2025-07-24 04:45:00+00:00) - De 2025-10-28 08:31:00+00:00 à 2025-10-28 09:23:00+00:00 (durée: 0 days 00:52:00, manquants: 51, de
2025-10-28 08:32:00+00:00 à 2025-10-28 09:22:00+00:00) - De 2025-03-16 15:31:00+00:00 à 2025-03-16 16:20:00+00:00 (durée: 0 days 00:49:00, manquants: 48, de
2025-03-16 15:32:00+00:00 à 2025-03-16 16:19:00+00:00) - De 2025-06-21 12:22:00+00:00 à 2025-06-21 13:08:00+00:00 (durée: 0 days 00:46:00, manquants: 45, de
2025-06-21 12:23:00+00:00 à 2025-06-21 13:07:00+00:00) - De 2025-06-21 17:25:00+00:00 à 2025-06-21 18:10:00+00:00 (durée: 0 days 00:45:00, manquants: 44, de
2025-06-21 17:26:00+00:00 à 2025-06-21 18:09:00+00:00)
```
Ces trous dans les données peuvent correspondre à des pannes de connexion entre la station et mon réseau, un redémarrage de mon serveur (physique ou logiciel), au redémarrage de la box ou du point d'accès sans-fil, etc.

View File

@ -0,0 +1,8 @@
# Premiers graphiques
Ces premiers graphiques devraient être similaires à ce que sortirait InfluxDB.
On s'assure juste, ici, d'être capables de produire un résultat parlant, sans erreurs, et conforme à ce qui est vérifiable dans Influx.
## Température moyenne sur les 7 derniers jours
![](../figures/temperature_last_7_days.png)

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

67
meteo/config.py Normal file
View File

@ -0,0 +1,67 @@
# meteo/config.py
from __future__ import annotations
from dataclasses import dataclass
from typing import Self
import os
from dotenv import load_dotenv
@dataclass(frozen=True)
class InfluxSettings:
"""
Configuration nécessaire pour communiquer avec un serveur InfluxDB 2.x.
Les valeurs sont généralement chargées depuis des variables d'environnement,
éventuellement via un fichier `.env` à la racine du projet.
"""
url: str
token: str
org: str
bucket: str
@classmethod
def from_env(cls) -> Self:
"""
Construit un objet `InfluxSettings` à partir des variables d'environnement.
Variables attendues :
- INFLUXDB_URL
- INFLUXDB_TOKEN
- INFLUXDB_ORG
- INFLUXDB_BUCKET
Lève une RuntimeError si une variable obligatoire est manquante.
"""
# Charge un éventuel fichier .env (idempotent)
load_dotenv()
url = os.getenv("INFLUXDB_URL")
token = os.getenv("INFLUXDB_TOKEN")
org = os.getenv("INFLUXDB_ORG")
bucket = os.getenv("INFLUXDB_BUCKET")
values = {
"INFLUXDB_URL": url,
"INFLUXDB_TOKEN": token,
"INFLUXDB_ORG": org,
"INFLUXDB_BUCKET": bucket,
}
missing = [name for name, value in values.items() if not value]
if missing:
missing_str = ", ".join(missing)
raise RuntimeError(
f"Les variables d'environnement suivantes sont manquantes : {missing_str}. "
"Définissez-les dans votre environnement ou dans un fichier .env."
)
return cls(
url=url, # type: ignore[arg-type]
token=token, # type: ignore[arg-type]
org=org, # type: ignore[arg-type]
bucket=bucket, # type: ignore[arg-type]
)

171
meteo/dataset.py Normal file
View File

@ -0,0 +1,171 @@
# meteo/dataset.py
from __future__ import annotations
from pathlib import Path
from typing import Literal
import pandas as pd
import numpy as np
def fill_missing_with_previous(df: pd.DataFrame) -> pd.DataFrame:
"""
Remplit les valeurs manquantes en propageant, pour chaque capteur, la
dernière valeur connue vers le bas (forward-fill).
C'est adapté au comportement de Home Assistant, qui n'écrit une nouvelle
valeur que lorsque l'état change.
- Les premières lignes, avant toute mesure pour un capteur donné,
resteront NaN ; on supprime les lignes qui sont NaN pour toutes
les colonnes.
"""
if not isinstance(df.index, pd.DatetimeIndex):
raise TypeError(
"fill_missing_with_previous nécessite un DataFrame avec un DatetimeIndex. "
"Utilisez d'abord load_raw_csv() ou imposez un index temporel."
)
df = df.sort_index()
# Propage la dernière valeur connue vers le bas
df_filled = df.ffill()
# Supprime les lignes vraiment vides (avant la première donnée)
df_filled = df_filled.dropna(how="all")
return df_filled
def _circular_mean_deg(series: pd.Series) -> float | np.floating | float("nan"):
"""
Calcule la moyenne d'un angle en degrés en tenant compte de la circularité.
Exemple : la moyenne de 350° et 10° = 0° (et pas 180°).
Retourne NaN si la série est vide ou entièrement NaN.
"""
values = series.dropna().to_numpy(dtype=float)
if values.size == 0:
return float("nan")
radians = np.deg2rad(values)
sin_mean = np.sin(radians).mean()
cos_mean = np.cos(radians).mean()
angle = np.rad2deg(np.arctan2(sin_mean, cos_mean))
if angle < 0:
angle += 360.0
return angle
def resample_to_minutes(df: pd.DataFrame) -> pd.DataFrame:
"""
Ramène un DataFrame indexé par le temps à une granularité d'une minute.
Hypothèses :
- Index = DatetimeIndex (par exemple issu de load_raw_csv sur le CSV "formaté").
- Colonnes attendues :
temperature, humidity, pressure, illuminance,
wind_speed, wind_direction, rain_rate
Agrégation :
- Température, humidité, pression, illuminance, vitesse de vent :
moyenne sur la minute.
- Direction du vent :
moyenne circulaire en degrés.
- rain_rate (mm/h) :
moyenne sur la minute (on reste sur un taux, on ne convertit pas en cumul).
"""
if not isinstance(df.index, pd.DatetimeIndex):
raise TypeError(
"resample_to_minutes nécessite un DataFrame avec un DatetimeIndex. "
"Utilisez load_raw_csv() pour charger le CSV."
)
# On définit une stratégie d'agrégation par colonne
agg = {
"temperature": "mean",
"humidity": "mean",
"pressure": "mean",
"illuminance": "mean",
"wind_speed": "mean",
"wind_direction": _circular_mean_deg,
"rain_rate": "mean",
}
df_minutely = df.resample("60s").agg(agg)
# On supprime les minutes où il n'y a vraiment aucune donnée
df_minutely = df_minutely.dropna(how="all")
return df_minutely
def load_raw_csv(path: str | Path) -> pd.DataFrame:
"""
Charge le CSV brut exporté depuis InfluxDB et retourne un DataFrame
indexé par le temps.
- La colonne `time` est lue comme texte.
- On la parse explicitement en ISO8601 (gère les microsecondes optionnelles).
- `time` devient l'index.
- Les lignes sont triées par ordre chronologique.
"""
csv_path = Path(path)
# On lit sans parsing automatique pour garder le contrôle
df = pd.read_csv(csv_path, dtype={"time": "string"})
if "time" not in df.columns:
raise ValueError(
f"Le fichier {csv_path} ne contient pas de colonne 'time'. "
"Ce fichier n'a probablement pas été généré par export_station_data."
)
# Parsing robuste des timestamps ISO8601 (gère 2025-...SS+00:00 et 2025-...SS.ffffff+00:00)
df["time"] = pd.to_datetime(df["time"], format="ISO8601")
df = df.set_index("time").sort_index()
# On vérifie qu'on a bien un DatetimeIndex
if not isinstance(df.index, pd.DatetimeIndex):
raise TypeError(
f"L'index du fichier {csv_path} n'est pas un DatetimeIndex après parsing ISO8601."
)
return df
def combine_close_observations(
df: pd.DataFrame,
*,
freq: str = "1s",
agg: Literal["mean", "median", "first", "last"] = "mean",
) -> pd.DataFrame:
"""
Combine les lignes dont les timestamps tombent dans la même fenêtre temporelle.
Typiquement, avec `freq="1s"`, toutes les mesures comprises entre
HH:MM:SS.000 et HH:MM:SS.999 seront regroupées en une seule ligne
datée HH:MM:SS.
"""
if not isinstance(df.index, pd.DatetimeIndex):
raise TypeError(
"combine_close_observations nécessite un DataFrame avec un DatetimeIndex. "
"Utilisez d'abord load_raw_csv() ou imposez un index temporel."
)
freq = freq.lower() # évite le FutureWarning sur 'S'
if agg == "mean":
df_resampled = df.resample(freq).mean()
elif agg == "median":
df_resampled = df.resample(freq).median()
elif agg == "first":
df_resampled = df.resample(freq).first()
elif agg == "last":
df_resampled = df.resample(freq).last()
else:
raise ValueError(f"Fonction d'agrégation non supportée : {agg!r}")
df_resampled = df_resampled.dropna(how="all")
return df_resampled

158
meteo/export.py Normal file
View File

@ -0,0 +1,158 @@
# meteo/export.py
from __future__ import annotations
from pathlib import Path
from typing import Iterable, Literal
import pandas as pd
from influxdb_client import InfluxDBClient
from .station_config import SensorDefinition, StationConfig
def _build_sensor_flux_query(
bucket: str,
sensor: SensorDefinition,
start: str,
stop: str | None,
) -> str:
"""
Construit une requête Flux pour un capteur donné (measurement + entity_id).
On ne récupère que le champ `value`, qui contient la valeur physique,
et on laisse Influx nous renvoyer tous les points bruts (chez vous : 1 point / minute).
"""
stop_clause = f", stop: {stop}" if stop is not None else ""
return f"""
from(bucket: "{bucket}")
|> range(start: {start}{stop_clause})
|> filter(fn: (r) => r._measurement == "{sensor.measurement}")
|> filter(fn: (r) => r["entity_id"] == "{sensor.entity_id}")
|> filter(fn: (r) => r._field == "value")
|> keep(columns: ["_time", "_value"])
|> sort(columns: ["_time"])
"""
def fetch_sensor_series(
client: InfluxDBClient,
bucket: str,
sensor: SensorDefinition,
*,
start: str,
stop: str | None = None,
) -> pd.Series:
"""
Récupère la série temporelle d'un capteur unique sous forme de Series pandas.
Paramètres
----------
client :
Client InfluxDB déjà configuré.
bucket :
Nom du bucket.
sensor :
Définition du capteur (measurement + entity_id).
start, stop :
Intervalle temporel au format Flux (ex: "-7d", "2025-01-01T00:00:00Z").
Retour
------
Series pandas indexée par le temps, nommée `sensor.name`.
"""
query_api = client.query_api()
flux_query = _build_sensor_flux_query(bucket, sensor, start=start, stop=stop)
result = query_api.query_data_frame(flux_query)
if isinstance(result, list):
df = pd.concat(result, ignore_index=True)
else:
df = result
if df.empty:
# Série vide : on renvoie une Series vide avec le bon nom.
return pd.Series(name=sensor.name, dtype="float64")
df = df.rename(columns={"_time": "time", "_value": sensor.name})
df["time"] = pd.to_datetime(df["time"])
df = df.set_index("time").sort_index()
# On ne retourne qu'une seule colonne en tant que Series
series = df[sensor.name]
series.name = sensor.name
return series
def export_station_data(
client: InfluxDBClient,
bucket: str,
config: StationConfig,
*,
start: str,
stop: str | None = None,
output_path: str | Path = "data/weather_raw.csv",
file_format: Literal["csv", "parquet"] = "csv",
) -> Path:
"""
Exporte les données de la station météo vers un fichier (CSV ou Parquet).
Pour chaque capteur de `config.sensors`, cette fonction récupère la série
temporelle correspondante, puis assemble le tout dans un DataFrame unique.
Paramètres
----------
client :
Client InfluxDB.
bucket :
Nom du bucket à interroger (ex: "weather").
config :
Configuration de la station (liste de capteurs).
start, stop :
Intervalle temporel en syntaxe Flux (ex: "-30d", "2024-01-01T00:00:00Z").
Si `stop` est None, Influx utilisera `now()`.
output_path :
Chemin du fichier de sortie.
file_format :
"csv" ou "parquet".
Retour
------
Path :
Chemin absolu du fichier écrit.
"""
output_path = Path(output_path)
output_path.parent.mkdir(parents=True, exist_ok=True)
series_list: list[pd.Series] = []
for sensor in config.sensors:
series = fetch_sensor_series(
client,
bucket,
sensor,
start=start,
stop=stop,
)
if not series.empty:
series_list.append(series)
else:
# On pourrait logger un warning ici si vous le souhaitez.
pass
if not series_list:
raise RuntimeError(
"Aucune donnée récupérée pour la station sur l'intervalle demandé."
)
# Assemblage des séries : jointure externe sur l'index temps.
df = pd.concat(series_list, axis=1).sort_index()
if file_format == "csv":
df.to_csv(output_path, index_label="time")
elif file_format == "parquet":
df.to_parquet(output_path)
else:
raise ValueError(f"Format de fichier non supporté : {file_format!r}")
return output_path.resolve()

80
meteo/gaps.py Normal file
View File

@ -0,0 +1,80 @@
# meteo/gaps.py
from __future__ import annotations
from dataclasses import dataclass
from typing import List
import pandas as pd
@dataclass(frozen=True)
class TimeGap:
"""
Représente une période pendant laquelle il manque des points dans une
série temporelle censée être échantillonnée à intervalle régulier.
"""
# Timestamp du dernier point avant le trou
before: pd.Timestamp
# Timestamp du premier point après le trou
after: pd.Timestamp
# Premier timestamp "attendu" manquant
missing_start: pd.Timestamp
# Dernier timestamp "attendu" manquant
missing_end: pd.Timestamp
# Nombre d'intervalles manquants (par ex. 3 => 3 minutes manquantes)
missing_intervals: int
# Durée totale du gap (after - before)
duration: pd.Timedelta
def find_time_gaps(
df: pd.DataFrame,
expected_freq: pd.Timedelta = pd.Timedelta(minutes=1),
) -> List[TimeGap]:
"""
Détecte les gaps temporels dans un DataFrame indexé par le temps.
Un "gap" est un intervalle entre deux timestamps successifs strictement
supérieur à `expected_freq`.
Exemple : si expected_freq = 1 minute et qu'on passe de 10:00 à 10:05,
on détecte un gap avec 4 minutes manquantes (10:01, 10:02, 10:03, 10:04).
"""
if not isinstance(df.index, pd.DatetimeIndex):
raise TypeError(
"find_time_gaps nécessite un DataFrame avec un DatetimeIndex."
)
index = df.index.sort_values()
diffs = index.to_series().diff()
gaps: list[TimeGap] = []
for i, delta in enumerate(diffs.iloc[1:], start=1):
if delta <= expected_freq:
continue
before_ts = index[i - 1]
after_ts = index[i]
# Nombre d'intervalles manquants (ex: 5min / 1min => 4 intervalles manquants)
missing_intervals = int(delta // expected_freq) - 1
missing_start = before_ts + expected_freq
missing_end = after_ts - expected_freq
gap = TimeGap(
before=before_ts,
after=after_ts,
missing_start=missing_start,
missing_end=missing_end,
missing_intervals=missing_intervals,
duration=delta,
)
gaps.append(gap)
return gaps

41
meteo/influx_client.py Normal file
View File

@ -0,0 +1,41 @@
# meteo/influx_client.py
from __future__ import annotations
from typing import Any
from influxdb_client import InfluxDBClient
from .config import InfluxSettings
def create_influx_client(settings: InfluxSettings) -> InfluxDBClient:
"""
Crée et retourne un client InfluxDB configuré.
Le client doit être fermé par l'appelant lorsqu'il n'est plus nécessaire.
"""
client = InfluxDBClient(
url=settings.url,
token=settings.token,
org=settings.org,
)
return client
def test_basic_query(client: InfluxDBClient, bucket: str) -> list[Any]:
"""
Exécute une requête Flux très simple sur le bucket donné pour vérifier
que la communication fonctionne et que le bucket est accessible.
Retourne la liste brute de tables renvoyées par InfluxDB.
Lève une exception en cas de problème réseau, d'authentification, etc.
"""
query_api = client.query_api()
flux_query = f"""
from(bucket: "{bucket}")
|> range(start: -1h)
|> limit(n: 5)
"""
tables = query_api.query(flux_query)
return tables

57
meteo/quality.py Normal file
View File

@ -0,0 +1,57 @@
# meteo/quality.py
from __future__ import annotations
from dataclasses import dataclass
from typing import Dict
import pandas as pd
@dataclass(frozen=True)
class MissingValuesSummary:
"""
Résumé des valeurs manquantes dans un DataFrame.
"""
total_rows: int
total_columns: int
total_cells: int
missing_cells: int
missing_by_column: Dict[str, int]
rows_with_missing: int
rows_fully_complete: int
@property
def fraction_missing(self) -> float:
return self.missing_cells / self.total_cells if self.total_cells else 0.0
@property
def fraction_rows_complete(self) -> float:
return self.rows_fully_complete / self.total_rows if self.total_rows else 0.0
def summarize_missing_values(df: pd.DataFrame) -> MissingValuesSummary:
"""
Calcule un résumé des valeurs manquantes d'un DataFrame.
Ne modifie pas le DataFrame.
"""
missing_mask = df.isna()
total_rows, total_columns = df.shape
total_cells = int(df.size)
missing_cells = int(missing_mask.sum().sum())
missing_by_column = missing_mask.sum().astype(int).to_dict()
rows_with_missing = int(missing_mask.any(axis=1).sum())
rows_fully_complete = int((~missing_mask.any(axis=1)).sum())
return MissingValuesSummary(
total_rows=total_rows,
total_columns=total_columns,
total_cells=total_cells,
missing_cells=missing_cells,
missing_by_column=missing_by_column,
rows_with_missing=rows_with_missing,
rows_fully_complete=rows_fully_complete,
)

143
meteo/schema.py Normal file
View File

@ -0,0 +1,143 @@
# meteo/schema.py
from __future__ import annotations
from dataclasses import dataclass
from typing import List
from influxdb_client import InfluxDBClient
@dataclass(frozen=True)
class MeasurementField:
"""
Représente un champ (_field) d'un measurement InfluxDB.
"""
name: str
type: str # "float", "string", "boolean", "integer", "unsigned"
def list_measurements(client: InfluxDBClient, bucket: str) -> list[str]:
"""
Retourne la liste des measurements présents dans le bucket donné.
Utilise le package Flux `schema.measurements`.
"""
query = f"""
import "influxdata/influxdb/schema"
schema.measurements(bucket: "{bucket}")
"""
query_api = client.query_api()
tables = query_api.query(query)
measurements: set[str] = set()
for table in tables:
for record in table.records:
value = record.get_value()
if isinstance(value, str):
measurements.add(value)
return sorted(measurements)
def list_measurement_fields(
client: InfluxDBClient,
bucket: str,
measurement: str,
) -> list[MeasurementField]:
"""
Retourne la liste des champs (_field) pour un measurement donné,
avec leur type InfluxDB (float, string, boolean, integer, unsigned).
Utilise le package Flux `schema.measurementFieldKeys`.
"""
query = f"""
import "influxdata/influxdb/schema"
schema.measurementFieldKeys(
bucket: "{bucket}",
measurement: "{measurement}",
)
"""
query_api = client.query_api()
tables = query_api.query(query)
fields: dict[str, str] = {}
for table in tables:
for record in table.records:
name = record.get_value()
type_str = str(record.values.get("type", "unknown"))
if isinstance(name, str):
fields[name] = type_str
return [
MeasurementField(name=n, type=t)
for n, t in sorted(fields.items(), key=lambda item: item[0])
]
def list_measurement_tag_keys(
client: InfluxDBClient,
bucket: str,
measurement: str,
) -> list[str]:
"""
Retourne la liste des clés de tags (tag keys) pour un measurement donné.
Utilise `schema.measurementTagKeys`.
"""
query = f"""
import "influxdata/influxdb/schema"
schema.measurementTagKeys(
bucket: "{bucket}",
measurement: "{measurement}",
)
"""
query_api = client.query_api()
tables = query_api.query(query)
keys: set[str] = set()
for table in tables:
for record in table.records:
value = record.get_value()
if isinstance(value, str):
keys.add(value)
return sorted(keys)
def list_measurement_tag_values(
client: InfluxDBClient,
bucket: str,
measurement: str,
tag: str,
) -> list[str]:
"""
Retourne la liste des valeurs possibles pour un tag donné (par exemple
`entity_id`) sur un measurement donné.
Utilise `schema.measurementTagValues`.
"""
query = f"""
import "influxdata/influxdb/schema"
schema.measurementTagValues(
bucket: "{bucket}",
measurement: "{measurement}",
tag: "{tag}",
)
"""
query_api = client.query_api()
tables = query_api.query(query)
values: set[str] = set()
for table in tables:
for record in table.records:
value = record.get_value()
if isinstance(value, str):
values.add(value)
return sorted(values)

84
meteo/station_config.py Normal file
View File

@ -0,0 +1,84 @@
# meteo/station_config.py
from __future__ import annotations
from dataclasses import dataclass
from typing import Tuple
@dataclass(frozen=True)
class SensorDefinition:
"""
Décrit un capteur individuel de la station météo, tel qu'il est stocké dans InfluxDB.
"""
name: str # nom logique dans nos fichiers (ex: "temperature")
measurement: str # nom du measurement InfluxDB (ex: "°C")
entity_id: str # valeur du tag `entity_id` (ex: "station_meteo_bresser_exterieur_temperature")
unit: str # unité humaine (ex: "°C", "%", "hPa")
@dataclass(frozen=True)
class StationConfig:
"""
Configuration complète de la station météo.
Pour l'instant, c'est simplement une liste immuable de capteurs.
"""
sensors: Tuple[SensorDefinition, ...]
def default_station_config() -> StationConfig:
"""
Retourne la configuration par défaut de la station météo Bresser extérieure.
Cette config est basée sur :
- les measurements (°, °C, %, hPa, km/h, lx, mm/h)
- les entity_id trouvés dans le bucket "weather".
"""
sensors = (
SensorDefinition(
name="temperature",
measurement="°C",
entity_id="station_meteo_bresser_exterieur_temperature",
unit="°C",
),
SensorDefinition(
name="humidity",
measurement="%",
entity_id="station_meteo_bresser_exterieur_humidite_relative",
unit="%",
),
SensorDefinition(
name="pressure",
measurement="hPa",
entity_id="station_meteo_bresser_exterieur_pression_atmospherique",
unit="hPa",
),
SensorDefinition(
name="illuminance",
measurement="lx",
entity_id="station_meteo_bresser_exterieur_luminance",
unit="lx",
),
SensorDefinition(
name="wind_speed",
measurement="km/h",
entity_id="station_meteo_bresser_exterieur_vitesse_du_vent",
unit="km/h",
),
SensorDefinition(
name="wind_direction",
measurement="°",
entity_id="station_meteo_bresser_exterieur_direction_du_vent",
unit="°",
),
SensorDefinition(
name="rain_rate",
measurement="mm/h",
entity_id="station_meteo_bresser_exterieur_precipitations",
unit="mm/h",
),
)
return StationConfig(sensors=sensors)

19
readme.md Normal file
View File

@ -0,0 +1,19 @@
# Exploration de mes données météorologiques
## Introduction
Je stocke les données fournies par ma station météo dans un serveur InfluxDB2 via HomeAssistant.
Je souhaite explorer ces données de différentes façons, de sorte à mettre en évidence des principes météorologiques connus.
Je vais également concevoir un petit outil prédictif.
## Notes
J'ai conscience des limites de ce que je peux faire.
Il ne s'agit nullement de concurrencer les services météorologiques nationaux.
## Objectifs
- Aller au-delà du plaisir d'avoir une station météo, de la collecte de données et des comparaisons simples
- Mettre en évidence des principes scientifiques connus
- Exploiter des données acquises par nos propres moyens

20
requirements.txt Normal file
View File

@ -0,0 +1,20 @@
# Accès à InfluxDB 2
influxdb-client
# Manipulation des données
pandas
numpy
# Visualisation
matplotlib
seaborn
# Modèles statistiques / ML
scikit-learn
statsmodels
# Gestion propre des secrets/config (.env)
python-dotenv
# Optionnel : rose des vents
windrose

0
scripts/__init__.py Normal file
View File

View File

@ -0,0 +1,52 @@
# scripts/check_missing_values.py
from __future__ import annotations
from pathlib import Path
from meteo.dataset import load_raw_csv
from meteo.quality import summarize_missing_values
CSV_PATH = Path("data/weather_minutely.csv")
def main() -> None:
if not CSV_PATH.exists():
print(f"⚠ Fichier introuvable : {CSV_PATH}")
print(" Assurez-vous d'avoir généré le dataset minuté.")
return
df = load_raw_csv(CSV_PATH)
print(f"Dataset chargé : {CSV_PATH}")
print(f" Lignes : {len(df)}")
print(f" Colonnes : {list(df.columns)}")
summary = summarize_missing_values(df)
print()
print("=== Synthèse des valeurs manquantes ===")
print(f"Total de cellules : {summary.total_cells}")
print(f"Cellules manquantes : {summary.missing_cells}")
print(f"Fraction manquante : {summary.fraction_missing:.6f}")
print(f"Lignes complètes : {summary.rows_fully_complete}")
print(f"Lignes avec des trous : {summary.rows_with_missing}")
print(f"Fraction lignes complètes : {summary.fraction_rows_complete:.6f}")
print()
print("Valeurs manquantes par colonne :")
for col, n_missing in summary.missing_by_column.items():
print(f" - {col:13s} : {n_missing}")
if summary.missing_cells == 0:
print()
print("✔ Aucune valeur manquante dans le dataset minuté.")
else:
print()
print("⚠ Il reste des valeurs manquantes.")
print(" Exemple de lignes concernées :")
rows_with_missing = df[df.isna().any(axis=1)]
print(rows_with_missing.head(10))
if __name__ == "__main__":
main()

View File

@ -0,0 +1,61 @@
# scripts/describe_minutely_dataset.py
from __future__ import annotations
from pathlib import Path
import pandas as pd
from meteo.dataset import load_raw_csv
CSV_PATH = Path("data/weather_minutely.csv")
def main() -> None:
if not CSV_PATH.exists():
print(f"⚠ Fichier introuvable : {CSV_PATH}")
print(" Assurez-vous d'avoir généré le dataset minuté.")
return
df = load_raw_csv(CSV_PATH)
print(f"Dataset minuté chargé : {CSV_PATH}")
print(f" Lignes : {len(df)}")
print(f" Colonnes : {list(df.columns)}")
print(f" Période : {df.index[0]}{df.index[-1]}")
print()
# 1. Résumé statistique classique
print("=== describe() ===")
print(df.describe())
print()
# 2. Min / max par variable avec leurs dates
print("=== Min / max avec dates ===")
for col in df.columns:
series = df[col]
min_val = series.min()
max_val = series.max()
min_ts = series.idxmin()
max_ts = series.idxmax()
print(f"- {col}:")
print(f" min = {min_val} à {min_ts}")
print(f" max = {max_val} à {max_ts}")
print()
# 3. Vérification rapide de la continuité temporelle
print("=== Vérification de la continuité temporelle ===")
diffs = df.index.to_series().diff().dropna()
counts = diffs.value_counts().sort_index()
print("Différences d'intervalle (top 5):")
print(counts.head())
print()
nb_not_60s = (diffs != pd.Timedelta(minutes=1)).sum()
print(f"Nombre d'intervalles ≠ 60s : {nb_not_60s}")
if __name__ == "__main__":
main()

View File

@ -0,0 +1,44 @@
# tests/export_station_data.py
from __future__ import annotations
from contextlib import closing
from meteo.config import InfluxSettings
from meteo.influx_client import create_influx_client
from meteo.station_config import default_station_config
from meteo.export import export_station_data
def main() -> None:
"""
Exporte les données de la station météo vers un fichier CSV brut.
Par défaut, on exporte les 7 derniers jours dans `data/weather_raw_7d.csv`.
"""
settings = InfluxSettings.from_env()
station_config = default_station_config()
print("Configuration InfluxDB :")
print(f" URL : {settings.url}")
print(f" Org : {settings.org}")
print(f" Bucket : {settings.bucket}")
print()
with closing(create_influx_client(settings)) as client:
print("→ Export des 7 derniers jours…")
output_path = export_station_data(
client=client,
bucket=settings.bucket,
config=station_config,
start="-7d", # à ajuster plus tard si besoin
stop=None, # now()
output_path="data/weather_raw_7d.csv",
file_format="csv",
)
print()
print(f"✔ Export terminé : {output_path}")
if __name__ == "__main__":
main()

View File

@ -0,0 +1,49 @@
# tests/export_station_data_full.py
from __future__ import annotations
from contextlib import closing
from meteo.config import InfluxSettings
from meteo.influx_client import create_influx_client
from meteo.station_config import default_station_config
from meteo.export import export_station_data
def main() -> None:
"""
Exporte l'historique complet de la station météo vers un fichier CSV.
On utilise `start=0`, ce qui signifie "depuis le début des données"
(en pratique depuis l'epoch, donc tout ce que le bucket contient).
"""
settings = InfluxSettings.from_env()
station_config = default_station_config()
print("Configuration InfluxDB :")
print(f" URL : {settings.url}")
print(f" Org : {settings.org}")
print(f" Bucket : {settings.bucket}")
print()
print("⚠ Attention : un export complet peut produire un fichier volumineux "
"et prendre un certain temps si l'historique est long.")
print()
with closing(create_influx_client(settings)) as client:
print("→ Export de l'historique complet…")
output_path = export_station_data(
client=client,
bucket=settings.bucket,
config=station_config,
start="0", # depuis le début des données
stop=None, # jusqu'à maintenant
output_path="data/weather_raw_full.csv",
file_format="csv", # vous pouvez mettre "parquet" si vous préférez
)
print()
print(f"✔ Export terminé : {output_path}")
if __name__ == "__main__":
main()

View File

@ -0,0 +1,32 @@
# scripts/fill_formatted_1s.py
from __future__ import annotations
from pathlib import Path
from meteo.dataset import load_raw_csv, fill_missing_with_previous
INPUT_CSV_PATH = Path("data/weather_formatted_1s.csv")
OUTPUT_CSV_PATH = Path("data/weather_filled_1s.csv")
def main() -> None:
if not INPUT_CSV_PATH.exists():
print(f"⚠ Fichier introuvable : {INPUT_CSV_PATH}")
print(" Lancez d'abord : python -m scripts.format_raw_csv")
return
df_1s = load_raw_csv(INPUT_CSV_PATH)
print(f"Fichier 1s formaté chargé : {INPUT_CSV_PATH}")
print(f" Lignes : {len(df_1s)}, colonnes : {list(df_1s.columns)}")
df_filled = fill_missing_with_previous(df_1s)
print(f"Après propagation des dernières valeurs connues : {len(df_filled)} lignes")
OUTPUT_CSV_PATH.parent.mkdir(parents=True, exist_ok=True)
df_filled.to_csv(OUTPUT_CSV_PATH, index_label="time")
print(f"✔ Fichier 1s 'complet' écrit dans : {OUTPUT_CSV_PATH.resolve()}")
if __name__ == "__main__":
main()

31
scripts/format_raw_csv.py Normal file
View File

@ -0,0 +1,31 @@
from __future__ import annotations
from pathlib import Path
from meteo.dataset import load_raw_csv, combine_close_observations
RAW_CSV_PATH = Path("data/weather_raw_full.csv")
OUTPUT_CSV_PATH = Path("data/weather_formatted_1s.csv")
def main() -> None:
if not RAW_CSV_PATH.exists():
print(f"⚠ Fichier brut introuvable : {RAW_CSV_PATH}")
return
df_raw = load_raw_csv(RAW_CSV_PATH)
print(f"Fichier brut chargé : {RAW_CSV_PATH}")
print(f" Lignes : {len(df_raw)}, colonnes : {list(df_raw.columns)}")
print(f" Type d'index : {type(df_raw.index)}")
df_fmt = combine_close_observations(df_raw, freq="1s", agg="mean")
print(f"Après combinaison (1s) : {len(df_fmt)} lignes")
OUTPUT_CSV_PATH.parent.mkdir(parents=True, exist_ok=True)
df_fmt.to_csv(OUTPUT_CSV_PATH, index_label="time")
print(f"✔ Fichier formaté écrit dans : {OUTPUT_CSV_PATH.resolve()}")
if __name__ == "__main__":
main()

View File

@ -0,0 +1,29 @@
# scripts/inspect_time_column.py
from __future__ import annotations
import pandas as pd
from pathlib import Path
CSV_PATH = Path("data/weather_raw_full.csv")
def main() -> None:
df = pd.read_csv(CSV_PATH, dtype={"time": "string"})
print("Aperçu brut de la colonne 'time' :")
print(df["time"].head(10))
print()
# On tente de parser en ISO8601, mais sans lever d'erreur :
parsed = pd.to_datetime(df["time"], format="ISO8601", errors="coerce")
invalid_mask = parsed.isna()
nb_invalid = invalid_mask.sum()
print(f"Nombre de valeurs 'time' non parsables en ISO8601 : {nb_invalid}")
if nb_invalid > 0:
print("Exemples de valeurs problématiques :")
print(df.loc[invalid_mask, "time"].drop_duplicates().head(20))
if __name__ == "__main__":
main()

46
scripts/list_time_gaps.py Normal file
View File

@ -0,0 +1,46 @@
# scripts/list_time_gaps.py
from __future__ import annotations
from pathlib import Path
from meteo.dataset import load_raw_csv
from meteo.gaps import find_time_gaps
CSV_PATH = Path("data/weather_minutely.csv")
def main() -> None:
if not CSV_PATH.exists():
print(f"⚠ Fichier introuvable : {CSV_PATH}")
return
df = load_raw_csv(CSV_PATH)
print(f"Dataset minuté chargé : {CSV_PATH}")
print(f" Lignes : {len(df)}")
gaps = find_time_gaps(df)
total_missing = sum(g.missing_intervals for g in gaps)
print()
print("=== Gaps temporels détectés ===")
print(f"Nombre de gaps : {len(gaps)}")
print(f"Total minutes manquantes (théoriques) : {total_missing}")
print()
if not gaps:
print("✔ Aucun gap détecté, la série est parfaitement régulière.")
return
print("Top 10 des gaps les plus longs :")
gaps_sorted = sorted(gaps, key=lambda g: g.missing_intervals, reverse=True)[:10]
for g in gaps_sorted:
print(
f"- De {g.before} à {g.after} "
f"(durée: {g.duration}, manquants: {g.missing_intervals}, "
f"de {g.missing_start} à {g.missing_end})"
)
if __name__ == "__main__":
main()

View File

@ -0,0 +1,32 @@
# scripts/make_minutely_dataset.py
from __future__ import annotations
from pathlib import Path
from meteo.dataset import load_raw_csv, resample_to_minutes
FORMATTED_CSV_PATH = Path("data/weather_filled_1s.csv")
OUTPUT_CSV_PATH = Path("data/weather_minutely.csv")
def main() -> None:
if not FORMATTED_CSV_PATH.exists():
print(f"⚠ Fichier formaté introuvable : {FORMATTED_CSV_PATH}")
print(" Lancez d'abord : python -m scripts.format_raw_csv")
return
df_1s = load_raw_csv(FORMATTED_CSV_PATH)
print(f"Fichier 1s chargé : {FORMATTED_CSV_PATH}")
print(f" Lignes : {len(df_1s)}, colonnes : {list(df_1s.columns)}")
df_min = resample_to_minutes(df_1s)
print(f"Après resampling 60s : {len(df_min)} lignes")
OUTPUT_CSV_PATH.parent.mkdir(parents=True, exist_ok=True)
df_min.to_csv(OUTPUT_CSV_PATH, index_label="time")
print(f"✔ Dataset minuté écrit dans : {OUTPUT_CSV_PATH.resolve()}")
if __name__ == "__main__":
main()

View File

@ -0,0 +1,65 @@
# scripts/plot_temperature.py
from __future__ import annotations
from pathlib import Path
from datetime import timedelta
import matplotlib.pyplot as plt
import pandas as pd
from meteo.dataset import load_raw_csv
CSV_PATH = Path("data/weather_minutely.csv")
OUTPUT_DIR = Path("figures")
def main() -> None:
if not CSV_PATH.exists():
print(f"⚠ Fichier introuvable : {CSV_PATH}")
print(" Assurez-vous d'avoir généré le dataset minuté.")
return
# Chargement du dataset minuté
df = load_raw_csv(CSV_PATH)
print(f"Dataset minuté chargé : {CSV_PATH}")
print(f" Lignes : {len(df)}")
print(f" Colonnes : {list(df.columns)}")
print(f" Période : {df.index[0]}{df.index[-1]}")
print()
# On ne garde que les N derniers jours pour un premier graphique
last_n_days = 7
end = df.index.max()
start = end - pd.Timedelta(days=last_n_days)
df_slice = df.loc[start:end]
if df_slice.empty:
print("⚠ Aucun point dans l'intervalle choisi.")
return
# Moyenne horaire pour lisser un peu la courbe
df_hourly = df_slice.resample("1H").mean()
print(f"Intervalle tracé : {df_hourly.index[0]}{df_hourly.index[-1]}")
print(f"Nombre de points (moyenne horaire) : {len(df_hourly)}")
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
output_path = OUTPUT_DIR / "temperature_last_7_days.png"
plt.figure()
plt.plot(df_hourly.index, df_hourly["temperature"])
plt.xlabel("Temps (UTC)")
plt.ylabel("Température (°C)")
plt.title("Température - Moyenne horaire sur les 7 derniers jours")
plt.grid(True)
plt.tight_layout()
plt.savefig(output_path, dpi=150)
print(f"✔ Figure sauvegardée dans : {output_path.resolve()}")
# Optionnel : afficher la fenêtre graphique si vous lancez ça depuis un environnement graphique
# plt.show()
if __name__ == "__main__":
main()

View File

@ -0,0 +1,61 @@
# tests/test_influx_connection.py
from __future__ import annotations
from contextlib import closing
from influxdb_client.client.exceptions import InfluxDBError
from meteo.config import InfluxSettings
from meteo.influx_client import create_influx_client, test_basic_query
def main() -> None:
"""
Teste la communication avec le serveur InfluxDB :
1. Chargement de la configuration depuis l'environnement.
2. Ping du serveur.
3. Exécution d'une requête simple sur le bucket configuré.
"""
settings = InfluxSettings.from_env()
print("Configuration InfluxDB chargée :")
print(f" URL : {settings.url}")
print(f" Org : {settings.org}")
print(f" Bucket : {settings.bucket}")
print()
# Utilisation de `closing` pour garantir la fermeture du client.
with closing(create_influx_client(settings)) as client:
print("→ Ping du serveur InfluxDB…")
if not client.ping():
raise SystemExit("Échec du ping InfluxDB. Vérifiez l'URL et l'état du serveur.")
print("✔ Ping OK")
print("→ Requête de test sur le bucket…")
try:
tables = test_basic_query(client, settings.bucket)
except InfluxDBError as exc:
raise SystemExit(f"Erreur lors de la requête Flux : {exc}") from exc
# On fait un retour synthétique
nb_tables = len(tables)
nb_records = sum(len(table.records) for table in tables)
print(f"✔ Requête de test réussie : {nb_tables} table(s), {nb_records} enregistrement(s) trouvés.")
if nb_records == 0:
print("⚠ Le bucket est accessible, mais aucune donnée sur la dernière heure.")
else:
# Affiche un aperçu de la première table / premier record
first_table = tables[0]
first_record = first_table.records[0]
print("Exemple de point :")
print(f" time : {first_record.get_time()}")
print(f" measurement : {first_record.get_measurement()}")
print(f" field : {first_record.get_field()}")
print(f" value : {first_record.get_value()}")
if __name__ == "__main__":
main()

View File

@ -0,0 +1,61 @@
# tests/test_influx_entities.py
from __future__ import annotations
from contextlib import closing
from meteo.config import InfluxSettings
from meteo.influx_client import create_influx_client
from meteo.schema import (
list_measurements,
list_measurement_tag_keys,
list_measurement_tag_values,
)
def main() -> None:
"""
Explore les tags des measurements du bucket :
- affiche les keys de tags pour chaque measurement
- si un tag `entity_id` est présent, affiche la liste de ses valeurs
"""
settings = InfluxSettings.from_env()
print(f"Bucket InfluxDB : {settings.bucket}")
print()
with closing(create_influx_client(settings)) as client:
measurements = list_measurements(client, settings.bucket)
if not measurements:
print("⚠ Aucun measurement trouvé dans ce bucket.")
return
for meas in measurements:
print(f"Measurement « {meas} »")
tag_keys = list_measurement_tag_keys(client, settings.bucket, meas)
if not tag_keys:
print(" (aucun tag trouvé)")
print()
continue
print(" Tag keys :")
for key in tag_keys:
print(f" - {key}")
if "entity_id" in tag_keys:
entity_ids = list_measurement_tag_values(
client,
settings.bucket,
meas,
tag="entity_id",
)
print(" entity_id possibles :")
for eid in entity_ids:
print(f" - {eid}")
print()
if __name__ == "__main__":
main()

View File

@ -0,0 +1,47 @@
# tests/test_influx_schema.py
from __future__ import annotations
from contextlib import closing
from meteo.config import InfluxSettings
from meteo.influx_client import create_influx_client
from meteo.schema import list_measurements, list_measurement_fields
def main() -> None:
"""
Explore le schéma du bucket InfluxDB configuré :
- liste des measurements disponibles
- pour chacun, liste des champs (_field) et de leur type
"""
settings = InfluxSettings.from_env()
print(f"Bucket InfluxDB : {settings.bucket}")
print()
with closing(create_influx_client(settings)) as client:
measurements = list_measurements(client, settings.bucket)
if not measurements:
print("⚠ Aucun measurement trouvé dans ce bucket.")
return
print("Measurements disponibles :")
for name in measurements:
print(f" - {name}")
print()
for name in measurements:
print(f"Champs pour measurement « {name} » :")
fields = list_measurement_fields(client, settings.bucket, name)
if not fields:
print(" (aucun champ trouvé)")
else:
for field in fields:
print(f" - {field.name} (type: {field.type})")
print()
if __name__ == "__main__":
main()