Le pipeline ML n'avait aucun test touchant PostgreSQL : `ml/README.md` le disait, faute de base joignable en CI. Le marqueur `integration` de `ml/pyproject.toml` etait declare et porte par zero test. - `ml/tests/conftest.py` : deux fixtures d'acces a la base, jamais interchangeables. `connexion_ml` annule sa transaction, `parc` valide ses ecritures parce que `run_scoring` ouvre sa propre connexion et ne verrait rien d'autre. Garde sur le nom de base, marque uuid sur chaque site, nettoyage dans l'ordre des cles etrangeres. - `test_data_integration.py` : les neuf colonnes du contrat confrontees au schema Alembic reel, la borne `since`, l'ordre de tri dont dependent des lags positionnels, et le typage des colonnes entierement nulles. - `test_score_integration.py` : les contraintes de `prediction` vues depuis le code qui ecrit, l'empilement volontaire de deux runs, et `run_scoring` de bout en bout sur un booster reel. - `apps/backend/tests/test_chaine_ml_api.py` : lance les vrais binaires `enervision_ml.train` et `.score` en sous-processus, comme les DAGs, puis relit par `GET /api/v1/predictions`. Marqueur `chaine` distinct : le job `integration` du backend n'a pas l'environnement de ml/. - `ml.yml` : job `integration`, seul du depot a reunir les deux environnements uv et une base. Ses `paths` incluent les migrations du backend, sans quoi le schema deriverait du SQL du pipeline sans que rien ne casse. - Makefile : `migrate-test`, qui manquait (`enervision_test` n'a jamais recu de table), `ml-test-integration` et `test-chaine`.
155 lines
4.8 KiB
Python
155 lines
4.8 KiB
Python
from datetime import timedelta
|
|
from pathlib import Path
|
|
|
|
import pandas as pd
|
|
import pytest
|
|
from sqlalchemy import Connection
|
|
|
|
from enervision_ml.data import (
|
|
OUTPUT_COLUMNS,
|
|
load_from_csv,
|
|
load_from_database,
|
|
load_recent_from_database,
|
|
)
|
|
from tests.conftest import ANCRAGE, insere_lecture, insere_lectures, insere_site
|
|
|
|
pytestmark = pytest.mark.integration
|
|
|
|
|
|
def test_load_from_database_returns_the_nine_contract_columns(connexion_ml: Connection) -> None:
|
|
site_id = insere_site(connexion_ml)
|
|
insere_lectures(connexion_ml, site_id, heures=3, fin=ANCRAGE)
|
|
|
|
frame = load_from_database(connexion_ml)
|
|
|
|
assert list(frame.columns) == OUTPUT_COLUMNS
|
|
|
|
|
|
def test_load_from_database_joins_the_site_attributes_to_every_reading(
|
|
connexion_ml: Connection,
|
|
) -> None:
|
|
site_id = insere_site(connexion_ml, site_type="factory", capacity_kw=250.0)
|
|
insere_lectures(connexion_ml, site_id, heures=3, fin=ANCRAGE)
|
|
|
|
frame = load_from_database(connexion_ml)
|
|
|
|
mien = frame[frame["site_id"] == site_id]
|
|
assert len(mien) == 3
|
|
assert set(mien["site_type"]) == {"factory"}
|
|
assert set(mien["capacity_kw"]) == {250.0}
|
|
|
|
|
|
def test_load_recent_from_database_excludes_readings_before_the_since_bound(
|
|
connexion_ml: Connection,
|
|
) -> None:
|
|
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))
|
|
|
|
assert list(frame["timestamp"]) == [
|
|
ANCRAGE - timedelta(hours=2),
|
|
ANCRAGE - timedelta(hours=1),
|
|
ANCRAGE,
|
|
]
|
|
|
|
|
|
def test_load_recent_from_database_includes_a_reading_exactly_at_the_since_bound(
|
|
connexion_ml: Connection,
|
|
) -> None:
|
|
site_id = insere_site(connexion_ml)
|
|
insere_lecture(connexion_ml, site_id, instant=ANCRAGE)
|
|
|
|
frame = load_recent_from_database(connexion_ml, since=ANCRAGE)
|
|
|
|
assert len(frame) == 1
|
|
|
|
|
|
def test_load_recent_from_database_keeps_timestamps_timezone_aware(
|
|
connexion_ml: Connection,
|
|
) -> None:
|
|
site_id = insere_site(connexion_ml)
|
|
insere_lecture(connexion_ml, site_id, instant=ANCRAGE)
|
|
|
|
frame = load_recent_from_database(connexion_ml, since=ANCRAGE)
|
|
|
|
assert frame["timestamp"].dt.tz is not None
|
|
|
|
|
|
def test_load_recent_from_database_orders_readings_by_site_then_timestamp(
|
|
connexion_ml: Connection,
|
|
) -> None:
|
|
site_id = insere_site(connexion_ml)
|
|
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)
|
|
|
|
assert list(frame["timestamp"]) == [
|
|
ANCRAGE,
|
|
ANCRAGE + timedelta(hours=1),
|
|
ANCRAGE + timedelta(hours=2),
|
|
]
|
|
|
|
|
|
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))
|
|
|
|
assert frame.empty
|
|
assert list(frame.columns) == OUTPUT_COLUMNS
|
|
|
|
|
|
def test_load_recent_from_database_types_a_fully_null_capacity_kw_as_float64(
|
|
connexion_ml: Connection,
|
|
) -> None:
|
|
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))
|
|
|
|
assert frame["capacity_kw"].dtype == "float64"
|
|
assert frame["capacity_kw"].isna().all()
|
|
|
|
|
|
def test_load_recent_from_database_types_a_null_is_working_hours_as_float64(
|
|
connexion_ml: Connection,
|
|
) -> None:
|
|
site_id = insere_site(connexion_ml)
|
|
insere_lecture(connexion_ml, site_id, instant=ANCRAGE, is_working_hours=None)
|
|
insere_lecture(
|
|
connexion_ml, site_id, instant=ANCRAGE + timedelta(hours=1), is_working_hours=True
|
|
)
|
|
|
|
frame = load_recent_from_database(connexion_ml, since=ANCRAGE)
|
|
|
|
assert frame["is_working_hours"].dtype == "float64"
|
|
assert list(frame["is_working_hours"].isna()) == [True, False]
|
|
|
|
|
|
def test_both_loaders_produce_the_same_columns_in_the_same_order(
|
|
connexion_ml: Connection, tmp_path: Path
|
|
) -> None:
|
|
site_id = insere_site(connexion_ml)
|
|
insere_lectures(connexion_ml, site_id, heures=2, fin=ANCRAGE)
|
|
csv_path = tmp_path / "lectures.csv"
|
|
pd.DataFrame(
|
|
{
|
|
"site_id": [site_id],
|
|
"timestamp": [ANCRAGE],
|
|
"consumption_kwh": [50.0],
|
|
"temperature_celsius": [15.0],
|
|
"humidity_percent": [50.0],
|
|
"solar_irradiance_wm2": [0.0],
|
|
"is_working_hours": [True],
|
|
"site_type": ["office"],
|
|
}
|
|
).to_csv(csv_path, index=False)
|
|
|
|
depuis_la_base = load_recent_from_database(connexion_ml, since=ANCRAGE - timedelta(hours=1))
|
|
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()
|