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.
This commit is contained in:
Johan LEROY
2026-09-22 14:29:03 +02:00
parent 68239371f6
commit cb961ec2c5
7 changed files with 105 additions and 26 deletions
+37 -8
View File
@@ -45,7 +45,9 @@ def test_load_recent_from_database_excludes_readings_before_the_since_bound(
site_id = insere_site(connexion_ml)
insere_lectures(connexion_ml, site_id, heures=5, fin=ANCRAGE)
frame = load_recent_from_database(connexion_ml, since=ANCRAGE - timedelta(hours=2))
frame = load_recent_from_database(
connexion_ml, since=ANCRAGE - timedelta(hours=2), until=ANCRAGE
)
assert list(frame["timestamp"]) == [
ANCRAGE - timedelta(hours=2),
@@ -60,7 +62,9 @@ def test_load_recent_from_database_includes_a_reading_exactly_at_the_since_bound
site_id = insere_site(connexion_ml)
insere_lecture(connexion_ml, site_id, instant=ANCRAGE)
frame = load_recent_from_database(connexion_ml, since=ANCRAGE)
frame = load_recent_from_database(
connexion_ml, since=ANCRAGE, until=ANCRAGE + timedelta(hours=3)
)
assert len(frame) == 1
@@ -71,7 +75,9 @@ def test_load_recent_from_database_keeps_timestamps_timezone_aware(
site_id = insere_site(connexion_ml)
insere_lecture(connexion_ml, site_id, instant=ANCRAGE)
frame = load_recent_from_database(connexion_ml, since=ANCRAGE)
frame = load_recent_from_database(
connexion_ml, since=ANCRAGE, until=ANCRAGE + timedelta(hours=3)
)
assert frame["timestamp"].dt.tz is not None
@@ -83,7 +89,9 @@ def test_load_recent_from_database_orders_readings_by_site_then_timestamp(
for decalage in (2, 0, 1):
insere_lecture(connexion_ml, site_id, instant=ANCRAGE + timedelta(hours=decalage))
frame = load_recent_from_database(connexion_ml, since=ANCRAGE)
frame = load_recent_from_database(
connexion_ml, since=ANCRAGE, until=ANCRAGE + timedelta(hours=3)
)
assert list(frame["timestamp"]) == [
ANCRAGE,
@@ -95,7 +103,9 @@ def test_load_recent_from_database_orders_readings_by_site_then_timestamp(
def test_load_recent_from_database_returns_the_contract_columns_even_without_any_row(
connexion_ml: Connection,
) -> None:
frame = load_recent_from_database(connexion_ml, since=ANCRAGE + timedelta(days=365))
frame = load_recent_from_database(
connexion_ml, since=ANCRAGE + timedelta(days=365), until=ANCRAGE + timedelta(days=400)
)
assert frame.empty
assert list(frame.columns) == OUTPUT_COLUMNS
@@ -107,7 +117,9 @@ def test_load_recent_from_database_types_a_fully_null_capacity_kw_as_float64(
site_id = insere_site(connexion_ml, capacity_kw=None)
insere_lectures(connexion_ml, site_id, heures=3, fin=ANCRAGE)
frame = load_recent_from_database(connexion_ml, since=ANCRAGE - timedelta(hours=2))
frame = load_recent_from_database(
connexion_ml, since=ANCRAGE - timedelta(hours=2), until=ANCRAGE
)
assert frame["capacity_kw"].dtype == "float64"
assert frame["capacity_kw"].isna().all()
@@ -122,7 +134,9 @@ def test_load_recent_from_database_types_a_null_is_working_hours_as_float64(
connexion_ml, site_id, instant=ANCRAGE + timedelta(hours=1), is_working_hours=True
)
frame = load_recent_from_database(connexion_ml, since=ANCRAGE)
frame = load_recent_from_database(
connexion_ml, since=ANCRAGE, until=ANCRAGE + timedelta(hours=3)
)
assert frame["is_working_hours"].dtype == "float64"
assert list(frame["is_working_hours"].isna()) == [True, False]
@@ -147,8 +161,23 @@ def test_both_loaders_produce_the_same_columns_in_the_same_order(
}
).to_csv(csv_path, index=False)
depuis_la_base = load_recent_from_database(connexion_ml, since=ANCRAGE - timedelta(hours=1))
depuis_la_base = load_recent_from_database(
connexion_ml, since=ANCRAGE - timedelta(hours=1), until=ANCRAGE
)
depuis_le_csv = load_from_csv(csv_path)
assert list(depuis_la_base.columns) == list(depuis_le_csv.columns)
assert depuis_la_base.dtypes.to_dict() == depuis_le_csv.dtypes.to_dict()
def test_load_recent_from_database_excludes_readings_after_the_until_bound(
connexion_ml: Connection,
) -> None:
site_id = insere_site(connexion_ml)
insere_lectures(connexion_ml, site_id, heures=5, fin=ANCRAGE + timedelta(hours=4))
frame = load_recent_from_database(
connexion_ml, since=ANCRAGE - timedelta(days=1), until=ANCRAGE
)
assert list(frame["timestamp"]) == [ANCRAGE]
+21
View File
@@ -275,3 +275,24 @@ def test_run_scoring_in_csv_mode_scores_without_touching_a_database(tmp_path: Pa
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)]
+13
View File
@@ -223,3 +223,16 @@ def test_run_scoring_appends_a_second_row_when_it_runs_twice(
ecrites = parc.predictions_ecrites(site_id)
assert len(ecrites) == 2
assert ecrites[0].target_at == ecrites[1].target_at
def test_run_scoring_targets_the_hour_after_the_reference_instant(
parc: Parc, modele_jetable: Path
) -> None:
site_id = parc.site()
parc.lectures(site_id, heures=200, fin=ANCRAGE + timedelta(hours=48))
rattrapage = ANCRAGE
run_scoring(model_path=modele_jetable, now=rattrapage)
ligne = parc.predictions_ecrites(site_id)[0]
assert ligne.target_at == rattrapage + timedelta(hours=1)