diff --git a/.github/workflows/ml.yml b/.github/workflows/ml.yml index 4145c4e..0fddf2d 100644 --- a/.github/workflows/ml.yml +++ b/.github/workflows/ml.yml @@ -8,12 +8,12 @@ on: paths: - "ml/**" - ".github/workflows/ml.yml" - # Le job `integration` monte son schema avec les migrations du backend et joue le test de - # chaine qui vit dans ses tests : sans ces chemins, une migration modifiee ne declencherait - # rien et le schema deriverait du SQL du pipeline sans que rien ne casse. Meme raisonnement + # Piege : `backend.yml` ne joue jamais `-m chaine`, ce job est le seul. Le test de chaine + # traverse tout `apps/backend/app` jusqu'a GET /predictions : restreindre le filtre aux + # migrations et aux modeles le laisserait muet sur la PR meme qui le casse. Meme raisonnement # que le filtre d'airflow.yml, qui inclut deja des chemins de ml/ et de apps/backend/. - "apps/backend/alembic/**" - - "apps/backend/app/models/**" + - "apps/backend/app/**" - "apps/backend/tests/test_chaine_ml_api.py" - "apps/backend/pyproject.toml" - "apps/backend/uv.lock" @@ -21,12 +21,12 @@ on: paths: - "ml/**" - ".github/workflows/ml.yml" - # Le job `integration` monte son schema avec les migrations du backend et joue le test de - # chaine qui vit dans ses tests : sans ces chemins, une migration modifiee ne declencherait - # rien et le schema deriverait du SQL du pipeline sans que rien ne casse. Meme raisonnement + # Piege : `backend.yml` ne joue jamais `-m chaine`, ce job est le seul. Le test de chaine + # traverse tout `apps/backend/app` jusqu'a GET /predictions : restreindre le filtre aux + # migrations et aux modeles le laisserait muet sur la PR meme qui le casse. Meme raisonnement # que le filtre d'airflow.yml, qui inclut deja des chemins de ml/ et de apps/backend/. - "apps/backend/alembic/**" - - "apps/backend/app/models/**" + - "apps/backend/app/**" - "apps/backend/tests/test_chaine_ml_api.py" - "apps/backend/pyproject.toml" - "apps/backend/uv.lock" diff --git a/apps/backend/app/repositories/drift.py b/apps/backend/app/repositories/drift.py index 712228c..cc2026b 100644 --- a/apps/backend/app/repositories/drift.py +++ b/apps/backend/app/repositories/drift.py @@ -8,6 +8,10 @@ même heure plusieurs fois, et la moyenne d'erreur pèserait ces sites en double On retient la prédiction du run le plus récent, celle que sert `GET /api/v1/predictions`, avec `prediction_id` en départage : `created_at` vaut l'heure de début de transaction et ne distingue pas deux lignes du même run. + +Côté lectures, le départage est `reading_id` décroissant, la règle même de +`GET /api/v1/sites/{site_id}/current`. La dérive se mesure donc contre le réalisé que l'API +affiche, et non contre une source élue ici et nulle part ailleurs. """ from collections.abc import Sequence diff --git a/apps/backend/app/services/drift.py b/apps/backend/app/services/drift.py index 1ede7f5..43c835e 100644 --- a/apps/backend/app/services/drift.py +++ b/apps/backend/app/services/drift.py @@ -95,7 +95,10 @@ class DriftService: self, *, now: datetime | None = None, site_id: str | None = None ) -> list[NouveauRapportDerive]: """Une ligne par site, plus une ligne globale dont le `site_id` est nul.""" - fin = (now or datetime.now(UTC)) - self._seuils.grace + # Piège : `window_end` est la clé d'idempotence de `uq_drift_report_window`. Sans + # troncature à l'heure, deux exécutions ne collident jamais, aux microsecondes près. + instant = (now or datetime.now(UTC)).replace(minute=0, second=0, microsecond=0) + fin = instant - self._seuils.grace debut = fin - self._seuils.fenetre reference_fin = debut reference_debut = reference_fin - self._seuils.fenetre @@ -132,8 +135,12 @@ class DriftService: anciennes=[p for p in anciennes if p.site_id == site], comptages=[c for c in comptages if c.site_id == site], ) + # Piège : un site présent dans la référence et absent de la fenêtre récente a cessé + # d'être scoré. C'est la panne à crier, pas une ligne à omettre. for site in sorted( - {paire.site_id for paire in recentes} | {c.site_id for c in comptages} + {paire.site_id for paire in recentes} + | {paire.site_id for paire in anciennes} + | {c.site_id for c in comptages} ) ] rapports.append( @@ -191,7 +198,14 @@ class DriftService: ) plafond = _plafond(reference_mae, ratio=seuils.ratio_derive, plancher=seuils.mae_plancher) - if metriques.mae is not None and plafond is not None and metriques.mae > plafond: + if plafond is None: + return Verdict( + STATUT_INDETERMINE, + "Fenêtre de référence sans paire vérifiée, et aucun plancher de MAE : " + "rien à quoi comparer cette fenêtre.", + ) + + if metriques.mae is not None and metriques.mae > plafond: return Verdict( STATUT_DERIVE, f"MAE de {metriques.mae:.2f} kWh au-delà de {plafond:.2f} kWh, " diff --git a/apps/backend/tests/api/conftest.py b/apps/backend/tests/api/conftest.py index 12ca9f9..4022733 100644 --- a/apps/backend/tests/api/conftest.py +++ b/apps/backend/tests/api/conftest.py @@ -25,7 +25,7 @@ from app.api.deps import get_current_principal from app.core.principal import Principal from app.core.roles import AccountKind, Role from app.db.session import get_session_factory -from app.models.energy import Alert, Prediction, Reading, Recommendation, Site +from app.models.energy import Alert, DriftReport, Prediction, Reading, Recommendation, Site from tests.repositories.test_alert import creer_alerte from tests.repositories.test_prediction import creer_prediction from tests.repositories.test_reading import creer_lecture @@ -52,6 +52,7 @@ async def _supprime(session: AsyncSession, sites: list[str]) -> None: alertes = select(Alert.alert_id).where(Alert.site_id.in_(sites)) await session.execute(delete(Recommendation).where(Recommendation.alert_id.in_(alertes))) await session.execute(delete(Alert).where(Alert.site_id.in_(sites))) + await session.execute(delete(DriftReport).where(DriftReport.site_id.in_(sites))) await session.execute(delete(Prediction).where(Prediction.site_id.in_(sites))) await session.execute(delete(Reading).where(Reading.site_id.in_(sites))) await session.execute(delete(Site).where(Site.site_id.in_(sites))) diff --git a/apps/backend/tests/api/test_monitoring.py b/apps/backend/tests/api/test_monitoring.py index 68b2da1..0bcbed7 100644 --- a/apps/backend/tests/api/test_monitoring.py +++ b/apps/backend/tests/api/test_monitoring.py @@ -63,7 +63,8 @@ def servi(app: FastAPI) -> Iterator[list[DriftReport]]: app.dependency_overrides[get_current_principal] = operateur app.dependency_overrides[get_drift_service] = lambda: service yield rapports - app.dependency_overrides.clear() + app.dependency_overrides.pop(get_current_principal, None) + app.dependency_overrides.pop(get_drift_service, None) async def test_drift_returns_the_latest_report_of_every_site( @@ -86,14 +87,19 @@ async def test_drift_exposes_the_metrics_of_the_stored_report( assert premier["model_references"] == ["lightgbm-aaa"] -async def test_drift_returns_an_empty_list_when_no_report_exists( - app: FastAPI, client: AsyncClient -) -> None: +@pytest.fixture +def sans_rapport(app: FastAPI) -> Iterator[None]: app.dependency_overrides[get_current_principal] = operateur app.dependency_overrides[get_drift_service] = lambda: FauxService([]) + yield + app.dependency_overrides.pop(get_current_principal, None) + app.dependency_overrides.pop(get_drift_service, None) + +async def test_drift_returns_an_empty_list_when_no_report_exists( + sans_rapport: None, client: AsyncClient +) -> None: reponse = await client.get("/api/v1/monitoring/drift") assert reponse.status_code == 200 assert reponse.json() == [] - app.dependency_overrides.clear() diff --git a/apps/backend/tests/services/test_drift.py b/apps/backend/tests/services/test_drift.py index ad6a0f2..c03683a 100644 --- a/apps/backend/tests/services/test_drift.py +++ b/apps/backend/tests/services/test_drift.py @@ -142,6 +142,19 @@ async def test_drift_reports_stable_when_the_recent_mae_stays_close_to_the_refer assert global_.reason is None +async def test_drift_reports_indetermine_when_the_reference_window_is_empty() -> None: + depot = FauxDepot( + recentes=paires(nombre=30, prevu=14.0, reel=10.0), + comptages=[ComptageStatut(site_id="SITE001", status="available", nombre=30)], + ) + + rapports = await service(depot, min_observations=10).evaluate(now=INSTANT) + + global_ = next(rapport for rapport in rapports if rapport.site_id is None) + assert global_.status == STATUT_INDETERMINE + assert global_.reference_mae is None + + async def test_drift_reports_derive_when_the_coverage_ratio_falls_under_the_threshold() -> None: depot = FauxDepot( recentes=paires(nombre=30, prevu=10.0, reel=10.0), @@ -174,6 +187,23 @@ async def test_drift_reports_one_line_per_site_and_one_global_line() -> None: assert next(r for r in rapports if r.site_id is None).n_observations == 24 +async def test_drift_still_reports_a_site_that_stopped_being_scored() -> None: + depot = FauxDepot( + recentes=paires(site_id="SITE001", nombre=30, prevu=10.0, reel=10.0), + anciennes=[ + *paires(site_id="SITE001", nombre=30, prevu=10.0, reel=10.0), + *paires(site_id="SITE002", nombre=30, prevu=10.0, reel=10.0), + ], + comptages=[ComptageStatut(site_id="SITE001", status="available", nombre=30)], + ) + + rapports = await service(depot, min_observations=10).evaluate(now=INSTANT) + + disparu = next(rapport for rapport in rapports if rapport.site_id == "SITE002") + assert disparu.status == STATUT_INDETERMINE + assert disparu.n_observations == 0 + + async def test_drift_measures_the_share_of_sites_left_without_enough_history() -> None: depot = FauxDepot( recentes=paires(nombre=30, prevu=10.0, reel=10.0), @@ -201,6 +231,16 @@ async def test_drift_closes_the_window_before_the_grace_delay() -> None: assert reference[1] == recente[0] +async def test_drift_truncates_the_reference_instant_to_the_hour() -> None: + premier = FauxDepot() + second = FauxDepot() + + await service(premier).evaluate(now=INSTANT + timedelta(minutes=17, microseconds=3)) + await service(second).evaluate(now=INSTANT + timedelta(minutes=48)) + + assert premier.fenetres[0] == second.fenetres[0] + + @pytest.mark.parametrize( ("prevu", "attendu"), [(10.0, STATUT_STABLE), (30.0, STATUT_DERIVE)], diff --git a/apps/backend/tests/test_chaine_ml_api.py b/apps/backend/tests/test_chaine_ml_api.py index f2718a5..af977de 100644 --- a/apps/backend/tests/test_chaine_ml_api.py +++ b/apps/backend/tests/test_chaine_ml_api.py @@ -219,5 +219,5 @@ async def test_the_api_serves_the_latest_run_when_the_score_cli_runs_twice( await executer("enervision_ml.score", "--model", str(modele), journal=tmp_path) second = await resume_du_site(client, parc.sites[0]) - assert second["prediction"]["created_at"] >= premier["prediction"]["created_at"] + assert second["prediction"]["created_at"] > premier["prediction"]["created_at"] assert second["prediction"]["model_reference"] == premier["prediction"]["model_reference"] diff --git a/docs/adr/0011-surveillance-de-derive-dans-le-backend.md b/docs/adr/0011-surveillance-de-derive-dans-le-backend.md index 5d92c07..4073250 100644 --- a/docs/adr/0011-surveillance-de-derive-dans-le-backend.md +++ b/docs/adr/0011-surveillance-de-derive-dans-le-backend.md @@ -81,14 +81,22 @@ modèle change n'est pas une dérive, c'est une régression de réentraînement. | Écrire le résultat dans `alert` | `ck_alert_source` et `ck_alert_type` bornent les valeurs autorisées, `alert.site_id` est `NOT NULL` et n'accueillerait donc pas la ligne globale, et toute alerte est ensuite relue par le moteur de recommandations, qui devrait apprendre une règle qui ne le concerne pas (ADR 0006). | | Une jauge Prometheus | `monitoring/` ne contient que des `.gitkeep` et aucun collecteur ne lit `/metrics` : une jauge que personne ne scrute n'est pas une preuve. Le calcul est de surcroît un traitement par lot, pas le processus qui sert l'API : la jauge disparaîtrait avec lui. | | Ne rien persister, journaliser seulement | La question posée à un jury est « comment savez-vous que le modèle se dégrade ? ». La réponse est une série dans le temps, pas une ligne de journal perdue avec le conteneur. Sans ligne écrite, l'endpoint n'a rien à lire et le test d'intégration rien à vérifier. | -| Une tâche de plus dans le DAG `alertes` | La fenêtre fait 168 h : la recalculer chaque heure écrirait vingt-quatre lignes identiques par jour. Surtout, un échec de dérive ferait rougir `alertes` et laisserait croire que la détection a échoué. | +| Une tâche de plus dans le DAG `alertes` | La fenêtre fait 168 h : la recalculer chaque heure écrirait vingt-quatre lignes par jour pour un verdict qui ne bouge pas à cette cadence. Surtout, un échec de dérive ferait rougir `alertes` et laisserait croire que la détection a échoué. | ## Conséquences - Une migration ajoute `drift_report`. Son idempotence passe par un **index unique à `coalesce(site_id, '')`** et non par une `UniqueConstraint` : deux lignes globales ont toutes deux `site_id` à NULL, et NULL n'est égal à rien, pas même à lui-même. Même forme que - `uq_reading_source`. + `uq_reading_source`. Cet index n'a de sens que parce que `evaluate()` **tronque son instant de + référence à l'heure** : avec les microsecondes de `now()`, deux exécutions ne porteraient jamais + la même clé et l'index ne dédoublonnerait rien. +- **Sans fenêtre de référence, le verdict est `indetermine`, pas `stable`.** Au premier + lancement, et après tout trou d'ingestion de plus de 168 h, il n'y a rien à quoi comparer : + annoncer `stable` serait affirmer ce que la donnée ne dit pas. +- **Un site présent dans la fenêtre de référence et absent de la récente reçoit sa ligne**, à + zéro observation. Un site qui cesse d'être scoré est exactement la panne que cette surveillance + existe pour dire : le taire en ne produisant aucune ligne serait l'inverse du besoin. - `GET /api/v1/monitoring/drift` est réservé à partir du rôle `operateur` : c'est l'opérateur qui agit sur un pipeline dégradé, pas l'administrateur de comptes. La route est classée dans `tests/api/acces.py`, donc couverte gratuitement par la matrice de rôles rejouée avec de vrais diff --git a/docs/architecture/20-backend.md b/docs/architecture/20-backend.md index cbc853a..0465780 100644 --- a/docs/architecture/20-backend.md +++ b/docs/architecture/20-backend.md @@ -14,9 +14,9 @@ Les quatre couches existent désormais, portées par l'authentification. flowchart TB ep["endpoints
health, auth, users, sites, alerts,
recommendations, stats, readings, sensors,
predictions, monitoring"] sc["schemas
Pydantic"] - sv["services
AuthService, UserService,
SiteService, AlertService, RecommendationService,
StatsService, ReadingService, SensorService, PredictionService"] - rp["repositories
user, refresh_token,
login_attempt, audit_log,
site, alert, recommendation, reading, prediction"] - md["models
10 tables"] + sv["services
AuthService, UserService,
SiteService, AlertService, RecommendationService,
StatsService, ReadingService, SensorService,
PredictionService, DriftService"] + rp["repositories
user, refresh_token,
login_attempt, audit_log,
site, alert, recommendation, reading,
prediction, drift"] + md["models
13 tables"] db[("PostgreSQL")] ep --> sc @@ -229,8 +229,9 @@ côté pilote, en `500` plutôt qu'un refus propre. `DriftService.evaluate()` joint `prediction` et `reading` sur `(site_id, target_at = timestamp)` et compare deux fenêtres vives de 168 h, la récente et celle qui la précède. Il rend une ligne par site plus une ligne globale, que `DriftRepository.enregistre()` écrit dans `drift_report` avec -`ON CONFLICT DO NOTHING` sur `uq_drift_report_window` : rejouer la commande sur la même fenêtre -n'ajoute rien. +`ON CONFLICT DO NOTHING` sur `uq_drift_report_window`. L'instant de référence est tronqué à +l'heure, ce qui est la condition pour que cet index serve : rejouer la commande dans la même +heure n'ajoute rien. | Métrique | Ce qu'elle dit | |---|---| @@ -242,7 +243,10 @@ n'ajoute rien. | `model_references` | Les modèles vus dans la fenêtre : une MAE qui saute à l'instant où le modèle change est une régression de réentraînement, pas une dérive | Le verdict a trois valeurs, `stable`, `derive` et `indetermine` : sous un nombre minimal -d'observations, le service dit qu'il ne sait pas plutôt que de rendre un chiffre trompeur. La +d'observations, ou faute de fenêtre de référence à laquelle comparer, le service dit qu'il ne +sait pas plutôt que de rendre un chiffre trompeur. Un site qui figure dans la fenêtre de +référence mais plus dans la récente reçoit sa ligne à zéro observation : cesser d'être scoré est +la panne que cette surveillance existe pour dire. La fenêtre est fermée à droite par un délai de grâce de 2 h, le temps que l'ingestion livre le réalisé de la dernière heure. `python -m app.monitoring.drift` l'exécute, le DAG `derive` l'ordonnance, et `GET /api/v1/monitoring/drift` sert le dernier rapport de chaque site. Les diff --git a/docs/architecture/50-cicd.md b/docs/architecture/50-cicd.md index d46455d..38486f2 100644 --- a/docs/architecture/50-cicd.md +++ b/docs/architecture/50-cicd.md @@ -258,7 +258,7 @@ régénère, ce qui invalide les sessions et les connexions chiffrées par Airfl ### Pourquoi le job d'intégration ML installe aussi le backend -Le schéma de la base n'a qu'une source, les six révisions Alembic de `apps/backend/alembic` : le +Le schéma de la base n'a qu'une source, les sept révisions Alembic de `apps/backend/alembic` : le backend est propriétaire du schéma, `ml/` n'en est que consommateur. Reconstruire ce schéma à la main dans le job ML donnerait un job vert sur une base qui n'est pas la nôtre, exactement l'erreur qu'évite déjà le choix de l'image `timescaledb-ha` plutôt qu'un `postgres` nu. Le job installe @@ -266,12 +266,14 @@ donc les deux environnements uv, applique `alembic upgrade head`, puis joue `-m `ml/` et `-m chaine` côté backend. Conséquence sur le déclenchement : les `paths` de `ml.yml` incluent `apps/backend/alembic/**` et -`apps/backend/app/models/**`. Sans eux, une migration qui renomme une colonne de `reading` ne +`apps/backend/app/**`. Sans eux, une migration qui renomme une colonne de `reading` ne déclencherait pas ce job, le SQL brut du pipeline dériverait du schéma, et **rien ne casserait -avant la production**. Le prix est qu'une PR touchant seulement une migration lance aussi le lint -et le typage de `ml/` : environ deux minutes de runner, en parallèle. Même arbitrage que le filtre -d'`airflow.yml`, qui écoute déjà `ml/**` et `apps/backend/app/**` parce que son image réunit les -deux. +avant la production**. `app/**` en entier, et non les seuls modèles : ce job est le seul à jouer +`-m chaine`, or la chaîne traverse les endpoints, les services et les schémas jusqu'à +`GET /predictions`. Un filtre plus étroit laisserait le test muet sur la PR même qui le casse. Le +prix est qu'une PR backend lance aussi le lint et le typage de `ml/` : environ deux minutes de +runner, en parallèle. Même arbitrage que le filtre d'`airflow.yml`, qui écoute déjà `ml/**` et +`apps/backend/app/**` parce que son image réunit les deux. Le marqueur `chaine` est distinct d'`integration` pour une raison mécanique : le job `integration` de `backend.yml` n'installe pas `ml/.venv`, et sélectionnerait sinon un test qui lance les @@ -297,7 +299,7 @@ Les tests d'intégration demandent une base **migrée**, et `db/init` ne crée ` vide : ```bash -make db-up migrate-test # la base de test reçoit les six révisions Alembic +make db-up migrate-test # la base de test reçoit les sept révisions Alembic make test-integration # backend, marqueur `integration` make ml-test-integration # pipeline ML, marqueur `integration` make test-chaine # vrais binaires ML puis relecture par l'API, marqueur `chaine` diff --git a/etl/README.md b/etl/README.md index d789d6b..ee1a256 100644 --- a/etl/README.md +++ b/etl/README.md @@ -673,6 +673,6 @@ Le DAG `historical_import` est déclenché manuellement. Il exécute `/opt/data/raw`. L'orchestration de l'import API Mock et la réconciliation globale des deux sources restent couvertes par l'issue #15. -Airflow permet de planifier les traitements, gérer leur ordre d'exécution, suivre leur état et remonter les erreurs. Il ne remplace pas la logique ETL Python existante : les scripts actuels restent responsables de l'extraction, de la validation, de la transformation et du chargement. `etl/airflow/dags/ml_train.py`, `ml_score.py` et `alertes.py` et `historical_import.py` montrent le patron retenu (des `BashOperator` qui invoquent le script tel quel, dans l'environnement `uv` que l'image embarque pour lui). +Airflow permet de planifier les traitements, gérer leur ordre d'exécution, suivre leur état et remonter les erreurs. Il ne remplace pas la logique ETL Python existante : les scripts actuels restent responsables de l'extraction, de la validation, de la transformation et du chargement. `etl/airflow/dags/ml_train.py`, `ml_score.py`, `alertes.py`, `historical_import.py` et `derive.py` montrent le patron retenu (des `BashOperator` qui invoquent le script tel quel, dans l'environnement `uv` que l'image embarque pour lui). Le pipeline Data servira ensuite à préparer les données nécessaires au modèle de Machine Learning. diff --git a/etl/airflow/dags/derive.py b/etl/airflow/dags/derive.py index ba9b973..0179128 100644 --- a/etl/airflow/dags/derive.py +++ b/etl/airflow/dags/derive.py @@ -1,9 +1,12 @@ """DAG de surveillance de la dérive du modèle de prévision (issue #45). Quotidien, pas horaire : la fenêtre mesurée couvre 168 h, la recalculer chaque heure écrirait -vingt-quatre lignes presque identiques par jour et se heurterait à l'index d'idempotence -`uq_drift_report_window`. Planifié après les scorings de la nuit, et décalé de `ml_score` (à -l'heure pile) comme de `alertes` (à la quinzième minute). +vingt-quatre lignes par jour pour un verdict qui ne bouge pas à cette cadence. Planifié après +les scorings de la nuit, et décalé de `ml_score` (à l'heure pile) comme de `alertes` (à la +quinzième minute). + +Sans `--now` : le service tronque son instant de référence à l'heure, donc deux exécutions de +la même heure portent la même clé `uq_drift_report_window` et la seconde n'écrit rien. Tâche distincte du DAG `alertes` plutôt qu'ajoutée à lui : un échec de dérive y ferait croire que la détection d'alertes a échoué, et ce DAG porte un budget temporel déjà argumenté face à diff --git a/ml/tests/test_data_integration.py b/ml/tests/test_data_integration.py index bd473c6..237d85e 100644 --- a/ml/tests/test_data_integration.py +++ b/ml/tests/test_data_integration.py @@ -16,6 +16,12 @@ 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) @@ -49,7 +55,7 @@ def test_load_recent_from_database_excludes_readings_before_the_since_bound( connexion_ml, since=ANCRAGE - timedelta(hours=2), until=ANCRAGE ) - assert list(frame["timestamp"]) == [ + assert list(du_site(frame, site_id)["timestamp"]) == [ ANCRAGE - timedelta(hours=2), ANCRAGE - timedelta(hours=1), ANCRAGE, @@ -66,7 +72,7 @@ def test_load_recent_from_database_includes_a_reading_exactly_at_the_since_bound connexion_ml, since=ANCRAGE, until=ANCRAGE + timedelta(hours=3) ) - assert len(frame) == 1 + assert len(du_site(frame, site_id)) == 1 def test_load_recent_from_database_keeps_timestamps_timezone_aware( @@ -93,7 +99,7 @@ def test_load_recent_from_database_orders_readings_by_site_then_timestamp( connexion_ml, since=ANCRAGE, until=ANCRAGE + timedelta(hours=3) ) - assert list(frame["timestamp"]) == [ + assert list(du_site(frame, site_id)["timestamp"]) == [ ANCRAGE, ANCRAGE + timedelta(hours=1), ANCRAGE + timedelta(hours=2), @@ -122,7 +128,7 @@ def test_load_recent_from_database_types_a_fully_null_capacity_kw_as_float64( ) assert frame["capacity_kw"].dtype == "float64" - assert frame["capacity_kw"].isna().all() + assert du_site(frame, site_id)["capacity_kw"].isna().all() def test_load_recent_from_database_types_a_null_is_working_hours_as_float64( @@ -139,7 +145,7 @@ def test_load_recent_from_database_types_a_null_is_working_hours_as_float64( ) assert frame["is_working_hours"].dtype == "float64" - assert list(frame["is_working_hours"].isna()) == [True, False] + 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( @@ -180,4 +186,4 @@ def test_load_recent_from_database_excludes_readings_after_the_until_bound( connexion_ml, since=ANCRAGE - timedelta(days=1), until=ANCRAGE ) - assert list(frame["timestamp"]) == [ANCRAGE] + assert list(du_site(frame, site_id)["timestamp"]) == [ANCRAGE]