docs(ml,backend,etl): ordonnance la derive et corrige ce que le depot disait faux
Trois phrases du depot annonçaient une surveillance de derive inexistante, et une quatrieme disait qu'aucune base PostgreSQL n'etait joignable pour tester le chargement ML. Les quatre sont maintenant fausses, donc reecrites plutot que laissees en dette. - ADR 0011 : ou vit le calcul et pourquoi pas dans `ml/`, les deux dedoublonnages qu'impose la jointure, et quatre alternatives ecartees avec la contrainte qui les interdit (la metrique MLflow n'est pas la meme grandeur, `alert` borne ses valeurs et refuse un site nul, Prometheus n'a pas de collecteur, ne rien persister ne repond pas a la question du jury). - DAG `derive` quotidien, hors du DAG `alertes` : un echec de derive y ferait croire que la detection a echoue, et la fenetre de 168 h ne se recalcule pas toutes les heures. - ML-START : la limite de `--now` est dite au lieu d'etre decouverte en demonstration. Elle ne decale que l'instant de reference, pas la fenetre de lecture, donc aucun rattrapage ne peut fabriquer de paires prevu/realise sur un jeu fige. - 50-cicd : pourquoi le job ML installe aussi le backend (le schema n'a qu'une source), et ce que coute le filtre de chemins qui l'accompagne.
This commit is contained in:
@@ -70,7 +70,7 @@ Le lien `front -.-> api` reste en pointillé : le frontend appelle bien une API,
|
||||
intercepteur répond à sa place tant que les endpoints n'existent pas. Voir
|
||||
[30-frontend.md](30-frontend.md).
|
||||
|
||||
Le lien `airflow --> db` est maintenant en trait plein : quatre DAGs tournent, deux pour
|
||||
Le lien `airflow --> db` est maintenant en trait plein : cinq DAGs tournent, deux pour
|
||||
l'entraînement et le scoring du modèle ML (issue #115), un pour la détection d'alertes et la
|
||||
génération des recommandations (issue #116), et `historical_import` pour l'ingestion du dataset
|
||||
historique (issue #119). L'orchestration de l'import API Mock et la réconciliation globale des
|
||||
@@ -86,16 +86,16 @@ collecteur ne vient le lire.
|
||||
| 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`, 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`, orchestré par Airflow (`ml_train`/`ml_score`). Voir [ADR 0005](../adr/0005-modele-prediction-lightgbm.md) et [ML-START.md](../ML-START.md). Surveillance de dérive (EC06, #44/#45) pas encore construite |
|
||||
| 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`, orchestré par Airflow (`ml_train`/`ml_score`). Voir [ADR 0005](../adr/0005-modele-prediction-lightgbm.md) et [ML-START.md](../ML-START.md). Surveillance de dérive livrée côté backend (`app.monitoring.drift`, table `drift_report`, `GET /monitoring/drift`, DAG `derive`), voir [ADR 0011](../adr/0011-surveillance-de-derive-dans-le-backend.md) |
|
||||
| Infra | Docker Compose, Nginx, Terraform, k3s single-node | `infra`, `docker-compose.prod.yml` | `En cours` | Reverse proxy et overlay de déploiement écrits et validés, jamais lancés sur le serveur ([ADR 0007](../adr/0007-terminaison-tls-et-reverse-proxy-nginx.md)). Provisionnement de la VM par Terraform, qui installe Docker, prépare les deux environnements et enregistre le runner, jamais appliqué ([ADR 0010](../adr/0010-terraform-provisionne-github-actions-deploie.md)). Module d'installation k3s jamais appliqué, aucune ressource Kubernetes déclarée |
|
||||
| Monitoring | Prometheus, Grafana, Alertmanager | `monitoring` | `Cible` | Rien, hors le `/metrics` exposé par l'API |
|
||||
| ETL | Apache Airflow | `etl/airflow` | `En cours` | Webserver + scheduler (LocalExecutor) tournent via docker-compose, base de métadonnées Postgres dédiée. Quatre DAGs en sous-processus `uv run` : `ml_train`, `ml_score`, `alertes` et `historical_import`. Le DAG historique orchestre `app.etl.historical_import` et charge `dataset`, `site` et `reading`. L'orchestration API Mock reste à compléter dans #15 |
|
||||
| ETL | Apache Airflow | `etl/airflow` | `En cours` | Webserver + scheduler (LocalExecutor) tournent via docker-compose, base de métadonnées Postgres dédiée. Cinq DAGs en sous-processus `uv run` : `ml_train`, `ml_score`, `alertes`, `historical_import` et `derive` (quotidien, surveillance de dérive). Le DAG historique orchestre `app.etl.historical_import` et charge `dataset`, `site` et `reading`. L'orchestration API Mock reste à compléter dans #15 |
|
||||
| CI/CD | GitHub Actions | `.github/workflows` | `En cours` | 7 workflows, 19 jobs : lint, typage, tests avec seuil de couverture bloquant, tests d'intégration sur TimescaleDB réel, audit de dépendances, SAST Bandit, quality gate SonarCloud, intégrité des DAGs Airflow, formatage et validation du Terraform. Déploiement continu vers la VM ENI écrit par `deploy.yml`, `dev` en recette et `main` en production après approbation ([ADR 0009](../adr/0009-deux-environnements-compose-sur-la-vm-eni.md)), mais jamais exécuté : la machine n'est pas provisionnée et le runner n'y est pas enregistré. Détail dans [50-cicd.md](50-cicd.md) |
|
||||
|
||||
## Flux bout en bout
|
||||
|
||||
Statut : `En cours`. **Le chemin de lecture tourne** : base, API et frontend. **Le chemin
|
||||
d'ingestion dessiné ci-dessous n'existe pas** : les trois DAGs livrés (`ml_train`, `ml_score`,
|
||||
d'ingestion dessiné ci-dessous n'existe pas** : les DAGs livrés (`ml_train`, `ml_score`,
|
||||
issue #115 ; `alertes`, issue #116) orchestrent le pipeline ML et la détection d'alertes, pas
|
||||
l'ingestion, qui reste lancée à la main par les scripts d'import (issues #15 et #16).
|
||||
|
||||
|
||||
@@ -86,6 +86,7 @@ l'[ADR 0008](../adr/0008-airflow-execute-le-code-du-backend.md).
|
||||
| `ml_score` | `0 * * * *` | `enervision_ml.score`, dans `/opt/ml/.venv` |
|
||||
| `alertes` | `15 * * * *` | `app.detection.internal_alerts` puis `app.cli generate-recommendations`, dans `/opt/backend/.venv` |
|
||||
| `historical_import` | manuelle | `app.etl.historical_import`, dans `/opt/backend/.venv` ; les fichiers de `data/raw` sont montés en lecture seule dans `/opt/data/raw` |
|
||||
| `derive` | `30 5 * * *` | `app.monitoring.drift`, dans `/opt/backend/.venv` ; quotidien parce que sa fenêtre couvre 168 h, et sans reprise parce qu'une dérive n'est pas une panne passagère |
|
||||
|
||||
Le DAG `historical_import` réutilise le pipeline historique existant sans dupliquer sa logique.
|
||||
Il reste manuel, car le dataset sert à initialiser l'environnement. Le montage
|
||||
|
||||
@@ -12,7 +12,7 @@ Les quatre couches existent désormais, portées par l'authentification.
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
ep["endpoints<br/>health, auth, users, sites, alerts,<br/>recommendations, stats, readings, sensors, predictions"]
|
||||
ep["endpoints<br/>health, auth, users, sites, alerts,<br/>recommendations, stats, readings, sensors,<br/>predictions, monitoring"]
|
||||
sc["schemas<br/>Pydantic"]
|
||||
sv["services<br/>AuthService, UserService,<br/>SiteService, AlertService, RecommendationService,<br/>StatsService, ReadingService, SensorService, PredictionService"]
|
||||
rp["repositories<br/>user, refresh_token,<br/>login_attempt, audit_log,<br/>site, alert, recommendation, reading, prediction"]
|
||||
@@ -151,6 +151,7 @@ Deux fichiers d'environnement, deux usages : `.env` à la racine alimente `docke
|
||||
| GET | `/api/v1/readings` | Historique des lectures, filtrable par `site_id`, fenêtre `start`/`end` (24h par défaut, 90 jours maximum) et paginé par `limit`/`offset`. `lecteur` | 400, 401, 403, 422, 500 |
|
||||
| GET | `/api/v1/sensors/status` | État de santé des capteurs par site, dérivé de la dernière lecture. `admin` | 401, 403, 500 |
|
||||
| GET | `/api/v1/predictions` | Dernière prévision de consommation par site, calculée hors ligne par le pipeline de scoring (`ml/`). `lecteur` | 401, 403, 500 |
|
||||
| GET | `/api/v1/monitoring/drift` | Dernier rapport de dérive par site, plus la ligne globale. `operateur` | 401, 403, 422, 500 |
|
||||
| GET | `/metrics` | Format Prometheus, hors du schéma. Jeton requis si `APP_METRICS_TOKEN` est posé | |
|
||||
| GET | `/docs`, `/redoc`, `/openapi.json` | Hors du schéma. Fermés en `staging` et en `prod` | |
|
||||
|
||||
@@ -223,6 +224,30 @@ 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.
|
||||
|
||||
### Surveillance de dérive
|
||||
|
||||
`DriftService.evaluate()` joint `prediction` et `reading` sur `(site_id, target_at = timestamp)`
|
||||
et compare deux fenêtres vives de 168 h, la récente et celle qui la précède. Il rend une ligne par
|
||||
site plus une ligne globale, que `DriftRepository.enregistre()` écrit dans `drift_report` avec
|
||||
`ON CONFLICT DO NOTHING` sur `uq_drift_report_window` : rejouer la commande sur la même fenêtre
|
||||
n'ajoute rien.
|
||||
|
||||
| Métrique | Ce qu'elle dit |
|
||||
|---|---|
|
||||
| `mae` | Erreur moyenne en kWh, la métrique même qu'optimise LightGBM |
|
||||
| `bias` | Erreur moyenne **signée** : c'est elle qui distingue un modèle plus bruyant d'un modèle qui se trompe systématiquement du même côté |
|
||||
| `mape` | Comparable entre sites de tailles différentes, hors réalisés nuls |
|
||||
| `coverage_ratio` | Part des prévisions disponibles qui ont trouvé leur réalisé : mesure le pipeline, pas le modèle |
|
||||
| `insufficient_data_ratio` | Part des sites privés d'historique suffisant |
|
||||
| `model_references` | Les modèles vus dans la fenêtre : une MAE qui saute à l'instant où le modèle change est une régression de réentraînement, pas une dérive |
|
||||
|
||||
Le verdict a trois valeurs, `stable`, `derive` et `indetermine` : sous un nombre minimal
|
||||
d'observations, le service dit qu'il ne sait pas plutôt que de rendre un chiffre trompeur. La
|
||||
fenêtre est fermée à droite par un délai de grâce de 2 h, le temps que l'ingestion livre le
|
||||
réalisé de la dernière heure. `python -m app.monitoring.drift` l'exécute, le DAG `derive`
|
||||
l'ordonnance, et `GET /api/v1/monitoring/drift` sert le dernier rapport de chaque site. Les
|
||||
arbitrages sont dans l'[ADR 0011](../adr/0011-surveillance-de-derive-dans-le-backend.md).
|
||||
|
||||
### Détection d'alertes internes
|
||||
|
||||
`AlertService` n'est plus lecture seule : `AlertService.detect()` compare les `reading` (et, pour
|
||||
|
||||
@@ -48,7 +48,8 @@ Statut : `Fait`.
|
||||
et refuse de s'appliquer si l'extension TimescaleDB manque.
|
||||
- Les révisions suivantes créent les tables liées à l'authentification :
|
||||
`app_user`, `login_attempt`, `audit_log` et `refresh_token`.
|
||||
- La révision `e6d2026091501` crée les six tables Data et déclare l'hypertable `reading`.
|
||||
- La révision `e6d2026091501` crée six des sept tables Data et déclare l'hypertable `reading`.
|
||||
- La révision `d3f1a2b7c904` ajoute `drift_report`, la septième.
|
||||
- La révision `c0adab96238c` ajoute les tables `password_reset_attempt`
|
||||
et `password_reset_token`.
|
||||
|
||||
@@ -261,8 +262,8 @@ Cette modélisation prend en compte :
|
||||
- leurs métadonnées JSON ;
|
||||
- les données de l'API Mock.
|
||||
|
||||
Elle comprend six tables Data, depuis le stockage des mesures jusqu'aux recommandations proposées
|
||||
à l'utilisateur.
|
||||
Elle comprend sept tables Data, depuis le stockage des mesures jusqu'aux recommandations
|
||||
proposées à l'utilisateur, et jusqu'au suivi de la dérive du modèle.
|
||||
|
||||
### Schéma de données
|
||||
|
||||
@@ -286,11 +287,18 @@ Chaque table remplit un rôle précis dans le traitement et l'exploitation des d
|
||||
| `prediction` | Conserver les prévisions, leur période cible et la référence du modèle utilisé | Traitements ML d'EnerVision |
|
||||
| `alert` | Enregistrer les alertes, leur type, leur gravité et leur message | API Mock `/alerts` et détections EnerVision |
|
||||
| `recommendation` | Proposer des actions et expliquer la règle qui les motive | Règles métier d'EnerVision |
|
||||
| `drift_report` | Suivre l'écart entre prévisions et réalisé, par site et tous sites confondus | Surveillance de dérive d'EnerVision |
|
||||
|
||||
Les anomalies historiques décrites dans les JSON sont conservées dans `dataset.metadata`.
|
||||
|
||||
Elles servent à l'analyse des données et ne sont pas considérées comme des alertes actuelles.
|
||||
|
||||
Les lignes de `drift_report` sont écrites par `app.monitoring.drift`, ordonnancé par le DAG
|
||||
`derive`. Une ligne dont le `site_id` est `NULL` porte le résultat global, tous sites confondus :
|
||||
c'est pourquoi l'unicité passe par un index sur `coalesce(site_id, '')` et non par une contrainte,
|
||||
qui ne dédoublonnerait jamais deux lignes globales. Le calcul, ses seuils et ce qu'il refuse de
|
||||
comparer sont dans l'[ADR 0011](../adr/0011-surveillance-de-derive-dans-le-backend.md).
|
||||
|
||||
Les lignes de `recommendation` sont écrites par le moteur de règles du backend
|
||||
(`app/services/recommendation_rules.py`), déclenché par `POST /api/v1/recommendations/generate`,
|
||||
par `make recommendations`, ou par la seconde tâche du DAG `alertes`, à partir des alertes déjà en
|
||||
@@ -304,6 +312,7 @@ n'ajoute aucune ligne.
|
||||
- Les mesures API ne sont pas rattachées à un dataset historique.
|
||||
- Une alerte peut être associée à une prévision du même site.
|
||||
- Une alerte peut donner lieu à plusieurs recommandations.
|
||||
- Un site possède plusieurs rapports de dérive ; un rapport global n'est rattaché à aucun site.
|
||||
|
||||
## Ingestion des données historiques
|
||||
|
||||
|
||||
@@ -155,6 +155,8 @@ dans [10-infra.md](10-infra.md).
|
||||
| Typage `mypy` | backend (`app`), ml (strict) | zéro erreur | Bloque |
|
||||
| Tests unitaires `pytest` | backend, ml | **`--cov-fail-under=85`** côté backend | Bloque |
|
||||
| Tests d'intégration | backend | marqueur `integration`, base réelle | Bloque |
|
||||
| Tests d'intégration ML ↔ DB | ml | marqueur `integration`, base réelle migrée par Alembic | Bloque |
|
||||
| Chaîne ML → DB → API | ml | marqueur `chaine`, vrais binaires en sous-processus | Bloque |
|
||||
| Audit de dépendances `pip-audit` | backend | sur le **verrou figé** | Bloque |
|
||||
| Audit de dépendances `npm audit` | frontend | `--audit-level=high` | Bloque |
|
||||
| **SAST `bandit`** | backend (`app`), ml (`enervision_ml`) | **MEDIUM et au-dessus** | Bloque |
|
||||
@@ -254,6 +256,28 @@ Ils ne transitent ni par git ni par GitHub, et le runner, qui travaille dans ce
|
||||
à recevoir. Le revers : ils ne sont sauvegardés nulle part ailleurs. Un `.env` perdu se
|
||||
régénère, ce qui invalide les sessions et les connexions chiffrées par Airflow.
|
||||
|
||||
### Pourquoi le job d'intégration ML installe aussi le backend
|
||||
|
||||
Le schéma de la base n'a qu'une source, les six révisions Alembic de `apps/backend/alembic` : le
|
||||
backend est propriétaire du schéma, `ml/` n'en est que consommateur. Reconstruire ce schéma à la
|
||||
main dans le job ML donnerait un job vert sur une base qui n'est pas la nôtre, exactement l'erreur
|
||||
qu'évite déjà le choix de l'image `timescaledb-ha` plutôt qu'un `postgres` nu. Le job installe
|
||||
donc les deux environnements uv, applique `alembic upgrade head`, puis joue `-m integration` côté
|
||||
`ml/` et `-m chaine` côté backend.
|
||||
|
||||
Conséquence sur le déclenchement : les `paths` de `ml.yml` incluent `apps/backend/alembic/**` et
|
||||
`apps/backend/app/models/**`. Sans eux, une migration qui renomme une colonne de `reading` ne
|
||||
déclencherait pas ce job, le SQL brut du pipeline dériverait du schéma, et **rien ne casserait
|
||||
avant la production**. Le prix est qu'une PR touchant seulement une migration lance aussi le lint
|
||||
et le typage de `ml/` : environ deux minutes de runner, en parallèle. Même arbitrage que le filtre
|
||||
d'`airflow.yml`, qui écoute déjà `ml/**` et `apps/backend/app/**` parce que son image réunit les
|
||||
deux.
|
||||
|
||||
Le marqueur `chaine` est distinct d'`integration` pour une raison mécanique : le job `integration`
|
||||
de `backend.yml` n'installe pas `ml/.venv`, et sélectionnerait sinon un test qui lance les
|
||||
binaires du pipeline. Il est aussi exclu d'`addopts`, sans quoi `make test` échouerait sur tout
|
||||
poste où `ml/` n'est pas installé.
|
||||
|
||||
## Ce qui manque, et pourquoi
|
||||
|
||||
| Manque | Issue | Conséquence assumée |
|
||||
@@ -267,8 +291,17 @@ régénère, ce qui invalide les sessions et les connexions chiffrées par Airfl
|
||||
## Reproduire la CI en local
|
||||
|
||||
`make check` enchaîne formatage, analyse statique, typage et tests du backend, c'est à dire le job
|
||||
`verification`. `make ml-check` fait la même chose pour le module ML. Les tests d'intégration
|
||||
demandent une base : `make db-up` puis `uv run pytest -m integration`.
|
||||
`verification`. `make ml-check` fait la même chose pour le module ML.
|
||||
|
||||
Les tests d'intégration demandent une base **migrée**, et `db/init` ne crée `enervision_test` que
|
||||
vide :
|
||||
|
||||
```bash
|
||||
make db-up migrate-test # la base de test reçoit les six révisions Alembic
|
||||
make test-integration # backend, marqueur `integration`
|
||||
make ml-test-integration # pipeline ML, marqueur `integration`
|
||||
make test-chaine # vrais binaires ML puis relecture par l'API, marqueur `chaine`
|
||||
```
|
||||
|
||||
Le SAST se rejoue à l'identique : `uvx bandit==1.9.4 --recursive app --severity-level medium
|
||||
--confidence-level medium` depuis `apps/backend`, et la même commande sur `enervision_ml` depuis
|
||||
|
||||
Reference in New Issue
Block a user