fix(ml,backend,frontend): borne la peremption des predictions et isole les erreurs par flux
This commit is contained in:
+46
-25
@@ -1,34 +1,55 @@
|
||||
from pathlib import Path
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from enervision_ml.data import NUMERIC_COLUMNS, OUTPUT_COLUMNS, _typer
|
||||
from enervision_ml.data import NUMERIC_COLUMNS, load_from_csv
|
||||
|
||||
_CSV_HEADER = (
|
||||
"site_id,timestamp,consumption_kwh,temperature_celsius,humidity_percent,"
|
||||
"solar_irradiance_wm2,is_working_hours,site_type"
|
||||
)
|
||||
|
||||
|
||||
def make_frame_with_object_dtype_capacity() -> pd.DataFrame:
|
||||
# Reproduit ce que `pd.read_sql` renvoie pour une colonne entierement `NULL` en base :
|
||||
# dtype `object` rempli de `None`, pas `float64` rempli de `NaN`.
|
||||
frame = pd.DataFrame(
|
||||
{colonne: [1.0, 2.0] for colonne in OUTPUT_COLUMNS if colonne not in NUMERIC_COLUMNS}
|
||||
def write_csv(tmp_path: Path, *lignes: str) -> Path:
|
||||
csv_path = tmp_path / "recent.csv"
|
||||
csv_path.write_text("\n".join([_CSV_HEADER, *lignes]) + "\n")
|
||||
return csv_path
|
||||
|
||||
|
||||
def test_load_from_csv_types_every_numeric_column_as_float(tmp_path: Path) -> None:
|
||||
csv_path = write_csv(tmp_path, "SITE001,2026-01-01T00:00:00,10.5,15.0,50.0,0.0,True,office")
|
||||
|
||||
frame = load_from_csv(csv_path)
|
||||
|
||||
for colonne in NUMERIC_COLUMNS:
|
||||
assert frame[colonne].dtype == "float64"
|
||||
|
||||
|
||||
def test_load_from_csv_coerces_a_corrupted_measurement_to_nan(tmp_path: Path) -> None:
|
||||
# Reproduit une valeur de capteur corrompue plutot que vraiment manquante : `pandas` type
|
||||
# alors la colonne entiere en `object`, pas en `float64` rempli de `NaN` -- le meme genre de
|
||||
# divergence de typage que celle que `pd.read_sql` produit sur une colonne SQL entierement
|
||||
# `NULL` (cf. `site.capacity_kw`, jamais peuplee par aucun pipeline d'ingestion aujourd'hui).
|
||||
csv_path = write_csv(
|
||||
tmp_path,
|
||||
"SITE001,2026-01-01T00:00:00,10.5,15.0,50.0,0.0,True,office",
|
||||
"SITE001,2026-01-01T01:00:00,capteur_hs,15.2,50.5,0.0,True,office",
|
||||
)
|
||||
for colonne in NUMERIC_COLUMNS:
|
||||
frame[colonne] = pd.Series([None, None], dtype="object")
|
||||
return frame
|
||||
|
||||
frame = load_from_csv(csv_path)
|
||||
|
||||
assert frame["consumption_kwh"].dtype == "float64"
|
||||
assert frame["consumption_kwh"].iloc[0] == 10.5
|
||||
assert pd.isna(frame["consumption_kwh"].iloc[1])
|
||||
|
||||
|
||||
def test_typer_coerces_an_all_null_object_column_to_float() -> None:
|
||||
frame = make_frame_with_object_dtype_capacity()
|
||||
def test_load_from_csv_always_types_capacity_kw_as_float(tmp_path: Path) -> None:
|
||||
# `capacity_kw` n'existe pas dans ce CSV : `load_from_csv` la pose elle-meme a `NaN`. Cette
|
||||
# affectation directe est deja un `float`, contrairement au cas `pd.read_sql` -- ce test
|
||||
# garde le contrat visible malgre tout, au cas ou l'implementation changerait.
|
||||
csv_path = write_csv(tmp_path, "SITE001,2026-01-01T00:00:00,10.5,15.0,50.0,0.0,True,office")
|
||||
|
||||
typee = _typer(frame)
|
||||
frame = load_from_csv(csv_path)
|
||||
|
||||
for colonne in NUMERIC_COLUMNS:
|
||||
assert typee[colonne].dtype == "float64"
|
||||
assert typee[colonne].isna().all()
|
||||
|
||||
|
||||
def test_typer_preserves_real_numeric_values() -> None:
|
||||
frame = make_frame_with_object_dtype_capacity()
|
||||
frame["capacity_kw"] = pd.Series([100.0, None], dtype="object")
|
||||
|
||||
typee = _typer(frame)
|
||||
|
||||
assert typee["capacity_kw"].tolist()[0] == 100.0
|
||||
assert pd.isna(typee["capacity_kw"].tolist()[1])
|
||||
assert frame["capacity_kw"].dtype == "float64"
|
||||
assert pd.isna(frame["capacity_kw"].iloc[0])
|
||||
|
||||
+49
-2
@@ -8,6 +8,7 @@ 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,
|
||||
@@ -123,12 +124,23 @@ def test_build_scoring_frame_returns_empty_when_there_is_no_recent_reading() ->
|
||||
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, scoring_frame) # type: ignore[arg-type]
|
||||
resultats = score(
|
||||
booster, # type: ignore[arg-type]
|
||||
scoring_frame,
|
||||
instant=target_at_for(depart, 100),
|
||||
)
|
||||
|
||||
assert resultats == [
|
||||
ScoredSite(
|
||||
@@ -147,7 +159,11 @@ def test_score_predicts_when_history_is_sufficient() -> None:
|
||||
scoring_frame = build_scoring_frame(make_recent("site-a", heures=200, depart=depart))
|
||||
booster = FakeBooster(valeur=99.5)
|
||||
|
||||
resultats = score(booster, scoring_frame) # type: ignore[arg-type]
|
||||
resultats = score(
|
||||
booster, # type: ignore[arg-type]
|
||||
scoring_frame,
|
||||
instant=target_at_for(depart, 200),
|
||||
)
|
||||
|
||||
assert len(resultats) == 1
|
||||
assert resultats[0].status == "available"
|
||||
@@ -156,6 +172,37 @@ def test_score_predicts_when_history_is_sufficient() -> 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()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user