feat(etl,ml): orchestre l'entrainement et le scoring LightGBM via deux DAGs Airflow
This commit is contained in:
@@ -17,3 +17,18 @@ APP_LOG_LEVEL=INFO
|
|||||||
APP_SECRET_KEY=change_me
|
APP_SECRET_KEY=change_me
|
||||||
APP_CORS_ORIGINS=http://localhost:4200
|
APP_CORS_ORIGINS=http://localhost:4200
|
||||||
BACKEND_PORT=8000
|
BACKEND_PORT=8000
|
||||||
|
|
||||||
|
# Airflow (webserver + scheduler, LocalExecutor). Base de métadonnées dédiée `airflow` dans le
|
||||||
|
# même conteneur `db` (cf. db/init/120-airflow-database.sql), pas un conteneur de plus.
|
||||||
|
AIRFLOW_PORT=8080
|
||||||
|
# Chiffre les connexions/variables stockées par Airflow. Générer la vôtre :
|
||||||
|
# python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
|
||||||
|
AIRFLOW_FERNET_KEY=change_me
|
||||||
|
# Clé Flask du webserver Airflow (signature de session), distincte de la précédente. Générer la
|
||||||
|
# vôtre : python -c "import secrets; print(secrets.token_urlsafe(48))"
|
||||||
|
AIRFLOW_WEBSERVER_SECRET_KEY=change_me
|
||||||
|
AIRFLOW_ADMIN_USERNAME=admin
|
||||||
|
# Compte Airflow créé au premier démarrage (service `airflow-init`), sans rapport avec les
|
||||||
|
# comptes `app_user` d'EnerVision.
|
||||||
|
AIRFLOW_ADMIN_PASSWORD=change_me
|
||||||
|
AIRFLOW_ADMIN_EMAIL=admin@enervision.fr
|
||||||
|
|||||||
@@ -66,6 +66,9 @@ ml/mlruns/
|
|||||||
ml/mlartifacts/
|
ml/mlartifacts/
|
||||||
ml/mlflow.db
|
ml/mlflow.db
|
||||||
|
|
||||||
|
# Airflow : base sqlite locale generee par les tests d'integrite des DAGs (etl/airflow/tests)
|
||||||
|
etl/airflow/tests/.airflow_home/
|
||||||
|
|
||||||
# IDE et OS
|
# IDE et OS
|
||||||
.idea/
|
.idea/
|
||||||
.vscode/
|
.vscode/
|
||||||
|
|||||||
@@ -1,17 +1,20 @@
|
|||||||
BACKEND := apps/backend
|
BACKEND := apps/backend
|
||||||
FRONTEND := apps/frontend
|
FRONTEND := apps/frontend
|
||||||
ML := ml
|
ML := ml
|
||||||
|
AIRFLOW := etl/airflow
|
||||||
|
|
||||||
.DEFAULT_GOAL := help
|
.DEFAULT_GOAL := help
|
||||||
.PHONY: help install install-backend install-frontend install-ml dev dev-backend dev-frontend \
|
.PHONY: help install install-backend install-frontend install-ml install-airflow \
|
||||||
|
dev dev-backend dev-frontend \
|
||||||
lint format typecheck test test-cov test-integration check \
|
lint format typecheck test test-cov test-integration check \
|
||||||
openapi docker-build db-up db-down db-reset db-logs db-psql migrate bootstrap-admin \
|
openapi docker-build db-up db-down db-reset db-logs db-psql migrate bootstrap-admin \
|
||||||
ml-lint ml-typecheck ml-test ml-check ml-train ml-score
|
ml-lint ml-typecheck ml-test ml-check ml-train ml-score \
|
||||||
|
airflow-lint airflow-test airflow-check airflow-up airflow-down airflow-logs
|
||||||
|
|
||||||
help: ## Liste les cibles disponibles
|
help: ## Liste les cibles disponibles
|
||||||
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-16s\033[0m %s\n", $$1, $$2}'
|
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-16s\033[0m %s\n", $$1, $$2}'
|
||||||
|
|
||||||
install: install-backend install-frontend install-ml ## Installe les dépendances backend, frontend et ML
|
install: install-backend install-frontend install-ml install-airflow ## Installe les dépendances backend, frontend, ML et Airflow
|
||||||
|
|
||||||
install-backend: ## Installe les dépendances du backend
|
install-backend: ## Installe les dépendances du backend
|
||||||
cd $(BACKEND) && uv sync --all-groups
|
cd $(BACKEND) && uv sync --all-groups
|
||||||
@@ -22,6 +25,9 @@ install-frontend: ## Installe les dépendances du frontend
|
|||||||
install-ml: ## Installe les dépendances du pipeline ML
|
install-ml: ## Installe les dépendances du pipeline ML
|
||||||
cd $(ML) && uv sync --all-groups
|
cd $(ML) && uv sync --all-groups
|
||||||
|
|
||||||
|
install-airflow: ## Installe les dépendances de lint/test des DAGs Airflow
|
||||||
|
cd $(AIRFLOW) && uv sync --all-groups
|
||||||
|
|
||||||
dev: ## Lance toute la stack (backend + frontend) en rechargement à chaud
|
dev: ## Lance toute la stack (backend + frontend) en rechargement à chaud
|
||||||
@trap 'kill 0' EXIT INT TERM; \
|
@trap 'kill 0' EXIT INT TERM; \
|
||||||
$(MAKE) --no-print-directory dev-backend & \
|
$(MAKE) --no-print-directory dev-backend & \
|
||||||
@@ -77,6 +83,24 @@ ml-train: ## Entraine le modele LightGBM. CSV=chemin optionnel, sinon lit ML_DAT
|
|||||||
ml-score: ## Score le prochain pas horaire et l'ecrit dans `prediction`. CSV=chemin optionnel
|
ml-score: ## Score le prochain pas horaire et l'ecrit dans `prediction`. CSV=chemin optionnel
|
||||||
cd $(ML) && uv run python -m enervision_ml.score $(if $(CSV),--csv $(CSV),)
|
cd $(ML) && uv run python -m enervision_ml.score $(if $(CSV),--csv $(CSV),)
|
||||||
|
|
||||||
|
airflow-lint: ## Analyse statique des DAGs Airflow
|
||||||
|
cd $(AIRFLOW) && uv run ruff check .
|
||||||
|
|
||||||
|
airflow-test: ## Verifie que les DAGs s'importent sans erreur et ont la structure attendue
|
||||||
|
cd $(AIRFLOW) && uv run pytest
|
||||||
|
|
||||||
|
airflow-check: airflow-lint airflow-test ## Chaîne de vérification complète des DAGs Airflow
|
||||||
|
|
||||||
|
airflow-up: ## Démarre Airflow (webserver + scheduler, LocalExecutor). db-up requis avant.
|
||||||
|
docker compose up -d airflow-init airflow-webserver airflow-scheduler
|
||||||
|
@echo "airflow -> http://localhost:$${AIRFLOW_PORT:-8080}"
|
||||||
|
|
||||||
|
airflow-down: ## Arrête le webserver et le scheduler Airflow
|
||||||
|
docker compose stop airflow-webserver airflow-scheduler
|
||||||
|
|
||||||
|
airflow-logs: ## Suit les journaux du scheduler Airflow (où tournent les tâches, LocalExecutor)
|
||||||
|
docker compose logs -f airflow-scheduler
|
||||||
|
|
||||||
docker-build: ## Construit l'image du backend
|
docker-build: ## Construit l'image du backend
|
||||||
docker build -t enervision-backend:local $(BACKEND)
|
docker build -t enervision-backend:local $(BACKEND)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
-- Base de metadonnees Airflow (webserver + scheduler, LocalExecutor). Separee de la base
|
||||||
|
-- applicative : les tables internes d'Airflow (dag_run, task_instance, ...) n'ont rien a faire
|
||||||
|
-- dans le schema metier. Meme conteneur Postgres que `enervision`/`enervision_test` plutot qu'un
|
||||||
|
-- service dedie, pour ne pas ajouter un conteneur de plus (issue #115).
|
||||||
|
CREATE DATABASE airflow;
|
||||||
@@ -5,6 +5,32 @@
|
|||||||
|
|
||||||
name: enervision
|
name: enervision
|
||||||
|
|
||||||
|
# Piege : LocalExecutor fait tourner les taches comme sous-processus du scheduler, jamais du
|
||||||
|
# webserver. `airflow_ml_state` (modele entraine, magasin MLflow) n'a donc besoin d'etre monte
|
||||||
|
# que sur `airflow-scheduler` en pratique, mais reste partage avec le webserver pour que ce
|
||||||
|
# dernier puisse au besoin l'inspecter sans en devenir dependant.
|
||||||
|
x-airflow-common: &airflow-common
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: etl/airflow/Dockerfile
|
||||||
|
environment: &airflow-common-env
|
||||||
|
AIRFLOW__CORE__EXECUTOR: LocalExecutor
|
||||||
|
AIRFLOW__CORE__LOAD_EXAMPLES: "false"
|
||||||
|
AIRFLOW__CORE__FERNET_KEY: ${AIRFLOW_FERNET_KEY:?}
|
||||||
|
AIRFLOW__WEBSERVER__SECRET_KEY: ${AIRFLOW_WEBSERVER_SECRET_KEY:?}
|
||||||
|
AIRFLOW__DATABASE__SQL_ALCHEMY_CONN: postgresql+psycopg2://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/airflow
|
||||||
|
# Role `enervision_ml` dedie pas encore provisionne (dette assumee, cf. ADR 0003/CLAUDE.md) :
|
||||||
|
# memes identifiants que le backend en attendant.
|
||||||
|
ML_DATABASE_URL: postgresql+psycopg://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
|
||||||
|
MLFLOW_TRACKING_URI: sqlite:////opt/ml/state/mlflow.db
|
||||||
|
volumes:
|
||||||
|
- ./etl/airflow/dags:/opt/airflow/dags
|
||||||
|
- ./etl/airflow/plugins:/opt/airflow/plugins
|
||||||
|
- ./etl/airflow/include:/opt/airflow/include
|
||||||
|
- airflow_logs:/opt/airflow/logs
|
||||||
|
- airflow_ml_state:/opt/ml/state
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
services:
|
services:
|
||||||
db:
|
db:
|
||||||
image: timescale/timescaledb-ha:pg17
|
image: timescale/timescaledb-ha:pg17
|
||||||
@@ -19,6 +45,7 @@ services:
|
|||||||
- pgdata:/home/postgres/pgdata/data
|
- pgdata:/home/postgres/pgdata/data
|
||||||
- ./db/init/100-extensions.sql:/docker-entrypoint-initdb.d/100-extensions.sql:ro
|
- ./db/init/100-extensions.sql:/docker-entrypoint-initdb.d/100-extensions.sql:ro
|
||||||
- ./db/init/110-test-database.sql:/docker-entrypoint-initdb.d/110-test-database.sql:ro
|
- ./db/init/110-test-database.sql:/docker-entrypoint-initdb.d/110-test-database.sql:ro
|
||||||
|
- ./db/init/120-airflow-database.sql:/docker-entrypoint-initdb.d/120-airflow-database.sql:ro
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
|
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
|
||||||
interval: 10s
|
interval: 10s
|
||||||
@@ -65,6 +92,53 @@ services:
|
|||||||
- "${FRONTEND_PORT:-3000}:80"
|
- "${FRONTEND_PORT:-3000}:80"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
|
# Conteneur unique, jamais redemarre : migre la base de metadonnees puis cree le premier compte
|
||||||
|
# (idempotent, `|| true` sur la creation qui echoue si le compte existe deja). `webserver` et
|
||||||
|
# `scheduler` attendent qu'il se termine avec succes avant de demarrer.
|
||||||
|
airflow-init:
|
||||||
|
<<: *airflow-common
|
||||||
|
restart: "no"
|
||||||
|
command:
|
||||||
|
- bash
|
||||||
|
- -c
|
||||||
|
- |
|
||||||
|
airflow db migrate
|
||||||
|
airflow users create \
|
||||||
|
--username "${AIRFLOW_ADMIN_USERNAME:-admin}" \
|
||||||
|
--password "${AIRFLOW_ADMIN_PASSWORD:?}" \
|
||||||
|
--firstname Admin \
|
||||||
|
--lastname EnerVision \
|
||||||
|
--role Admin \
|
||||||
|
--email "${AIRFLOW_ADMIN_EMAIL:-admin@enervision.fr}" \
|
||||||
|
|| true
|
||||||
|
|
||||||
|
airflow-webserver:
|
||||||
|
<<: *airflow-common
|
||||||
|
command: webserver
|
||||||
|
ports:
|
||||||
|
- "${AIRFLOW_PORT:-8080}:8080"
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
airflow-init:
|
||||||
|
condition: service_completed_successfully
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "--fail", "http://localhost:8080/health"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 5
|
||||||
|
start_period: 60s
|
||||||
|
|
||||||
|
airflow-scheduler:
|
||||||
|
<<: *airflow-common
|
||||||
|
command: scheduler
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
airflow-init:
|
||||||
|
condition: service_completed_successfully
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
pgdata:
|
pgdata:
|
||||||
|
airflow_logs:
|
||||||
|
airflow_ml_state:
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ flowchart TB
|
|||||||
navigateur --> front
|
navigateur --> front
|
||||||
front -.-> api
|
front -.-> api
|
||||||
api --> db
|
api --> db
|
||||||
airflow -.-> db
|
airflow --> db
|
||||||
prom -.-> api
|
prom -.-> api
|
||||||
grafana -.-> db
|
grafana -.-> db
|
||||||
grafana -.-> prom
|
grafana -.-> prom
|
||||||
@@ -67,6 +67,10 @@ 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
|
intercepteur répond à sa place tant que les endpoints n'existent pas. Voir
|
||||||
[30-frontend.md](30-frontend.md).
|
[30-frontend.md](30-frontend.md).
|
||||||
|
|
||||||
|
Le lien `airflow --> db` est maintenant en trait plein : deux DAGs orchestrent l'entraînement et
|
||||||
|
le scoring du modèle ML (issue #115), cf. plus bas et [20-backend.md](20-backend.md). Le reste du
|
||||||
|
périmètre Airflow envisagé (ingestion, issues #15/#16) reste en pointillé, non construit.
|
||||||
|
|
||||||
Le lien `prom -.-> api` de même : l'API expose bien `/metrics` au format Prometheus, mais aucun
|
Le lien `prom -.-> api` de même : l'API expose bien `/metrics` au format Prometheus, mais aucun
|
||||||
collecteur ne vient le lire.
|
collecteur ne vient le lire.
|
||||||
|
|
||||||
@@ -77,15 +81,17 @@ 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) |
|
| 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 |
|
| 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`, 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 |
|
||||||
| 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 |
|
||||||
| Monitoring | Prometheus, Grafana, Alertmanager | `monitoring` | `Cible` | Rien, hors le `/metrics` exposé par l'API |
|
| Monitoring | Prometheus, Grafana, Alertmanager | `monitoring` | `Cible` | Rien, hors le `/metrics` exposé par l'API |
|
||||||
| ETL | Apache Airflow | `etl/airflow` | `Cible` | Rien |
|
| ETL | Apache Airflow | `etl/airflow` | `En cours` | Webserver + scheduler (LocalExecutor) tournent via docker-compose, base de métadonnées Postgres dédiée. Deux DAGs (`ml_train` manuel, `ml_score` `@hourly`) orchestrent le pipeline ML existant en sous-processus `uv run` (issue #115). L'ingestion (issues #15/#16) n'a pas encore de DAG |
|
||||||
| CI/CD | GitHub Actions | `.github/workflows` | `Cible` | Rien |
|
| CI/CD | GitHub Actions | `.github/workflows` | `Cible` | Rien |
|
||||||
|
|
||||||
## Flux bout en bout
|
## Flux bout en bout
|
||||||
|
|
||||||
Statut : `Cible`. Aucun maillon de cette chaîne n'existe aujourd'hui, à l'exception de la base.
|
Statut : `Cible`. Ce flux d'ingestion (Source → Airflow → hypertable) n'existe pas encore : les
|
||||||
|
deux DAGs livrés à ce jour (`ml_train`/`ml_score`, issue #115) orchestrent le pipeline ML, pas
|
||||||
|
l'ingestion. Seule la base tourne réellement parmi les maillons ci-dessous.
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
sequenceDiagram
|
sequenceDiagram
|
||||||
|
|||||||
@@ -48,6 +48,33 @@ Deux pièges sont documentés en tête du `docker-compose.yml`, ils ne se devine
|
|||||||
l'image, dont `timescaledb-tune`. Ajouter un fichier dans `db/init/` impose donc une ligne dans
|
l'image, dont `timescaledb-tune`. Ajouter un fichier dans `db/init/` impose donc une ligne dans
|
||||||
le compose. Voir [`db/README.md`](../../db/README.md).
|
le compose. Voir [`db/README.md`](../../db/README.md).
|
||||||
|
|
||||||
|
### Airflow (`ml_train`/`ml_score`, issue #115)
|
||||||
|
|
||||||
|
Trois services, `docker compose profiles` non utilisés (démarrage explicite via `make
|
||||||
|
airflow-up`, pas dans `make dev`) :
|
||||||
|
|
||||||
|
| Service | Rôle | Points notables |
|
||||||
|
|---|---|---|
|
||||||
|
| `airflow-init` | Migre la base de métadonnées, crée le compte admin | Conteneur jetable (`restart: "no"`), ne redémarre jamais. `webserver`/`scheduler` attendent qu'il se termine avec succès |
|
||||||
|
| `airflow-webserver` | UI, port `8080` | `LocalExecutor` : n'exécute aucune tâche lui-même |
|
||||||
|
| `airflow-scheduler` | Planifie et **exécute** les tâches (`LocalExecutor`) | Les DAGs y tournent en sous-processus (`uv run --frozen --no-dev python -m enervision_ml...`), c'est lui qui a besoin du volume `airflow_ml_state` |
|
||||||
|
|
||||||
|
Construits depuis `etl/airflow/Dockerfile`, contexte `.` (racine du repo, pas `etl/airflow/`) :
|
||||||
|
l'image doit pouvoir `COPY` `ml/pyproject.toml`/`ml/uv.lock`/`ml/enervision_ml` pour se
|
||||||
|
synchroniser un second environnement Python **3.14** (`/opt/ml/.venv`, `uv sync --locked` à la
|
||||||
|
construction), distinct du Python 3.12 qui fait tourner Airflow lui-même. Les DAGs shellent vers
|
||||||
|
ce venv plutôt que d'importer LightGBM/MLflow dans le process Airflow.
|
||||||
|
|
||||||
|
Piège à connaître : sur un volume `pgdata` déjà peuplé (poste de dev existant plutôt que premier
|
||||||
|
`make db-up`), `db/init/120-airflow-database.sql` ne se rejoue pas (PostgreSQL n'exécute
|
||||||
|
`docker-entrypoint-initdb.d/` que sur un volume vide). Créer la base `airflow` à la main une fois :
|
||||||
|
`docker compose exec db psql -U $POSTGRES_USER -d $POSTGRES_DB -c "CREATE DATABASE airflow;"`.
|
||||||
|
|
||||||
|
`libgomp1` est installé explicitement dans l'image (`apt-get`, en root) : l'image Airflow de base
|
||||||
|
est minimale et n'embarque pas la runtime OpenMP dont LightGBM a besoin, sans quoi l'erreur
|
||||||
|
(`OSError: libgomp.so.1`) n'apparaît qu'à la première tâche réellement exécutée, pas à la
|
||||||
|
construction de l'image.
|
||||||
|
|
||||||
## Cible de déploiement
|
## Cible de déploiement
|
||||||
|
|
||||||
Statut : `En cours`. Le module `infra/terraform/modules/k3s/` installe le cluster. Il n'a jamais
|
Statut : `En cours`. Le module `infra/terraform/modules/k3s/` installe le cluster. Il n'a jamais
|
||||||
@@ -116,6 +143,8 @@ Ces arbitrages sont pris. Ils ne vivaient jusqu'ici que dans des commentaires de
|
|||||||
| SSH du serveur | `22` par défaut | `ssh_port`, redéfinissable |
|
| SSH du serveur | `22` par défaut | `ssh_port`, redéfinissable |
|
||||||
| Base applicative | `enervision` | Variable `POSTGRES_DB` |
|
| Base applicative | `enervision` | Variable `POSTGRES_DB` |
|
||||||
| Base de test | `enervision_test` | Créée par `db/init/110-test-database.sql`, nom attendu en dur par `apps/backend/tests/conftest.py` |
|
| Base de test | `enervision_test` | Créée par `db/init/110-test-database.sql`, nom attendu en dur par `apps/backend/tests/conftest.py` |
|
||||||
|
| Base de métadonnées Airflow | `airflow` | Créée par `db/init/120-airflow-database.sql`, même conteneur `db` |
|
||||||
|
| Webserver Airflow | `8080` | `make airflow-up`. Scheduler et webserver ne publient que ce port ; les tâches (`LocalExecutor`) tournent côté scheduler, sans port propre |
|
||||||
|
|
||||||
## Le trou entre les deux topologies
|
## Le trou entre les deux topologies
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -344,6 +344,6 @@ uv run ruff check app\etl tests\etl
|
|||||||
|
|
||||||
L'import historique constitue la première brique du pipeline Data EnerVision.
|
L'import historique constitue la première brique du pipeline Data EnerVision.
|
||||||
|
|
||||||
La prochaine étape consiste à orchestrer les traitements ETL avec Apache Airflow, puis à préparer les données nécessaires à l'entraînement du modèle de Machine Learning.
|
Airflow tourne désormais réellement (`etl/airflow/`, `make airflow-up`), mais orchestre pour l'instant le pipeline ML (`ml_train`/`ml_score`, issue #115), pas encore ce pipeline ETL : orchestrer `historical_import.py` (normalisation et chargement micro-batch, issues #15/#16) reste à faire.
|
||||||
|
|
||||||
Airflow sera utilisé comme orchestrateur des traitements existants et ne remplacera pas la logique métier déjà implémentée dans le pipeline ETL.
|
Le principe reste le même que documenté à l'origine : Airflow orchestre les traitements existants sans remplacer leur logique métier, cf. `etl/airflow/dags/ml_train.py`/`ml_score.py` pour un exemple concret de ce patron (des `BashOperator` qui invoquent le script tel quel).
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# Image Airflow EnerVision : ajoute le projet ml/ dans son propre environnement Python 3.14,
|
||||||
|
# distinct du Python 3.12 qui fait tourner Airflow lui-meme, pour que les DAGs puissent lancer
|
||||||
|
# `uv run python -m enervision_ml.train`/`.score` en sous-processus (cf. docs/architecture/
|
||||||
|
# 20-backend.md, section Détection d'alertes internes pour le meme raisonnement applique a
|
||||||
|
# app/detection). Airflow ne devient jamais un consommateur direct de LightGBM/MLflow.
|
||||||
|
FROM apache/airflow:2.10.4-python3.12
|
||||||
|
|
||||||
|
# LightGBM est compile contre libgomp (OpenMP), absent de l'image de base (minimale, sans
|
||||||
|
# toolchain de compilation). Sans lui : `OSError: libgomp.so.1: cannot open shared object file`
|
||||||
|
# au premier `import lightgbm`, seulement au moment ou une tache tourne reellement.
|
||||||
|
USER root
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install --no-install-recommends -y libgomp1 \
|
||||||
|
&& apt-get clean \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
# Pre-cree, appartenant a `airflow` : docker-compose y monte un volume nomme partage entre
|
||||||
|
# `ml_train` et `ml_score` (le modele ecrit par l'un, lu par l'autre). Un volume nomme herite des
|
||||||
|
# permissions du repertoire qu'il recouvre a son premier montage ; sans ce chown prealable, il
|
||||||
|
# serait cree root:root et illisible par le conteneur, qui tourne en `airflow` (uid 50000).
|
||||||
|
RUN mkdir -p /opt/ml/state && chown -R airflow:root /opt/ml
|
||||||
|
USER airflow
|
||||||
|
|
||||||
|
# L'image de base embarque deja un `uv`, mais trop ancien (0.4.29) pour le format de verrou de
|
||||||
|
# `ml/uv.lock`. On le remplace par la version deja pinnee ailleurs dans le depot
|
||||||
|
# (apps/backend/Dockerfile).
|
||||||
|
COPY --from=ghcr.io/astral-sh/uv:0.11.26 /uv /home/airflow/.local/bin/uv
|
||||||
|
|
||||||
|
ENV UV_COMPILE_BYTECODE=1 \
|
||||||
|
UV_LINK_MODE=copy \
|
||||||
|
UV_PROJECT_ENVIRONMENT=/opt/ml/.venv
|
||||||
|
|
||||||
|
WORKDIR /opt/ml
|
||||||
|
|
||||||
|
COPY --chown=airflow:root ml/pyproject.toml ml/uv.lock ./
|
||||||
|
RUN uv sync --locked --no-install-project --no-dev
|
||||||
|
|
||||||
|
COPY --chown=airflow:root ml/enervision_ml ./enervision_ml
|
||||||
|
RUN uv sync --locked --no-dev
|
||||||
|
|
||||||
|
WORKDIR /opt/airflow
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
"""DAG de scoring horaire du modele LightGBM (issue #115).
|
||||||
|
|
||||||
|
Planifie toutes les heures, au rythme documente par `enervision_ml.score` (score le prochain pas
|
||||||
|
horaire par site). Reutilise le modele ecrit par `ml_train` (DAG separe, declenche a la main) :
|
||||||
|
ce DAG ne reentraine jamais rien. Si aucun modele n'a encore ete entraine, la tache echoue
|
||||||
|
(`FileNotFoundError`) plutot que de rester silencieuse.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from airflow.models.dag import DAG
|
||||||
|
from airflow.operators.bash import BashOperator
|
||||||
|
|
||||||
|
MODEL_PATH = "/opt/ml/state/models/lightgbm-consumption.txt"
|
||||||
|
|
||||||
|
with DAG(
|
||||||
|
dag_id="ml_score",
|
||||||
|
description="Score le prochain pas horaire par site (enervision_ml.score).",
|
||||||
|
schedule="@hourly",
|
||||||
|
start_date=datetime(2026, 1, 1),
|
||||||
|
catchup=False,
|
||||||
|
tags=["ml"],
|
||||||
|
) as dag:
|
||||||
|
# `--frozen --no-dev` : cf. `ml_train.py`, meme raisonnement.
|
||||||
|
BashOperator(
|
||||||
|
task_id="score",
|
||||||
|
bash_command=(
|
||||||
|
"cd /opt/ml && uv run --frozen --no-dev python -m enervision_ml.score "
|
||||||
|
f"--model {MODEL_PATH}"
|
||||||
|
),
|
||||||
|
)
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
"""DAG d'entrainement du modele LightGBM (issue #115).
|
||||||
|
|
||||||
|
Pas de planification : reentrainer est couteux et sa cadence n'est pas une decision prise
|
||||||
|
(cf. `docs/architecture/20-backend.md`). Declenchement manuel depuis l'UI ou la CLI Airflow en
|
||||||
|
attendant. `ml_score` (DAG separe, planifie toutes les heures) reutilise le modele que ce DAG
|
||||||
|
ecrit, il ne reentraine jamais rien lui-meme.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from airflow.models.dag import DAG
|
||||||
|
from airflow.operators.bash import BashOperator
|
||||||
|
|
||||||
|
MODEL_PATH = "/opt/ml/state/models/lightgbm-consumption.txt"
|
||||||
|
MLFLOW_TRACKING_URI = "sqlite:////opt/ml/state/mlflow.db"
|
||||||
|
|
||||||
|
with DAG(
|
||||||
|
dag_id="ml_train",
|
||||||
|
description="Entraine le modele LightGBM de prevision de consommation (enervision_ml.train).",
|
||||||
|
schedule=None,
|
||||||
|
start_date=datetime(2026, 1, 1),
|
||||||
|
catchup=False,
|
||||||
|
tags=["ml"],
|
||||||
|
) as dag:
|
||||||
|
# `--frozen --no-dev` : l'environnement `/opt/ml/.venv` est fige a la construction de l'image
|
||||||
|
# (groupe `dev` exclu). Sans `--no-dev` ici, `uv run` resynchronise ruff/mypy a chaque
|
||||||
|
# execution : un acces reseau evitable, sur le chemin d'execution d'une tache planifiee.
|
||||||
|
BashOperator(
|
||||||
|
task_id="train",
|
||||||
|
bash_command=(
|
||||||
|
"cd /opt/ml && uv run --frozen --no-dev python -m enervision_ml.train "
|
||||||
|
f"--model-output {MODEL_PATH} --mlflow-tracking-uri {MLFLOW_TRACKING_URI}"
|
||||||
|
),
|
||||||
|
)
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
[project]
|
||||||
|
name = "enervision-airflow"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "DAGs d'orchestration EnerVision (Airflow)"
|
||||||
|
requires-python = ">=3.12,<3.13"
|
||||||
|
dependencies = [
|
||||||
|
"apache-airflow==2.10.4",
|
||||||
|
]
|
||||||
|
|
||||||
|
[dependency-groups]
|
||||||
|
dev = [
|
||||||
|
"ruff>=0.16.7",
|
||||||
|
"pytest>=9.1.1",
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.uv]
|
||||||
|
package = false
|
||||||
|
|
||||||
|
[tool.ruff]
|
||||||
|
line-length = 100
|
||||||
|
target-version = "py312"
|
||||||
|
src = ["dags", "tests"]
|
||||||
|
|
||||||
|
[tool.ruff.lint]
|
||||||
|
select = ["E", "W", "F", "I", "N", "UP", "B", "SIM", "RUF"]
|
||||||
|
|
||||||
|
[tool.ruff.format]
|
||||||
|
quote-style = "double"
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
testpaths = ["tests"]
|
||||||
|
addopts = "-q"
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
"""Isole Airflow d'un `~/airflow` reel : `AIRFLOW_HOME` doit etre pose avant le premier `import
|
||||||
|
airflow`, donc ici plutot que dans une fixture (les fixtures s'executent trop tard, apres que les
|
||||||
|
modules de test aient deja importe `airflow`)."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
_AIRFLOW_HOME = Path(__file__).resolve().parent / ".airflow_home"
|
||||||
|
_AIRFLOW_HOME.mkdir(exist_ok=True)
|
||||||
|
|
||||||
|
os.environ.setdefault("AIRFLOW_HOME", str(_AIRFLOW_HOME))
|
||||||
|
os.environ.setdefault("AIRFLOW__CORE__LOAD_EXAMPLES", "False")
|
||||||
|
os.environ.setdefault("AIRFLOW__CORE__UNIT_TEST_MODE", "True")
|
||||||
|
os.environ.setdefault(
|
||||||
|
"AIRFLOW__DATABASE__SQL_ALCHEMY_CONN", f"sqlite:///{_AIRFLOW_HOME / 'airflow.db'}"
|
||||||
|
)
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
"""Tests d'integrite des DAGs : s'importent sans erreur, structure attendue. Pas d'execution
|
||||||
|
reelle des taches (ca reclamerait le conteneur avec `uv`/`enervision_ml`), juste la definition."""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from airflow.models.dagbag import DagBag
|
||||||
|
|
||||||
|
DAGS_FOLDER = Path(__file__).resolve().parent.parent / "dags"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def dagbag() -> DagBag:
|
||||||
|
return DagBag(dag_folder=str(DAGS_FOLDER), include_examples=False)
|
||||||
|
|
||||||
|
|
||||||
|
def test_dags_folder_has_no_import_error(dagbag: DagBag) -> None:
|
||||||
|
assert dagbag.import_errors == {}
|
||||||
|
|
||||||
|
|
||||||
|
def test_every_expected_dag_is_discovered(dagbag: DagBag) -> None:
|
||||||
|
assert set(dagbag.dag_ids) == {"ml_train", "ml_score"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_ml_train_has_no_schedule() -> None:
|
||||||
|
dagbag = DagBag(dag_folder=str(DAGS_FOLDER), include_examples=False)
|
||||||
|
assert dagbag.dags["ml_train"].timetable.summary == "None"
|
||||||
|
|
||||||
|
|
||||||
|
def test_ml_score_runs_every_hour() -> None:
|
||||||
|
# `@hourly` est un alias Airflow pour ce cron, c'est sous cette forme que `.summary` le rend.
|
||||||
|
dagbag = DagBag(dag_folder=str(DAGS_FOLDER), include_examples=False)
|
||||||
|
assert dagbag.dags["ml_score"].timetable.summary == "0 * * * *"
|
||||||
|
|
||||||
|
|
||||||
|
def test_ml_train_task_calls_the_training_module(dagbag: DagBag) -> None:
|
||||||
|
tache = dagbag.dags["ml_train"].get_task("train")
|
||||||
|
assert "enervision_ml.train" in tache.bash_command
|
||||||
|
|
||||||
|
|
||||||
|
def test_ml_score_task_calls_the_scoring_module(dagbag: DagBag) -> None:
|
||||||
|
tache = dagbag.dags["ml_score"].get_task("score")
|
||||||
|
assert "enervision_ml.score" in tache.bash_command
|
||||||
|
|
||||||
|
|
||||||
|
def test_ml_score_reuses_the_model_path_written_by_ml_train(dagbag: DagBag) -> None:
|
||||||
|
entrainement = dagbag.dags["ml_train"].get_task("train").bash_command
|
||||||
|
scoring = dagbag.dags["ml_score"].get_task("score").bash_command
|
||||||
|
chemin_modele = "/opt/ml/state/models/lightgbm-consumption.txt"
|
||||||
|
|
||||||
|
assert chemin_modele in entrainement
|
||||||
|
assert chemin_modele in scoring
|
||||||
Generated
+1970
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user