diff --git a/apps/backend/tests/api/test_predictions.py b/apps/backend/tests/api/test_predictions.py index cbc1cb6..184afc6 100644 --- a/apps/backend/tests/api/test_predictions.py +++ b/apps/backend/tests/api/test_predictions.py @@ -15,11 +15,14 @@ TARGET_AT = datetime(2026, 9, 16, 13, 0, tzinfo=UTC) CREATED_AT = datetime(2026, 9, 16, 12, 0, tzinfo=UTC) -def principal(role: Role = Role.LECTEUR) -> Principal: +def lecteur() -> Principal: + # Le garde-fou de rôle (`lecteur` minimum) est déjà couvert par l'ensemble `ROUTES_A_ROLE` + # de `tests/api/test_openapi.py` : pas besoin ici d'un paramètre de rôle jamais appelé avec + # autre chose que sa valeur par défaut. return Principal( id=uuid4(), - email=f"{role.value}@enervision.fr", - role=role, + email="lecteur@enervision.fr", + role=Role.LECTEUR, kind=AccountKind.HUMAIN, must_change_password=False, ) @@ -57,7 +60,7 @@ def servi(app: FastAPI) -> Iterator[Callable[[], FauxService]]: def installe() -> FauxService: service = FauxService() app.dependency_overrides[get_prediction_service] = lambda: service - app.dependency_overrides[get_current_principal] = lambda: principal() + app.dependency_overrides[get_current_principal] = lambda: lecteur() return service yield installe diff --git a/apps/frontend/src/app/core/mocks/predictions.fixture.ts b/apps/frontend/src/app/core/mocks/predictions.fixture.ts deleted file mode 100644 index 5397e27..0000000 --- a/apps/frontend/src/app/core/mocks/predictions.fixture.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { PredictionSummary } from '../../shared/models/prediction.model'; - -export const PREDICTIONS_FIXTURE: PredictionSummary = { - timestamp: '2026-09-18T09:00:00Z', - sites: [ - { - site_id: 'SITE001', - site_name: 'Bureau Paris La Défense', - prediction: { - target_at: '2026-09-18T10:00:00Z', - target_metric: 'consumption_kwh', - period_minutes: 60, - predicted_value: 89.2, - status: 'available', - failure_reason: null, - model_reference: 'lightgbm-16b431449a50', - created_at: '2026-09-18T09:00:00Z', - }, - }, - { - site_id: 'SITE002', - site_name: 'Usine Lyon Vénissieux', - prediction: { - target_at: '2026-09-18T10:00:00Z', - target_metric: 'consumption_kwh', - period_minutes: 60, - predicted_value: 561.4, - status: 'available', - failure_reason: null, - model_reference: 'lightgbm-16b431449a50', - created_at: '2026-09-18T09:00:00Z', - }, - }, - { - site_id: 'SITE003', - site_name: 'Data Center Marseille', - prediction: { - target_at: '2026-09-18T10:00:00Z', - target_metric: 'consumption_kwh', - period_minutes: 60, - predicted_value: null, - status: 'insufficient_data', - failure_reason: - "Historique insuffisant : moins de 168h de consumption_kwh disponibles pour ce site.", - model_reference: 'lightgbm-16b431449a50', - created_at: '2026-09-18T09:00:00Z', - }, - }, - { - site_id: 'SITE004', - site_name: 'Bureau Bordeaux', - prediction: { - target_at: '2026-09-18T10:00:00Z', - target_metric: 'consumption_kwh', - period_minutes: 60, - predicted_value: 58.9, - status: 'available', - failure_reason: null, - model_reference: 'lightgbm-16b431449a50', - created_at: '2026-09-18T09:00:00Z', - }, - }, - { - site_id: 'SITE005', - site_name: 'Usine Toulouse', - prediction: { - target_at: '2026-09-18T10:00:00Z', - target_metric: 'consumption_kwh', - period_minutes: 60, - predicted_value: 402.7, - status: 'available', - failure_reason: null, - model_reference: 'lightgbm-16b431449a50', - created_at: '2026-09-18T09:00:00Z', - }, - }, - { - site_id: 'SITE006', - site_name: 'Bureau Lille', - prediction: { - target_at: '2026-09-18T10:00:00Z', - target_metric: 'consumption_kwh', - period_minutes: 60, - predicted_value: 91.3, - status: 'available', - failure_reason: null, - model_reference: 'lightgbm-16b431449a50', - created_at: '2026-09-18T09:00:00Z', - }, - }, - { - // Illustre le cas d'un site jamais scoré : `prediction` reste `null`, pas un statut inventé - // (même contrat que `PredictionService.summary()` côté backend). - site_id: 'SITE007', - site_name: 'Data Center Nantes', - prediction: null, - }, - ], -}; diff --git a/apps/frontend/src/app/features/dashboard/dashboard.html b/apps/frontend/src/app/features/dashboard/dashboard.html index d3c5a87..bd5f927 100644 --- a/apps/frontend/src/app/features/dashboard/dashboard.html +++ b/apps/frontend/src/app/features/dashboard/dashboard.html @@ -21,7 +21,13 @@ - @if (error(); as message) { + @if (statsError(); as message) { + + } + @if (alertsError(); as message) { + + } + @if (predictionsError(); as message) { } @@ -85,7 +91,7 @@ {{ prediction.predicted_value | number: '1.0-1' }} kWh à {{ prediction.target_at | date: 'HH:mm' }}{{ prediction.target_at | date: "dd/MM 'à' HH:mm" }} } @else { diff --git a/apps/frontend/src/app/features/dashboard/dashboard.spec.ts b/apps/frontend/src/app/features/dashboard/dashboard.spec.ts index 120ff25..5d716ae 100644 --- a/apps/frontend/src/app/features/dashboard/dashboard.spec.ts +++ b/apps/frontend/src/app/features/dashboard/dashboard.spec.ts @@ -52,7 +52,9 @@ describe('Dashboard', () => { expect(predictions.getPredictions).toHaveBeenCalled(); expect(fixture.componentInstance.alerts().length).toBe(1); expect(fixture.componentInstance.predictions().length).toBe(1); - expect(fixture.componentInstance.error()).toBeNull(); + expect(fixture.componentInstance.statsError()).toBeNull(); + expect(fixture.componentInstance.alertsError()).toBeNull(); + expect(fixture.componentInstance.predictionsError()).toBeNull(); }); it("signale l'indisponibilité puis repart au rafraîchissement suivant", () => { @@ -80,13 +82,13 @@ describe('Dashboard', () => { vi.advanceTimersByTime(1); expect(statsMock.getSummary).toHaveBeenCalledTimes(1); - expect(fixture.componentInstance.error()).not.toBeNull(); + expect(fixture.componentInstance.statsError()).not.toBeNull(); expect(fixture.componentInstance.stats()).toBeNull(); vi.advanceTimersByTime(10000); expect(statsMock.getSummary).toHaveBeenCalledTimes(2); expect(fixture.componentInstance.stats()).not.toBeNull(); - expect(fixture.componentInstance.error()).toBeNull(); + expect(fixture.componentInstance.statsError()).toBeNull(); }); it("n'interrompt pas la page quand le chargement des alertes échoue", () => { @@ -107,6 +109,7 @@ describe('Dashboard', () => { fixture.detectChanges(); expect(fixture.componentInstance.alerts().length).toBe(0); + expect(fixture.componentInstance.alertsError()).not.toBeNull(); }); it("n'interrompt pas la page quand le chargement des prévisions échoue", () => { @@ -130,7 +133,38 @@ describe('Dashboard', () => { fixture.detectChanges(); expect(fixture.componentInstance.predictions().length).toBe(0); - expect(fixture.componentInstance.error()).not.toBeNull(); + expect(fixture.componentInstance.predictionsError()).not.toBeNull(); + }); + + it("un rafraîchissement de stats n'efface pas une erreur de prévisions en attente", () => { + vi.useFakeTimers(); + const statsMock = { getSummary: vi.fn().mockReturnValue(of({ total_sites: 7, sites: [] })) }; + const alertsMock = { getAlerts: vi.fn().mockReturnValue(of([])) }; + const predictions = { + getPredictions: vi.fn().mockReturnValue(throwError(() => new Error('nope'))), + }; + + TestBed.configureTestingModule({ + imports: [Dashboard], + providers: [ + { provide: StatsService, useValue: statsMock }, + { provide: AlertsService, useValue: alertsMock }, + { provide: PredictionsService, useValue: predictions }, + provideRouter([]), + ], + }); + + const fixture = TestBed.createComponent(Dashboard); + fixture.detectChanges(); + + expect(fixture.componentInstance.predictionsError()).not.toBeNull(); + + // Plusieurs cycles de `timer(0, 10_000)` (stats) plus tard, l'erreur des prévisions doit + // toujours être visible : rien ne vient la rafraîchir tant que la section n'est pas rechargée. + vi.advanceTimersByTime(30000); + + expect(fixture.componentInstance.predictionsError()).not.toBeNull(); + expect(fixture.componentInstance.statsError()).toBeNull(); }); it('appelle logout et redirige vers /login au clic sur le bouton de déconnexion', () => { diff --git a/apps/frontend/src/app/features/dashboard/dashboard.ts b/apps/frontend/src/app/features/dashboard/dashboard.ts index d41d258..3671919 100644 --- a/apps/frontend/src/app/features/dashboard/dashboard.ts +++ b/apps/frontend/src/app/features/dashboard/dashboard.ts @@ -1,4 +1,4 @@ -import { Component, OnInit, inject, signal, DestroyRef } from '@angular/core'; +import { Component, OnInit, inject, signal, DestroyRef, WritableSignal } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { timer, switchMap, catchError, EMPTY, Observable } from 'rxjs'; import { DecimalPipe, DatePipe } from '@angular/common'; @@ -67,32 +67,44 @@ export class Dashboard implements OnInit { stats = signal(null); alerts = signal([]); predictions = signal([]); - error = signal(null); + + // Un signal par flux, pas un seul `error` partagé : sinon le tick suivant de `timer` (stats) + // efface silencieusement un message d'échec des prévisions ou des alertes après 10s au plus, + // sans retry ni indication pour l'utilisateur que la section correspondante est restée vide. + statsError = signal(null); + alertsError = signal(null); + predictionsError = signal(null); ngOnInit(): void { this.alertsService .getAlerts() - .pipe(catchError(() => this.reportUnavailable())) - .subscribe((alerts) => this.alerts.set(alerts)); + .pipe(catchError(() => this.reportUnavailable(this.alertsError))) + .subscribe((alerts) => { + this.alertsError.set(null); + this.alerts.set(alerts); + }); // Les prévisions viennent d'un scoring hors ligne, pas d'un calcul à la demande : un seul // chargement au démarrage suffit, pas besoin du rafraîchissement périodique de `stats`. this.predictionsService .getPredictions() - .pipe(catchError(() => this.reportUnavailable())) - .subscribe((summary) => this.predictions.set(summary.sites)); + .pipe(catchError(() => this.reportUnavailable(this.predictionsError))) + .subscribe((summary) => { + this.predictionsError.set(null); + this.predictions.set(summary.sites); + }); // Piège : le catchError porte sur l'observable interne. Sur le flux externe il // terminerait le timer, et le rafraîchissement ne repartirait jamais. timer(0, REFRESH_INTERVAL_MS) .pipe( switchMap(() => - this.statsService.getSummary().pipe(catchError(() => this.reportUnavailable())), + this.statsService.getSummary().pipe(catchError(() => this.reportUnavailable(this.statsError))), ), takeUntilDestroyed(this.destroyRef), ) .subscribe((stats) => { - this.error.set(null); + this.statsError.set(null); this.stats.set(stats); }); } @@ -116,8 +128,8 @@ export class Dashboard implements OnInit { }); } - private reportUnavailable(): Observable { - this.error.set(UNAVAILABLE_MESSAGE); + private reportUnavailable(target: WritableSignal): Observable { + target.set(UNAVAILABLE_MESSAGE); return EMPTY; } } diff --git a/docs/architecture/00-vue-ensemble.md b/docs/architecture/00-vue-ensemble.md index 26b890e..a650a1e 100644 --- a/docs/architecture/00-vue-ensemble.md +++ b/docs/architecture/00-vue-ensemble.md @@ -75,7 +75,7 @@ collecteur ne vient le lire. | Domaine | Technologie | Emplacement | Statut | Ce qui existe réellement | |---|---|---|---|---| | Backend | FastAPI, Python 3.14 | `apps/backend` | `En cours` | Factory, configuration, journalisation, 2 sondes de santé, `/metrics`, contrat OpenAPI versionné, routes `sites`, `alerts`, `recommendations`, `stats/summary`, `readings`, `sensors/status` et `predictions` en lecture (endpoints → services → repositories → models) | -| Frontend | Angular 22, Node 24 | `apps/frontend` | `En cours` | Tableau de bord sur route `/dashboard`, deux services HTTP, graphiques Chart.js, données servies par des fixtures | +| Frontend | Angular 22, Node 24 | `apps/frontend` | `En cours` | Tableau de bord sur route `/dashboard`, authentification complète (garde de route, intercepteur de jeton), cinq services HTTP, graphiques Chart.js. `stats`/`alerts` sur fixtures, `predictions` branché sur l'API réelle | | Base | PostgreSQL 17 + TimescaleDB | `db` | `Fait` | Bootstrap de l'extension, base de test, chaîne Alembic. Schéma applicatif créé (`site`, `dataset`, `reading` en hypertable, `prediction`, `alert`, `recommendation`) | | ML | LightGBM, MLflow | `ml` | `En cours` | Pipeline d'entraînement et de scoring (`enervision_ml.train`/`.score`, features par lags/moyennes glissantes partagées entre les deux, baseline de persistance saisonnière, suivi MLflow local), exposé en lecture via `GET /predictions`. Voir [ADR 0005](../adr/0005-modele-prediction-lightgbm.md) et [ML-START.md](../../ML-START.md). Automatisation (Airflow) et surveillance de dérive (EC06, #44/#45) pas encore construites | | Infra | Terraform, k3s single-node | `infra/terraform` | `En cours` | Module d'installation du cluster. Jamais appliqué, aucune ressource Kubernetes déclarée | diff --git a/docs/architecture/20-backend.md b/docs/architecture/20-backend.md index 308a8bf..f60397d 100644 --- a/docs/architecture/20-backend.md +++ b/docs/architecture/20-backend.md @@ -205,19 +205,6 @@ par exemple `limit` hors bornes). Un datetime sans fuseau dans `start`/`end` est l'UTC plutôt que rejeté : le comparer tel quel à `reading.timestamp` (`timestamptz`) échouerait côté pilote, en `500` plutôt qu'un refus propre. -`GET /readings` reprend le même gabarit mais s'en écarte sur un point : `reading` est l'hypertable, -donc la seule table métier pouvant porter des années d'historique, ce que `docs/architecture/ -owasp-traceabilite.md` documentait comme un risque ouvert (API4, aucune pagination plafonnée ni -fenêtre temporelle maximale). `ReadingService` porte donc une couche de validation absente des -autres routes de lecture : `start`/`end` sont optionnels (24 dernières heures par défaut si les -deux sont omis, l'un défaut par rapport à l'autre sinon), l'écart entre les deux est plafonné à 90 -jours (`FENETRE_MAXIMALE`), et `limit`/`offset` (défaut 500, plafond 2000) empêchent qu'une fenêtre -large mais peu dense reste malgré tout coûteuse. Un dépassement de plafond répond `400` (règle -métier, portée par le service) plutôt que `422` (réservé à la validation structurelle de FastAPI, -par exemple `limit` hors bornes). Un datetime sans fuseau dans `start`/`end` est traité comme de -l'UTC plutôt que rejeté : le comparer tel quel à `reading.timestamp` (`timestamptz`) échouerait -côté pilote, en `500` plutôt qu'un refus propre. - ### `/health/ready` Cette sonde porte une garde décrite dans l'[ADR 0001](../adr/0001-postgresql-timescaledb.md) : un diff --git a/docs/architecture/30-frontend.md b/docs/architecture/30-frontend.md index b7f6d10..fb10f92 100644 --- a/docs/architecture/30-frontend.md +++ b/docs/architecture/30-frontend.md @@ -31,10 +31,11 @@ Ce qui est en place : Ce qui n'existe pas encore : -- **Aucun endpoint métier réel derrière l'écran du tableau de bord.** `GET /api/v1/stats/summary`, - `GET /api/v1/alerts` et `GET /api/v1/predictions` sont servis par l'intercepteur de fixtures ; - l'API expose bien ces routes désormais, mais rien ne bascule `useMockFixtures` à `false` en - développement pour les consommer réellement. +- **`stats`/`alerts` restent sur fixtures.** `GET /api/v1/stats/summary` et `GET /api/v1/alerts` + sont servis par l'intercepteur de fixtures ; l'API expose bien ces routes désormais, mais rien + ne bascule `useMockFixtures` à `false` en développement pour les consommer réellement. + `GET /api/v1/predictions` fait exception : jamais mocké, branché sur l'API réelle depuis cette + PR (voir plus bas). - Aucun état de chargement : tant que la première réponse n'est pas arrivée, la page reste vide. - Aucun lint : ESLint n'est pas installé. @@ -85,10 +86,12 @@ sequenceDiagram S-->>C: modèle typé ``` -`mockApiInterceptor` n'intercepte que `/stats/summary`, `/alerts` et `/predictions`, et seulement -si `environment.useMockFixtures` est vrai. Le drapeau est à `true` en développement, à `false` en -production : toute autre requête (dont tout ce qui touche `/auth`), et toutes les requêtes en -production, suivent le chemin réel. +`mockApiInterceptor` n'intercepte que `/stats/summary` et `/alerts`, et seulement si +`environment.useMockFixtures` est vrai. Le drapeau est à `true` en développement, à `false` en +production : toute autre requête, et toutes les requêtes en production, suivent le chemin réel. +`/predictions` est volontairement exclu de cette liste (contrairement à `stats`/`alerts`) : il +suit toujours le chemin réel, comme `/auth/*` - en développement, ça veut dire qu'un jeton valide +et un backend joignable sont nécessaires pour que la section prévisions du dashboard s'affiche. En développement, `proxy.conf.json` redirige tout `/api` vers `http://localhost:8000`. C'est ce qui évite le CORS sur le poste, et c'est pourquoi `environment.development.ts` se contente d'un diff --git a/ml/enervision_ml/data.py b/ml/enervision_ml/data.py index 6e08b54..f8e7bf9 100644 --- a/ml/enervision_ml/data.py +++ b/ml/enervision_ml/data.py @@ -119,6 +119,11 @@ def _typer(frame: pd.DataFrame) -> pd.DataFrame: LightGBM refuse ("pandas dtypes must be int, float or bool"). `pd.to_numeric` corrige aussi n'importe quelle autre colonne mesuree entierement absente sur une fenetre de scoring, pas seulement `capacity_kw`. + + Piege additionnel : `NUMERIC_COLUMNS` inclut `consumption_kwh`, la cible du modele, pas + seulement des variables explicatives. Une valeur non numerique y devient donc silencieusement + `NaN` aussi bien a l'entrainement (ou `train.py` l'exclura ensuite via son `dropna`) qu'au + scoring -- ce n'est pas un effet de bord limite aux colonnes mesurees. """ typee = frame.copy() for colonne in NUMERIC_COLUMNS: diff --git a/ml/enervision_ml/score.py b/ml/enervision_ml/score.py index adf9634..c0b7e3f 100644 --- a/ml/enervision_ml/score.py +++ b/ml/enervision_ml/score.py @@ -30,6 +30,13 @@ from enervision_ml.features import TARGET_COLUMN, WEATHER_COLUMNS, build_feature # Marge au-dessus des 168h necessaires au lag hebdomadaire, pour absorber les trous de mesure. LOOKBACK = timedelta(days=21) +# Au-dela de ce seuil, la derniere lecture d'un site est trop vieille pour que "l'heure +# suivante" ait un sens operationnel : ce n'est plus une prevision a un pas, c'est un site dont +# l'ingestion s'est probablement arretee. Sans cette borne, `build_scoring_frame` produirait +# quand meme un `target_at` (derniere lecture + 1h), et rien en aval (ni l'API, ni le dashboard) +# ne distingue une prevision fraiche d'une prevision vieille de plusieurs jours. +MAX_STALENESS = timedelta(hours=24) + TARGET_METRIC = "consumption_kwh" PERIOD_MINUTES = 60 LAG_168H_COLUMN = f"{TARGET_COLUMN}_lag_168h" @@ -38,6 +45,14 @@ INSUFFICIENT_DATA_REASON = ( ) +def _stale_reason(age: pd.Timedelta) -> str: + return ( + f"Dernière lecture vieille de {age.total_seconds() / 3600:.0f}h " + f"(seuil {MAX_STALENESS.total_seconds() / 3600:.0f}h) : ingestion probablement " + "arrêtée pour ce site." + ) + + @dataclass(frozen=True, slots=True) class ScoredSite: site_id: str @@ -86,10 +101,31 @@ def build_scoring_frame(recent: pd.DataFrame, *, site_id: str | None = None) -> return features.groupby("site_id", as_index=False, sort=False).tail(1).reset_index(drop=True) -def score(booster: lgb.Booster, scoring_frame: pd.DataFrame) -> list[ScoredSite]: +def score( + booster: lgb.Booster, scoring_frame: pd.DataFrame, *, instant: datetime +) -> list[ScoredSite]: resultats: list[ScoredSite] = [] - insuffisants = scoring_frame[scoring_frame[LAG_168H_COLUMN].isna()] + # `timestamp` de la ligne de scoring vaut derniere lecture + 1h (cf. `build_scoring_frame`) : + # on en deduit l'age de cette derniere lecture par rapport a `instant`. + travail = scoring_frame.copy() + travail["_age"] = instant - (travail["timestamp"] - pd.Timedelta(hours=1)) + + perimes = travail[travail["_age"] > MAX_STALENESS] + for enregistrement in _records(perimes): + resultats.append( + ScoredSite( + site_id=enregistrement["site_id"], + target_at=enregistrement["timestamp"].to_pydatetime(), + status="insufficient_data", + predicted_value=None, + failure_reason=_stale_reason(enregistrement["_age"]), + ) + ) + + a_jour = travail[travail["_age"] <= MAX_STALENESS] + + insuffisants = a_jour[a_jour[LAG_168H_COLUMN].isna()] for enregistrement in _records(insuffisants): resultats.append( ScoredSite( @@ -101,7 +137,7 @@ def score(booster: lgb.Booster, scoring_frame: pd.DataFrame) -> list[ScoredSite] ) ) - suffisants = scoring_frame[scoring_frame[LAG_168H_COLUMN].notna()] + suffisants = a_jour[a_jour[LAG_168H_COLUMN].notna()] if not suffisants.empty: typee = suffisants.copy() typee["site_type"] = typee["site_type"].astype("category") @@ -163,20 +199,23 @@ def write_predictions( connection.execute(_INSERT_PREDICTION, lignes) -def _load_recent(*, csv_path: Path | None, now: datetime | None) -> tuple[pd.DataFrame, datetime]: - if csv_path is not None: - brute = load_from_csv(csv_path) - instant = now or ( - brute["timestamp"].max().to_pydatetime() if not brute.empty else datetime.now(UTC) - ) - return brute[brute["timestamp"] >= instant - LOOKBACK], instant +def _load_recent_from_csv(csv_path: Path, *, now: datetime | None) -> tuple[pd.DataFrame, datetime]: + brute = load_from_csv(csv_path) + instant = now or ( + brute["timestamp"].max().to_pydatetime() if not brute.empty else datetime.now(UTC) + ) + return brute[brute["timestamp"] >= instant - LOOKBACK], instant - instant = now or datetime.now(UTC) - engine = create_engine(config.database_url()) - try: - return load_recent_from_database(engine, since=instant - LOOKBACK), instant - finally: - engine.dispose() + +def _score_frame( + recent: pd.DataFrame, *, model_path: Path, site_id: str | None, instant: datetime +) -> list[ScoredSite]: + scoring_frame = build_scoring_frame(recent, site_id=site_id) + if scoring_frame.empty: + return [] + + booster = lgb.Booster(model_file=str(model_path)) + return score(booster, scoring_frame, instant=instant) def run_scoring( @@ -190,29 +229,27 @@ def run_scoring( En mode `--csv`, rien n'est ecrit : c'est un instantane historique fige (l'heure "future" calculee n'existe dans aucune base reelle), utile pour valider le pipeline sans base - joignable, cf. `ml/README.md`. + joignable, cf. `ml/README.md`. `site_id` n'est filtre qu'une fois, dans + `build_scoring_frame` : le filtrer aussi ici serait redondant. """ - recent, _instant = _load_recent(csv_path=csv_path, now=now) - if site_id is not None: - recent = recent[recent["site_id"] == site_id] + if csv_path is not None: + recent, instant = _load_recent_from_csv(csv_path, now=now) + return _score_frame(recent, model_path=model_path, site_id=site_id, instant=instant) - scoring_frame = build_scoring_frame(recent, site_id=site_id) - if scoring_frame.empty: - return [] + # Un seul engine pour la lecture et l'ecriture de ce run, plutot qu'un par etape. + engine = create_engine(config.database_url()) + try: + instant = now or datetime.now(UTC) + recent = load_recent_from_database(engine, since=instant - LOOKBACK) + resultats = _score_frame(recent, model_path=model_path, site_id=site_id, instant=instant) - booster = lgb.Booster(model_file=str(model_path)) - resultats = score(booster, scoring_frame) - - if csv_path is None: reference = model_reference(model_path) - engine = create_engine(config.database_url()) - try: - with engine.begin() as connection: - write_predictions(connection, resultats, reference=reference) - finally: - engine.dispose() + with engine.begin() as connection: + write_predictions(connection, resultats, reference=reference) - return resultats + return resultats + finally: + engine.dispose() def parse_args() -> argparse.Namespace: diff --git a/ml/tests/test_data.py b/ml/tests/test_data.py index 0a1f022..0aa42a1 100644 --- a/ml/tests/test_data.py +++ b/ml/tests/test_data.py @@ -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]) diff --git a/ml/tests/test_score.py b/ml/tests/test_score.py index 28b0af5..fb3e015 100644 --- a/ml/tests/test_score.py +++ b/ml/tests/test_score.py @@ -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()