etl : agrégation et matérialisation de la zone or, export et lignage (#35) #124

Merged
justine merged 10 commits from olivier/35-etl-zone-or into develop 2026-09-04 11:45:02 +00:00
Member

Ce que ça change

La zone or est alimentée : un service services/etl/ agrège la zone argent, la charge dans les hypertables, en produit l'export de reporting et sait remonter n'importe quelle valeur affichée jusqu'à sa lecture bronze. La migration 0016 matérialise les deux agrégats que le §12 annonçait sans qu'ils existent en base.

Le critère 1 du ticket ne demandait plus qu'une zone argent réelle : le #34 est fusionné, la branche est à jour, et les deux zones vivent maintenant dans un seul paquet — etl/silver/ et etl/gold/, un seul config.py. Le job lit le schéma que la zone argent produit réellement, et les fixtures sont dérivées de son schéma déclaré au lieu de le recopier.

Avance #35

Preuve

Rejouée sur e974508, branche à jour de develop :

$ ruff check services packages && ruff format --check services packages
All checks passed!
52 files already formatted

$ mypy --config-file etl/pyproject.toml etl        # strict, depuis services/etl/
Success: no issues found in 20 source files

$ pytest tests/unit -q
413 passed

$ pytest tests/integration/gold -q                 # la commande de vérification du ticket
8 passed, 9 skipped                                # les 9 sautés demandent ENERVISION_ETL_DATABASE_URL

$ coverage report --omit='*/.venv/*'                # hors .venv local
TOTAL                                    2002    226    89%    # palier global : 70 %
$ coverage report --include='<zones sensibles de la chaîne>'
TOTAL                                    1360    104    92%    # palier ciblé : 85 %
$ coverage report --include='services/etl/etl/gold/*,services/etl/etl/config.py'
TOTAL                                     460     40    91%    # pour information

Les 251 cas de tests/unit/{gold,silver,db} passent ensemble : c'est le premier
moment où les deux zones sont exercées dans la même passe.

Les tests d'intégration de tests/integration/gold/test_chaine_zone_or.py jouent la chaîne entière sur une journée de 1 440 minutes pour deux sites : agrégation, qualite_jour, export horaire, reconstitution de l'export à la mesure près, et remontée d'un échantillon de 20 lignes tirées au hasard jusqu'à l'objet bronze. Sans MinIO, sans base, sans réseau — les fixtures de zone argent sont construites en Parquet local.

Ce qui n'est pas encore prouvé, et qui ne peut pas l'être ici : la latence ENF-04 sur la zone or réellement chargée et la remontée depuis la base. Les deux cas sont écrits (test_chargement_postgres.py) et se sautent faute de base joignable. Ils demandent que les migrations 00080016 soient appliquées, ce qui est le lot de la #114. La chaîne de préparation du jeu de latence, elle, est jouée à blanc sans base : 70 560 lignes de zone argent construites, projetées et contrôlées.

Relecture

  • Un pair a relu et laissé un commentaire, même court — @justine (relecture complète, code en main) et @lenaic (coordination)
  • Ses remarques sont traitées, ou une réponse explique pourquoi elles ne le sont pas — réponse point par point

Suite à la relecture, un commit par point

Retour Ce qui a été fait Commit
Bloquant — deux paquets etl au même chemin, et le même garde-fou sous deux variables etl/silver/ + etl/gold/, un seul config.py, un seul pyproject.toml à quatre dépendances, ENERVISION_ETL_TAUX_TROUS_MAX pour les deux zones c254f26
COLONNES_ARGENT a dérivé (23 colonnes contre 38) La fabrique dérive de silver.entrepot.SCHEMAS et écrit par le puits de la zone argent lui-même ; un garde-fou refuse une ligne qui s'en écarte fc453cf
L'EF-06 calculé deux fois, sur deux dénominateurs qualite_jour matérialise disponibilite_jour au lieu de le recalculer ; le caveat du site muet disparaît, le réglage de cadence aussi 6aaed09
Le relevé de latence ne peut pas servir de preuve Mesuré sur mesure_horaire (l'objet que l'API servira), sept jours × sept sites, quatre lecteurs simultanés d62d462
Une fiche de décision pour la forme des agrégats ? Non : l'ADR 0011 la tranche déjà, elle est citée en tête de la 0016 e974508

Trois défauts que ces retours ont fait apparaître, et qu'ils n'avaient pas
demandés : la fenêtre de latence ne couvrait que les heures écoulées depuis
minuit et non 24 h ; refresh_continuous_aggregate refuse d'être appelé dans
une transaction, donc le cas de concordance de l'agrégat aurait échoué à sa
première exécution réelle ; et CadenceIncoherente n'était pas attrapée par
main(), qui rendait 1 là où le README annonce 4.

Ce qui suit le code

  • Une nouvelle variable d'environnement est apparue, elle est dans .env.example

Toutes sous le préfixe ENERVISION_ETL_, avec un défaut utilisable partout sauf pour les identifiants MinIO et l'URL de la base ; les lanceurs sourcent les mêmes /etc/enervision/minio.env et postgres.env que le collecteur. Le tableau complet est dans services/etl/README.md, la configuration lue nulle part ailleurs que dans etl/config.py.

docs/data/etl-pipeline.md §12 est réécrit sur ce qui est posé, son §14 referme le point ouvert « chargement gold → PostgreSQL non spécifié », le tableau d'exposition Grafana de docs/POSTGRESQL.md passe les deux nouveaux objets au même crible table par table que les sept du #105, et db/migrations/README.md documente le motif de l'agrégat continu.

Pas de fiche de décision, et la relecture a confirmé pourquoi : l'ADR 0011 se termine par « une agrégation pure, sans règle, n'a aucune raison de quitter le SQL ». Elle est citée en tête de la 0016. Le choix de forme, lui, reste réversible (les deux objets se suppriment sans rien perdre, public.mesure reste la seule source) et motivé dans le fichier SQL.

Où regarder en priorité

1. Le choix de la forme des agrégatsdb/migrations/0016_zone_or_agregats.sql, en tête de fichier. Le §12 décrivait mesure_horaire et charge_site comme des tables ; elles deviennent un agrégat continu TimescaleDB et une vue. L'alternative écartée est écrite, et l'ADR 0011 citée. @marvin, c'est toi qui les liras depuis l'API (#38) : c'est le seul point de cette demande qui attend encore une réponse, et le relevé de latence porte désormais sur mesure_horaire, donc sur ce que tu serviras.

2. La zone or reste au pas de la minute, et c'est délibéréservices/etl/etl/gold/agregation.py. Un agrégat horaire à la place de la série aurait coupé le lignage à l'endroit exact où l'ENF-07 le demande : c'est l'identité de la clé (site_id, horodatage) de bronze à or qui rend le contrôle par échantillon possible.

3. Le garde-fou de la fenêtre de réécritureetl/gold/chargement.py. mesure est comprimée au-delà de sept jours ; un on conflict do update qui y retombe décomprime les segments touchés, pour 35 Mo relevés le 3 septembre sur préproduction. Le chargement refuse ces jours-là sauf --forcer. Si quelqu'un trouve la fenêtre trop stricte pour un rattrapage, c'est le paramètre ENERVISION_ETL_FENETRE_REECRITURE_JOURS.

4. Trois pièges attrapés en écrivant, qui valent une lecture — DuckDB rendait les horodatages dans le fuseau de la machine, donc le même jour exporté depuis un poste en CEST et depuis le serveur en UTC donnait deux fichiers différents (SET TimeZone = 'UTC' dans gold/duck.py) ; pytz est une dépendance qu'aucun fichier n'importe et qu'un cas de test garde, sinon quelqu'un la retirera comme inutilisée ; la lecture en partitionnement Hive s'active toute seule et ajoute domain, table et dt aux colonnes du fichier.

Deux choses hors de ce lot, à ne pas perdre

  • docs/data est ignoré par .gitignore : la règle data/ matche à tous les niveaux. Le fichier existant reste suivi, mais tout nouveau document déposé là serait silencieusement invisible. Le correctif tient en un caractère (/data/), hors périmètre de ce ticket.
  • Le PRD rattache l'ENF-04 aux tickets #38 et #40, pas au #35 qui en porte le critère, et ne reprend pas le p95 déjà mesuré au #29. À corriger côté PO.
## Ce que ça change La zone or est alimentée : un service `services/etl/` agrège la zone argent, la charge dans les hypertables, en produit l'export de reporting et sait remonter n'importe quelle valeur affichée jusqu'à sa lecture bronze. La migration `0016` matérialise les deux agrégats que le §12 annonçait sans qu'ils existent en base. Le critère 1 du ticket ne demandait plus qu'une zone argent réelle : le #34 est fusionné, la branche est à jour, et les deux zones vivent maintenant dans un seul paquet — `etl/silver/` et `etl/gold/`, un seul `config.py`. Le job lit le schéma que la zone argent produit réellement, et les fixtures sont dérivées de son schéma déclaré au lieu de le recopier. Avance #35 ## Preuve Rejouée sur `e974508`, branche à jour de `develop` : ``` $ ruff check services packages && ruff format --check services packages All checks passed! 52 files already formatted $ mypy --config-file etl/pyproject.toml etl # strict, depuis services/etl/ Success: no issues found in 20 source files $ pytest tests/unit -q 413 passed $ pytest tests/integration/gold -q # la commande de vérification du ticket 8 passed, 9 skipped # les 9 sautés demandent ENERVISION_ETL_DATABASE_URL $ coverage report --omit='*/.venv/*' # hors .venv local TOTAL 2002 226 89% # palier global : 70 % $ coverage report --include='<zones sensibles de la chaîne>' TOTAL 1360 104 92% # palier ciblé : 85 % $ coverage report --include='services/etl/etl/gold/*,services/etl/etl/config.py' TOTAL 460 40 91% # pour information ``` Les 251 cas de `tests/unit/{gold,silver,db}` passent ensemble : c'est le premier moment où les deux zones sont exercées dans la même passe. Les tests d'intégration de `tests/integration/gold/test_chaine_zone_or.py` jouent la chaîne entière sur une journée de **1 440 minutes pour deux sites** : agrégation, `qualite_jour`, export horaire, reconstitution de l'export à la mesure près, et remontée d'un échantillon de 20 lignes tirées au hasard jusqu'à l'objet bronze. Sans MinIO, sans base, sans réseau — les fixtures de zone argent sont construites en Parquet local. **Ce qui n'est pas encore prouvé, et qui ne peut pas l'être ici :** la latence ENF-04 sur la zone or réellement chargée et la remontée depuis la base. Les deux cas sont écrits (`test_chargement_postgres.py`) et se sautent faute de base joignable. Ils demandent que les migrations `0008`–`0016` soient appliquées, ce qui est le lot de la #114. La chaîne de préparation du jeu de latence, elle, est jouée à blanc sans base : 70 560 lignes de zone argent construites, projetées et contrôlées. ## Relecture - [x] Un pair a relu et laissé un commentaire, même court — @justine (relecture complète, code en main) et @lenaic (coordination) - [x] Ses remarques sont traitées, ou une réponse explique pourquoi elles ne le sont pas — [réponse point par point](#issuecomment-2279) ### Suite à la relecture, un commit par point | Retour | Ce qui a été fait | Commit | |---|---|---| | **Bloquant** — deux paquets `etl` au même chemin, et le même garde-fou sous deux variables | `etl/silver/` + `etl/gold/`, un seul `config.py`, un seul `pyproject.toml` à quatre dépendances, `ENERVISION_ETL_TAUX_TROUS_MAX` pour les deux zones | `c254f26` | | `COLONNES_ARGENT` a dérivé (23 colonnes contre 38) | La fabrique dérive de `silver.entrepot.SCHEMAS` et écrit par le puits de la zone argent lui-même ; un garde-fou refuse une ligne qui s'en écarte | `fc453cf` | | L'EF-06 calculé deux fois, sur deux dénominateurs | `qualite_jour` **matérialise** `disponibilite_jour` au lieu de le recalculer ; le caveat du site muet disparaît, le réglage de cadence aussi | `6aaed09` | | Le relevé de latence ne peut pas servir de preuve | Mesuré sur `mesure_horaire` (l'objet que l'API servira), sept jours × sept sites, quatre lecteurs simultanés | `d62d462` | | Une fiche de décision pour la forme des agrégats ? | Non : l'ADR 0011 la tranche déjà, elle est citée en tête de la 0016 | `e974508` | Trois défauts que ces retours ont fait apparaître, et qu'ils n'avaient pas demandés : la fenêtre de latence ne couvrait que les heures écoulées depuis minuit et non 24 h ; `refresh_continuous_aggregate` refuse d'être appelé dans une transaction, donc le cas de concordance de l'agrégat aurait échoué à sa première exécution réelle ; et `CadenceIncoherente` n'était pas attrapée par `main()`, qui rendait 1 là où le README annonce 4. ## Ce qui suit le code - [x] Une nouvelle variable d'environnement est apparue, elle est dans `.env.example` Toutes sous le préfixe `ENERVISION_ETL_`, avec un défaut utilisable partout sauf pour les identifiants MinIO et l'URL de la base ; les lanceurs sourcent les mêmes `/etc/enervision/minio.env` et `postgres.env` que le collecteur. Le tableau complet est dans `services/etl/README.md`, la configuration lue nulle part ailleurs que dans `etl/config.py`. `docs/data/etl-pipeline.md` §12 est réécrit sur ce qui est posé, son §14 referme le point ouvert « chargement gold → PostgreSQL non spécifié », le tableau d'exposition Grafana de `docs/POSTGRESQL.md` passe les deux nouveaux objets au même crible table par table que les sept du #105, et `db/migrations/README.md` documente le motif de l'agrégat continu. Pas de fiche de décision, et la relecture a confirmé pourquoi : l'ADR 0011 se termine par « une agrégation pure, sans règle, n'a aucune raison de quitter le SQL ». Elle est citée en tête de la 0016. Le choix de *forme*, lui, reste **réversible** (les deux objets se suppriment sans rien perdre, `public.mesure` reste la seule source) et motivé dans le fichier SQL. ## Où regarder en priorité **1. Le choix de la forme des agrégats** — `db/migrations/0016_zone_or_agregats.sql`, en tête de fichier. Le §12 décrivait `mesure_horaire` et `charge_site` comme des tables ; elles deviennent un agrégat continu TimescaleDB et une vue. L'alternative écartée est écrite, et l'ADR 0011 citée. @marvin, c'est toi qui les liras depuis l'API (#38) : c'est le seul point de cette demande qui attend encore une réponse, et le relevé de latence porte désormais sur `mesure_horaire`, donc sur ce que tu serviras. **2. La zone or reste au pas de la minute, et c'est délibéré** — `services/etl/etl/gold/agregation.py`. Un agrégat horaire à la place de la série aurait coupé le lignage à l'endroit exact où l'ENF-07 le demande : c'est l'identité de la clé `(site_id, horodatage)` de bronze à or qui rend le contrôle par échantillon possible. **3. Le garde-fou de la fenêtre de réécriture** — `etl/gold/chargement.py`. `mesure` est comprimée au-delà de sept jours ; un `on conflict do update` qui y retombe décomprime les segments touchés, pour 35 Mo relevés le 3 septembre sur préproduction. Le chargement refuse ces jours-là sauf `--forcer`. Si quelqu'un trouve la fenêtre trop stricte pour un rattrapage, c'est le paramètre `ENERVISION_ETL_FENETRE_REECRITURE_JOURS`. **4. Trois pièges attrapés en écrivant, qui valent une lecture** — DuckDB rendait les horodatages dans le fuseau de la machine, donc le même jour exporté depuis un poste en CEST et depuis le serveur en UTC donnait deux fichiers différents (`SET TimeZone = 'UTC'` dans `gold/duck.py`) ; `pytz` est une dépendance qu'aucun fichier n'importe et qu'un cas de test garde, sinon quelqu'un la retirera comme inutilisée ; la lecture en partitionnement Hive s'active toute seule et ajoute `domain`, `table` et `dt` aux colonnes du fichier. ## Deux choses hors de ce lot, à ne pas perdre - **`docs/data` est ignoré par `.gitignore`** : la règle `data/` matche à tous les niveaux. Le fichier existant reste suivi, mais tout nouveau document déposé là serait silencieusement invisible. Le correctif tient en un caractère (`/data/`), hors périmètre de ce ticket. - **Le PRD rattache l'ENF-04 aux tickets #38 et #40**, pas au #35 qui en porte le critère, et ne reprend pas le p95 déjà mesuré au #29. À corriger côté PO.
olivier self-assigned this 2026-09-03 13:42:23 +00:00
Quatre entrées, dans un service nouveau : `services/etl/` n'existait pas, le
README racine n'attribuait aucun chemin à l'agrégation, et la mettre sous
`services/collector/` aurait fait du collecteur « collecte + imputation +
agrégation + chargement ».

    etl.agregation   zone argent → zone or, `qualite_jour` comprise
    etl.chargement   zone or → `mesure` et `qualite_jour`, en upsert
    etl.export       export de reporting horaire, et sa reconstitution
    etl.lignage      contrôle ENF-07 : remonte un échantillon jusqu'au bronze

**La zone or n'est pas un agrégat de plus.** `public.mesure` porte la même
série au pas de la minute que la zone argent, sur la même clé, sans table de
correspondance : c'est cette identité de bout en bout qui rend l'ENF-07
vérifiable par échantillon. Un agrégat horaire à la place aurait coupé le
lignage à l'endroit exact où l'exigence le demande. Les agrégats sont dérivés,
en base, et c'est la migration 0016 qui les pose.

**Le chargement refuse les jours déjà comprimés.** Un `on conflict do update`
qui retombe dans un fragment comprimé décomprime les segments touchés, pour un
coût sans rapport avec le nombre de lignes chargées — 35 Mo pour 5,4 Mo,
relevé le 3 septembre sur `enervision_preprod`. Recharger un vieux jour reste
possible, avec `--forcer` : c'est un geste délibéré, pas un effet de bord d'un
job de cron. Le message porte la commande de décompression.

**Trois choses que la mise en place a révélées, et qui valent plus que le code.**

1. DuckDB rendait les horodatages dans le fuseau de la machine. Le même jour
   exporté depuis un poste en CEST et depuis le serveur en UTC donnait deux
   fichiers différents — ce qui suffit à casser le « reconstituable à la mesure
   près » du ticket. `SET TimeZone = 'UTC'` est posé dans la connexion.
2. `pytz` est une dépendance que rien n'importe. DuckDB en a besoin pour rendre
   un `TIMESTAMPTZ` en objet Python : sans lui, le job échoue au `fetchall` du
   chargement, donc après l'agrégation, sur une machine où tout paraissait
   installé. Un cas de test garde la dépendance, sinon quelqu'un la retirera
   comme inutilisée.
3. La lecture en partitionnement Hive s'active toute seule dès qu'un segment de
   chemin ressemble à `clé=valeur`, et ajoute `domain`, `table` et `dt` aux
   colonnes du fichier. Le chargement nomme donc ses colonnes une par une.

Le job échoue plutôt que d'écrire une journée douteuse : au-delà de 30 % de
trous (§11), sur une valeur d'énumération que la base refuserait, ou sur plus
de relevés disponibles que la cadence n'en permet — un doublon de zone argent
donnerait sinon un taux de disponibilité au-dessus de 100 %.

62 cas unitaires, 91 % de couverture sur le service, plus la chaîne entière sur
une journée de 1 440 minutes dans `tests/integration/gold/` — le répertoire que
le ticket nomme. Les fixtures de zone argent sont construites en Parquet local
et non versionnées : `.gitignore` exclut `*.parquet`, et un binaire de test que
personne ne peut relire est un binaire que personne ne corrige.

Le job d'agrégation lit le schéma du §10, qui est figé : il tourne donc dès que
le #34 livre une zone argent réelle, sans rien changer ici.
db: matérialise l'agrégat horaire et le taux de charge en 0016 (#35)
All checks were successful
Intégration / Qualité du code Python (pull_request) Successful in 57s
Intégration / Tests unitaires et couverture (pull_request) Successful in 1m25s
Intégration / Dépendances du tableau de bord (pull_request) Successful in 12s
Intégration / Images épinglées par version (pull_request) Successful in 3s
Intégration / Tableau de bord (pull_request) Successful in 18s
Intégration / Dépendances Python et inventaire applicatif (pull_request) Successful in 1m33s
Intégration / Aucun secret commité (pull_request) Successful in 2s
f2d273277b
Le §12 de `docs/data/etl-pipeline.md` décrivait la zone or comme trois tables —
`mesure_horaire`, `qualite_jour`, `charge_site`. En base il y en a sept, dont
`mesure` au pas de la minute, et ni `mesure_horaire` ni `charge_site`
n'existaient : le document et la base ne parlaient pas de la même chose, et
« la zone or est écrite dans les hypertables » n'avait pas de cible.

`mesure_horaire` devient un **agrégat continu TimescaleDB**, `charge_site` une
**vue**. Aucun des deux ne porte de donnée propre : ils dérivent de
`public.mesure`, ligne pour ligne.

**Ce qui a été écarté**, et pourquoi : deux tables ordinaires alimentées par le
job de chargement, comme le §12 les décrivait. Elles auraient été uniformes
avec les cinq autres tables du #105, et c'est leur seul avantage. Contre elles,
un agrégat rechargé en même temps que la série peut la contredire le temps
d'une transaction, il faut le rejouer quand on rejoue la série, et il faut
écrire le code qui le fait. TimescaleDB est là depuis le #29 et tient l'agrégat
à jour sans une ligne de Python.

**Le choix est réversible**, et c'est ce qui a permis de le trancher sans
attendre l'arbitrage du 04/09 : les deux objets se suppriment sans rien perdre,
`public.mesure` restant la seule source.

**`WITH NO DATA`, délibérément.** Une migration s'applique au démarrage du
serveur : un `WITH DATA` matérialiserait les 352 807 lignes de `mesure` — en
lisant 28 fragments comprimés — à un moment que personne n'a choisi. La
politique de rafraîchissement remplit l'agrégat à l'heure, en revenant sur trois
jours pour couvrir un rechargement, et en laissant l'heure courante dehors : une
heure en train de se remplir donne une moyenne qui bouge à chaque passage, et un
tableau de bord qui change de valeur à chaque rafraîchissement fait douter du
reste.

**La vue ne classe pas le taux en paliers.** Les quatre paliers du §3 du
glossaire (70 %, 85 %, 95 %) y ont leur source unique ; les recopier dans un
`case` en ferait un second endroit à corriger, et c'est le moteur de règles
versionnées du #39 qui doit les lire. La vue rend le taux, `NULL` quand la
mesure manque — un zéro ferait de chaque panne de capteur le meilleur élève du
parc.

Le `grant` à `grafana` est explicite, comme dans la 0007 et pour la même raison,
mais sous garde d'existence du rôle : un `grant` sec ferait échouer cette
migration, donc toutes les suivantes, sur une base de développement montée à la
main.

Neuf cas relisent le fichier sans base, dont un qui compare la définition de
l'agrégat à celle de l'export de reporting : deux définitions d'un même agrégat
finissent par diverger, et la divergence ne se voit pas — deux chiffres
plausibles, dont un faux.

Le §12 est réécrit sur ce qui est posé, le §14 referme son point ouvert
« chargement gold → PostgreSQL non spécifié », et le tableau d'exposition
Grafana de `docs/POSTGRESQL.md` passe les deux objets au même crible table par
table que les sept du #105.
justine added this to the EnerVision project 2026-09-03 14:41:01 +00:00
Member

Relu en entier, code en main, depuis la branche du #34. Le travail est solide et la sémantique colle à la zone argent sans que rien n'ait été concerté — c'est plutôt bon signe. Un point de structure à trancher avant fusion, trois points à reprendre, et deux questions.

Ce qui s'aligne déjà tout seul
Les trois énumérations de etl/config.py sont identiques au caractère près à ce que la zone argent produit : measured / interpolated / forward_fill / none, good / imputed / suspect / critical, good / partial / degraded / critical. Aucune couche de traduction ne sera nécessaire. SQL_PROJECTION ne lit que des colonnes réellement écrites par le #34 — y compris après le changement de schéma qu'il a subi en relecture, qui est purement additif.

SET TimeZone = 'UTC' dans duck.py rejoint le §8.4, le garde-fou à 30 % existe des deux côtés avec le même défaut, et RELEVES_ATTENDUS_PAR_JOUR = 1440 correspond exactement à la grille régularisée.

Sur la question posée en point 1 — la forme des agrégats mérite-t-elle une fiche ? L'ADR 0011, introduite par le #34, se termine par : « Une agrégation pure, sans règle, n'a aucune raison de quitter le SQL. » La décision est déjà tracée, il suffit de la citer en tête de 0016. Pas de nouvelle fiche à écrire.

1. Bloquant : deux paquets etl différents au même chemin

Les deux PR créent services/etl/etl/. Quatre fichiers en conflit dur, avec des contenus incompatibles : etl/init.py, etl/pyproject.toml, etl/config.py, services/etl/README.md. Et les dispositions divergent : le #34 range la zone argent dans etl/silver/, cette PR pose ses modules à plat dans etl/. Après fusion on aurait etl/agregation.py à côté de etl/silver/, ce qui ne se lit pas.
La forme qui marche, et que le #34 avait anticipée en sortant config.py du sous-paquet : etl/silver/ + etl/gold/, un seul config.py, un seul pyproject.toml réunissant les quatre dépendances.

Sous-problème du même ordre — le même garde-fou sous deux variables :

  #34 : ENERVISION_ETL_TAUX_TROUS_MAX       défaut 0.30                                                                                                                                                                            
  #35 : ENERVISION_ETL_TAUX_TROUS_MAXIMAL   défaut 0.30  

Même préfixe, même concept, même défaut, deux noms. Un exploitant en règlera une et s'étonnera que l'autre job ne bouge pas.

2. COLONNES_ARGENT a déjà dérivé, et le docstring l'avait prédit
tests/conftest.py dit très justement qu'« une fabrique recopiée dérive de la table qu'elle est censée imiter ». C'en est une, et elle a dérivé par rapport à ce que le #34 écrit :

  • temperature_celsius* et humidity_percent* sont totalement absents — alors que le commentaire au-dessus annonce que les quatre colonnes explicatives sont là pour vérifier que la projection les laisse derrière elle. Le contrôle décrit n'est exercé que sur deux des quatre.
  • Manquent aussi voltage_v_method, power_factor_method, consumption_kwh_imputed, consumption_kwh_invalid_reason, voltage_v_invalid_reason, power_factor_invalid_reason, site_type, capacity_kw, silver_run_id.
    Ce n'est pas bloquant pour le job — la projection ne lit que l'intersection. C'est la fabrique qui ment sur ce qu'elle couvre. Le correctif propre, une fois les deux fusionnés : la dériver de etl.silver.entrepot.SCHEMAS[TABLE_MESURE], que le #34 expose déjà pour cette raison exacte et garde par un test anti-dérive.

3. L'EF-06 est calculé deux fois, sur deux dénominateurs
#34 silver/disponibilite_jour │ grille régularisée │ 1440 minutes, trous compris │
#35 public.qualite_jour │ lignes présentes en argent │ releves_attendus │

qualite.py documente honnêtement sa limite : « Un site totalement absent de la zone argent n'y produit aucune ligne […] le taux de 0 % qu'il mériterait n'apparaît nulle part. » Cette limite disparaît dès que le #34 est fusionné : la régularisation de la grille garantit 1440 lignes par site et par jour, même sans une seule collecte — c'est précisément ce qu'elle sert à rendre visible. Le caveat sera donc à supprimer, pas à conserver.

Reste à décider qui porte l'EF-06 au §15, et si qualite_jour recalcule ou lit disponibilite_jour. Recalculer est défendable (la base doit être autoportante), mais alors les deux dénominateurs doivent être documentés comme équivalents, sinon deux chiffres circuleront pour la même exigence.

Relu en entier, code en main, depuis la branche du #34. Le travail est solide et la sémantique colle à la zone argent sans que rien n'ait été concerté — c'est plutôt bon signe. Un point de structure à trancher avant fusion, trois points à reprendre, et deux questions. **Ce qui s'aligne déjà tout seul** Les trois énumérations de etl/config.py sont identiques au caractère près à ce que la zone argent produit : measured / interpolated / forward_fill / none, good / imputed / suspect / critical, good / partial / degraded / critical. Aucune couche de traduction ne sera nécessaire. SQL_PROJECTION ne lit que des colonnes réellement écrites par le #34 — y compris après le changement de schéma qu'il a subi en relecture, qui est purement additif. SET TimeZone = 'UTC' dans duck.py rejoint le §8.4, le garde-fou à 30 % existe des deux côtés avec le même défaut, et RELEVES_ATTENDUS_PAR_JOUR = 1440 correspond exactement à la grille régularisée. Sur la question posée en point 1 — la forme des agrégats mérite-t-elle une fiche ? L'ADR 0011, introduite par le #34, se termine par : « Une agrégation pure, sans règle, n'a aucune raison de quitter le SQL. » La décision est déjà tracée, il suffit de la citer en tête de 0016. Pas de nouvelle fiche à écrire. **1. Bloquant : deux paquets etl différents au même chemin** Les deux PR créent services/etl/etl/. Quatre fichiers en conflit dur, avec des contenus incompatibles : etl/__init__.py, etl/pyproject.toml, etl/config.py, services/etl/README.md. Et les dispositions divergent : le #34 range la zone argent dans etl/silver/, cette PR pose ses modules à plat dans etl/. Après fusion on aurait etl/agregation.py à côté de etl/silver/, ce qui ne se lit pas. La forme qui marche, et que le #34 avait anticipée en sortant config.py du sous-paquet : etl/silver/ + etl/gold/, un seul config.py, un seul pyproject.toml réunissant les quatre dépendances. Sous-problème du même ordre — le même garde-fou sous deux variables : ``` #34 : ENERVISION_ETL_TAUX_TROUS_MAX défaut 0.30 #35 : ENERVISION_ETL_TAUX_TROUS_MAXIMAL défaut 0.30 ``` Même préfixe, même concept, même défaut, deux noms. Un exploitant en règlera une et s'étonnera que l'autre job ne bouge pas. **2. COLONNES_ARGENT a déjà dérivé, et le docstring l'avait prédit** tests/conftest.py dit très justement qu'« une fabrique recopiée dérive de la table qu'elle est censée imiter ». C'en est une, et elle a dérivé par rapport à ce que le #34 écrit : - temperature_celsius* et humidity_percent* sont totalement absents — alors que le commentaire au-dessus annonce que les quatre colonnes explicatives sont là pour vérifier que la projection les laisse derrière elle. Le contrôle décrit n'est exercé que sur deux des quatre. - Manquent aussi voltage_v_method, power_factor_method, consumption_kwh_imputed, consumption_kwh_invalid_reason, voltage_v_invalid_reason, power_factor_invalid_reason, site_type, capacity_kw, silver_run_id. Ce n'est pas bloquant pour le job — la projection ne lit que l'intersection. C'est la fabrique qui ment sur ce qu'elle couvre. Le correctif propre, une fois les deux fusionnés : la dériver de etl.silver.entrepot.SCHEMAS[TABLE_MESURE], que le #34 expose déjà pour cette raison exacte et garde par un test anti-dérive. **3. L'EF-06 est calculé deux fois, sur deux dénominateurs** #34 silver/disponibilite_jour │ grille régularisée │ 1440 minutes, trous compris │ #35 public.qualite_jour │ lignes présentes en argent │ releves_attendus │ qualite.py documente honnêtement sa limite : « Un site totalement absent de la zone argent n'y produit aucune ligne […] le taux de 0 % qu'il mériterait n'apparaît nulle part. » Cette limite disparaît dès que le #34 est fusionné : la régularisation de la grille garantit 1440 lignes par site et par jour, même sans une seule collecte — c'est précisément ce qu'elle sert à rendre visible. Le caveat sera donc à supprimer, pas à conserver. Reste à décider qui porte l'EF-06 au §15, et si qualite_jour recalcule ou lit disponibilite_jour. Recalculer est défendable (la base doit être autoportante), mais alors les deux dénominateurs doivent être documentés comme équivalents, sinon deux chiffres circuleront pour la même exigence.
Member

Sur les critères de ton ticket, les critères 3 et 4 sont tenus. Le 1 le sera mécaniquement quand le #34 sera fusionné et la 0016 appliquée (donc en théorie rien à changer dans le code). Le 2 est le seul qui pourra poser problème au niveau de tes preuves à fournir.
Le test est bien construit (20 exécutions, p95, relevé horodaté non versionné) mais son chiffre ne peut pas servir de preuve, pour trois raisons :

  1. Volume. journee_chargee charge 2 880 lignes (2 sites × 1440). Le relevé du #29 que le docstring cite portait sur 352 807 lignes, 120 fois plus : sous 400 ms est acquis d'avance. Charger plusieurs jours et les sept sites.
  2. Concurrence. Le ticket dit « sous charge légère » ; les 20 exécutions sont séquentielles sur une connexion.
  3. Objet mesuré — celui qui dessert. La requête recalcule un time_bucket en direct sur public.mesure, alors que sa définition est exactement celle de public.mesure_horaire, l'agrégat continu livré par la 0016 dans cette même PR. Lire l'agrégat donnera un meilleur chiffre, et surtout celui de l'objet que l'API servira. test_l_agregat_horaire_dit_la_meme_chose_que_la_serie fait déjà le refresh_continuous_aggregate nécessaire.

Le code tient le critère, la migration aussi ; seule la mesure ne le démontre pas encore.

Sur les critères de ton ticket, les critères 3 et 4 sont tenus. Le 1 le sera mécaniquement quand le #34 sera fusionné et la 0016 appliquée (donc en théorie rien à changer dans le code). Le 2 est le seul qui pourra poser problème au niveau de tes preuves à fournir. Le test est bien construit (20 exécutions, p95, relevé horodaté non versionné) mais son chiffre ne peut pas servir de preuve, pour trois raisons : 1. Volume. journee_chargee charge 2 880 lignes (2 sites × 1440). Le relevé du #29 que le docstring cite portait sur 352 807 lignes, 120 fois plus : sous 400 ms est acquis d'avance. Charger plusieurs jours et les sept sites. 2. Concurrence. Le ticket dit « sous charge légère » ; les 20 exécutions sont séquentielles sur une connexion. 3. Objet mesuré — celui qui dessert. La requête recalcule un time_bucket en direct sur public.mesure, alors que sa définition est exactement celle de public.mesure_horaire, l'agrégat continu livré par la 0016 dans cette même PR. Lire l'agrégat donnera un meilleur chiffre, et surtout celui de l'objet que l'API servira. test_l_agregat_horaire_dit_la_meme_chose_que_la_serie fait déjà le refresh_continuous_aggregate nécessaire. Le code tient le critère, la migration aussi ; seule la mesure ne le démontre pas encore.
Owner

Point de coordination : la #123 de Justine est approuvée et part en premier. Elle crée
services/etl/ de son côté, comme toi.

Vos deux demandes se marchent dessus sur cinq fichiers, en conflits add/add donc à
résoudre à la main :

services/etl/etl/config.py
services/etl/etl/__init__.py
services/etl/etl/pyproject.toml
services/etl/README.md
docs/data/etl-pipeline.md

Rien de grave, mais ce n'est pas un rebase automatique : il faudra fusionner les deux
config.py et les deux pyproject.toml plutôt que d'en choisir un.

Ta #124 reste par ailleurs en état, sa chaîne est verte et ma relecture n'avait que deux
remarques mineures.

Point de coordination : la **#123 de Justine** est approuvée et part en premier. Elle crée `services/etl/` de son côté, comme toi. Vos deux demandes se marchent dessus sur cinq fichiers, en conflits **add/add** donc à résoudre à la main : ``` services/etl/etl/config.py services/etl/etl/__init__.py services/etl/etl/pyproject.toml services/etl/README.md docs/data/etl-pipeline.md ``` Rien de grave, mais ce n'est pas un rebase automatique : il faudra fusionner les deux `config.py` et les deux `pyproject.toml` plutôt que d'en choisir un. Ta #124 reste par ailleurs en état, sa chaîne est verte et ma relecture n'avait que deux remarques mineures.
gabriel removed this from the EnerVision project 2026-09-03 16:11:04 +00:00
Le #34 (demande #123, fusionnée depuis) a créé `services/etl/etl/` de son côté.
Cinq fichiers en conflit add/add, et surtout deux dispositions incompatibles :
la zone argent dans `etl/silver/`, la zone or à plat dans `etl/`. Après fusion
on aurait eu `etl/agregation.py` à côté de `etl/silver/`, ce qui ne se lit pas.
Point bloquant n°1 de la relecture de Justine.

Ce que la résolution retient, et que le #34 avait anticipé en sortant
`config.py` du sous-paquet :

- `etl/silver/` + `etl/gold/`, un seul `etl/config.py`, un seul
  `pyproject.toml` réunissant les quatre dépendances (duckdb, minio, psycopg,
  pytz). Le plancher DuckDB est celui de la version épinglée dans
  `requirements-dev.txt` : deux planchers pour un moteur installé se
  contredisent au premier `pip install` ;
- **un seul nom pour le garde-fou de trous** : `ENERVISION_ETL_TAUX_TROUS_MAX`,
  défaut 0,30, pour les deux zones. `TAUX_TROUS_MAXIMAL` disparaît — même
  préfixe, même concept, même défaut et deux noms, un exploitant en aurait
  réglé une et se serait étonné que l'autre job ne bouge pas ;
- les entrées deviennent `python -m etl.gold.<module>` ; lanceurs de cron,
  README du service et §12 du document data suivent ;
- `tests/unit/etl/` devient `tests/unit/gold/`, en regard de
  `tests/unit/silver/`. Son `test_qualite.py` devient `test_qualite_jour.py` :
  deux modules de test de même nom sans `__init__.py` se marchent dessus à la
  collecte, et le message de pytest ne parle pas de doublon de nom.

Les champs propres à la zone or portent un défaut dans `Settings`, pour qu'une
passe argent construite à la main — les doubles de `tests/unit/silver/` — n'ait
pas à les nommer. Les identifiants MinIO restent obligatoires hors racine
locale, comme avant, dans les deux zones.

Un cas garde désormais l'identité des trois énumérations avec ce que
`etl.silver` **produit** (`imputation.py`, `mesure.py`), là où elle n'était
constatée qu'en relecture. Elle l'était déjà avec ce que la migration 0010
accepte : la zone or est tendue entre ces deux bords, et un régime ajouté d'un
seul côté ferait refuser en bloc une journée d'argent parfaitement valide.

Au passage : le tableau d'état du §2 avait gardé trois lignes en double de la
fusion du #123. Dédoublonné, les deux zones y sont en « partiel ».

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`tests/conftest.py` disait lui-même qu'« une fabrique recopiée dérive de la
table qu'elle est censée imiter ». C'en était une, et elle avait dérivé : 23
colonnes contre les 38 que `silver.mesure` écrit. Manquaient les quatre
colonnes de température et d'humidité — alors que le commentaire au-dessus
annonçait que les quatre grandeurs explicatives étaient là pour vérifier que la
projection de la zone or les laisse derrière elle, contrôle qui ne portait donc
que sur deux d'entre elles — plus `voltage_v_method`, `power_factor_method`,
`consumption_kwh_imputed`, les trois motifs de rejet, `site_type`,
`capacity_kw` et `silver_run_id`. Point 2 de la relecture de Justine.

Plus rien n'est recopié :

- les colonnes et leurs types viennent de `etl.silver.entrepot.SCHEMAS`, que
  le #34 expose pour cette raison exacte ;
- l'écriture passe par `PuitsDuckDB`, le puits de la zone argent lui-même, dont
  seule la **cible** est détournée vers le disque. Le `CREATE TABLE`, l'ordre
  des colonnes et le `COPY` sont ceux de la production ;
- l'indicateur quotidien est calculé par `silver.qualite.disponibilite_du_jour`.
  La fabrique produit désormais les **deux** tables que la zone or lit,
  `mesure` et `disponibilite_jour`, et pas `journal_capteur` que rien ne lit ;
- `_verifier_couverture` refuse une ligne dont le jeu de colonnes s'écarte du
  schéma. `PuitsDuckDB.ecrire` lit par `ligne.get(nom)` : sans ce garde-fou,
  une colonne ajoutée en argent redeviendrait un NULL silencieux.

Effet de bord attrapé au passage : l'objet écrit porte maintenant, comme en
production, la colonne `dt` **en plus** du segment `dt=` du chemin — l'ancienne
fabrique passait par `PARTITION_BY (dt)`, qui la retire du fichier. DuckDB 1.5
résout ce doublon en faveur de la partition, donc la lecture de la zone or
fonctionne des deux façons ; elle n'était vérifiée que sur celle que la zone
argent n'utilise pas.

Le référentiel des sept sites (type, capacité) est celui de la migration 0009,
et un cas le garde. La consommation du jeu d'essai vaut 60 % de la capacité
déclarée du site : les deux valeurs en dur — 120 kW pour SITE001, 600 pour
SITE002 — étaient déjà exactement cela, mais la règle tient pour les cinq
autres sites, dont deux qu'un forfait de 600 kW aurait mis au-dessus de leur
capacité.

`tests/unit/gold/test_fabrique_argent.py` garde ce qui reste inévitablement
recopié : les noms des familles de grandeurs, comparés à
`etl.silver.grandeurs`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
L'EF-06 était calculé deux fois, sur deux dénominateurs : la grille régularisée
de 1 440 minutes en zone argent (`silver/disponibilite_jour`), les lignes
présentes en zone or (`public.qualite_jour`). Point 3 de la relecture de
Justine. Les deux chiffres se valaient tant que les deux zones s'accordaient,
et le premier changement de règle d'imputation les aurait fait diverger sans que
rien ne le dise.

**Le §10 du document data avait déjà tranché**, écrit par le #34 lui-même : « la
zone or le matérialisera sous `qualite_jour` (§12) sans le recalculer, ce qui
évite deux définitions de la disponibilité qui divergeraient au premier
changement de règle. » C'est donc `disponibilite_jour` qui porte l'exigence, et
la zone or qui la sert.

`gold.agregation` ouvre désormais les **deux** partitions de zone argent, et
`gold.qualite` projette l'indicateur au schéma de la table :

    taux_disponibilite  ← taux_de_disponibilite
    releves_attendus    ← minutes_attendues
    releves_manquants   ← minutes_attendues − mesurees − imputees

Les chiffres ne changent pas, et c'est vérifiable : en argent, une minute non
imputable porte la méthode `none` ; en or, la même ligne porte `critical`. Les
deux définitions du « relevé disponible » coïncident exactement, et le nouveau
contrôle le garde.

**Le caveat disparaît.** Ce module assumait de ne pas voir un site totalement
absent de la zone argent — « le taux de 0 % qu'il mériterait n'apparaît nulle
part ». La grille de la zone argent lui donne ses 1 440 lignes même sans une
seule collecte, ce qu'elle sert précisément à rendre visible : le site muet
arrive maintenant en base à 0 %, et un cas le vérifie.

**Ce qui reste calculé en or**, parce que la zone argent ne le publie pas : la
répartition par méthode. Ses totaux sont confrontés à l'indicateur, site par
site, par une jointure externe des deux côtés — un site présent dans une seule
des deux tables est le cas le plus grave, une jointure interne l'écarterait en
silence. `IndicateurDiscordant` remplace `CadenceIncoherente` et attrape la
même faute (déduplication du §8.2 non jouée, grille plus fine que la cadence)
plus celles que l'ancien contrôle ne voyait pas.

**Deux conséquences, et un défaut corrigé au passage :**

- `ENERVISION_ETL_RELEVES_ATTENDUS` disparaît. Plus personne ne le lisait après
  ce changement, et un réglage que rien ne lit est un piège : on le règle, et
  rien ne bouge. Le dénominateur vient de la seule zone qui sait combien de
  minutes la journée comptait. La fixture `settings_journee` disparaît avec lui
  — elle n'existait que pour désarmer le contrôle de cadence.
- `CadenceIncoherente` n'était **pas** attrapée par `main()` : un exploitant
  aurait eu une trace d'appels et le code 1, là où le README annonce 4.
  `IndicateurDiscordant` l'est.

Les noms de tables de la zone argent sont lus dans `silver.entrepot`, comme le
reste : « mesure » et « disponibilite_jour » écrits en dur dans la zone or
auraient été deux chaînes de plus à tenir à jour à distance.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Le critère 2 du ticket demande la latence de lecture d'une fenêtre de 24 h sous
charge légère. Le cas était bien construit — vingt exécutions, p95, relevé
horodaté non versionné — mais son chiffre ne pouvait pas servir de preuve, pour
les trois raisons que Justine a relevées. Elles sont traitées dans l'ordre de
leur importance, qui n'est pas celui où elles se voient.

**1. L'objet mesuré.** La requête recalculait un `time_bucket` sur
`public.mesure`, alors que sa définition est **exactement** celle de
`public.mesure_horaire`, l'agrégat continu que la 0016 livre dans cette même
demande. Un chiffre relevé sur un objet que l'API ne servira pas ne dit rien de
ce que l'utilisateur attendra. Deux relevés sont maintenant exigés, un par vue
du tableau de bord :

    Parc entier, agrégat horaire        public.mesure_horaire
    Un site, série à la minute          public.mesure

Le recalcul direct reste mesuré et imprimé, **sans être exigé** : l'écart entre
lui et l'agrégat est ce qui justifie la migration 0016, et un lecteur du ticket
doit pouvoir le vérifier au lieu de me croire.

**2. Le volume.** 2 880 lignes mettaient le résultat sous la cible d'avance. Le
jeu passe à sept jours et aux sept sites du référentiel, soit 70 560 lignes
chargées par le fichier lui-même — indépendamment de ce que la base porte déjà —
et le relevé imprime les deux comptes : le total de `public.mesure` et la part
qui tombe dans la fenêtre mesurée. Deux journées auraient suffi à ce que la
fenêtre de 24 h soit **pleine**, et c'est un défaut que je n'avais pas vu : elle
commence en milieu de journée d'hier, la version précédente ne chargeait
qu'aujourd'hui, donc ne mesurait que les heures écoulées depuis minuit.

**3. La concurrence.** Quatre lecteurs simultanés, chacun sur sa connexion,
chacun ses vingt exécutions, durées mises en commun. Quatre parce que le
tableau de bord a une poignée d'utilisateurs et que le serveur partage 8 Go :
au-delà on mesurerait la machine.

**Un défaut de la base attrapé en écrivant :**
`refresh_continuous_aggregate` ne s'exécute **pas** dans une transaction, et
psycopg en ouvre une dès le premier ordre. Le cas de concordance de l'agrégat
l'appelait sur la connexion ordinaire : il aurait échoué à la première exécution
contre un vrai PostgreSQL, sur un message qui ne parle pas de transaction. Une
seconde connexion en `autocommit` lui est dédiée.

Un cas s'ajoute au passage, que les sept sites rendent possible : le taux de
charge de **chaque** site reste sous sa capacité déclarée. Tant que le jeu ne
portait que SITE001 et SITE002, un forfait de 600 kW sur SITE006 — 180 kW au
référentiel — aurait donné un taux de 330 % sans que rien ne rougisse.

Ces cas se sautent toujours faute de base joignable depuis ce poste : le chiffre
reste à relever sur la préproduction, une fois les migrations 0008–0016
appliquées (#114). Ce qui est vérifié ici est ce qui peut l'être — la chaîne de
préparation du jeu, jouée à blanc : 70 560 lignes de zone argent construites,
projetées et contrôlées, sept lignes de qualité par jour à 1 440 relevés
attendus et 32 manquants.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
db: la 0016 cite l'ADR 0011 au lieu d'appeler une fiche de plus (#35)
Some checks failed
Intégration / Contrôles statiques du dépôt (pull_request) Successful in 4s
Intégration / Aucun secret commité (pull_request) Successful in 2s
Intégration / Python — qualité, tests et dépendances (pull_request) Successful in 2m58s
Intégration / Tableau de bord — dépendances, tests et construction (pull_request) Has been cancelled
e974508aac
La demande posait la question en relecture : le choix de forme des deux agrégats
mérite-t-il une fiche de décision ? Non, et la réponse existait déjà. L'ADR 0011,
introduite par le #34, se termine par « une agrégation pure, sans règle, n'a
aucune raison de quitter le SQL ». C'est exactement le cas de `mesure_horaire`
et de `charge_site` : ils ne portent aucune règle, ils résument.

La distinction est écrite en tête du fichier SQL : le *pourquoi en SQL* est
tracé par la fiche, la *forme* — agrégat continu plutôt que table — ne l'est pas
et n'a pas à l'être, puisqu'elle est réversible. Reste à la confirmer avec qui
portera le #38, ce que le point ouvert du §14 dit maintenant en renvoyant à la
fiche.

Au passage : le titre de la fiche portait encore « ADR 0010 », report de la
renumérotation du #34 — deux fiches s'appelaient 0010 alors que l'index en
distingue bien 0010 et 0011. Corrigé, puisque la migration la cite désormais par
son numéro.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Author
Member

Merci à tous les deux — la relecture a trouvé un vrai bloquant et trois vrais
défauts. Tout est traité, un commit par point, et la branche est à jour de
develop (la #123 est passée entre-temps, donc les cinq conflits add/add sont
résolus et la demande est de nouveau mergeable).

Point Commit
1 — deux paquets etl au même chemin c254f26 (merge)
2 — COLONNES_ARGENT a dérivé fc453cf
3 — l'EF-06 calculé deux fois 6aaed09
Critère 2 — le relevé de latence d62d462
La fiche de décision e974508

1. Bloquant — un seul paquet, etl/silver/ + etl/gold/

Résolu dans le sens que tu proposes, et que le #34 avait anticipé en sortant
config.py du sous-paquet : un seul etl/config.py, un seul pyproject.toml
réunissant les quatre dépendances (duckdb, minio, psycopg, pytz), mes
modules descendus dans etl/gold/. Les entrées deviennent
python -m etl.gold.<module> — lanceurs de cron, README et §12 suivis. Et
tests/unit/etl/ devient tests/unit/gold/, en regard de tests/unit/silver/.

Sur le plancher DuckDB, j'ai pris le tien vers le haut : >=1.5, la version
épinglée dans requirements-dev.txt (1.5.5). Deux planchers pour un seul moteur
installé se contredisent au premier pip install.

Le garde-fou sous deux noms : ENERVISION_ETL_TAUX_TROUS_MAXIMAL disparaît,
ENERVISION_ETL_TAUX_TROUS_MAX (le tien) sert les deux zones. Tu avais raison
sur la conséquence : un exploitant en aurait réglé une et se serait étonné que
l'autre job ne bouge pas.

Un détail de disposition : les champs propres à la zone or portent un défaut
dans Settings, pour que les doubles de tests/unit/silver/ continuent de
construire un Settings(...) à neuf champs sans rien savoir de la zone or.

Et un cas de plus, qui vient de ta première remarque : tu as constaté à la
main
que les trois énumérations de config.py étaient identiques à ce que la
zone argent produit. C'est maintenant un test — les constantes sont lues dans
silver.imputation et silver.mesure, jamais recopiées. Elles l'étaient déjà
contre la migration 0010 : la zone or est tendue entre ces deux bords, et un
régime ajouté d'un seul côté ferait refuser en bloc une journée d'argent
parfaitement valide.

2. La fabrique avait dérivé, et le docstring l'avait prédit

23 colonnes contre les 38 que silver.mesure écrit. Tu as raison sur le fond
comme sur le correctif : plus rien n'est recopié.

  • les colonnes et leurs types viennent de etl.silver.entrepot.SCHEMAS ;
  • l'écriture passe par PuitsDuckDB lui-même, dont seule la cible est
    détournée vers le disque — même CREATE TABLE, même ordre de colonnes, même
    COPY ;
  • l'indicateur quotidien est calculé par silver.qualite.disponibilite_du_jour ;
  • _verifier_couverture refuse une ligne qui s'écarte du schéma :
    PuitsDuckDB.ecrire lit par ligne.get(nom), donc sans ce garde-fou une
    colonne ajoutée en argent redeviendrait un NULL silencieux.

Un effet de bord que la dérive cachait, et qui valait la peine : l'ancienne
fabrique écrivait par PARTITION_BY (dt), ce qui retire dt du fichier.
La zone argent, elle, écrit l'objet à son chemin exact et garde dt dans le
fichier — donc la lecture de la zone or voyait dt deux fois en production et
jamais en test. DuckDB 1.5 résout ce doublon en faveur de la partition
(vérifié), donc rien n'était cassé, mais ce n'était pas vérifié. La fabrique
écrit maintenant comme toi.

Le référentiel des sept sites (type, capacité) est celui de la migration 0009,
et un cas le garde. La consommation du jeu d'essai vaut 60 % de la capacité
du site : les deux valeurs en dur — 120 kW pour SITE001, 600 pour SITE002 —
étaient déjà exactement cela, mais la règle tient pour les cinq autres, dont
SITE006 (180 kW) qu'un forfait de 600 kW aurait mis à 330 % de charge.

3. L'EF-06 — la zone argent le porte, la zone or le sert

Tranché dans le sens que ton §10 annonçait déjà : « la zone or le matérialisera
sous qualite_jour sans le recalculer ». gold.qualite lit
disponibilite_jour et le projette au schéma de la table :

taux_disponibilite  ← taux_de_disponibilite
releves_attendus    ← minutes_attendues
releves_manquants   ← minutes_attendues − mesurees − imputees

Les chiffres ne changent pas, et c'est vérifiable : en argent une minute non
imputable porte none, en or la même ligne porte critical. Les deux
définitions du « relevé disponible » coïncident exactement.

Le caveat est supprimé, pas conservé — tu avais raison. Un site muet arrive
maintenant à 0 %, et un cas le vérifie.

Ce qui reste calculé en or est la répartition par méthode, que la zone
argent ne publie pas ; ses totaux sont confrontés à l'indicateur par une
jointure externe des deux côtés. IndicateurDiscordant remplace
CadenceIncoherente et attrape la même faute (déduplication du §8.2 non jouée)
plus celles que l'ancien contrôle ne voyait pas : un site présent dans une seule
des deux tables, un indicateur calculé sur une autre journée.

Deux conséquences :

  • ENERVISION_ETL_RELEVES_ATTENDUS disparaît. Plus personne ne le lisait, et
    un réglage que rien ne lit est un piège : on le règle, et rien ne bouge. Le
    dénominateur vient de la seule zone qui sait combien de minutes la journée
    comptait. La fixture settings_journee disparaît avec lui.
  • CadenceIncoherente n'était pas attrapée par main() : un exploitant
    aurait eu une trace d'appels et le code 1, là où le README annonce 4. Corrigé.

Le §15 dit maintenant qui porte quoi : « calculé une seule fois en argent (§10),
matérialisé sans recalcul en or (§12) ».

Critère 2 — le relevé de latence

Tes trois raisons sont justes, et la troisième est la plus grave : je mesurais un
objet que l'API ne servira pas. Traitées dans cet ordre.

L'objet. Deux relevés sont maintenant exigés, un par vue du tableau de bord :
le parc à l'heure sur public.mesure_horaire (l'agrégat continu de la 0016), et
un site à la minute sur public.mesure. Le recalcul direct du time_bucket
reste mesuré et imprimé sans être exigé : l'écart entre lui et l'agrégat est
ce qui justifie la migration, et un lecteur du ticket doit pouvoir le vérifier
au lieu de me croire.

Le volume. Sept jours × sept sites = 70 560 lignes chargées par le fichier
lui-même, et le relevé imprime les deux comptes — le total de public.mesure et
la part qui tombe dans la fenêtre. Au passage, ta remarque en a révélé une
autre : la fenêtre now() - 24 h commence en milieu de journée d'hier, et je ne
chargeais qu'aujourd'hui. Je ne mesurais donc même pas 24 h, seulement les
heures écoulées depuis minuit.

La concurrence. Quatre lecteurs simultanés, chacun sur sa connexion, chacun
ses vingt exécutions, durées mises en commun. Quatre parce que le tableau de
bord a une poignée d'utilisateurs et que le serveur partage 8 Go — au-delà, on
mesurerait la machine.

Un défaut de la base attrapé en écrivant : refresh_continuous_aggregate ne
s'exécute pas dans une transaction, et psycopg en ouvre une dès le premier
ordre. Le cas de concordance de l'agrégat l'appelait sur la connexion
ordinaire : il aurait échoué à la première exécution contre un vrai PostgreSQL,
sur un message qui ne parle pas de transaction. Une connexion en autocommit
lui est dédiée.

Ce qui n'est toujours pas prouvé, et c'est la seule chose qui reste. Le
chiffre demande une base joignable avec les migrations 00080016 appliquées,
donc la #114. Ce que j'ai pu vérifier depuis ce poste est la chaîne de
préparation du jeu, jouée à blanc sans base : 70 560 lignes de zone argent
construites, projetées et contrôlées en 130 s, sept lignes de qualité par jour à
1 440 relevés attendus et 32 manquants. Qui peut me dire si les migrations
sont passées en préproduction ?
Je joue le relevé et je le colle ici dans
l'heure.

La fiche de décision

Tu as raison, et la citation est la bonne : « une agrégation pure, sans règle,
n'a aucune raison de quitter le SQL ». Pas de nouvelle fiche. L'ADR 0011 est
citée en tête de la 0016, avec la distinction qui reste utile : le pourquoi en
SQL
est tracé par la fiche, la forme — agrégat continu plutôt que table — ne
l'est pas et n'a pas à l'être puisqu'elle est réversible. Le point ouvert du §14
renvoie à la fiche et ne demande plus que la confirmation de la forme.

Un report de la renumérotation du #34 au passage : le titre de la fiche portait
encore « ADR 0010 », comme la fiche du tunnel SSH, alors que l'index en
distingue bien 0010 et 0011. Corrigé, puisque la migration la cite maintenant
par son numéro — dis-moi si tu préfères le porter toi-même.


@lenaic

Les cinq fichiers sont fusionnés et non choisis, comme tu le demandais : les
deux config.py n'en font qu'un (un seul Settings, un seul nom pour le
garde-fou de trous), les deux pyproject.toml réunissent les quatre
dépendances, et le §2 du document data était en plus dédoublonné — la fusion de
la #123 y avait laissé trois lignes en double.

Deux choses pour toi :

  1. Tes deux remarques mineures, je ne les trouve pas — ni dans les
    commentaires de la demande, ni en relecture attachée. Peux-tu les reposter ?
    Je préfère les traiter dans ce lot que les perdre.
  2. services/etl/etl/gold dans les zones sensibles de la chaîne ? Le
    sous-paquet est à 91 % aujourd'hui, donc au-dessus du palier de 85 %.
    Je ne touche pas à ci.yml sans ton avis : c'est ton fichier, et l'ADR 0011
    ne nomme que la zone argent.

Et une divergence que je laisse en dehors de ce lot parce qu'elle touche les deux
zones : sur une journée refusée, la zone argent rend 1 et la zone or 4.
Le README du service le dit noir sur blanc en attendant qu'un lot aligne les
deux — ça se fera bien quand les lignes de crontab existeront pour de vrai.


Preuve

$ ruff check services packages && ruff format --check services packages
All checks passed!
52 files already formatted

$ mypy --config-file etl/pyproject.toml etl        # strict, depuis services/etl/
Success: no issues found in 20 source files

$ pytest tests/unit -q
413 passed

$ pytest tests/integration/gold -q                 # la commande de vérification du ticket
8 passed, 9 skipped                                # les 9 sautés demandent ENERVISION_ETL_DATABASE_URL

$ coverage report --omit='*/.venv/*'
TOTAL                                    2002    226    89%     # palier global : 70 %
$ coverage report --include='<zones sensibles>'
TOTAL                                    1360    104    92%     # palier ciblé : 85 %
$ coverage report --include='services/etl/etl/gold/*,services/etl/etl/config.py'
TOTAL                                     460     40    91%     # pour information

Les 251 cas de tests/unit/{gold,silver,db} passent ensemble, ce qui est le
premier moment où les deux zones sont exercées dans la même passe.

Merci à tous les deux — la relecture a trouvé un vrai bloquant et trois vrais défauts. Tout est traité, **un commit par point**, et la branche est à jour de `develop` (la #123 est passée entre-temps, donc les cinq conflits add/add sont résolus et la demande est de nouveau `mergeable`). | Point | Commit | |---|---| | 1 — deux paquets `etl` au même chemin | `c254f26` (merge) | | 2 — `COLONNES_ARGENT` a dérivé | `fc453cf` | | 3 — l'EF-06 calculé deux fois | `6aaed09` | | Critère 2 — le relevé de latence | `d62d462` | | La fiche de décision | `e974508` | --- ## 1. Bloquant — un seul paquet, `etl/silver/` + `etl/gold/` Résolu dans le sens que tu proposes, et que le #34 avait anticipé en sortant `config.py` du sous-paquet : un seul `etl/config.py`, un seul `pyproject.toml` réunissant les quatre dépendances (`duckdb`, `minio`, `psycopg`, `pytz`), mes modules descendus dans `etl/gold/`. Les entrées deviennent `python -m etl.gold.<module>` — lanceurs de cron, README et §12 suivis. Et `tests/unit/etl/` devient `tests/unit/gold/`, en regard de `tests/unit/silver/`. Sur le plancher DuckDB, j'ai pris le tien vers le haut : `>=1.5`, la version épinglée dans `requirements-dev.txt` (1.5.5). Deux planchers pour un seul moteur installé se contredisent au premier `pip install`. **Le garde-fou sous deux noms** : `ENERVISION_ETL_TAUX_TROUS_MAXIMAL` disparaît, `ENERVISION_ETL_TAUX_TROUS_MAX` (le tien) sert les deux zones. Tu avais raison sur la conséquence : un exploitant en aurait réglé une et se serait étonné que l'autre job ne bouge pas. Un détail de disposition : les champs propres à la zone or portent un défaut dans `Settings`, pour que les doubles de `tests/unit/silver/` continuent de construire un `Settings(...)` à neuf champs sans rien savoir de la zone or. **Et un cas de plus, qui vient de ta première remarque** : tu as constaté *à la main* que les trois énumérations de `config.py` étaient identiques à ce que la zone argent produit. C'est maintenant un test — les constantes sont lues dans `silver.imputation` et `silver.mesure`, jamais recopiées. Elles l'étaient déjà contre la migration 0010 : la zone or est tendue entre ces deux bords, et un régime ajouté d'un seul côté ferait refuser **en bloc** une journée d'argent parfaitement valide. ## 2. La fabrique avait dérivé, et le docstring l'avait prédit 23 colonnes contre les 38 que `silver.mesure` écrit. Tu as raison sur le fond comme sur le correctif : plus rien n'est recopié. - les colonnes et leurs types viennent de `etl.silver.entrepot.SCHEMAS` ; - l'écriture passe par **`PuitsDuckDB` lui-même**, dont seule la cible est détournée vers le disque — même `CREATE TABLE`, même ordre de colonnes, même `COPY` ; - l'indicateur quotidien est calculé par `silver.qualite.disponibilite_du_jour` ; - `_verifier_couverture` refuse une ligne qui s'écarte du schéma : `PuitsDuckDB.ecrire` lit par `ligne.get(nom)`, donc sans ce garde-fou une colonne ajoutée en argent redeviendrait un `NULL` silencieux. **Un effet de bord que la dérive cachait**, et qui valait la peine : l'ancienne fabrique écrivait par `PARTITION_BY (dt)`, ce qui **retire** `dt` du fichier. La zone argent, elle, écrit l'objet à son chemin exact et garde `dt` **dans** le fichier — donc la lecture de la zone or voyait `dt` deux fois en production et jamais en test. DuckDB 1.5 résout ce doublon en faveur de la partition (vérifié), donc rien n'était cassé, mais ce n'était pas vérifié. La fabrique écrit maintenant comme toi. Le référentiel des sept sites (type, capacité) est celui de la migration 0009, et un cas le garde. La consommation du jeu d'essai vaut 60 % de la capacité **du site** : les deux valeurs en dur — 120 kW pour SITE001, 600 pour SITE002 — étaient déjà exactement cela, mais la règle tient pour les cinq autres, dont SITE006 (180 kW) qu'un forfait de 600 kW aurait mis à 330 % de charge. ## 3. L'EF-06 — la zone argent le porte, la zone or le sert Tranché dans le sens que ton §10 annonçait déjà : « la zone or le matérialisera sous `qualite_jour` sans le recalculer ». `gold.qualite` lit `disponibilite_jour` et le projette au schéma de la table : taux_disponibilite ← taux_de_disponibilite releves_attendus ← minutes_attendues releves_manquants ← minutes_attendues − mesurees − imputees Les chiffres ne changent pas, et c'est vérifiable : en argent une minute non imputable porte `none`, en or la même ligne porte `critical`. Les deux définitions du « relevé disponible » coïncident exactement. **Le caveat est supprimé, pas conservé** — tu avais raison. Un site muet arrive maintenant à 0 %, et un cas le vérifie. **Ce qui reste calculé en or** est la répartition par méthode, que la zone argent ne publie pas ; ses totaux sont **confrontés** à l'indicateur par une jointure externe des deux côtés. `IndicateurDiscordant` remplace `CadenceIncoherente` et attrape la même faute (déduplication du §8.2 non jouée) plus celles que l'ancien contrôle ne voyait pas : un site présent dans une seule des deux tables, un indicateur calculé sur une autre journée. Deux conséquences : - **`ENERVISION_ETL_RELEVES_ATTENDUS` disparaît.** Plus personne ne le lisait, et un réglage que rien ne lit est un piège : on le règle, et rien ne bouge. Le dénominateur vient de la seule zone qui sait combien de minutes la journée comptait. La fixture `settings_journee` disparaît avec lui. - **`CadenceIncoherente` n'était pas attrapée par `main()`** : un exploitant aurait eu une trace d'appels et le code 1, là où le README annonce 4. Corrigé. Le §15 dit maintenant qui porte quoi : « calculé une seule fois en argent (§10), matérialisé sans recalcul en or (§12) ». ## Critère 2 — le relevé de latence Tes trois raisons sont justes, et la troisième est la plus grave : je mesurais un objet que l'API ne servira pas. Traitées dans cet ordre. **L'objet.** Deux relevés sont maintenant exigés, un par vue du tableau de bord : le parc à l'heure sur `public.mesure_horaire` (l'agrégat continu de la 0016), et un site à la minute sur `public.mesure`. Le recalcul direct du `time_bucket` reste mesuré et imprimé **sans être exigé** : l'écart entre lui et l'agrégat est ce qui justifie la migration, et un lecteur du ticket doit pouvoir le vérifier au lieu de me croire. **Le volume.** Sept jours × sept sites = 70 560 lignes chargées par le fichier lui-même, et le relevé imprime les deux comptes — le total de `public.mesure` et la part qui tombe dans la fenêtre. Au passage, ta remarque en a révélé une autre : la fenêtre `now() - 24 h` commence en milieu de journée d'hier, et je ne chargeais qu'aujourd'hui. Je ne mesurais donc même pas 24 h, seulement les heures écoulées depuis minuit. **La concurrence.** Quatre lecteurs simultanés, chacun sur sa connexion, chacun ses vingt exécutions, durées mises en commun. Quatre parce que le tableau de bord a une poignée d'utilisateurs et que le serveur partage 8 Go — au-delà, on mesurerait la machine. **Un défaut de la base attrapé en écrivant** : `refresh_continuous_aggregate` ne s'exécute **pas** dans une transaction, et psycopg en ouvre une dès le premier ordre. Le cas de concordance de l'agrégat l'appelait sur la connexion ordinaire : il aurait échoué à la première exécution contre un vrai PostgreSQL, sur un message qui ne parle pas de transaction. Une connexion en `autocommit` lui est dédiée. **Ce qui n'est toujours pas prouvé, et c'est la seule chose qui reste.** Le chiffre demande une base joignable avec les migrations `0008`–`0016` appliquées, donc la #114. Ce que j'ai pu vérifier depuis ce poste est la chaîne de préparation du jeu, jouée à blanc sans base : 70 560 lignes de zone argent construites, projetées et contrôlées en 130 s, sept lignes de qualité par jour à 1 440 relevés attendus et 32 manquants. **Qui peut me dire si les migrations sont passées en préproduction ?** Je joue le relevé et je le colle ici dans l'heure. ## La fiche de décision Tu as raison, et la citation est la bonne : « une agrégation pure, sans règle, n'a aucune raison de quitter le SQL ». Pas de nouvelle fiche. L'ADR 0011 est citée en tête de la 0016, avec la distinction qui reste utile : le *pourquoi en SQL* est tracé par la fiche, la *forme* — agrégat continu plutôt que table — ne l'est pas et n'a pas à l'être puisqu'elle est réversible. Le point ouvert du §14 renvoie à la fiche et ne demande plus que la confirmation de la forme. Un report de la renumérotation du #34 au passage : le titre de la fiche portait encore « ADR 0010 », comme la fiche du tunnel SSH, alors que l'index en distingue bien 0010 et 0011. Corrigé, puisque la migration la cite maintenant par son numéro — dis-moi si tu préfères le porter toi-même. --- ## @lenaic Les cinq fichiers sont **fusionnés et non choisis**, comme tu le demandais : les deux `config.py` n'en font qu'un (un seul `Settings`, un seul nom pour le garde-fou de trous), les deux `pyproject.toml` réunissent les quatre dépendances, et le §2 du document data était en plus dédoublonné — la fusion de la #123 y avait laissé trois lignes en double. Deux choses pour toi : 1. **Tes deux remarques mineures, je ne les trouve pas** — ni dans les commentaires de la demande, ni en relecture attachée. Peux-tu les reposter ? Je préfère les traiter dans ce lot que les perdre. 2. **`services/etl/etl/gold` dans les zones sensibles de la chaîne ?** Le sous-paquet est à **91 %** aujourd'hui, donc au-dessus du palier de 85 %. Je ne touche pas à `ci.yml` sans ton avis : c'est ton fichier, et l'ADR 0011 ne nomme que la zone argent. Et une divergence que je laisse en dehors de ce lot parce qu'elle touche les deux zones : sur une journée refusée, la zone argent rend **1** et la zone or **4**. Le README du service le dit noir sur blanc en attendant qu'un lot aligne les deux — ça se fera bien quand les lignes de crontab existeront pour de vrai. --- ## Preuve ``` $ ruff check services packages && ruff format --check services packages All checks passed! 52 files already formatted $ mypy --config-file etl/pyproject.toml etl # strict, depuis services/etl/ Success: no issues found in 20 source files $ pytest tests/unit -q 413 passed $ pytest tests/integration/gold -q # la commande de vérification du ticket 8 passed, 9 skipped # les 9 sautés demandent ENERVISION_ETL_DATABASE_URL $ coverage report --omit='*/.venv/*' TOTAL 2002 226 89% # palier global : 70 % $ coverage report --include='<zones sensibles>' TOTAL 1360 104 92% # palier ciblé : 85 % $ coverage report --include='services/etl/etl/gold/*,services/etl/etl/config.py' TOTAL 460 40 91% # pour information ``` Les 251 cas de `tests/unit/{gold,silver,db}` passent ensemble, ce qui est le premier moment où les deux zones sont exercées dans la même passe.
Deux lots de la zone argent sont passés pendant la relecture, et les deux
touchent ce que la zone or lit :

- **#135** — la grille de la journée en cours s'arrête aux minutes révolues, et
  la passe de minuit clôt la veille. `disponibilite_jour` gagne une colonne
  `journee_complete` ;
- **#136** — la transformation vers la zone argent est planifiée par le rôle
  Ansible `app`, dont la liste de tâches sait désormais porter un service autre
  que le collecteur.

Sans conflit, et la fabrique de zone argent a absorbé la nouvelle colonne sans
qu'une ligne de test change : c'est exactement ce que la dérivation du schéma
(commit `fc453cf`) devait produire. Les 442 cas unitaires passent.

Le raccordement des deux zones dans le temps, lui, demande un correctif — voir
le commit suivant.
etl: la zone or prend la journée que la zone argent a écrite, pas « aujourd'hui » (#35)
Some checks failed
Intégration / Contrôles statiques du dépôt (pull_request) Successful in 4s
Intégration / Aucun secret commité (pull_request) Successful in 3s
Intégration / Python — qualité, tests et dépendances (pull_request) Successful in 2m55s
Intégration / Tableau de bord — dépendances, tests et construction (pull_request) Failing after 7m17s
dcc76db10f
Le #135 a changé la façon dont la zone argent choisit sa journée : sa grille
s'arrête aux minutes révolues, et sa passe de minuit **clôt la veille** au lieu
d'ouvrir le jour. Mes lanceurs, eux, passaient `date -u +%F`. Le raccordement
des deux zones se serait donc trompé deux fois par nuit :

- **à 00 h 17, la partition du jour n'existe pas encore.** La zone argent ne
  l'écrira qu'à 01 h 00 : `gold_daily` sortirait en « partition absente »
  chaque nuit, code 3, dans un journal que personne ne lit tant qu'un tableau
  de bord n'est pas vide ;
- **la veille ne serait jamais reprise complète.** La dernière passe portant sa
  date a tourné à 23 h 17, sur une grille arrêtée à 22 h 59. La dernière heure
  de chaque journée n'atteindrait jamais la zone or, et `qualite_jour` garderait
  pour toujours un `releves_attendus` de 1 380 — un chiffre plausible, donc
  invisible. Un point de surveillance est ajouté au §13 pour ce cas précis.

`etl/gold/jour.py` porte la règle pour les quatre entrées : la journée demandée,
sinon la dernière que la zone argent a écrite. Elle n'est pas recopiée de la
zone argent, elle lui est **demandée** — `silver.grille.minutes_attendues` est
la seule à savoir à quelle heure une journée n'a encore aucune minute révolue,
et si ce seuil change la zone or suit.

En Python et non dans les lanceurs, pour la raison qui a fait sortir la même
règle du shell côté zone argent : une règle qui décide d'une journée mérite des
cas d'essai, et `tests/unit/gold/test_jour.py` en donne six à horloge fixe,
minuit et fuseau local compris. `--jour` devient donc facultatif sur les quatre
entrées, et les lanceurs ne calculent plus de date.

**Les deux lanceurs adoptent aussi le garde-fou du #136** : le python du venv
par `ENERVISION_PYTHON`, et un contrôle des clients avant de lancer le job.
`gold-daily` veut DuckDB, `load-postgres` DuckDB et psycopg — tous deux importés
tardivement pour que les tests tournent sans eux, donc leur absence ressortait
en trace brute au milieu du job, avec le code 1. C'est le mode de panne déjà
relevé sur le collecteur (#114) et sur la zone argent (#136).

Enfin, ce que la journée en cours donne dans `qualite_jour` est écrit au §12 :
`releves_attendus` vaut les minutes écoulées, et 1 440 après la passe de minuit.
Le drapeau `journee_complete` que la zone argent publie n'est pas repris en base
— la migration 0013 n'a pas la colonne, et `releves_attendus < 1440` le dit
déjà. À trancher avec qui portera le #38 : ce serait une 0017, pas ce lot.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Author
Member

Deuxième tour, mais pas de relecture : develop a bougé sous la branche

Les #142 et #143 (tes #135 et #136, @lenaic) sont passées pendant que je
traitais les retours. Les deux touchent ce que la zone or lit, donc j'ai
refusionné et corrigé. Deux commits : 958bd39 (merge) et dcc76db.

Une bonne nouvelle d'abord : ta colonne journee_complete est arrivée dans
disponibilite_jour et aucune ligne de test n'a eu à changer. La fabrique
dérive du schéma que silver.entrepot déclare depuis fc453cf — c'est
exactement ce que le point 2 de @justine devait produire, et c'est la première
fois que ça se vérifie sur un vrai changement de schéma.

Le raccordement des deux zones dans le temps était cassé

Pas par ton lot, par le mien : mes lanceurs passaient date -u +%F. Avec la
règle du #135, la chaîne se serait trompée deux fois par nuit :

  • à 00 h 17, la partition du jour n'existe pas encore. Ta passe de minuit
    clôt la veille, et la journée du jour n'est écrite qu'à 01 h 00 : gold_daily
    sortait en « partition absente », chaque nuit, code 3 — dans un journal que
    personne ne lit tant qu'un tableau de bord n'est pas vide ;
  • la veille n'était jamais reprise complète. La dernière passe portant sa
    date tournait à 23 h 17, sur une grille arrêtée à 22 h 59. La dernière heure
    de chaque journée n'aurait jamais atteint la zone or, et qualite_jour
    garderait pour toujours releves_attendus = 1380. Plausible, donc invisible.

etl/gold/jour.py porte maintenant la règle pour les quatre entrées, et elle
t'est demandée plutôt que recopiée : c'est silver.grille.minutes_attendues
qui sait à quelle heure une journée n'a aucune minute révolue. Si tu déplaces ce
seuil, la zone or suit. --jour devient facultatif, les lanceurs ne calculent
plus de date, et six cas à horloge fixe gardent la règle — minuit et fuseau
local compris.

Tes deux garde-fous du #136 sont adoptés dans gold-daily.sh et
load-postgres.sh : ENERVISION_PYTHON, et le contrôle des clients avant de
lancer. gold-daily veut DuckDB, load-postgres DuckDB et psycopg — même mode
de panne que celui que tu as relevé, l'import tardif qui laisse le module
s'importer et casse au milieu du job.

Trois choses pour toi, @lenaic

  1. Les deux lignes de crontab de la zone or. Ta liste taches_planifiees
    sait porter un autre service depuis le #136 — c'est toi qui as sorti
    services/collector du chemin en dur. Il n'y manque que deux entrées,
    gold-daily.sh à la minute 17 et load-postgres.sh à la 27. Je ne les ai
    pas ajoutées
    : tu travailles dans ce rôle en ce moment et je préfère un
    conflit en moins. Dis-moi si tu les prends ou si je pousse les deux lignes.
  2. La chaîne est rouge pour une raison qui n'est pas la mienne. Sur
    e974508, « Python — qualité, tests et dépendances » passe en 2 min 58, et
    « Tableau de bord » a été annulé à 09:04:05 — à la seconde où les
    exécutions de la #121 démarraient. Les deux premières tâches de la #121 ont
    été annulées de la même façon. Hypothèse : concurrency.group vaut
    ${{ github.workflow }}-${{ github.ref }}, et github.ref ne distingue pas
    deux demandes de fusion sur un événement pull_request — auquel cas toutes
    les demandes partagent un seul groupe et s'annulent entre elles avec
    cancel-in-progress: true. À vérifier de ton côté, je n'ai pas touché à
    ci.yml.
  3. Et tes deux remarques mineures, toujours introuvables dans le fil.

Pour @justine

Une conséquence de ton point 3 que ton #135 rend visible : puisque le
dénominateur vient de chez toi, qualite_jour.releves_attendus vaut les minutes
écoulées tant que la journée n'est pas révolue, et 1 440 après la passe de
minuit. Je ne reprends pas journee_complete en base — la migration 0013
n'a pas la colonne, et releves_attendus < 1440 dit la même chose. C'est écrit
au §12, et c'est à trancher avec @marvin s'il préfère un drapeau explicite à une
comparaison : ce serait une 0017, pas ce lot.

Preuve, rejouée sur dcc76db

$ ruff check services packages && ruff format --check services packages
All checks passed! / 53 files already formatted

$ mypy --config-file etl/pyproject.toml etl        # strict, depuis services/etl/
Success: no issues found in 21 source files

$ pytest tests/unit -q
442 passed

$ pytest tests/integration -q
8 passed, 45 skipped
## Deuxième tour, mais pas de relecture : `develop` a bougé sous la branche Les #142 et #143 (tes #135 et #136, @lenaic) sont passées pendant que je traitais les retours. Les deux touchent ce que la zone or lit, donc j'ai refusionné et corrigé. Deux commits : `958bd39` (merge) et `dcc76db`. **Une bonne nouvelle d'abord** : ta colonne `journee_complete` est arrivée dans `disponibilite_jour` et **aucune ligne de test n'a eu à changer**. La fabrique dérive du schéma que `silver.entrepot` déclare depuis `fc453cf` — c'est exactement ce que le point 2 de @justine devait produire, et c'est la première fois que ça se vérifie sur un vrai changement de schéma. ### Le raccordement des deux zones dans le temps était cassé Pas par ton lot, par le mien : mes lanceurs passaient `date -u +%F`. Avec la règle du #135, la chaîne se serait trompée **deux fois par nuit** : - **à 00 h 17, la partition du jour n'existe pas encore.** Ta passe de minuit clôt la veille, et la journée du jour n'est écrite qu'à 01 h 00 : `gold_daily` sortait en « partition absente », chaque nuit, code 3 — dans un journal que personne ne lit tant qu'un tableau de bord n'est pas vide ; - **la veille n'était jamais reprise complète.** La dernière passe portant sa date tournait à 23 h 17, sur une grille arrêtée à 22 h 59. La dernière heure de chaque journée n'aurait jamais atteint la zone or, et `qualite_jour` garderait pour toujours `releves_attendus = 1380`. Plausible, donc invisible. `etl/gold/jour.py` porte maintenant la règle pour les quatre entrées, et elle **t'est demandée** plutôt que recopiée : c'est `silver.grille.minutes_attendues` qui sait à quelle heure une journée n'a aucune minute révolue. Si tu déplaces ce seuil, la zone or suit. `--jour` devient facultatif, les lanceurs ne calculent plus de date, et six cas à horloge fixe gardent la règle — minuit et fuseau local compris. Tes deux garde-fous du #136 sont adoptés dans `gold-daily.sh` et `load-postgres.sh` : `ENERVISION_PYTHON`, et le contrôle des clients avant de lancer. `gold-daily` veut DuckDB, `load-postgres` DuckDB et psycopg — même mode de panne que celui que tu as relevé, l'import tardif qui laisse le module s'importer et casse au milieu du job. ### Trois choses pour toi, @lenaic 1. **Les deux lignes de crontab de la zone or.** Ta liste `taches_planifiees` sait porter un autre service depuis le #136 — c'est toi qui as sorti `services/collector` du chemin en dur. Il n'y manque que deux entrées, `gold-daily.sh` à la minute 17 et `load-postgres.sh` à la 27. **Je ne les ai pas ajoutées** : tu travailles dans ce rôle en ce moment et je préfère un conflit en moins. Dis-moi si tu les prends ou si je pousse les deux lignes. 2. **La chaîne est rouge pour une raison qui n'est pas la mienne.** Sur `e974508`, « Python — qualité, tests et dépendances » passe en 2 min 58, et « Tableau de bord » a été **annulé** à 09:04:05 — à la seconde où les exécutions de la #121 démarraient. Les deux premières tâches de la #121 ont été annulées de la même façon. Hypothèse : `concurrency.group` vaut `${{ github.workflow }}-${{ github.ref }}`, et `github.ref` ne distingue pas deux demandes de fusion sur un événement `pull_request` — auquel cas toutes les demandes partagent un seul groupe et s'annulent entre elles avec `cancel-in-progress: true`. À vérifier de ton côté, je n'ai pas touché à `ci.yml`. 3. Et **tes deux remarques mineures**, toujours introuvables dans le fil. ### Pour @justine Une conséquence de ton point 3 que ton #135 rend visible : puisque le dénominateur vient de chez toi, `qualite_jour.releves_attendus` vaut les minutes écoulées tant que la journée n'est pas révolue, et 1 440 après la passe de minuit. Je **ne reprends pas** `journee_complete` en base — la migration 0013 n'a pas la colonne, et `releves_attendus < 1440` dit la même chose. C'est écrit au §12, et c'est à trancher avec @marvin s'il préfère un drapeau explicite à une comparaison : ce serait une 0017, pas ce lot. ### Preuve, rejouée sur `dcc76db` ``` $ ruff check services packages && ruff format --check services packages All checks passed! / 53 files already formatted $ mypy --config-file etl/pyproject.toml etl # strict, depuis services/etl/ Success: no issues found in 21 source files $ pytest tests/unit -q 442 passed $ pytest tests/integration -q 8 passed, 45 skipped ```
Author
Member

La chaîne sur dcc76db : le job « Tableau de bord » échoue, et ce n'est pas ce lot

Contrôles statiques du dépôt                     4 s     ✓
Aucun secret commité                             3 s     ✓
Python — qualité, tests et dépendances        2 min 55   ✓
Tableau de bord — dépendances, tests, build   7 min 17   ✗

Ce que je peux affirmer, et sur quoi je m'appuie :

L'arbre du tableau de bord est identique, au bit près, à celui de develop.
Le même objet git sur les trois références — mon sommet, develop (eb12df2),
et le develop sur lequel ce même job a réussi il y a une heure
(d043bbe) :

$ git rev-parse dcc76db:services/dashboard eb12df2:services/dashboard d043bbe:services/dashboard
c999ca840c3577dfc6fb665c466529f38fef88bb
c999ca840c3577dfc6fb665c466529f38fef88bb
c999ca840c3577dfc6fb665c466529f38fef88bb

Mon lot ne touche ni .forgejo/, ni services/dashboard, ni un
package-lock.json. Les seuls fichiers que ce job lit et que je pourrais
influencer sont le workflow et le CA épinglé, intacts tous les deux.

Et tout ce que ce job fait passe sur mon poste, sur ce même sommet :

$ npm audit --audit-level=high      found 0 vulnerabilities
$ npm run test:unit                 5 fichiers, 42 cas, tous verts
$ npm run build                     ✓ built in 74 ms
$ openssl x509 -in .forgejo/certs/caddy-local-authority-root.pem -checkend 0
                                    valide jusqu'au 9 juillet 2036

Restent, dans le conteneur et hors de ma portée : npm ci contre le registre,
le cache npm de actions/cache, ou npm sbom. Les 7 min 17 avant l'échec
pointent vers l'installation plutôt que vers les tests, qui prennent moins d'une
seconde.

Ce que je ne peux pas faire : lire le journal du job. L'API de la forge
n'expose pas les journaux d'exécution dans cette version (404 sur
actions/jobs/{id}/logs, actions/runs/{id}/jobs et
actions/tasks/{id}/logs) — il faut l'interface web.

@lenaic, deux demandes, dans l'ordre :

  1. Un coup d'œil au journal de l'exécution 273, job « Tableau de bord ». Si
    c'est bien npm ci, une relance suffit ; si c'est le cache, la clé est
    npm-${{ hashFiles('services/dashboard/package-lock.json') }} et elle est
    partagée par toutes les branches.
  2. Une relance du job, que je ne peux pas déclencher sans pousser un commit
    vide — ce que je préfère éviter sur une branche en relecture.

Rappel de l'exécution précédente (e974508), pour le cas où les deux seraient
liées : ce même job avait été annulé à la seconde où les exécutions de la
#121 démarraient, en même temps que les deux premières tâches de la #121
elles-mêmes. L'hypothèse du groupe de concurrence partagé entre demandes de
fusion tient toujours, et elle expliquerait qu'un job soit tué au milieu d'un
npm ci — ce qui laisse un cache à moitié écrit.

### La chaîne sur `dcc76db` : le job « Tableau de bord » échoue, et ce n'est pas ce lot ``` Contrôles statiques du dépôt 4 s ✓ Aucun secret commité 3 s ✓ Python — qualité, tests et dépendances 2 min 55 ✓ Tableau de bord — dépendances, tests, build 7 min 17 ✗ ``` Ce que je peux affirmer, et sur quoi je m'appuie : **L'arbre du tableau de bord est identique, au bit près, à celui de `develop`.** Le même objet git sur les trois références — mon sommet, `develop` (`eb12df2`), et le `develop` sur lequel ce même job a **réussi** il y a une heure (`d043bbe`) : ``` $ git rev-parse dcc76db:services/dashboard eb12df2:services/dashboard d043bbe:services/dashboard c999ca840c3577dfc6fb665c466529f38fef88bb c999ca840c3577dfc6fb665c466529f38fef88bb c999ca840c3577dfc6fb665c466529f38fef88bb ``` Mon lot ne touche ni `.forgejo/`, ni `services/dashboard`, ni un `package-lock.json`. Les seuls fichiers que ce job lit et que je pourrais influencer sont le workflow et le CA épinglé, intacts tous les deux. **Et tout ce que ce job fait passe sur mon poste**, sur ce même sommet : ``` $ npm audit --audit-level=high found 0 vulnerabilities $ npm run test:unit 5 fichiers, 42 cas, tous verts $ npm run build ✓ built in 74 ms $ openssl x509 -in .forgejo/certs/caddy-local-authority-root.pem -checkend 0 valide jusqu'au 9 juillet 2036 ``` Restent, dans le conteneur et hors de ma portée : `npm ci` contre le registre, le cache npm de `actions/cache`, ou `npm sbom`. Les 7 min 17 avant l'échec pointent vers l'installation plutôt que vers les tests, qui prennent moins d'une seconde. **Ce que je ne peux pas faire** : lire le journal du job. L'API de la forge n'expose pas les journaux d'exécution dans cette version (404 sur `actions/jobs/{id}/logs`, `actions/runs/{id}/jobs` et `actions/tasks/{id}/logs`) — il faut l'interface web. @lenaic, deux demandes, dans l'ordre : 1. **Un coup d'œil au journal** de l'exécution 273, job « Tableau de bord ». Si c'est bien `npm ci`, une relance suffit ; si c'est le cache, la clé est `npm-${{ hashFiles('services/dashboard/package-lock.json') }}` et elle est partagée par toutes les branches. 2. **Une relance du job**, que je ne peux pas déclencher sans pousser un commit vide — ce que je préfère éviter sur une branche en relecture. Rappel de l'exécution précédente (`e974508`), pour le cas où les deux seraient liées : ce même job avait été **annulé** à la seconde où les exécutions de la #121 démarraient, en même temps que les deux premières tâches de la #121 elles-mêmes. L'hypothèse du groupe de concurrence partagé entre demandes de fusion tient toujours, et elle expliquerait qu'un job soit tué au milieu d'un `npm ci` — ce qui laisse un cache à moitié écrit.
Author
Member

Correction : c'est le #148, et il est déjà ouvert

J'annule les deux demandes de mon message précédent, @lenaic — inutile d'aller
lire le journal, le diagnostic existe. Le #148 l'a fait mieux que moi : ce
n'est pas npm ci mais npm audit, non borné, fetch-timeout à 300 000 ms
et deux réessais. Les échecs s'y massent à 433–435 s.

Mon échec fait 437 s (7 min 17). Même signature, à la seconde près.

Deux conséquences pour cette demande :

  • le rouge n'est pas le mien, et il n'est pas propre à cette branche : le
    #148 relève que develop est rouge sur ce même job (exécution #240, échec à
    433 s). L'arbre services/dashboard de mon sommet est l'objet git identique à
    celui de develop, donc les deux branches échouent pour la même raison, au
    même endroit ;
  • le job qui juge ce lot passe : « Python — qualité, tests et dépendances »,
    2 min 55, et il porte le ruff, le mypy strict, les 442 cas unitaires et les
    deux paliers de couverture.

Je ne relance donc rien et je ne touche à rien de la chaîne : le #148 est à toi,
et une relance ne ferait que retirer un ticket à sa file. La relecture de cette
demande peut se faire sur le job Python.

### Correction : c'est le #148, et il est déjà ouvert J'annule les deux demandes de mon message précédent, @lenaic — inutile d'aller lire le journal, le diagnostic existe. Le **#148** l'a fait mieux que moi : ce n'est pas `npm ci` mais **`npm audit`, non borné**, `fetch-timeout` à 300 000 ms et deux réessais. Les échecs s'y massent à 433–435 s. Mon échec fait **437 s** (7 min 17). Même signature, à la seconde près. Deux conséquences pour cette demande : - **le rouge n'est pas le mien, et il n'est pas propre à cette branche** : le #148 relève que `develop` est rouge sur ce même job (exécution #240, échec à 433 s). L'arbre `services/dashboard` de mon sommet est l'objet git identique à celui de `develop`, donc les deux branches échouent pour la même raison, au même endroit ; - **le job qui juge ce lot passe** : « Python — qualité, tests et dépendances », 2 min 55, et il porte le ruff, le mypy strict, les 442 cas unitaires et les deux paliers de couverture. Je ne relance donc rien et je ne touche à rien de la chaîne : le #148 est à toi, et une relance ne ferait que retirer un ticket à sa file. La relecture de cette demande peut se faire sur le job Python.
justine approved these changes 2026-09-04 11:40:06 +00:00
Merge branch 'develop' into olivier/35-etl-zone-or
All checks were successful
Intégration / Contrôles statiques du dépôt (pull_request) Successful in 4s
Intégration / Aucun secret commité (pull_request) Successful in 3s
Intégration / Tableau de bord — dépendances, tests et construction (pull_request) Successful in 23s
Intégration / Python — qualité, tests et dépendances (pull_request) Successful in 2m54s
57bfa491eb
justine merged commit 0c5df7d7e1 into develop 2026-09-04 11:45:02 +00:00
justine deleted branch olivier/35-etl-zone-or 2026-09-04 11:45:02 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
3 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
g2/enervision!124
No description provided.