`load_recent_from_database` n'avait qu'une borne basse. `build_scoring_frame` repartait donc de la derniere lecture de toute la table quel que soit `--now` : `target_at` valait toujours "fin du jeu + 1h", et `_age = instant - derniere_lecture` devenait negatif, ce qui passait le seuil de peremption sans rien signaler. Consequence concrete : sur le jeu historique, arrete au 31/12/2024, aucune boucle de rattrapage ne pouvait produire une prevision dont le realise existe deja. La surveillance de derive livree par la migration precedente n'aurait donc rien eu a comparer en demonstration. `until` est desormais obligatoire sur ce chargeur, ce qui interdit de l'oublier, et le mode CSV filtre symetriquement. En exploitation rien ne change, aucune lecture n'etant posterieure a l'heure courante.
143 lines
5.4 KiB
Python
143 lines
5.4 KiB
Python
"""Chargement des donnees d'entrainement.
|
|
|
|
Deux chemins, qui doivent produire le meme schema de sortie (colonnes `site_id`, `timestamp`,
|
|
`consumption_kwh`, `temperature_celsius`, `humidity_percent`, `solar_irradiance_wm2`,
|
|
`is_working_hours`, `site_type`, `capacity_kw`), consomme ensuite par `enervision_ml.features` :
|
|
|
|
- `load_from_database` : le chemin cible decrit dans `docs/ML-START.md`, connexion PostgreSQL
|
|
directe (`reading` + `site`), pas par l'API. C'est celui qu'utilisera le pipeline en
|
|
production, une fois le role PostgreSQL dedie `enervision_ml` provisionne (dette assumee,
|
|
documentee dans `CLAUDE.md` et l'ADR 0003 : pour l'instant, la meme chaine de connexion que le
|
|
backend applicatif convient en developpement).
|
|
- `load_from_csv` : chemin de demarrage, tant que la base locale n'est pas peuplee. Lit
|
|
directement `ml/data/all_sites_combined.csv` (jeu de donnees fourni pour le jalon J3, cf.
|
|
issue #89), le meme fichier que celui consomme par
|
|
`apps/backend/app/etl/historical_import.py`. `capacity_kw` n'existe pas dans ce CSV : la
|
|
colonne est renvoyee a `NaN`, que LightGBM gere nativement comme valeur manquante.
|
|
"""
|
|
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
|
|
import pandas as pd
|
|
from sqlalchemy import text
|
|
from sqlalchemy.engine import Connectable
|
|
|
|
OUTPUT_COLUMNS = [
|
|
"site_id",
|
|
"timestamp",
|
|
"consumption_kwh",
|
|
"temperature_celsius",
|
|
"humidity_percent",
|
|
"solar_irradiance_wm2",
|
|
"is_working_hours",
|
|
"site_type",
|
|
"capacity_kw",
|
|
]
|
|
|
|
NUMERIC_COLUMNS = [
|
|
"consumption_kwh",
|
|
"temperature_celsius",
|
|
"humidity_percent",
|
|
"solar_irradiance_wm2",
|
|
"capacity_kw",
|
|
]
|
|
|
|
# Piege : `reading.is_working_hours` est nullable et entre dans les features. Une seule lecture a
|
|
# NULL rend la colonne `object`, que LightGBM refuse ("pandas dtypes must be int, float or bool").
|
|
FLAG_COLUMNS = ["is_working_hours"]
|
|
|
|
_READING_QUERY = text(
|
|
"""
|
|
SELECT
|
|
r.site_id,
|
|
r.timestamp,
|
|
r.consumption_kwh,
|
|
r.temperature_celsius,
|
|
r.humidity_percent,
|
|
r.solar_irradiance_wm2,
|
|
r.is_working_hours,
|
|
s.site_type,
|
|
s.capacity_kw
|
|
FROM reading r
|
|
JOIN site s ON s.site_id = r.site_id
|
|
ORDER BY r.site_id, r.timestamp
|
|
"""
|
|
)
|
|
|
|
|
|
_RECENT_READING_QUERY = text(
|
|
"""
|
|
SELECT
|
|
r.site_id,
|
|
r.timestamp,
|
|
r.consumption_kwh,
|
|
r.temperature_celsius,
|
|
r.humidity_percent,
|
|
r.solar_irradiance_wm2,
|
|
r.is_working_hours,
|
|
s.site_type,
|
|
s.capacity_kw
|
|
FROM reading r
|
|
JOIN site s ON s.site_id = r.site_id
|
|
WHERE r.timestamp >= :since AND r.timestamp <= :until
|
|
ORDER BY r.site_id, r.timestamp
|
|
"""
|
|
)
|
|
|
|
|
|
def load_from_database(connection: Connectable) -> pd.DataFrame:
|
|
"""Lit l'historique complet `reading` + `site` depuis PostgreSQL. Entrainement seulement :
|
|
le scoring n'a besoin que d'une fenetre recente, cf. `load_recent_from_database`.
|
|
"""
|
|
frame = pd.read_sql(_READING_QUERY, connection)
|
|
return _typer(frame[OUTPUT_COLUMNS])
|
|
|
|
|
|
def load_recent_from_database(
|
|
connection: Connectable, *, since: datetime, until: datetime
|
|
) -> pd.DataFrame:
|
|
"""Lit `reading` + `site` sur la fenetre `[since, until]`, pour le scoring.
|
|
|
|
Piege evite cote bas : un `SELECT` sans borne sur l'hypertable complete juste pour scorer le
|
|
prochain pas horaire serait la meme erreur que celle corrigee sur `GET /readings` (fenetre non
|
|
plafonnee sur une table pouvant porter des annees d'historique).
|
|
|
|
Piege evite cote haut : `until` est obligatoire, et c'est ce qui donne son sens a `--now`.
|
|
Sans lui, `build_scoring_frame` repartait de la derniere lecture de toute la table quel que
|
|
soit l'instant demande, donc `target_at` valait toujours "fin du jeu + 1h" et l'age de la
|
|
derniere lecture devenait negatif sans que rien ne le signale.
|
|
"""
|
|
frame = pd.read_sql(_RECENT_READING_QUERY, connection, params={"since": since, "until": until})
|
|
return _typer(frame[OUTPUT_COLUMNS])
|
|
|
|
|
|
def load_from_csv(csv_path: Path) -> pd.DataFrame:
|
|
"""Lit le jeu de donnees CSV historique (chemin de demarrage, hors base)."""
|
|
frame = pd.read_csv(csv_path, parse_dates=["timestamp"])
|
|
frame["capacity_kw"] = float("nan")
|
|
frame["is_working_hours"] = frame["is_working_hours"].astype(bool)
|
|
|
|
return _typer(frame[OUTPUT_COLUMNS])
|
|
|
|
|
|
def _typer(frame: pd.DataFrame) -> pd.DataFrame:
|
|
"""Force le typage numerique attendu par LightGBM.
|
|
|
|
Piege reel, pas theorique : `site.capacity_kw` n'est peuple par aucun pipeline d'ingestion
|
|
aujourd'hui (`historical_import.py` ne pose que `site_type`/`site_name`). Une colonne
|
|
entierement `NULL` revient de `pd.read_sql` en dtype `object` plutot que `float64`, ce que
|
|
LightGBM refuse ("pandas dtypes must be int, float or bool"). `pd.to_numeric` corrige aussi
|
|
n'importe quelle autre colonne mesuree entierement absente sur une fenetre de scoring, pas
|
|
seulement `capacity_kw`.
|
|
|
|
Piege additionnel : `NUMERIC_COLUMNS` inclut `consumption_kwh`, la cible du modele, pas
|
|
seulement des variables explicatives. Une valeur non numerique y devient donc silencieusement
|
|
`NaN` aussi bien a l'entrainement (ou `train.py` l'exclura ensuite via son `dropna`) qu'au
|
|
scoring -- ce n'est pas un effet de bord limite aux colonnes mesurees.
|
|
"""
|
|
typee = frame.copy()
|
|
for colonne in (*NUMERIC_COLUMNS, *FLAG_COLUMNS):
|
|
typee[colonne] = pd.to_numeric(typee[colonne], errors="coerce")
|
|
return typee
|