test(ml,backend): couvre ML vers DB, puis la chaine complete jusqu'a l'API

Le pipeline ML n'avait aucun test touchant PostgreSQL : `ml/README.md` le disait, faute de
base joignable en CI. Le marqueur `integration` de `ml/pyproject.toml` etait declare et porte
par zero test.

- `ml/tests/conftest.py` : deux fixtures d'acces a la base, jamais interchangeables.
  `connexion_ml` annule sa transaction, `parc` valide ses ecritures parce que `run_scoring`
  ouvre sa propre connexion et ne verrait rien d'autre. Garde sur le nom de base, marque uuid
  sur chaque site, nettoyage dans l'ordre des cles etrangeres.
- `test_data_integration.py` : les neuf colonnes du contrat confrontees au schema Alembic
  reel, la borne `since`, l'ordre de tri dont dependent des lags positionnels, et le typage
  des colonnes entierement nulles.
- `test_score_integration.py` : les contraintes de `prediction` vues depuis le code qui
  ecrit, l'empilement volontaire de deux runs, et `run_scoring` de bout en bout sur un
  booster reel.
- `apps/backend/tests/test_chaine_ml_api.py` : lance les vrais binaires `enervision_ml.train`
  et `.score` en sous-processus, comme les DAGs, puis relit par `GET /api/v1/predictions`.
  Marqueur `chaine` distinct : le job `integration` du backend n'a pas l'environnement de ml/.
- `ml.yml` : job `integration`, seul du depot a reunir les deux environnements uv et une base.
  Ses `paths` incluent les migrations du backend, sans quoi le schema deriverait du SQL du
  pipeline sans que rien ne casse.
