fix(backend,airflow,ml): applique les corrections de revue sur la PR #162

This commit is contained in:
Dorian
2026-09-23 15:19:39 +02:00
parent 101ebd404f
commit b78322bd61
7 changed files with 328 additions and 189 deletions
+1 -1
View File
@@ -80,7 +80,7 @@ API Mock est accepté comme définitivement perdu, aucune mesure réelle n'exist
période. `mock_api_import` refuse toute fenêtre qui recouvrirait des lectures déjà importées du
CSV plutôt que de laisser les deux sources dupliquer silencieusement un même instant, et le
pipeline ML déduplique par construction (`DISTINCT ON`, source `csv` préférée) au cas où un
recouvrement se produirait malgré tout — voir [40-data.md](40-data.md).
recouvrement se produirait malgré tout, voir [40-data.md](40-data.md).
Les liens de la supervision sont en trait plein depuis le 23/09 (issue #26) : Prometheus scrute
`/metrics` avec un jeton, Grafana lit Prometheus et, par un rôle en lecture seule, les tables
+42 -17
View File
@@ -437,19 +437,23 @@ Les paramètres de ligne de commande disponibles pour l'import sont :
```text
--start-time
--end-time
--limit
--dry-run
```
**Piège sur `--limit`** : l'API ne renvoie pas un flux à un rythme naturel, elle répartit
exactement `limit` lectures, espacées uniformément, sur toute la fenêtre `[start_time, end_time)`
demandée. Une fenêtre d'une heure avec `limit=1000` renvoie donc 1000 lectures espacées de 3,6
secondes à l'intérieur de cette heure, pas une lecture horaire — vérifié empiriquement en
interrogeant directement l'API. Le seul réglage qui produise une lecture par heure, alignée sur
l'heure et cohérente avec le grain horaire du reste du schéma (`period_minutes=60`, historique
CSV à une ligne par heure), est `limit` = nombre d'heures de la fenêtre. Le DAG `mock_api_import`
interroge toujours une fenêtre d'1h (`interval=timedelta(hours=1)`, voir
[10-infra.md](10-infra.md)), donc `limit=1`.
**Piège sur `limit`, corrigé dans le code plutôt que documenté** : l'API ne renvoie pas un flux à
un rythme naturel, elle répartit exactement `limit` lectures, espacées uniformément, sur toute la
fenêtre `[start_time, end_time)` demandée (vérifié empiriquement en interrogeant directement
l'API). Une fenêtre d'une heure avec `limit=1000`, le réglage d'origine, renvoyait donc 1000
lectures espacées de 3,6 secondes à l'intérieur de cette heure, pas une lecture horaire,
incompatible avec les lags positionnels de `build_features`. Plutôt que documenter la règle
« `limit` = nombre d'heures de la fenêtre » et compter sur chaque appelant pour la respecter,
`limit_for_window()` la porte : `import_mock_api_history()` calcule `limit` depuis la fenêtre
reçue, refuse une fenêtre qui ne couvre pas un nombre entier d'heures, et refuse un intervalle de
plus de 1000 heures (le plafond `limit` de l'API). `--limit` n'existe donc plus côté CLI. Le DAG
`mock_api_import` interroge toujours une fenêtre d'1h (`interval=timedelta(hours=1)`, voir
[10-infra.md](10-infra.md)) : la fenêtre `[:45, :45)` place chaque lecture à :45, pas à :00 (la
première lecture atterrit au début de la fenêtre demandée), un décalage constant sans effet sur
les lags positionnels ni sur les jointures en aval.
### Flux d'ingestion API Mock
@@ -528,22 +532,43 @@ deux dans `reading`. Trois décisions ferment cette réconciliation :
- **Le trou temporel est accepté.** Le dataset historique s'arrête au 31/12/2024, et
`mock_api_import` n'importe que l'heure précédant chaque déclenchement : rien ne comble
automatiquement la période intermédiaire, et rien ne le pourra jamais — aucune mesure réelle
n'existe pour ces instants.
automatiquement la période intermédiaire, et rien ne le pourra jamais, aucune mesure réelle
n'existe pour ces instants. Conséquence pour le ML, pas nouvelle mais que ce trou rend
définitive : `build_features()` calcule ses lags par `shift(n)` positionnel, et `train.py`
n'écarte que les lignes où `lag_168h` est `NaN`. Pour un site présent dans les deux sources, les
168 premières lectures `api_history` qui suivent le trou héritent donc de lags et de moyennes
glissantes calculés sur décembre 2024 (et tant que l'ingestion a moins de 7 jours, c'est le cas
de toutes les lectures). Même effet, plus ponctuel, pour chaque heure que le DAG manque
(`mock_api_import` en échec, Airflow arrêté). Aucun garde-fou ne détecte aujourd'hui un lag
calculé sur un écart réel différent de celui attendu ; issue de suivi à ouvrir.
- **Le recouvrement est refusé à l'ingestion.** `uq_reading_source` autorise deux lignes au même
`(site_id, timestamp)` dès que `source` diffère : rien dans le schéma n'empêche donc un import
Mock API manuel avec une fenêtre passée (le script accepte `--start-time`/`--end-time`
arbitraires) de dupliquer un point déjà couvert par le CSV. `import_mock_api_history()` appelle
`refuse_if_overlaps_historical_dataset()` avant toute écriture : si la fenêtre demandée recouvre
au moins une lecture `source='csv'`, l'import est refusé (`ValueError`) plutôt que d'écrire un
doublon inter-source silencieux.
`refuse_if_overlaps_historical_dataset()` avant toute écriture, y compris en `--dry-run` (le
contrôle est en lecture seule) et avant le moindre appel à l'API Mock : si la fenêtre demandée
recouvre au moins une lecture `source='csv'`, l'import est refusé (`ValueError`) plutôt que
d'écrire un doublon inter-source silencieux. Le contrôle ne porte que sur la fenêtre demandée,
pas sur les lectures reçues : `fetch_readings()` écarte donc toute lecture dont le `timestamp`
déborde de `[start_time, end_time)`, pour qu'une réponse hors fenêtre (bug du mock, ou hostile)
ne puisse pas le contourner. Ce contrôle compare des instants, pas des chaînes : `parse_datetime()`
pose `tzinfo=UTC` sur une entrée sans fuseau (même pattern que `_vers_utc()` dans
`app/services/reading.py`), sans quoi l'encodeur `timestamptz` d'asyncpg lirait un datetime naïf
dans le fuseau local du **processus**, correct dans le conteneur Airflow (UTC) mais décalé pour
un import manuel lancé depuis un poste en Europe/Paris.
- **Le pipeline ML déduplique en défense.** Le garde-fou ci-dessus protège l'ingestion, pas
la lecture : si un recouvrement se produisait malgré tout (import direct en base, contournement
du script), `ml/enervision_ml/data.py` ne doit pas casser silencieusement l'hypothèse de
`build_features` (« une ligne par `(site_id, timestamp)` »). `load_from_database()` et
`load_recent_from_database()` utilisent donc `SELECT DISTINCT ON (site_id, timestamp)`, `source
= 'csv'` gagnant sur `'api_history'` en cas d'égalité — l'historique étant une source vérifiée,
l'API Mock une entrée hostile (cf. ci-dessus).
= 'csv'` gagnant sur `'api_history'` en cas d'égalité, l'historique étant une source vérifiée,
l'API Mock une entrée hostile (cf. ci-dessus). **Cette préférence est spécifique au chargeur
ML.** `GET /readings` renvoie les deux lignes sans les fusionner, et `DriftRepository` /
`ReadingRepository.latest_by_site()` / `.latest_for_site()` départagent par `reading_id` le plus
grand (en pratique la ligne insérée en dernier, pas forcément `csv`) : en cas de recouvrement, la
dérive comparerait alors une prévision à une valeur différente de celle sur laquelle le modèle a
appris. Pas d'incohérence aujourd'hui tant que le recouvrement reste refusé à l'ingestion ; à
aligner si ce garde-fou devait un jour être contourné.
### Qualité des données de l'API Mock