Airflow / Construction de l'image (push) Successful in 1m2s
Backend / Analyse statique de sécurité (push) Successful in 7s
Backend / Tests exigeant une base (push) Failing after 5m3s
Airflow / Lint et intégrité des DAGs (push) Successful in 9m41s
ML / Analyse statique de sécurité (push) Successful in 6s
Backend / Lint, typage et tests (push) Successful in 10m10s
Backend / Audit des dépendances (push) Successful in 9m36s
ML / ML - DB et chaîne ML - DB - API (push) Failing after 5m6s
SonarQube / test-ml (push) Failing after 6m6s
ML / Lint, typage et tests (push) Successful in 11m31s
SonarQube / build-front (push) Successful in 9m49s
SonarQube / build-back (push) Successful in 9m54s
SonarQube / test-front (push) Failing after 5m10s
SonarQube / test-back (push) Failing after 5m13s
SonarQube / SonarQube (push) Skipped
Revue de la branche : trois defauts empechaient la surveillance de tenir ce qu'elle annonce. - `evaluate()` gardait les microsecondes de `now()` dans `window_end`, la cle de `uq_drift_report_window`. Deux executions ne collidaient donc jamais et l'index ne dedoublonnait rien, contrairement a ce qu'affirmaient l'ADR 0011, 20-backend et le docstring du DAG. L'instant de reference est desormais tronque a l'heure. - Un site qui cessait d'etre score disparaissait du rapport : la liste des sites ne venait que de la fenetre recente. La panne que cette surveillance existe pour dire etait exactement celle qu'elle taisait. La fenetre de reference entre maintenant dans l'union, et le site recoit sa ligne `indetermine` a zero observation. - Sans fenetre de reference, `_plafond` rendait `None` et le verdict tombait sur `stable`, une affirmation que la donnee ne portait pas. C'est `indetermine` desormais. `ml.yml` ecoute `apps/backend/app/**` et non les seuls modeles : ce workflow est le seul a jouer `-m chaine`, or la chaine traverse les endpoints, les services et les schemas jusqu'a `GET /predictions`. Une PR touchant `predictions.py` ne declenchait pas le test qui l'assert. Hygiene de tests : le nettoyage des fixtures API connait `drift_report` (cle etrangere RESTRICT vers `site`), le test sans rapport rend ses overrides en teardown, `test_chaine_ml_api` compare les `created_at` strictement (un `>=` passait aussi quand l'API resservait la premiere ligne), et `test_data_integration` filtre sur le site seme au lieu de juger tout le contenu d'une fenetre dans une base partagee. Docs remises d'aplomb : sept revisions Alembic et non six, `derive.py` dans l'inventaire de etl/README, et le diagramme de 20-backend gagne DriftService, le depot drift et sa treizieme table.
224 lines
7.4 KiB
Python
224 lines
7.4 KiB
Python
"""Piege : ce fichier porte le marqueur `chaine`, pas `integration` - test_the_ml_binaries...()
|
|
|
|
Il lance les vrais binaires `enervision_ml.train` et `enervision_ml.score` dans l'environnement
|
|
uv de `ml/`, que le job `integration` de `backend.yml` n'installe pas. Un marqueur distinct evite
|
|
que ce job, et `make test`, ne le selectionnent et n'echouent faute de `ml/.venv`.
|
|
"""
|
|
|
|
import math
|
|
import os
|
|
import subprocess
|
|
from collections.abc import AsyncIterator, Iterator
|
|
from dataclasses import dataclass, field
|
|
from datetime import UTC, datetime, timedelta
|
|
from functools import partial
|
|
from pathlib import Path
|
|
from typing import Any
|
|
from uuid import uuid4
|
|
|
|
import anyio
|
|
import pytest
|
|
from fastapi import FastAPI
|
|
from httpx import AsyncClient
|
|
from sqlalchemy import delete, insert, make_url
|
|
|
|
from app.api.deps import get_current_principal
|
|
from app.core.config import get_settings
|
|
from app.core.principal import Principal
|
|
from app.core.roles import AccountKind, Role
|
|
from app.db.session import get_session_factory
|
|
from app.models.energy import Prediction, Reading, Site
|
|
|
|
pytestmark = pytest.mark.chaine
|
|
|
|
RACINE = Path(__file__).resolve().parents[3]
|
|
ML = RACINE / "ml"
|
|
PYTHON_ML = Path(os.environ.get("ML_PYTHON", ML / ".venv" / "bin" / "python"))
|
|
|
|
HEURES_COMPLETES = 400
|
|
HEURES_INSUFFISANTES = 100
|
|
|
|
|
|
def lecteur() -> Principal:
|
|
return Principal(
|
|
id=uuid4(),
|
|
email="lecteur@enervision.fr",
|
|
role=Role.LECTEUR,
|
|
kind=AccountKind.HUMAIN,
|
|
must_change_password=False,
|
|
)
|
|
|
|
|
|
def url_ml() -> str:
|
|
"""Derive la chaine du pipeline de celle du backend plutot que de la recopier : les deux
|
|
cotes visent ainsi la meme base, dans leur dialecte respectif."""
|
|
return (
|
|
make_url(get_settings().database_url)
|
|
.set(drivername="postgresql+psycopg")
|
|
.render_as_string(hide_password=False)
|
|
)
|
|
|
|
|
|
def lance_ml(module: str, *arguments: str, journal: Path) -> subprocess.CompletedProcess[str]:
|
|
if not PYTHON_ML.exists():
|
|
pytest.fail(
|
|
f"Environnement ml/ absent ({PYTHON_ML}). Lancer `cd ml && uv sync --all-groups`."
|
|
)
|
|
|
|
return subprocess.run( # noqa: S603 -- argv en liste, sans shell, binaire resolu dans le depot
|
|
[str(PYTHON_ML), "-m", module, *arguments],
|
|
cwd=ML,
|
|
text=True,
|
|
capture_output=True,
|
|
timeout=600,
|
|
check=False,
|
|
env={
|
|
**os.environ,
|
|
"ML_DATABASE_URL": url_ml(),
|
|
"MLFLOW_TRACKING_URI": f"sqlite:///{journal}/mlflow.db",
|
|
},
|
|
)
|
|
|
|
|
|
async def executer(module: str, *arguments: str, journal: Path) -> subprocess.CompletedProcess[str]:
|
|
resultat = await anyio.to_thread.run_sync(
|
|
partial(lance_ml, module, *arguments, journal=journal)
|
|
)
|
|
assert resultat.returncode == 0, resultat.stderr
|
|
return resultat
|
|
|
|
|
|
@dataclass
|
|
class Parc:
|
|
sites: list[str] = field(default_factory=list)
|
|
|
|
|
|
def lignes_horaires(site_id: str, *, heures: int, fin: datetime) -> list[dict[str, Any]]:
|
|
return [
|
|
{
|
|
"site_id": site_id,
|
|
"timestamp": fin - timedelta(hours=decalage),
|
|
"source": "api_history",
|
|
"consumption_kwh": 50.0 + math.sin(decalage / 12.0) * 10.0,
|
|
"temperature_celsius": 15.0,
|
|
"humidity_percent": 50.0,
|
|
"solar_irradiance_wm2": 0.0,
|
|
"is_working_hours": True,
|
|
"raw_data": {},
|
|
}
|
|
for decalage in reversed(range(heures))
|
|
]
|
|
|
|
|
|
@pytest.fixture
|
|
async def parc() -> AsyncIterator[Parc]:
|
|
"""Deux sites dotes d'un historique complet, un troisieme qui n'atteint pas le lag de 168 h.
|
|
|
|
Les ecritures sont validees : les binaires ML ouvrent leur propre connexion et ne verraient
|
|
pas une transaction en cours.
|
|
"""
|
|
fin = datetime.now(UTC).replace(minute=0, second=0, microsecond=0) - timedelta(hours=1)
|
|
marque = uuid4().hex[:12]
|
|
complets = [f"TEST-{marque}-A", f"TEST-{marque}-B"]
|
|
partiel = f"TEST-{marque}-C"
|
|
parc = Parc(sites=[*complets, partiel])
|
|
|
|
async with get_session_factory()() as session:
|
|
await session.execute(
|
|
insert(Site),
|
|
[
|
|
{
|
|
"site_id": site_id,
|
|
"site_name": f"Site {site_id}",
|
|
"site_type": "office",
|
|
"capacity_kw": 100.0,
|
|
}
|
|
for site_id in parc.sites
|
|
],
|
|
)
|
|
for site_id in complets:
|
|
await session.execute(
|
|
insert(Reading), lignes_horaires(site_id, heures=HEURES_COMPLETES, fin=fin)
|
|
)
|
|
await session.execute(
|
|
insert(Reading), lignes_horaires(partiel, heures=HEURES_INSUFFISANTES, fin=fin)
|
|
)
|
|
await session.commit()
|
|
|
|
try:
|
|
yield parc
|
|
finally:
|
|
async with get_session_factory()() as session:
|
|
await session.execute(delete(Prediction).where(Prediction.site_id.in_(parc.sites)))
|
|
await session.execute(delete(Reading).where(Reading.site_id.in_(parc.sites)))
|
|
await session.execute(delete(Site).where(Site.site_id.in_(parc.sites)))
|
|
await session.commit()
|
|
|
|
|
|
@pytest.fixture
|
|
def principal_lecteur(app: FastAPI) -> Iterator[None]:
|
|
app.dependency_overrides[get_current_principal] = lecteur
|
|
yield
|
|
app.dependency_overrides.pop(get_current_principal, None)
|
|
|
|
|
|
async def resume_du_site(client: AsyncClient, site_id: str) -> dict[str, Any]:
|
|
reponse = await client.get("/api/v1/predictions")
|
|
|
|
assert reponse.status_code == 200
|
|
sites = reponse.json()["sites"]
|
|
return next(site for site in sites if site["site_id"] == site_id)
|
|
|
|
|
|
async def entraine_et_score(parc: Parc, tmp_path: Path, *arguments: str) -> Path:
|
|
modele = tmp_path / "lightgbm-consumption.txt"
|
|
|
|
await executer(
|
|
"enervision_ml.train",
|
|
"--model-output",
|
|
str(modele),
|
|
"--mlflow-tracking-uri",
|
|
f"sqlite:///{tmp_path}/mlflow.db",
|
|
journal=tmp_path,
|
|
)
|
|
await executer("enervision_ml.score", "--model", str(modele), *arguments, journal=tmp_path)
|
|
|
|
return modele
|
|
|
|
|
|
async def test_the_ml_binaries_produce_a_prediction_that_the_api_serves(
|
|
parc: Parc, tmp_path: Path, client: AsyncClient, principal_lecteur: None
|
|
) -> None:
|
|
await entraine_et_score(parc, tmp_path)
|
|
|
|
servi = await resume_du_site(client, parc.sites[0])
|
|
|
|
assert servi["prediction"]["status"] == "available"
|
|
assert servi["prediction"]["predicted_value"] is not None
|
|
assert servi["prediction"]["target_metric"] == "consumption_kwh"
|
|
|
|
|
|
async def test_the_api_exposes_the_failure_reason_of_a_site_without_enough_history(
|
|
parc: Parc, tmp_path: Path, client: AsyncClient, principal_lecteur: None
|
|
) -> None:
|
|
await entraine_et_score(parc, tmp_path)
|
|
|
|
servi = await resume_du_site(client, parc.sites[-1])
|
|
|
|
assert servi["prediction"]["status"] == "insufficient_data"
|
|
assert servi["prediction"]["predicted_value"] is None
|
|
assert servi["prediction"]["failure_reason"] is not None
|
|
|
|
|
|
async def test_the_api_serves_the_latest_run_when_the_score_cli_runs_twice(
|
|
parc: Parc, tmp_path: Path, client: AsyncClient, principal_lecteur: None
|
|
) -> None:
|
|
modele = await entraine_et_score(parc, tmp_path)
|
|
premier = await resume_du_site(client, parc.sites[0])
|
|
|
|
await executer("enervision_ml.score", "--model", str(modele), journal=tmp_path)
|
|
|
|
second = await resume_du_site(client, parc.sites[0])
|
|
assert second["prediction"]["created_at"] > premier["prediction"]["created_at"]
|
|
assert second["prediction"]["model_reference"] == premier["prediction"]["model_reference"]
|