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)
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
@@ -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>
</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>
}
@@ -85,7 +91,7 @@
<span class="prediction-item__value">
{{ prediction.predicted_value | number: '1.0-1' }} kWh
<span class="prediction-item__target"
>à {{ prediction.target_at | date: 'HH:mm' }}</span
>{{ prediction.target_at | date: "dd/MM 'à' HH:mm" }}</span
>
</span>
} @else {
@@ -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', () => {
@@ -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<StatsSummary | null>(null);
alerts = signal<Alert[]>([]);
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 {
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<never> {
this.error.set(UNAVAILABLE_MESSAGE);
private reportUnavailable(target: WritableSignal<string | null>): Observable<never> {
target.set(UNAVAILABLE_MESSAGE);
return EMPTY;
}
}