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) {
+ {{ message }}
+ }
+ @if (alertsError(); as message) {
+ {{ message }}
+ }
+ @if (predictionsError(); as message) {
{{ 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()