Files
ENI-projet-piscine/ml/tests/test_score.py
Johan LEROY cb961ec2c5 fix(ml): borne la fenetre de scoring a l'instant demande, pour que --now rejoue l'historique
`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.
2026-09-22 14:29:03 +02:00

299 lines
10 KiB
Python

from datetime import UTC, datetime, timedelta
from pathlib import Path
from typing import Any
import pandas as pd
import pytest
from enervision_ml.features import TARGET_COLUMN
from enervision_ml.score import (
LAG_168H_COLUMN,
MAX_STALENESS,
ScoredSite,
build_scoring_frame,
model_reference,
run_scoring,
score,
write_predictions,
)
def make_recent(
site_id: str, *, heures: int, depart: datetime, valeur: float = 10.0
) -> pd.DataFrame:
instants = [depart + timedelta(hours=h) for h in range(heures)]
return pd.DataFrame(
{
"site_id": site_id,
"timestamp": instants,
TARGET_COLUMN: [valeur + h for h in range(heures)],
"temperature_celsius": 15.0,
"humidity_percent": 50.0,
"solar_irradiance_wm2": 0.0,
"is_working_hours": True,
"site_type": "office",
"capacity_kw": 100.0,
}
)
class FakeBooster:
def __init__(self, valeur: float = 42.0) -> None:
self.valeur = valeur
self.appels: list[int] = []
def predict(self, X: Any) -> list[float]:
self.appels.append(len(X))
return [self.valeur] * len(X)
class FakeConnection:
def __init__(self) -> None:
self.appels: list[tuple[Any, Any]] = []
def execute(self, statement: Any, parameters: Any = None) -> None:
self.appels.append((statement, parameters))
def test_build_scoring_frame_adds_one_row_per_site_one_hour_after_the_last_reading() -> None:
depart = datetime(2026, 1, 1, tzinfo=UTC)
recent = pd.concat(
[
make_recent("site-a", heures=200, depart=depart),
make_recent("site-b", heures=200, depart=depart),
],
ignore_index=True,
)
scoring_frame = build_scoring_frame(recent)
assert set(scoring_frame["site_id"]) == {"site-a", "site-b"}
derniere_lecture = depart + timedelta(hours=199)
assert (scoring_frame["timestamp"] == derniere_lecture + timedelta(hours=1)).all()
def test_build_scoring_frame_computes_lags_from_real_history() -> None:
depart = datetime(2026, 1, 1, tzinfo=UTC)
recent = make_recent("site-a", heures=200, depart=depart)
scoring_frame = build_scoring_frame(recent)
ligne = scoring_frame.iloc[0]
# La cible future n'existe pas : le lag d'1h doit valoir la toute derniere valeur reelle.
assert ligne[f"{TARGET_COLUMN}_lag_1h"] == recent[TARGET_COLUMN].iloc[-1]
def test_build_scoring_frame_flags_insufficient_history_under_168_hours() -> None:
depart = datetime(2026, 1, 1, tzinfo=UTC)
recent = make_recent("site-a", heures=100, depart=depart)
scoring_frame = build_scoring_frame(recent)
assert pd.isna(scoring_frame.iloc[0][LAG_168H_COLUMN])
def test_build_scoring_frame_accepts_a_full_week_of_history() -> None:
depart = datetime(2026, 1, 1, tzinfo=UTC)
recent = make_recent("site-a", heures=169, depart=depart)
scoring_frame = build_scoring_frame(recent)
assert not pd.isna(scoring_frame.iloc[0][LAG_168H_COLUMN])
def test_build_scoring_frame_filters_to_a_single_site() -> None:
depart = datetime(2026, 1, 1, tzinfo=UTC)
recent = pd.concat(
[
make_recent("site-a", heures=200, depart=depart),
make_recent("site-b", heures=200, depart=depart),
],
ignore_index=True,
)
scoring_frame = build_scoring_frame(recent, site_id="site-a")
assert scoring_frame["site_id"].tolist() == ["site-a"]
def test_build_scoring_frame_returns_empty_when_there_is_no_recent_reading() -> None:
recent = make_recent("site-a", heures=0, depart=datetime(2026, 1, 1, tzinfo=UTC))
scoring_frame = build_scoring_frame(recent)
assert scoring_frame.empty
def target_at_for(depart: datetime, heures: int) -> datetime:
"""`target_at` que produira `build_scoring_frame` pour ce jeu synthetique (derniere lecture
+ 1h) : l'utiliser comme `instant` donne un age d'1h, largement sous le seuil de peremption,
pour les tests qui ne visent pas ce filtre."""
return depart + timedelta(hours=heures)
def test_score_marks_insufficient_history_without_calling_the_model() -> None:
depart = datetime(2026, 1, 1, tzinfo=UTC)
scoring_frame = build_scoring_frame(make_recent("site-a", heures=100, depart=depart))
booster = FakeBooster()
resultats = score(
booster, # type: ignore[arg-type]
scoring_frame,
instant=target_at_for(depart, 100),
)
assert resultats == [
ScoredSite(
site_id="site-a",
target_at=resultats[0].target_at,
status="insufficient_data",
predicted_value=None,
failure_reason=resultats[0].failure_reason,
)
]
assert booster.appels == []
def test_score_predicts_when_history_is_sufficient() -> None:
depart = datetime(2026, 1, 1, tzinfo=UTC)
scoring_frame = build_scoring_frame(make_recent("site-a", heures=200, depart=depart))
booster = FakeBooster(valeur=99.5)
resultats = score(
booster, # type: ignore[arg-type]
scoring_frame,
instant=target_at_for(depart, 200),
)
assert len(resultats) == 1
assert resultats[0].status == "available"
assert resultats[0].predicted_value == 99.5
assert resultats[0].failure_reason is None
assert booster.appels == [1]
def test_score_marks_a_stale_site_as_insufficient_data_without_calling_the_model() -> None:
depart = datetime(2026, 1, 1, tzinfo=UTC)
# Historique largement suffisant (168h+), mais l'instant de reference est loin apres la
# derniere lecture : la fraicheur doit primer sur la disponibilite de l'historique.
scoring_frame = build_scoring_frame(make_recent("site-a", heures=200, depart=depart))
instant = target_at_for(depart, 200) + MAX_STALENESS + timedelta(hours=1)
booster = FakeBooster()
resultats = score(booster, scoring_frame, instant=instant) # type: ignore[arg-type]
assert len(resultats) == 1
assert resultats[0].status == "insufficient_data"
assert resultats[0].predicted_value is None
assert "vieille" in (resultats[0].failure_reason or "")
assert booster.appels == []
def test_score_accepts_a_reading_exactly_at_the_staleness_threshold() -> None:
depart = datetime(2026, 1, 1, tzinfo=UTC)
scoring_frame = build_scoring_frame(make_recent("site-a", heures=200, depart=depart))
# `target_at_for(...)` donne deja un age d'1h (cf. sa docstring) : retrancher cette heure
# pour retomber exactement sur le seuil, ni en dessous ni au dessus.
instant = target_at_for(depart, 200) + MAX_STALENESS - timedelta(hours=1)
booster = FakeBooster(valeur=12.0)
resultats = score(booster, scoring_frame, instant=instant) # type: ignore[arg-type]
assert resultats[0].status == "available"
assert booster.appels == [1]
def test_write_predictions_does_nothing_when_there_is_nothing_to_write() -> None:
connection = FakeConnection()
write_predictions(connection, [], reference="lightgbm-test") # type: ignore[arg-type]
assert connection.appels == []
def test_write_predictions_sends_one_row_per_result() -> None:
connection = FakeConnection()
resultats = [
ScoredSite("site-a", datetime(2026, 1, 1, tzinfo=UTC), "available", 42.0, None),
ScoredSite(
"site-b",
datetime(2026, 1, 1, tzinfo=UTC),
"insufficient_data",
None,
"pas assez d'historique",
),
]
write_predictions(connection, resultats, reference="lightgbm-test") # type: ignore[arg-type]
assert len(connection.appels) == 1
_, lignes = connection.appels[0]
assert len(lignes) == 2
assert lignes[0]["model_reference"] == "lightgbm-test"
assert lignes[0]["target_metric"] == "consumption_kwh"
assert lignes[0]["period_minutes"] == 60
def test_model_reference_is_stable_for_the_same_file_content(tmp_path: Path) -> None:
model_path = tmp_path / "model.txt"
model_path.write_bytes(b"contenu-du-modele")
assert model_reference(model_path) == model_reference(model_path)
def test_model_reference_changes_with_the_file_content(tmp_path: Path) -> None:
premier = tmp_path / "model-a.txt"
premier.write_bytes(b"version-1")
second = tmp_path / "model-b.txt"
second.write_bytes(b"version-2")
assert model_reference(premier) != model_reference(second)
def test_run_scoring_in_csv_mode_scores_without_touching_a_database(tmp_path: Path) -> None:
depart = datetime(2026, 1, 1, tzinfo=UTC)
frame = pd.concat(
[
make_recent("site-a", heures=400, depart=depart),
make_recent("site-b", heures=400, depart=depart),
],
ignore_index=True,
)
csv_path = tmp_path / "recent.csv"
frame.to_csv(csv_path, index=False)
model_path = tmp_path / "model.txt"
model_path.write_bytes(b"peu importe le contenu pour ce test")
with pytest.MonkeyPatch.context() as monkeypatch:
monkeypatch.setattr(
"enervision_ml.score.lgb.Booster", lambda model_file: FakeBooster(valeur=7.0)
)
resultats = run_scoring(model_path=model_path, csv_path=csv_path)
assert {r.site_id for r in resultats} == {"site-a", "site-b"}
assert all(r.status == "available" for r in resultats)
assert all(r.predicted_value == 7.0 for r in resultats)
def test_run_scoring_in_csv_mode_targets_the_hour_after_the_reference_instant(
tmp_path: Path,
) -> None:
depart = datetime(2026, 1, 1, tzinfo=UTC)
frame = make_recent("site-a", heures=400, depart=depart)
csv_path = tmp_path / "recent.csv"
frame.to_csv(csv_path, index=False)
model_path = tmp_path / "model.txt"
model_path.write_bytes(b"peu importe le contenu pour ce test")
rattrapage = depart + timedelta(hours=300)
with pytest.MonkeyPatch.context() as monkeypatch:
monkeypatch.setattr(
"enervision_ml.score.lgb.Booster", lambda model_file: FakeBooster(valeur=7.0)
)
resultats = run_scoring(model_path=model_path, csv_path=csv_path, now=rattrapage)
assert [r.target_at for r in resultats] == [rattrapage + timedelta(hours=1)]