Airflow / Construction de l'image (push) Successful in 1m2s
Backend / Analyse statique de sécurité (push) Successful in 7s
Backend / Tests exigeant une base (push) Failing after 5m3s
Airflow / Lint et intégrité des DAGs (push) Successful in 9m41s
ML / Analyse statique de sécurité (push) Successful in 6s
Backend / Lint, typage et tests (push) Successful in 10m10s
Backend / Audit des dépendances (push) Successful in 9m36s
ML / ML - DB et chaîne ML - DB - API (push) Failing after 5m6s
SonarQube / test-ml (push) Failing after 6m6s
ML / Lint, typage et tests (push) Successful in 11m31s
SonarQube / build-front (push) Successful in 9m49s
SonarQube / build-back (push) Successful in 9m54s
SonarQube / test-front (push) Failing after 5m10s
SonarQube / test-back (push) Failing after 5m13s
SonarQube / SonarQube (push) Skipped
Revue de la branche : trois defauts empechaient la surveillance de tenir ce qu'elle annonce. - `evaluate()` gardait les microsecondes de `now()` dans `window_end`, la cle de `uq_drift_report_window`. Deux executions ne collidaient donc jamais et l'index ne dedoublonnait rien, contrairement a ce qu'affirmaient l'ADR 0011, 20-backend et le docstring du DAG. L'instant de reference est desormais tronque a l'heure. - Un site qui cessait d'etre score disparaissait du rapport : la liste des sites ne venait que de la fenetre recente. La panne que cette surveillance existe pour dire etait exactement celle qu'elle taisait. La fenetre de reference entre maintenant dans l'union, et le site recoit sa ligne `indetermine` a zero observation. - Sans fenetre de reference, `_plafond` rendait `None` et le verdict tombait sur `stable`, une affirmation que la donnee ne portait pas. C'est `indetermine` desormais. `ml.yml` ecoute `apps/backend/app/**` et non les seuls modeles : ce workflow est le seul a jouer `-m chaine`, or la chaine traverse les endpoints, les services et les schemas jusqu'a `GET /predictions`. Une PR touchant `predictions.py` ne declenchait pas le test qui l'assert. Hygiene de tests : le nettoyage des fixtures API connait `drift_report` (cle etrangere RESTRICT vers `site`), le test sans rapport rend ses overrides en teardown, `test_chaine_ml_api` compare les `created_at` strictement (un `>=` passait aussi quand l'API resservait la premiere ligne), et `test_data_integration` filtre sur le site seme au lieu de juger tout le contenu d'une fenetre dans une base partagee. Docs remises d'aplomb : sept revisions Alembic et non six, `derive.py` dans l'inventaire de etl/README, et le diagramme de 20-backend gagne DriftService, le depot drift et sa treizieme table.
190 lines
6.0 KiB
Python
190 lines
6.0 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 du_site(frame: pd.DataFrame, site_id: str) -> pd.DataFrame:
|
|
"""Piege : les chargeurs ne filtrent pas par site, et `enervision_test` est partagee avec
|
|
les tests qui valident leurs ecritures. Juger le contenu de toute la fenetre les couplerait."""
|
|
return frame[frame["site_id"] == site_id].reset_index(drop=True)
|
|
|
|
|
|
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), until=ANCRAGE
|
|
)
|
|
|
|
assert list(du_site(frame, site_id)["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, until=ANCRAGE + timedelta(hours=3)
|
|
)
|
|
|
|
assert len(du_site(frame, site_id)) == 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, until=ANCRAGE + timedelta(hours=3)
|
|
)
|
|
|
|
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, until=ANCRAGE + timedelta(hours=3)
|
|
)
|
|
|
|
assert list(du_site(frame, site_id)["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), until=ANCRAGE + timedelta(days=400)
|
|
)
|
|
|
|
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), until=ANCRAGE
|
|
)
|
|
|
|
assert frame["capacity_kw"].dtype == "float64"
|
|
assert du_site(frame, site_id)["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, until=ANCRAGE + timedelta(hours=3)
|
|
)
|
|
|
|
assert frame["is_working_hours"].dtype == "float64"
|
|
assert list(du_site(frame, site_id)["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), 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(du_site(frame, site_id)["timestamp"]) == [ANCRAGE]
|