# 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