You've already forked donnees_meteo
Commit initial
This commit is contained in:
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)
|
||||
Reference in New Issue
Block a user