278 lines
9.2 KiB
Python
278 lines
9.2 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)
|