fix(backend,ci): repare trois angles morts de la surveillance de derive
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.
This commit is contained in:
Johan LEROY
2026-09-22 14:58:34 +02:00
parent cb961ec2c5
commit d506e8f7ef
13 changed files with 131 additions and 43 deletions
+2 -1
View File
@@ -25,7 +25,7 @@ from app.api.deps import get_current_principal
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 Alert, Prediction, Reading, Recommendation, Site
from app.models.energy import Alert, DriftReport, Prediction, Reading, Recommendation, Site
from tests.repositories.test_alert import creer_alerte
from tests.repositories.test_prediction import creer_prediction
from tests.repositories.test_reading import creer_lecture
@@ -52,6 +52,7 @@ async def _supprime(session: AsyncSession, sites: list[str]) -> None:
alertes = select(Alert.alert_id).where(Alert.site_id.in_(sites))
await session.execute(delete(Recommendation).where(Recommendation.alert_id.in_(alertes)))
await session.execute(delete(Alert).where(Alert.site_id.in_(sites)))
await session.execute(delete(DriftReport).where(DriftReport.site_id.in_(sites)))
await session.execute(delete(Prediction).where(Prediction.site_id.in_(sites)))
await session.execute(delete(Reading).where(Reading.site_id.in_(sites)))
await session.execute(delete(Site).where(Site.site_id.in_(sites)))
+11 -5
View File
@@ -63,7 +63,8 @@ def servi(app: FastAPI) -> Iterator[list[DriftReport]]:
app.dependency_overrides[get_current_principal] = operateur
app.dependency_overrides[get_drift_service] = lambda: service
yield rapports
app.dependency_overrides.clear()
app.dependency_overrides.pop(get_current_principal, None)
app.dependency_overrides.pop(get_drift_service, None)
async def test_drift_returns_the_latest_report_of_every_site(
@@ -86,14 +87,19 @@ async def test_drift_exposes_the_metrics_of_the_stored_report(
assert premier["model_references"] == ["lightgbm-aaa"]
async def test_drift_returns_an_empty_list_when_no_report_exists(
app: FastAPI, client: AsyncClient
) -> None:
@pytest.fixture
def sans_rapport(app: FastAPI) -> Iterator[None]:
app.dependency_overrides[get_current_principal] = operateur
app.dependency_overrides[get_drift_service] = lambda: FauxService([])
yield
app.dependency_overrides.pop(get_current_principal, None)
app.dependency_overrides.pop(get_drift_service, None)
async def test_drift_returns_an_empty_list_when_no_report_exists(
sans_rapport: None, client: AsyncClient
) -> None:
reponse = await client.get("/api/v1/monitoring/drift")
assert reponse.status_code == 200
assert reponse.json() == []
app.dependency_overrides.clear()
+40
View File
@@ -142,6 +142,19 @@ async def test_drift_reports_stable_when_the_recent_mae_stays_close_to_the_refer
assert global_.reason is None
async def test_drift_reports_indetermine_when_the_reference_window_is_empty() -> None:
depot = FauxDepot(
recentes=paires(nombre=30, prevu=14.0, reel=10.0),
comptages=[ComptageStatut(site_id="SITE001", status="available", nombre=30)],
)
rapports = await service(depot, min_observations=10).evaluate(now=INSTANT)
global_ = next(rapport for rapport in rapports if rapport.site_id is None)
assert global_.status == STATUT_INDETERMINE
assert global_.reference_mae is None
async def test_drift_reports_derive_when_the_coverage_ratio_falls_under_the_threshold() -> None:
depot = FauxDepot(
recentes=paires(nombre=30, prevu=10.0, reel=10.0),
@@ -174,6 +187,23 @@ async def test_drift_reports_one_line_per_site_and_one_global_line() -> None:
assert next(r for r in rapports if r.site_id is None).n_observations == 24
async def test_drift_still_reports_a_site_that_stopped_being_scored() -> None:
depot = FauxDepot(
recentes=paires(site_id="SITE001", nombre=30, prevu=10.0, reel=10.0),
anciennes=[
*paires(site_id="SITE001", nombre=30, prevu=10.0, reel=10.0),
*paires(site_id="SITE002", nombre=30, prevu=10.0, reel=10.0),
],
comptages=[ComptageStatut(site_id="SITE001", status="available", nombre=30)],
)
rapports = await service(depot, min_observations=10).evaluate(now=INSTANT)
disparu = next(rapport for rapport in rapports if rapport.site_id == "SITE002")
assert disparu.status == STATUT_INDETERMINE
assert disparu.n_observations == 0
async def test_drift_measures_the_share_of_sites_left_without_enough_history() -> None:
depot = FauxDepot(
recentes=paires(nombre=30, prevu=10.0, reel=10.0),
@@ -201,6 +231,16 @@ async def test_drift_closes_the_window_before_the_grace_delay() -> None:
assert reference[1] == recente[0]
async def test_drift_truncates_the_reference_instant_to_the_hour() -> None:
premier = FauxDepot()
second = FauxDepot()
await service(premier).evaluate(now=INSTANT + timedelta(minutes=17, microseconds=3))
await service(second).evaluate(now=INSTANT + timedelta(minutes=48))
assert premier.fenetres[0] == second.fenetres[0]
@pytest.mark.parametrize(
("prevu", "attendu"),
[(10.0, STATUT_STABLE), (30.0, STATUT_DERIVE)],
+1 -1
View File
@@ -219,5 +219,5 @@ async def test_the_api_serves_the_latest_run_when_the_score_cli_runs_twice(
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"]["created_at"] > premier["prediction"]["created_at"]
assert second["prediction"]["model_reference"] == premier["prediction"]["model_reference"]