Commit initial
This commit is contained in:
commit
62a928ec85
4
.env.example
Normal file
4
.env.example
Normal file
@ -0,0 +1,4 @@
|
||||
INFLUXDB_URL=http://
|
||||
INFLUXDB_TOKEN=
|
||||
INFLUXDB_ORG=
|
||||
INFLUXDB_BUCKET=weather
|
||||
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@ -0,0 +1,5 @@
|
||||
.venv
|
||||
.env
|
||||
data
|
||||
scripts/__pycache__
|
||||
meteo/__pycache__
|
||||
17
docs/01 - Installation de l'environnement de base.md
Normal file
17
docs/01 - Installation de l'environnement de base.md
Normal 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
|
||||
12
docs/02 - Configuration.md
Normal file
12
docs/02 - Configuration.md
Normal 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
|
||||
169
docs/03 - Test de l'environnement de travail.md
Normal file
169
docs/03 - Test de l'environnement de travail.md
Normal 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`.
|
||||
19
docs/04 - Export des données.md
Normal file
19
docs/04 - Export des données.md
Normal 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
70
docs/05 - Ajustements.md
Normal 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
132
docs/06 - Downsampling.md
Normal 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.
|
||||
8
docs/07 - Premiers graphiques.md
Normal file
8
docs/07 - Premiers graphiques.md
Normal 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
|
||||
|
||||

|
||||
BIN
figures/temperature_last_7_days.png
Normal file
BIN
figures/temperature_last_7_days.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 66 KiB |
67
meteo/config.py
Normal file
67
meteo/config.py
Normal 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
171
meteo/dataset.py
Normal 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
158
meteo/export.py
Normal 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
80
meteo/gaps.py
Normal 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
41
meteo/influx_client.py
Normal 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
57
meteo/quality.py
Normal 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
143
meteo/schema.py
Normal 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
84
meteo/station_config.py
Normal 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
19
readme.md
Normal 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
20
requirements.txt
Normal 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
0
scripts/__init__.py
Normal file
52
scripts/check_missing_values.py
Normal file
52
scripts/check_missing_values.py
Normal 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()
|
||||
61
scripts/describe_minutely_dataset.py
Normal file
61
scripts/describe_minutely_dataset.py
Normal 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()
|
||||
44
scripts/export_station_data.py
Normal file
44
scripts/export_station_data.py
Normal 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()
|
||||
49
scripts/export_station_data_full.py
Normal file
49
scripts/export_station_data_full.py
Normal 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()
|
||||
32
scripts/fill_formatted_1s.py
Normal file
32
scripts/fill_formatted_1s.py
Normal 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
31
scripts/format_raw_csv.py
Normal 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()
|
||||
29
scripts/inspect_time_column.py
Normal file
29
scripts/inspect_time_column.py
Normal 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
46
scripts/list_time_gaps.py
Normal 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()
|
||||
32
scripts/make_minutely_dataset.py
Normal file
32
scripts/make_minutely_dataset.py
Normal 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()
|
||||
65
scripts/plot_temperature.py
Normal file
65
scripts/plot_temperature.py
Normal 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()
|
||||
61
scripts/test_influx_connection.py
Normal file
61
scripts/test_influx_connection.py
Normal 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()
|
||||
61
scripts/test_influx_entities.py
Normal file
61
scripts/test_influx_entities.py
Normal 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()
|
||||
47
scripts/test_influx_schema.py
Normal file
47
scripts/test_influx_schema.py
Normal 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()
|
||||
Loading…
x
Reference in New Issue
Block a user