1

Commit initial

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

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)