- Makefile : `migrate-test`, qui manquait (`enervision_test` n'a jamais recu de table),
  `ml-test-integration` et `test-chaine`.
This commit is contained in:
Johan LEROY
2026-09-22 14:10:50 +02:00
parent 5b5c97532d
commit aa4af62290
8 changed files with 1062 additions and 6 deletions
+332
View File
@@ -0,0 +1,332 @@
"""Piege : deux fixtures d'acces a la base, jamais interchangeables - `connexion_ml` et `parc`.
`connexion_ml` ouvre une transaction annulee a la fin du test : rien ne subsiste, et rien n'est
visible hors de cette connexion. Elle sert aux fonctions qui recoivent leur connexion en
argument (`load_from_database`, `load_recent_from_database`, `write_predictions`).
`run_scoring` fabrique en revanche son propre engine depuis `ML_DATABASE_URL` : il ne verrait
pas des lignes semees dans une transaction non validee, et ses propres ecritures survivraient a
l'annulation. Les tests qui l'appellent passent donc par `parc`, qui valide ce qu'il ecrit et
nettoie lui-meme, dans l'ordre impose par les cles etrangeres `RESTRICT`.
"""
import math
import os
from collections.abc import Iterator
from dataclasses import dataclass, field
from datetime import UTC, datetime, timedelta
from pathlib import Path
from typing import Any
from uuid import uuid4
import lightgbm as lgb
import pandas as pd
import pytest
from sqlalchemy import Connection, Engine, Row, bindparam, create_engine, text
from sqlalchemy.engine import URL, make_url
from enervision_ml.features import TARGET_COLUMN, build_features, feature_columns
BASE_ATTENDUE = "enervision_test"
# Piege : `load_from_database` lit toute la table, et `enervision_test` est partagee entre un run
# local et la CI. Les tests ancrent donc leurs lectures au-dela de tout jeu de donnees reel
# (l'historique s'arrete au 31/12/2024) pour que leur borne `since` ne ramene qu'eux.
ANCRAGE = datetime(2035, 1, 1, tzinfo=UTC)
SITE_TYPE = "office"
CAPACITY_KW = 100.0
_INSERT_SITE = text(
"""
INSERT INTO site (site_id, site_name, site_type, capacity_kw)
VALUES (:site_id, :site_name, :site_type, :capacity_kw)
"""
)
# `source = 'api_history'` impose `dataset_id IS NULL` (ck_reading_dataset_source), ce qui evite
# de creer une ligne `dataset`. `raw_data` est NOT NULL, d'ou le litteral jsonb.
_INSERT_READING = text(
"""
INSERT INTO reading (
site_id, timestamp, source, consumption_kwh, temperature_celsius,
humidity_percent, solar_irradiance_wm2, is_working_hours, raw_data
) VALUES (
:site_id, :timestamp, :source, :consumption_kwh, :temperature_celsius,
:humidity_percent, :solar_irradiance_wm2, :is_working_hours, '{}'::jsonb
)
"""
)
_SELECT_PREDICTIONS = text(
"""
SELECT target_at, predicted_value, status, failure_reason, model_reference
FROM prediction
WHERE site_id = :site_id
ORDER BY prediction_id
"""
)
_INSERT_PREDICTION = text(
"""
INSERT INTO prediction (
site_id, target_at, target_metric, period_minutes,
predicted_value, model_reference, status, failure_reason
) VALUES (
:site_id, :target_at, 'consumption_kwh', 60,
:predicted_value, :model_reference, :status, :failure_reason
)
"""
)
# Ordre impose par les cles etrangeres `RESTRICT` : une lecture avant son site, une prediction
# avant sa lecture.
_SUPPRESSIONS = tuple(
text(requete).bindparams(bindparam("sites", expanding=True))
for requete in (
"DELETE FROM prediction WHERE site_id IN :sites",
"DELETE FROM reading WHERE site_id IN :sites",
"DELETE FROM site WHERE site_id IN :sites",
)
)
def insere_site(
connexion: Connection,
*,
site_type: str = SITE_TYPE,
capacity_kw: float | None = CAPACITY_KW,
) -> str:
site_id = f"TEST-{uuid4().hex[:12]}"
connexion.execute(
_INSERT_SITE,
{
"site_id": site_id,
"site_name": "Site de test",
"site_type": site_type,
"capacity_kw": capacity_kw,
},
)
return site_id
def insere_lectures(
connexion: Connection,
site_id: str,
*,
heures: int,
fin: datetime,
valeur: float = 50.0,
source: str = "api_history",
is_working_hours: bool | None = True,
) -> list[datetime]:
"""Grille horaire contigue finissant a `fin`, incluse.
Contigue parce que les lags de `build_features` sont des `shift()` positionnels : un trou
dans la grille decalerait le lag de 168 h sans qu'aucune erreur ne se declenche.
"""
instants = [fin - timedelta(hours=decalage) for decalage in reversed(range(heures))]
connexion.execute(
_INSERT_READING,
[
{
"site_id": site_id,
"timestamp": instant,
"source": source,
"consumption_kwh": valeur + math.sin(rang / 12.0) * 10.0,
"temperature_celsius": 15.0,
"humidity_percent": 50.0,
"solar_irradiance_wm2": 0.0,
"is_working_hours": is_working_hours,
}
for rang, instant in enumerate(instants)
],
)
return instants
def insere_lecture(
connexion: Connection,
site_id: str,
*,
instant: datetime,
consumption_kwh: float | None = 50.0,
source: str = "api_history",
is_working_hours: bool | None = True,
) -> None:
"""Une lecture isolee, quand le test pilote sa valeur plutot que sa forme."""
connexion.execute(
_INSERT_READING,
{
"site_id": site_id,
"timestamp": instant,
"source": source,
"consumption_kwh": consumption_kwh,
"temperature_celsius": 15.0,
"humidity_percent": 50.0,
"solar_irradiance_wm2": 0.0,
"is_working_hours": is_working_hours,
},
)
def insere_prediction(
connexion: Connection,
site_id: str,
*,
target_at: datetime,
predicted_value: float | None = 42.0,
model_reference: str = "lightgbm-test000000",
status: str = "available",
failure_reason: str | None = None,
) -> None:
connexion.execute(
_INSERT_PREDICTION,
{
"site_id": site_id,
"target_at": target_at,
"predicted_value": predicted_value,
"model_reference": model_reference,
"status": status,
"failure_reason": failure_reason,
},
)
@pytest.fixture(scope="session")
def url_ml() -> URL:
valeur = os.environ.get("ML_DATABASE_URL")
if not valeur:
pytest.fail("ML_DATABASE_URL absente. Voir `make ml-test-integration`.")
url = make_url(valeur)
if url.database != BASE_ATTENDUE:
pytest.fail(
f"Ces tests ecrivent et suppriment : ML_DATABASE_URL doit viser {BASE_ATTENDUE}, "
f"pas {url.database}."
)
return url
@pytest.fixture(scope="session")
def moteur_ml(url_ml: URL) -> Iterator[Engine]:
moteur = create_engine(url_ml)
try:
yield moteur
finally:
moteur.dispose()
@pytest.fixture
def connexion_ml(moteur_ml: Engine) -> Iterator[Connection]:
with moteur_ml.connect() as connexion:
transaction = connexion.begin()
try:
yield connexion
finally:
transaction.rollback()
@dataclass
class Parc:
"""Semis valide en base, et son nettoyage, pour les tests qui appellent `run_scoring`.
Chaque `site_id` porte une marque unique : la base de test est partagee entre un run local
et la CI.
"""
moteur: Engine
sites: list[str] = field(default_factory=list)
def site(self, *, site_type: str = SITE_TYPE, capacity_kw: float | None = CAPACITY_KW) -> str:
with self.moteur.begin() as connexion:
site_id = insere_site(connexion, site_type=site_type, capacity_kw=capacity_kw)
self.sites.append(site_id)
return site_id
def lectures(self, site_id: str, **arguments: Any) -> list[datetime]:
with self.moteur.begin() as connexion:
return insere_lectures(connexion, site_id, **arguments)
def lecture(self, site_id: str, **arguments: Any) -> None:
with self.moteur.begin() as connexion:
insere_lecture(connexion, site_id, **arguments)
def prediction(self, site_id: str, **arguments: Any) -> None:
with self.moteur.begin() as connexion:
insere_prediction(connexion, site_id, **arguments)
def predictions_ecrites(self, site_id: str) -> list[Row[Any]]:
with self.moteur.connect() as connexion:
return list(connexion.execute(_SELECT_PREDICTIONS, {"site_id": site_id}))
def nettoie(self) -> None:
if not self.sites:
return
with self.moteur.begin() as connexion:
for suppression in _SUPPRESSIONS:
connexion.execute(suppression, {"sites": self.sites})
@pytest.fixture
def parc(moteur_ml: Engine) -> Iterator[Parc]:
semis = Parc(moteur=moteur_ml)
try:
yield semis
finally:
semis.nettoie()
def trame_synthetique(*, sites: int = 2, heures: int = 400) -> pd.DataFrame:
"""Lectures horaires deterministes, assez longues pour que le lag de 168 h existe."""
depart = datetime(2024, 1, 1, tzinfo=UTC)
morceaux = [
pd.DataFrame(
{
"site_id": f"SITE{numero:03d}",
"timestamp": [depart + timedelta(hours=rang) for rang in range(heures)],
TARGET_COLUMN: [
50.0 + 10.0 * math.sin(rang / 12.0) + numero * 5.0 for rang in range(heures)
],
"temperature_celsius": 15.0,
"humidity_percent": 50.0,
"solar_irradiance_wm2": 0.0,
"is_working_hours": True,
"site_type": SITE_TYPE,
"capacity_kw": CAPACITY_KW,
}
)
for numero in range(sites)
]
return pd.concat(morceaux, ignore_index=True)
@pytest.fixture(scope="session")
def modele_jetable(tmp_path_factory: pytest.TempPathFactory) -> Path:
"""Booster reel entraine sur une trame synthetique, ecrit dans un repertoire temporaire.
Ni `ml/models/` (ignore par git, et le polluer serait un effet de bord), ni
`enervision_ml.train.train()` (qui journalise dans MLflow sans garde). Le typage `category`
de `site_type` reproduit celui de l'entrainement : c'est le `pandas_categorical` enregistre
dans le modele que `score()` devra retrouver.
"""
features = build_features(trame_synthetique()).dropna(subset=feature_columns())
typee = features.copy()
typee["site_type"] = typee["site_type"].astype("category")
donnees = lgb.Dataset(
typee[feature_columns()],
label=typee[TARGET_COLUMN],
categorical_feature=["site_type"],
)
booster = lgb.train(
{"objective": "regression", "num_leaves": 7, "min_data_in_leaf": 5, "verbosity": -1},
donnees,
num_boost_round=5,
)
chemin = tmp_path_factory.mktemp("modele") / "lightgbm-consumption.txt"
booster.save_model(str(chemin))
return chemin