fix(ml,backend,frontend): borne la peremption des predictions et isole les erreurs par flux

This commit is contained in:
Dorian
2026-09-18 14:58:39 +02:00
parent db81290026
commit eb4291b10a
12 changed files with 258 additions and 202 deletions
+7 -4
View File
@@ -15,11 +15,14 @@ TARGET_AT = datetime(2026, 9, 16, 13, 0, tzinfo=UTC)
CREATED_AT = datetime(2026, 9, 16, 12, 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( return Principal(
id=uuid4(), id=uuid4(),
email=f"{role.value}@enervision.fr", email="lecteur@enervision.fr",
role=role, role=Role.LECTEUR,
kind=AccountKind.HUMAIN, kind=AccountKind.HUMAIN,
must_change_password=False, must_change_password=False,
) )
@@ -57,7 +60,7 @@ def servi(app: FastAPI) -> Iterator[Callable[[], FauxService]]:
def installe() -> FauxService: def installe() -> FauxService:
service = FauxService() service = FauxService()
app.dependency_overrides[get_prediction_service] = lambda: service 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 return service
yield installe yield installe
@@ -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,
},
],
};
@@ -21,7 +21,13 @@
</div> </div>
</header> </header>
@if (error(); as message) { @if (statsError(); as message) {
<ev-alert severity="danger" class="banner-error">{{ message }}</ev-alert>
}
@if (alertsError(); as message) {
<ev-alert severity="danger" class="banner-error">{{ message }}</ev-alert>
}
@if (predictionsError(); as message) {
<ev-alert severity="danger" class="banner-error">{{ message }}</ev-alert> <ev-alert severity="danger" class="banner-error">{{ message }}</ev-alert>
} }
@@ -85,7 +91,7 @@
<span class="prediction-item__value"> <span class="prediction-item__value">
{{ prediction.predicted_value | number: '1.0-1' }} kWh {{ prediction.predicted_value | number: '1.0-1' }} kWh
<span class="prediction-item__target" <span class="prediction-item__target"
>à {{ prediction.target_at | date: 'HH:mm' }}</span >{{ prediction.target_at | date: "dd/MM 'à' HH:mm" }}</span
> >
</span> </span>
} @else { } @else {
@@ -52,7 +52,9 @@ describe('Dashboard', () => {
expect(predictions.getPredictions).toHaveBeenCalled(); expect(predictions.getPredictions).toHaveBeenCalled();
expect(fixture.componentInstance.alerts().length).toBe(1); expect(fixture.componentInstance.alerts().length).toBe(1);
expect(fixture.componentInstance.predictions().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", () => { it("signale l'indisponibilité puis repart au rafraîchissement suivant", () => {
@@ -80,13 +82,13 @@ describe('Dashboard', () => {
vi.advanceTimersByTime(1); vi.advanceTimersByTime(1);
expect(statsMock.getSummary).toHaveBeenCalledTimes(1); expect(statsMock.getSummary).toHaveBeenCalledTimes(1);
expect(fixture.componentInstance.error()).not.toBeNull(); expect(fixture.componentInstance.statsError()).not.toBeNull();
expect(fixture.componentInstance.stats()).toBeNull(); expect(fixture.componentInstance.stats()).toBeNull();
vi.advanceTimersByTime(10000); vi.advanceTimersByTime(10000);
expect(statsMock.getSummary).toHaveBeenCalledTimes(2); expect(statsMock.getSummary).toHaveBeenCalledTimes(2);
expect(fixture.componentInstance.stats()).not.toBeNull(); 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", () => { it("n'interrompt pas la page quand le chargement des alertes échoue", () => {
@@ -107,6 +109,7 @@ describe('Dashboard', () => {
fixture.detectChanges(); fixture.detectChanges();
expect(fixture.componentInstance.alerts().length).toBe(0); 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", () => { it("n'interrompt pas la page quand le chargement des prévisions échoue", () => {
@@ -130,7 +133,38 @@ describe('Dashboard', () => {
fixture.detectChanges(); fixture.detectChanges();
expect(fixture.componentInstance.predictions().length).toBe(0); 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', () => { it('appelle logout et redirige vers /login au clic sur le bouton de déconnexion', () => {
@@ -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 { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { timer, switchMap, catchError, EMPTY, Observable } from 'rxjs'; import { timer, switchMap, catchError, EMPTY, Observable } from 'rxjs';
import { DecimalPipe, DatePipe } from '@angular/common'; import { DecimalPipe, DatePipe } from '@angular/common';
@@ -67,32 +67,44 @@ export class Dashboard implements OnInit {
stats = signal<StatsSummary | null>(null); stats = signal<StatsSummary | null>(null);
alerts = signal<Alert[]>([]); alerts = signal<Alert[]>([]);
predictions = signal<SitePredictionSummary[]>([]); predictions = signal<SitePredictionSummary[]>([]);
error = signal<string | null>(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<string | null>(null);
alertsError = signal<string | null>(null);
predictionsError = signal<string | null>(null);
ngOnInit(): void { ngOnInit(): void {
this.alertsService this.alertsService
.getAlerts() .getAlerts()
.pipe(catchError(() => this.reportUnavailable())) .pipe(catchError(() => this.reportUnavailable(this.alertsError)))
.subscribe((alerts) => this.alerts.set(alerts)); .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 // 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`. // chargement au démarrage suffit, pas besoin du rafraîchissement périodique de `stats`.
this.predictionsService this.predictionsService
.getPredictions() .getPredictions()
.pipe(catchError(() => this.reportUnavailable())) .pipe(catchError(() => this.reportUnavailable(this.predictionsError)))
.subscribe((summary) => this.predictions.set(summary.sites)); .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 // Piège : le catchError porte sur l'observable interne. Sur le flux externe il
// terminerait le timer, et le rafraîchissement ne repartirait jamais. // terminerait le timer, et le rafraîchissement ne repartirait jamais.
timer(0, REFRESH_INTERVAL_MS) timer(0, REFRESH_INTERVAL_MS)
.pipe( .pipe(
switchMap(() => switchMap(() =>
this.statsService.getSummary().pipe(catchError(() => this.reportUnavailable())), this.statsService.getSummary().pipe(catchError(() => this.reportUnavailable(this.statsError))),
), ),
takeUntilDestroyed(this.destroyRef), takeUntilDestroyed(this.destroyRef),
) )
.subscribe((stats) => { .subscribe((stats) => {
this.error.set(null); this.statsError.set(null);
this.stats.set(stats); this.stats.set(stats);
}); });
} }
@@ -116,8 +128,8 @@ export class Dashboard implements OnInit {
}); });
} }
private reportUnavailable(): Observable<never> { private reportUnavailable(target: WritableSignal<string | null>): Observable<never> {
this.error.set(UNAVAILABLE_MESSAGE); target.set(UNAVAILABLE_MESSAGE);
return EMPTY; return EMPTY;
} }
} }
+1 -1
View File
@@ -75,7 +75,7 @@ collecteur ne vient le lire.
| Domaine | Technologie | Emplacement | Statut | Ce qui existe réellement | | 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) | | 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`) | | 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 | | 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 | | Infra | Terraform, k3s single-node | `infra/terraform` | `En cours` | Module d'installation du cluster. Jamais appliqué, aucune ressource Kubernetes déclarée |
-13
View File
@@ -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 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. 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` ### `/health/ready`
Cette sonde porte une garde décrite dans l'[ADR 0001](../adr/0001-postgresql-timescaledb.md) : un Cette sonde porte une garde décrite dans l'[ADR 0001](../adr/0001-postgresql-timescaledb.md) : un
+11 -8
View File
@@ -31,10 +31,11 @@ Ce qui est en place :
Ce qui n'existe pas encore : 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`, - **`stats`/`alerts` restent sur fixtures.** `GET /api/v1/stats/summary` et `GET /api/v1/alerts`
`GET /api/v1/alerts` et `GET /api/v1/predictions` sont servis par l'intercepteur de fixtures ; sont servis par l'intercepteur de fixtures ; l'API expose bien ces routes désormais, mais rien
l'API expose bien ces routes désormais, mais rien ne bascule `useMockFixtures` à `false` en ne bascule `useMockFixtures` à `false` en développement pour les consommer réellement.
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 é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é. - Aucun lint : ESLint n'est pas installé.
@@ -85,10 +86,12 @@ sequenceDiagram
S-->>C: modèle typé S-->>C: modèle typé
``` ```
`mockApiInterceptor` n'intercepte que `/stats/summary`, `/alerts` et `/predictions`, et seulement `mockApiInterceptor` n'intercepte que `/stats/summary` et `/alerts`, et seulement si
si `environment.useMockFixtures` est vrai. Le drapeau est à `true` en développement, à `false` en `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 : toute autre requête, et toutes les requêtes en production, suivent le chemin réel.
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 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 qui évite le CORS sur le poste, et c'est pourquoi `environment.development.ts` se contente d'un
+5
View File
@@ -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 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 n'importe quelle autre colonne mesuree entierement absente sur une fenetre de scoring, pas
seulement `capacity_kw`. 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() typee = frame.copy()
for colonne in NUMERIC_COLUMNS: for colonne in NUMERIC_COLUMNS:
+71 -34
View File
@@ -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. # Marge au-dessus des 168h necessaires au lag hebdomadaire, pour absorber les trous de mesure.
LOOKBACK = timedelta(days=21) 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" TARGET_METRIC = "consumption_kwh"
PERIOD_MINUTES = 60 PERIOD_MINUTES = 60
LAG_168H_COLUMN = f"{TARGET_COLUMN}_lag_168h" 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) @dataclass(frozen=True, slots=True)
class ScoredSite: class ScoredSite:
site_id: str 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) 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] = [] 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): for enregistrement in _records(insuffisants):
resultats.append( resultats.append(
ScoredSite( 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: if not suffisants.empty:
typee = suffisants.copy() typee = suffisants.copy()
typee["site_type"] = typee["site_type"].astype("category") typee["site_type"] = typee["site_type"].astype("category")
@@ -163,20 +199,23 @@ def write_predictions(
connection.execute(_INSERT_PREDICTION, lignes) connection.execute(_INSERT_PREDICTION, lignes)
def _load_recent(*, csv_path: Path | None, now: datetime | None) -> tuple[pd.DataFrame, datetime]: def _load_recent_from_csv(csv_path: Path, *, now: datetime | None) -> tuple[pd.DataFrame, datetime]:
if csv_path is not None: brute = load_from_csv(csv_path)
brute = load_from_csv(csv_path) instant = now or (
instant = now or ( brute["timestamp"].max().to_pydatetime() if not brute.empty else datetime.now(UTC)
brute["timestamp"].max().to_pydatetime() if not brute.empty else datetime.now(UTC) )
) return brute[brute["timestamp"] >= instant - LOOKBACK], instant
return brute[brute["timestamp"] >= instant - LOOKBACK], instant
instant = now or datetime.now(UTC)
engine = create_engine(config.database_url()) def _score_frame(
try: recent: pd.DataFrame, *, model_path: Path, site_id: str | None, instant: datetime
return load_recent_from_database(engine, since=instant - LOOKBACK), instant ) -> list[ScoredSite]:
finally: scoring_frame = build_scoring_frame(recent, site_id=site_id)
engine.dispose() if scoring_frame.empty:
return []
booster = lgb.Booster(model_file=str(model_path))
return score(booster, scoring_frame, instant=instant)
def run_scoring( 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" 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 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 csv_path is not None:
if site_id is not None: recent, instant = _load_recent_from_csv(csv_path, now=now)
recent = recent[recent["site_id"] == site_id] return _score_frame(recent, model_path=model_path, site_id=site_id, instant=instant)
scoring_frame = build_scoring_frame(recent, site_id=site_id) # Un seul engine pour la lecture et l'ecriture de ce run, plutot qu'un par etape.
if scoring_frame.empty: engine = create_engine(config.database_url())
return [] 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) reference = model_reference(model_path)
engine = create_engine(config.database_url()) with engine.begin() as connection:
try: write_predictions(connection, resultats, reference=reference)
with engine.begin() as connection:
write_predictions(connection, resultats, reference=reference)
finally:
engine.dispose()
return resultats return resultats
finally:
engine.dispose()
def parse_args() -> argparse.Namespace: def parse_args() -> argparse.Namespace:
+46 -25
View File
@@ -1,34 +1,55 @@
from pathlib import Path
import pandas as pd 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: def write_csv(tmp_path: Path, *lignes: str) -> Path:
# Reproduit ce que `pd.read_sql` renvoie pour une colonne entierement `NULL` en base : csv_path = tmp_path / "recent.csv"
# dtype `object` rempli de `None`, pas `float64` rempli de `NaN`. csv_path.write_text("\n".join([_CSV_HEADER, *lignes]) + "\n")
frame = pd.DataFrame( return csv_path
{colonne: [1.0, 2.0] for colonne in OUTPUT_COLUMNS if colonne not in NUMERIC_COLUMNS}
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") frame = load_from_csv(csv_path)
return frame
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: def test_load_from_csv_always_types_capacity_kw_as_float(tmp_path: Path) -> None:
frame = make_frame_with_object_dtype_capacity() # `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 frame["capacity_kw"].dtype == "float64"
assert typee[colonne].dtype == "float64" assert pd.isna(frame["capacity_kw"].iloc[0])
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])
+49 -2
View File
@@ -8,6 +8,7 @@ import pytest
from enervision_ml.features import TARGET_COLUMN from enervision_ml.features import TARGET_COLUMN
from enervision_ml.score import ( from enervision_ml.score import (
LAG_168H_COLUMN, LAG_168H_COLUMN,
MAX_STALENESS,
ScoredSite, ScoredSite,
build_scoring_frame, build_scoring_frame,
model_reference, model_reference,
@@ -123,12 +124,23 @@ def test_build_scoring_frame_returns_empty_when_there_is_no_recent_reading() ->
assert scoring_frame.empty 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: def test_score_marks_insufficient_history_without_calling_the_model() -> None:
depart = datetime(2026, 1, 1, tzinfo=UTC) depart = datetime(2026, 1, 1, tzinfo=UTC)
scoring_frame = build_scoring_frame(make_recent("site-a", heures=100, depart=depart)) scoring_frame = build_scoring_frame(make_recent("site-a", heures=100, depart=depart))
booster = FakeBooster() 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 == [ assert resultats == [
ScoredSite( 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)) scoring_frame = build_scoring_frame(make_recent("site-a", heures=200, depart=depart))
booster = FakeBooster(valeur=99.5) 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 len(resultats) == 1
assert resultats[0].status == "available" assert resultats[0].status == "available"
@@ -156,6 +172,37 @@ def test_score_predicts_when_history_is_sufficient() -> None:
assert booster.appels == [1] 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: def test_write_predictions_does_nothing_when_there_is_nothing_to_write() -> None:
connection = FakeConnection() connection = FakeConnection()