From 268496a8c436e3b9a44cd3df130a6c1a40963f0d Mon Sep 17 00:00:00 2001 From: Valentin Date: Mon, 21 Sep 2026 12:04:49 +0200 Subject: [PATCH 01/71] =?UTF-8?q?feat(ml):=20enregistrer=20le=20mod=C3=A8l?= =?UTF-8?q?e=20dans=20le=20MLflow=20Model=20Registry?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ml/enervision_ml/train.py | 6 +++++- ml/tests/test_train.py | 16 ++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/ml/enervision_ml/train.py b/ml/enervision_ml/train.py index fad5b89..2d4e25c 100644 --- a/ml/enervision_ml/train.py +++ b/ml/enervision_ml/train.py @@ -181,7 +181,11 @@ def _log_to_mlflow( ) mlflow.log_metrics({f"model_{cle}": valeur for cle, valeur in model_metrics.items()}) mlflow.log_metrics({f"baseline_{cle}": valeur for cle, valeur in baseline_metrics.items()}) - mlflow.lightgbm.log_model(booster, name="model") + mlflow.lightgbm.log_model( + booster, + name="model", + registered_model_name="consumption-forecast-lightgbm", + ) mlflow.log_artifact(str(model_output)) diff --git a/ml/tests/test_train.py b/ml/tests/test_train.py index 3084983..047e069 100644 --- a/ml/tests/test_train.py +++ b/ml/tests/test_train.py @@ -3,6 +3,7 @@ from pathlib import Path import numpy as np import pandas as pd +import pytest from enervision_ml.features import TARGET_COLUMN, build_features, feature_columns from enervision_ml.train import chronological_split, prepare_dataset, train @@ -74,3 +75,18 @@ def test_train_runs_end_to_end_on_synthetic_data_and_beats_a_dummy_baseline( assert model_metrics["n_observations"] > 0 assert model_metrics["mae"] >= 0 assert baseline_metrics["n_observations"] == model_metrics["n_observations"] + assert model_metrics["mae"] < baseline_metrics["mae"] + +def test_train_raises_when_the_validation_window_is_empty(tmp_path: Path) -> None: + depart = datetime(2026, 1, 1, tzinfo=UTC) + frame = make_frame("site-a", heures=50, depart=depart) # trop court pour un lag de 168h + csv_path = tmp_path / "trop_court.csv" + frame.to_csv(csv_path, index=False) + + with pytest.raises(ValueError, match="Fenetre d'entrainement ou de validation vide"): + train( + csv_path=csv_path, + model_output=tmp_path / "model.txt", + test_fraction=0.2, + tracking_uri=f"sqlite:///{tmp_path / 'mlflow.db'}", + ) From 91752a2b0b94d248f120f358d673c79bbb4f7910 Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Mon, 21 Sep 2026 14:25:30 +0200 Subject: [PATCH 02/71] =?UTF-8?q?fix(frontend):=20aligne=20le=20mod=C3=A8l?= =?UTF-8?q?e=20Alert=20et=20AlertsService=20sur=20le=20contrat=20/alerts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le modèle front portait un alert_id texte, des value/threshold non nullables et ignorait metric et prediction_id, alors que l'API sérialise un entier, des flottants nullables et ces deux champs. Toute comparaison avec l'alert_id d'une recommandation échouait silencieusement. - alert.model.ts : alert_id number, value/threshold/metric/prediction_id nullables - alerts.service.ts : getAlerts(filters) pose site_id et severity en HttpParams, les deux filtres que l'API accepte - alerts.fixture.ts : réaligné sur le contrat (ids entiers, horodatages UTC, champs nuls sur outage/sensor, un cas spike) - alert-presentation.ts : tons, libellés et unités partagés ; low passe en neutre, le vert se lisait comme un état sain --- .../src/app/core/mocks/alerts.fixture.ts | 60 +++++++++++-------- .../app/core/services/alerts.service.spec.ts | 57 ++++++++++++------ .../src/app/core/services/alerts.service.ts | 20 +++++-- .../shared/models/alert-presentation.spec.ts | 37 ++++++++++++ .../app/shared/models/alert-presentation.ts | 41 +++++++++++++ .../src/app/shared/models/alert.model.ts | 13 ++-- 6 files changed, 177 insertions(+), 51 deletions(-) create mode 100644 apps/frontend/src/app/shared/models/alert-presentation.spec.ts create mode 100644 apps/frontend/src/app/shared/models/alert-presentation.ts diff --git a/apps/frontend/src/app/core/mocks/alerts.fixture.ts b/apps/frontend/src/app/core/mocks/alerts.fixture.ts index c1f7a9a..84c8c66 100644 --- a/apps/frontend/src/app/core/mocks/alerts.fixture.ts +++ b/apps/frontend/src/app/core/mocks/alerts.fixture.ts @@ -2,53 +2,63 @@ import { Alert } from '../../shared/models/alert.model'; export const ALERTS_FIXTURE: Alert[] = [ { - alert_id: 'ALR-SITE002-1718458320', - timestamp: '2026-09-15T11:12:00', + alert_id: 5, site_id: 'SITE002', + timestamp: '2026-09-15T11:12:00Z', + type: 'threshold', severity: 'critical', - type: 'outage', - message: 'Risque de surcharge sur Usine Lyon Vénissieux', + message: 'Puissance appelée 812.5 kW au-dessus de la capacité du site (720.0 kW)', value: 812.5, threshold: 720.0, + metric: 'consumption_kw', + prediction_id: null, }, { - alert_id: 'ALR-SITE003-1718458321', - timestamp: '2026-09-15T11:05:00', + alert_id: 4, site_id: 'SITE003', + timestamp: '2026-09-15T11:05:00Z', + type: 'outage', severity: 'critical', - type: 'sensor', - message: 'Perte réseau totale sur Data Center Marseille', - value: 0, - threshold: 0, + message: 'Aucune lecture depuis 5:00:00 (dernière lecture : 2026-09-15T06:05:00+00:00)', + value: null, + threshold: null, + metric: null, + prediction_id: null, }, { - alert_id: 'ALR-SITE005-1718458322', - timestamp: '2026-09-15T10:47:00', + alert_id: 3, site_id: 'SITE005', + timestamp: '2026-09-15T10:47:00Z', + type: 'spike', severity: 'high', - type: 'threshold', - message: 'Usine Toulouse approche de son seuil de capacité', + message: 'Variation brutale entre deux lectures consécutives (260.0 kW -> 410.0 kW)', value: 410.0, - threshold: 480.0, + threshold: 260.0, + metric: 'consumption_kw', + prediction_id: null, }, { - alert_id: 'ALR-SITE006-1718458323', - timestamp: '2026-09-15T10:30:00', + alert_id: 2, site_id: 'SITE006', - severity: 'medium', + timestamp: '2026-09-15T10:30:00Z', type: 'sensor', - message: 'Capteur de température défaillant sur Bureau Lille', - value: 0, - threshold: 0, + severity: 'medium', + message: 'Qualité de mesure degraded (capteur hors ligne, valeur nulle)', + value: null, + threshold: null, + metric: null, + prediction_id: null, }, { - alert_id: 'ALR-SITE004-1718458324', - timestamp: '2026-09-15T09:58:00', + alert_id: 1, site_id: 'SITE004', - severity: 'low', + timestamp: '2026-09-15T09:58:00Z', type: 'anomaly', - message: 'Comportement de consommation inhabituel sur Bureau Bordeaux', + severity: 'low', + message: 'Écart de 13% entre la consommation mesurée (62.0 kWh) et la prévision (55.0 kWh)', value: 62.0, threshold: 55.0, + metric: 'consumption_kwh', + prediction_id: 42, }, ]; diff --git a/apps/frontend/src/app/core/services/alerts.service.spec.ts b/apps/frontend/src/app/core/services/alerts.service.spec.ts index 68b5740..c17bc4b 100644 --- a/apps/frontend/src/app/core/services/alerts.service.spec.ts +++ b/apps/frontend/src/app/core/services/alerts.service.spec.ts @@ -3,6 +3,20 @@ import { provideHttpClient } from '@angular/common/http'; import { provideHttpClientTesting, HttpTestingController } from '@angular/common/http/testing'; import { AlertsService } from './alerts.service'; import { environment } from '../../../environments/environment'; +import { Alert } from '../../shared/models/alert.model'; + +const ALERT_API: Alert = { + alert_id: 1, + site_id: 'site-1', + timestamp: '2026-09-16T00:00:00Z', + type: 'threshold', + severity: 'high', + message: 'Dépassement du seuil configuré', + value: 812.5, + threshold: 720.0, + metric: 'consumption_kw', + prediction_id: null, +}; describe('AlertsService', () => { let service: AlertsService; @@ -18,26 +32,35 @@ describe('AlertsService', () => { afterEach(() => httpMock.verify()); - it("appelle le bon endpoint et retourne un tableau d'alertes", () => { - let result: unknown; + it("appelle le bon endpoint sans paramètre et retourne un tableau d'alertes", () => { + let result: Alert[] = []; service.getAlerts().subscribe((r) => (result = r)); - const req = httpMock.expectOne(`${environment.apiUrl}/alerts`); - expect(req.request.method).toBe('GET'); + const req = httpMock.expectOne( + (r) => r.url === `${environment.apiUrl}/alerts` && r.method === 'GET', + ); + expect(req.request.params.keys()).toEqual([]); + req.flush([ALERT_API]); - req.flush([ - { - alert_id: 'ALR-TEST-1', - timestamp: '2026-09-15T12:00:00', - site_id: 'SITE001', - severity: 'high', - type: 'threshold', - message: 'Test', - value: 100, - threshold: 90, - }, - ]); + expect(result.length).toBe(1); + expect(result[0].alert_id).toBe(1); + expect(result[0].prediction_id).toBeNull(); + }); - expect((result as unknown[]).length).toBe(1); + it('transmet les filtres site_id et severity en paramètres de requête', () => { + service.getAlerts({ site_id: 'SITE001', severity: 'high' }).subscribe(); + + const req = httpMock.expectOne((r) => r.url === `${environment.apiUrl}/alerts`); + expect(req.request.params.get('site_id')).toBe('SITE001'); + expect(req.request.params.get('severity')).toBe('high'); + req.flush([]); + }); + + it('ne pose pas de paramètre pour un filtre omis', () => { + service.getAlerts({ site_id: 'SITE001' }).subscribe(); + + const req = httpMock.expectOne((r) => r.url === `${environment.apiUrl}/alerts`); + expect(req.request.params.has('severity')).toBe(false); + req.flush([]); }); }); diff --git a/apps/frontend/src/app/core/services/alerts.service.ts b/apps/frontend/src/app/core/services/alerts.service.ts index ebd00e2..8d9a8bf 100644 --- a/apps/frontend/src/app/core/services/alerts.service.ts +++ b/apps/frontend/src/app/core/services/alerts.service.ts @@ -1,13 +1,25 @@ import { Service, inject } from '@angular/core'; -import { HttpClient } from '@angular/common/http'; +import { HttpClient, HttpParams } from '@angular/common/http'; import { environment } from '../../../environments/environment'; -import { Alert } from '../../shared/models/alert.model'; +import { Alert, AlertSeverity } from '../../shared/models/alert.model'; + +export interface AlertFilters { + site_id?: string; + severity?: AlertSeverity; +} @Service() export class AlertsService { private http = inject(HttpClient); - getAlerts() { - return this.http.get(`${environment.apiUrl}/alerts`); + getAlerts(filters: AlertFilters = {}) { + let params = new HttpParams(); + if (filters.site_id) { + params = params.set('site_id', filters.site_id); + } + if (filters.severity) { + params = params.set('severity', filters.severity); + } + return this.http.get(`${environment.apiUrl}/alerts`, { params }); } } diff --git a/apps/frontend/src/app/shared/models/alert-presentation.spec.ts b/apps/frontend/src/app/shared/models/alert-presentation.spec.ts new file mode 100644 index 0000000..b31fdb8 --- /dev/null +++ b/apps/frontend/src/app/shared/models/alert-presentation.spec.ts @@ -0,0 +1,37 @@ +import { + LIBELLE_PAR_SEVERITE, + LIBELLE_PAR_TYPE, + SEVERITES, + TON_PAR_SEVERITE, + TYPES_ALERTE, + UNITE_PAR_METRIQUE, +} from './alert-presentation'; + +describe('alert-presentation', () => { + it('distingue le ton des sévérités high et critical', () => { + expect(TON_PAR_SEVERITE.high).toBe('danger'); + expect(TON_PAR_SEVERITE.critical).toBe('critical'); + expect(TON_PAR_SEVERITE.high).not.toBe(TON_PAR_SEVERITE.critical); + }); + + it("n'affiche pas une alerte faible avec le ton de succès", () => { + expect(TON_PAR_SEVERITE.low).toBe('neutral'); + expect(TON_PAR_SEVERITE.medium).toBe('warning'); + }); + + it('donne un libellé français à chaque sévérité et à chaque type', () => { + for (const severite of SEVERITES) { + expect(LIBELLE_PAR_SEVERITE[severite]).toBeTruthy(); + } + for (const type of TYPES_ALERTE) { + expect(LIBELLE_PAR_TYPE[type]).toBeTruthy(); + } + expect(SEVERITES.length).toBe(4); + expect(TYPES_ALERTE.length).toBe(5); + }); + + it('associe une unité à chaque métrique du contrat', () => { + expect(UNITE_PAR_METRIQUE.consumption_kw).toBe('kW'); + expect(UNITE_PAR_METRIQUE.consumption_kwh).toBe('kWh'); + }); +}); diff --git a/apps/frontend/src/app/shared/models/alert-presentation.ts b/apps/frontend/src/app/shared/models/alert-presentation.ts new file mode 100644 index 0000000..cffa772 --- /dev/null +++ b/apps/frontend/src/app/shared/models/alert-presentation.ts @@ -0,0 +1,41 @@ +import { BadgeTone } from '../components/ui/badge/badge'; +import { AlertMetric, AlertSeverity, AlertType } from './alert.model'; + +// Pourquoi : `low` en neutre plutôt qu'en vert, une alerte faible reste une alerte ; le vert se +// lisait comme « tout va bien » à côté des rouges. +export const TON_PAR_SEVERITE: Record = { + low: 'neutral', + medium: 'warning', + high: 'danger', + critical: 'critical', +}; + +export const LIBELLE_PAR_SEVERITE: Record = { + low: 'Faible', + medium: 'Moyenne', + high: 'Élevée', + critical: 'Critique', +}; + +export const LIBELLE_PAR_TYPE: Record = { + spike: 'Pic de consommation', + threshold: 'Seuil dépassé', + anomaly: 'Anomalie', + outage: 'Coupure', + sensor: 'Capteur', +}; + +export const UNITE_PAR_METRIQUE: Record = { + consumption_kw: 'kW', + consumption_kwh: 'kWh', +}; + +export const SEVERITES: readonly AlertSeverity[] = ['low', 'medium', 'high', 'critical']; + +export const TYPES_ALERTE: readonly AlertType[] = [ + 'spike', + 'threshold', + 'anomaly', + 'outage', + 'sensor', +]; diff --git a/apps/frontend/src/app/shared/models/alert.model.ts b/apps/frontend/src/app/shared/models/alert.model.ts index 028f35a..8a51c93 100644 --- a/apps/frontend/src/app/shared/models/alert.model.ts +++ b/apps/frontend/src/app/shared/models/alert.model.ts @@ -1,13 +1,16 @@ export type AlertSeverity = 'low' | 'medium' | 'high' | 'critical'; export type AlertType = 'spike' | 'threshold' | 'anomaly' | 'outage' | 'sensor'; +export type AlertMetric = 'consumption_kw' | 'consumption_kwh'; export interface Alert { - alert_id: string; - timestamp: string; + alert_id: number; site_id: string; - severity: AlertSeverity; + timestamp: string; type: AlertType; + severity: AlertSeverity; message: string; - value: number; - threshold: number; + value: number | null; + threshold: number | null; + metric: AlertMetric | null; + prediction_id: number | null; } From 83caa9006ea7dcbd625bb5041a408d8318481e10 Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Mon, 21 Sep 2026 14:25:30 +0200 Subject: [PATCH 03/71] =?UTF-8?q?chore(frontend):=20active=20strict,=20ajo?= =?UTF-8?q?ute=20.form-select=20et=20documente=20le=20spec=20cibl=C3=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - tsconfig.json : "strict": true, vérifié sans erreur sur app et specs - _forms.scss : .form-select, select natif habillé comme .form-input avec un chevron, documenté dans le design système - TESTING.md : commande pour jouer un seul fichier ou dossier de specs --- apps/frontend/TESTING.md | 5 ++++- apps/frontend/src/styles/_forms.scss | 15 +++++++++++++++ apps/frontend/tsconfig.json | 1 + docs/architecture/32-design-systeme-frontend.md | 13 +++++++------ 4 files changed, 27 insertions(+), 7 deletions(-) diff --git a/apps/frontend/TESTING.md b/apps/frontend/TESTING.md index d4e92bf..e87dbaf 100644 --- a/apps/frontend/TESTING.md +++ b/apps/frontend/TESTING.md @@ -1,4 +1,4 @@ -# Conventions de tests unitaires — Frontend +# Conventions de tests unitaires : Frontend ## Outil Vitest (intégré nativement à Angular CLI, pas d'installation à faire). @@ -83,3 +83,6 @@ describe('MonComposant', () => { ## Lancer les tests - Développement (mode watch) : `npm test` - Rapport de couverture (CI) : `npm run test:ci -- --coverage`, puis ouvrir `coverage/index.html` +- Un fichier ou un dossier seulement : + `npx ng test --watch=false --coverage=false --include=src/app/core/services/alerts.service.spec.ts` + (répéter `--include` pour plusieurs cibles ; un dossier joue tous ses specs) diff --git a/apps/frontend/src/styles/_forms.scss b/apps/frontend/src/styles/_forms.scss index 9bbfb0c..7cde5d1 100644 --- a/apps/frontend/src/styles/_forms.scss +++ b/apps/frontend/src/styles/_forms.scss @@ -29,3 +29,18 @@ color: var(--color-disabled); margin-top: 0.25rem; } + +// Piège : le chevron est un SVG en data URI, où aucun token CSS n'est lisible ; sa couleur +// reprend en dur la valeur de --color-text-muted. +.form-select { + @extend .form-input; + padding-right: 2.25rem; + color: var(--color-text); + background-color: var(--color-surface); + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' fill='none' stroke='%236b7280' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M6 8l4 4 4-4'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 0.6rem center; + background-size: 1rem; + appearance: none; + cursor: pointer; +} diff --git a/apps/frontend/tsconfig.json b/apps/frontend/tsconfig.json index d2fbb9c..888336f 100644 --- a/apps/frontend/tsconfig.json +++ b/apps/frontend/tsconfig.json @@ -3,6 +3,7 @@ { "compileOnSave": false, "compilerOptions": { + "strict": true, "noImplicitOverride": true, "noPropertyAccessFromIndexSignature": true, "noImplicitReturns": true, diff --git a/docs/architecture/32-design-systeme-frontend.md b/docs/architecture/32-design-systeme-frontend.md index a7894d8..2d015bc 100644 --- a/docs/architecture/32-design-systeme-frontend.md +++ b/docs/architecture/32-design-systeme-frontend.md @@ -24,12 +24,13 @@ seule fois dans `src/styles.scss`. Disponibles partout sans import supplémentai | `--shadow-card` | Ombre portée des cartes | | `--space-1` à `--space-5` | Échelle d'espacement (0.35rem à 2.5rem) | -Les classes de formulaire partagées (`.form-label`, `.form-input`, `.form-hint`) sont dans -`apps/frontend/src/styles/_forms.scss`, importées globalement de la même façon. Elles -s'appliquent directement à des `