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_CORS_ORIGINS=http://localhost:4200
|
||||
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/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
|
||||
.idea/
|
||||
.vscode/
|
||||
|
||||
@@ -1,17 +1,20 @@
|
||||
BACKEND := apps/backend
|
||||
FRONTEND := apps/frontend
|
||||
ML := ml
|
||||
AIRFLOW := etl/airflow
|
||||
|
||||
.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 \
|
||||
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
|
||||
@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
|
||||
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
|
||||
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
|
||||
@trap 'kill 0' EXIT INT TERM; \
|
||||
$(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
|
||||
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 -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;
|
||||
+75
-1
@@ -5,6 +5,32 @@
|
||||
|
||||
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:
|
||||
db:
|
||||
image: timescale/timescaledb-ha:pg17
|
||||
@@ -19,6 +45,7 @@ services:
|
||||
- pgdata:/home/postgres/pgdata/data
|
||||
- ./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/120-airflow-database.sql:/docker-entrypoint-initdb.d/120-airflow-database.sql:ro
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
|
||||
interval: 10s
|
||||
@@ -64,7 +91,54 @@ services:
|
||||
ports:
|
||||
- "${FRONTEND_PORT:-3000}:80"
|
||||
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:
|
||||
pgdata:
|
||||
airflow_logs:
|
||||
airflow_ml_state:
|
||||
|
||||
@@ -57,7 +57,7 @@ flowchart TB
|
||||
navigateur --> front
|
||||
front -.-> api
|
||||
api --> db
|
||||
airflow -.-> db
|
||||
airflow --> db
|
||||
prom -.-> api
|
||||
grafana -.-> db
|
||||
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
|
||||
[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
|
||||
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) |
|
||||
| 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`. 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 |
|
||||
| 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 |
|
||||
|
||||
## 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
|
||||
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
|
||||
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
|
||||
|
||||
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 |
|
||||
| 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 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
|
||||
|
||||
|
||||
+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.
|
||||
|
||||
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