[EF-12] Agrégation et zone or matérialisée #35

Closed
opened 2026-09-01 10:35:25 +00:00 by lenaic · 1 comment
Owner

Exigence couverte

EF-12, ENF-04, ENF-07

Épreuve servie

EC05 · Data, ETL et BI

Charge estimée

2,5 j.h

Ce qu'on veut obtenir

Matérialiser la zone or dans PostgreSQL et TimescaleDB, seule couche que l'API et Grafana liront, pour tenir la cible de latence sans interroger MinIO en ligne.

Critères d'acceptation

  • La zone or est écrite dans les hypertables, alimentée depuis la zone argent.
  • Une série de 24 heures se lit sous 400 ms au 95e centile.
  • Un export agrégé pour le reporting est produit, reconstituable à la mesure près.
  • Un contrôle sur échantillon tiré au hasard remonte de la valeur or jusqu'à la lecture bronze d'origine.

Comment on le vérifie

Tests tests/integration/gold/
Commande pytest tests/integration/gold, puis la mesure de latence sous charge légère
Preuve le chiffre de latence au 95e centile et la trace de remontée d'un échantillon

Hors périmètre

Les écrans qui consomment ces données. Le moteur de recommandations.

### Exigence couverte EF-12, ENF-04, ENF-07 ### Épreuve servie EC05 · Data, ETL et BI ### Charge estimée 2,5 j.h ### Ce qu'on veut obtenir Matérialiser la zone or dans PostgreSQL et TimescaleDB, seule couche que l'API et Grafana liront, pour tenir la cible de latence sans interroger MinIO en ligne. ### Critères d'acceptation - [ ] La zone or est écrite dans les hypertables, alimentée depuis la zone argent. - [ ] Une série de 24 heures se lit sous 400 ms au 95e centile. - [ ] Un export agrégé pour le reporting est produit, reconstituable à la mesure près. - [ ] Un contrôle sur échantillon tiré au hasard remonte de la valeur or jusqu'à la lecture bronze d'origine. ### Comment on le vérifie Tests tests/integration/gold/ Commande pytest tests/integration/gold, puis la mesure de latence sous charge légère Preuve le chiffre de latence au 95e centile et la trace de remontée d'un échantillon ### Hors périmètre Les écrans qui consomment ces données. Le moteur de recommandations.
florian added this to the EnerVision project 2026-09-01 12:09:56 +00:00
olivier self-assigned this 2026-09-03 09:55:30 +00:00
gabriel added the due date 2026-09-09 2026-09-03 12:40:58 +00:00
Member

État : la chaîne de la zone or est écrite et testée, la demande de fusion #124 est ouverte, deux critères attendent la base et le #34

Un service services/etl/ et une migration 0016. Branche olivier/35-etl-zone-or, chaîne d'intégration verte sur les sept tâches.

Quatre entrées, qui suivent l'ordonnancement du §13 de docs/data/etl-pipeline.md :

Commande Ce qu'elle fait
python -m etl.agregation --jour AAAA-MM-JJ zone argent → zone or dans MinIO, qualite_jour comprise
python -m etl.chargement --jour AAAA-MM-JJ zone or → public.mesure et public.qualite_jour, en upsert
python -m etl.export --jour AAAA-MM-JJ --vers DOSSIER --controler export de reporting horaire, et sa reconstitution
python -m etl.lignage --jour AAAA-MM-JJ --taille 20 remonte un échantillon jusqu'à l'objet bronze

Les critères, un par un

  • La zone or est écrite dans les hypertables, alimentée depuis la zone argent. Le code est écrit et testé, mais sur des fixtures de zone argent : le #34 n'a pas démarré. Le job lit le schéma du §10, qui est figé, donc il tournera sur une zone argent réelle sans qu'une ligne change ici. Le chargement en base est couvert par huit cas d'intégration qui attendent une base joignable — voir ci-dessous.

  • Une série de 24 heures se lit sous 400 ms au 95e centile. Mesuré au #29 à 4,09 ms au p95 sur 352 807 lignes, marge d'un facteur 100. Le cas qui refait la mesure sur la zone or chargée est écrit (test_une_fenetre_de_24_heures_se_lit_sous_400_ms_au_95e_centile, vingt exécutions, relevé écrit dans un fichier) et se saute faute de base. À rejouer et coller ici.

  • Un export agrégé pour le reporting est produit, reconstituable à la mesure près. Trois choses le rendent reconstituable, et aucune n'est une affirmation de document : les compteurs releves_pris_en_compte et releves_exclus voyagent sur chaque ligne — sans eux personne ne peut refaire la moyenne ; la règle d'agrégation est écrite dans un manifeste JSON à côté du CSV, avec son empreinte SHA-256 ; et reconstituer() refait le calcul depuis la zone or et compare ligne à ligne. Deux cas vérifient qu'il échoue — un chiffre retouché à la main, une ligne disparue — parce qu'un contrôle qui ne peut pas échouer ne prouve rien.

  • Un contrôle sur échantillon tiré au hasard remonte de la valeur or jusqu'à la lecture bronze d'origine. etl.lignage, avec les deux formes d'objet bronze : un objet par minute pour /current, un objet comprimé par fenêtre pour /readings (#107) — un contrôle qui ne saurait remonter que le premier laisserait tout l'historique rattrapé hors de portée. Le cas nominal qui compte le plus est celui de la ligne imputée : brute nulle en or, valeur nulle en bronze, et concordance — parce que ce qui est comparé est la valeur brute et non la valeur retenue. Comparer la retenue rendrait toute imputation suspecte.

Ce que la mise en place a révélé, et qui vaut plus que le ticket

1. Le §12 et la base ne parlaient pas de la même chose. Le document 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 : « la zone or est écrite dans les hypertables » n'avait pas de cible. La migration 0016 les pose, mesure_horaire en agrégat continu TimescaleDB et charge_site en vue. L'alternative — deux tables chargées par le job — est écartée en tête du fichier : un agrégat rechargé en même temps que la série peut la contredire le temps d'une transaction, et il faut écrire le code qui le rejoue. Le choix est réversible : les deux objets se suppriment sans rien perdre, public.mesure restant la seule source. C'est ce qui a permis de trancher sans attendre la réunion. @marvin, c'est le #38 qui les lira : dis en relecture si la forme te va.

2. La zone or reste au pas de la minute, et c'est délibéré. 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, sans table de correspondance, qui rend le contrôle par échantillon possible — et c'est le critère 4 de ce ticket.

3. Le chargement refuse les jours déjà comprimés. mesure est comprimée au delà de sept jours (#29). 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 à décomprimer pour 5,4 Mo comprimés, relevé sur enervision_preprod le 3 septembre. Recharger un vieux jour après une correction de règle d'imputation reste possible, avec --forcer, et le message porte la commande de décompression : c'est un geste délibéré, pas un effet de bord d'un job de cron qui tourne toutes les heures.

4. Trois pièges attrapés en écrivant, qu'aucune relecture de code n'aurait vus.

  • 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 » de ce ticket. SET TimeZone = 'UTC' est posé dans la connexion, et tout le médaillon est en UTC (ADR 0005).
  • pytz est une dépendance que rien n'importe. DuckDB en a besoin pour rendre un TIMESTAMPTZ en objet Python : sans lui, « Required module 'pytz' failed to import » tombe 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.
  • 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 plutôt que de faire confiance à un SELECT *.

5. Le job échoue plutôt que d'écrire une journée douteuse. Au delà de 30 % de trous (garde-fou du §11), sur une valeur d'énumération que la base refuserait — attrapée ici et non dix minutes plus tard sur une ligne parmi dix mille — et sur plus de relevés disponibles que la cadence n'en permet, qui n'est pas une donnée mais un défaut de déduplication de la zone argent : charger quand même donnerait un taux de disponibilité au dessus de 100 % et des agrégats calculés sur des doublons.

Les preuves

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

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

$ pytest tests/unit -q
246 passed

$ pytest tests/integration/gold -q                   # la commande de vérification du ticket
8 passed, 8 skipped

$ pytest tests/unit --cov=services --cov=packages    # hors .venv local
TOTAL  1396  160  89%          dont services/etl : 91 %

tests/integration/gold/test_chaine_zone_or.py joue la chaîne entière sur une journée de 1 440 minutes pour deux sites — agrégation, journal de qualité, export horaire, reconstitution, remontée de vingt lignes tirées au hasard — sans MinIO, sans base et sans réseau : les fixtures de zone argent sont construites en Parquet local, .gitignore excluant *.parquet.

Ce qui reste, et pourquoi le ticket n'est pas fermé

  1. Le #34, zone argent (@justine) : le critère 1 en dépend. Ton périmètre s'arrête à l'écriture du Parquet argent au schéma du §10 — l'agrégation et le chargement sont faits.
  2. Les migrations 00080016 ne sont pas appliquées en enervision_prod, c'est le lot de la #114. Tant qu'elles ne le sont pas, les huit cas d'intégration base se sautent et les deux preuves manquantes ne peuvent pas être produites.
  3. La mise en cron des deux lanceurs par le rôle Ansible app : #113.
  4. Les deux preuves à coller ici une fois la base joignable : le p95 de latence sur la zone or chargée, et la trace de remontée d'un échantillon depuis la base.

Documentation

  • docs/data/etl-pipeline.md §12 réécrit sur ce qui est posé, §2 remis à l'état réel, et le point ouvert « chargement gold → PostgreSQL non spécifié » du §14 est refermé ;
  • docs/POSTGRESQL.md : les deux objets dérivés passés au même crible d'exposition Grafana, table par table, que les sept du #105 ;
  • db/migrations/README.md : le motif de l'agrégat continu, le WITH NO DATA et pourquoi un WITH DATA matérialiserait 352 807 lignes au démarrage du serveur ;
  • services/etl/README.md : les entrées, les codes de sortie que lit cron, et les deux pièges ;
  • tests/integration/README.md : ce que chaque test d'intégration exige, et le fait qu'un test sauté ne prouve rien.

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.
  • Le PRD rattache l'ENF-04 aux tickets #38 et #40, pas à celui qui en porte le critère, et ne reprend pas le p95 déjà mesuré au #29.
### État : la chaîne de la zone or est écrite et testée, la demande de fusion #124 est ouverte, deux critères attendent la base et le #34 Un service `services/etl/` et une migration `0016`. Branche `olivier/35-etl-zone-or`, chaîne d'intégration verte sur les sept tâches. Quatre entrées, qui suivent l'ordonnancement du §13 de `docs/data/etl-pipeline.md` : | Commande | Ce qu'elle fait | |---|---| | `python -m etl.agregation --jour AAAA-MM-JJ` | zone argent → zone or dans MinIO, `qualite_jour` comprise | | `python -m etl.chargement --jour AAAA-MM-JJ` | zone or → `public.mesure` et `public.qualite_jour`, en upsert | | `python -m etl.export --jour AAAA-MM-JJ --vers DOSSIER --controler` | export de reporting horaire, et sa reconstitution | | `python -m etl.lignage --jour AAAA-MM-JJ --taille 20` | remonte un échantillon jusqu'à l'objet bronze | ### Les critères, un par un - [ ] **La zone or est écrite dans les hypertables, alimentée depuis la zone argent.** Le code est écrit et testé, mais **sur des fixtures de zone argent** : le #34 n'a pas démarré. Le job lit le schéma du §10, qui est figé, donc il tournera sur une zone argent réelle sans qu'une ligne change ici. Le chargement en base est couvert par huit cas d'intégration qui attendent une base joignable — voir ci-dessous. - [ ] **Une série de 24 heures se lit sous 400 ms au 95e centile.** Mesuré au **#29 à 4,09 ms au p95** sur 352 807 lignes, marge d'un facteur 100. Le cas qui refait la mesure sur la zone or *chargée* est écrit (`test_une_fenetre_de_24_heures_se_lit_sous_400_ms_au_95e_centile`, vingt exécutions, relevé écrit dans un fichier) et se saute faute de base. À rejouer et coller ici. - [x] **Un export agrégé pour le reporting est produit, reconstituable à la mesure près.** Trois choses le rendent reconstituable, et aucune n'est une affirmation de document : les compteurs `releves_pris_en_compte` et `releves_exclus` voyagent **sur chaque ligne** — sans eux personne ne peut refaire la moyenne ; la règle d'agrégation est écrite dans un manifeste JSON à côté du CSV, avec son empreinte SHA-256 ; et `reconstituer()` refait le calcul depuis la zone or et compare ligne à ligne. Deux cas vérifient qu'il **échoue** — un chiffre retouché à la main, une ligne disparue — parce qu'un contrôle qui ne peut pas échouer ne prouve rien. - [x] **Un contrôle sur échantillon tiré au hasard remonte de la valeur or jusqu'à la lecture bronze d'origine.** `etl.lignage`, avec les **deux** formes d'objet bronze : un objet par minute pour `/current`, un objet comprimé par fenêtre pour `/readings` (#107) — un contrôle qui ne saurait remonter que le premier laisserait tout l'historique rattrapé hors de portée. Le cas nominal qui compte le plus est celui de la ligne **imputée** : brute nulle en or, valeur nulle en bronze, et concordance — parce que ce qui est comparé est la valeur brute et non la valeur retenue. Comparer la retenue rendrait toute imputation suspecte. ### Ce que la mise en place a révélé, et qui vaut plus que le ticket **1. Le §12 et la base ne parlaient pas de la même chose.** Le document 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** : « la zone or est écrite dans les hypertables » n'avait pas de cible. La migration `0016` les pose, `mesure_horaire` en **agrégat continu TimescaleDB** et `charge_site` en **vue**. L'alternative — deux tables chargées par le job — est écartée en tête du fichier : un agrégat rechargé en même temps que la série peut la contredire le temps d'une transaction, et il faut écrire le code qui le rejoue. Le choix est **réversible** : les deux objets se suppriment sans rien perdre, `public.mesure` restant la seule source. C'est ce qui a permis de trancher sans attendre la réunion. **@marvin, c'est le #38 qui les lira : dis en relecture si la forme te va.** **2. La zone or reste au pas de la minute, et c'est délibéré.** 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, sans table de correspondance, qui rend le contrôle par échantillon possible — et c'est le critère 4 de ce ticket. **3. Le chargement refuse les jours déjà comprimés.** `mesure` est comprimée au delà de sept jours (#29). 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 à décomprimer pour 5,4 Mo comprimés, relevé sur `enervision_preprod` le 3 septembre. Recharger un vieux jour après une correction de règle d'imputation reste possible, avec `--forcer`, et le message porte la commande de décompression : c'est un geste délibéré, pas un effet de bord d'un job de cron qui tourne toutes les heures. **4. Trois pièges attrapés en écrivant, qu'aucune relecture de code n'aurait vus.** - **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 » de ce ticket. `SET TimeZone = 'UTC'` est posé dans la connexion, et tout le médaillon est en UTC (ADR 0005). - **`pytz` est une dépendance que rien n'importe.** DuckDB en a besoin pour rendre un `TIMESTAMPTZ` en objet Python : sans lui, « Required module 'pytz' failed to import » tombe 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. - **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 plutôt que de faire confiance à un `SELECT *`. **5. Le job échoue plutôt que d'écrire une journée douteuse.** Au delà de 30 % de trous (garde-fou du §11), sur une valeur d'énumération que la base refuserait — attrapée ici et non dix minutes plus tard sur une ligne parmi dix mille — et sur plus de relevés disponibles que la cadence n'en permet, qui n'est pas une donnée mais un défaut de déduplication de la zone argent : charger quand même donnerait un taux de disponibilité au dessus de 100 % et des agrégats calculés sur des doublons. ### Les preuves ``` $ ruff check services packages && ruff format --check services packages All checks passed! · 39 files already formatted $ mypy --config-file etl/pyproject.toml etl # strict Success: no issues found in 8 source files $ pytest tests/unit -q 246 passed $ pytest tests/integration/gold -q # la commande de vérification du ticket 8 passed, 8 skipped $ pytest tests/unit --cov=services --cov=packages # hors .venv local TOTAL 1396 160 89% dont services/etl : 91 % ``` `tests/integration/gold/test_chaine_zone_or.py` joue la chaîne entière sur une journée de **1 440 minutes pour deux sites** — agrégation, journal de qualité, export horaire, reconstitution, remontée de vingt lignes tirées au hasard — sans MinIO, sans base et sans réseau : les fixtures de zone argent sont construites en Parquet local, `.gitignore` excluant `*.parquet`. ### Ce qui reste, et pourquoi le ticket n'est pas fermé 1. **Le #34, zone argent** (@justine) : le critère 1 en dépend. Ton périmètre s'arrête à l'écriture du Parquet argent au schéma du §10 — l'agrégation et le chargement sont faits. 2. **Les migrations `0008`–`0016` ne sont pas appliquées en `enervision_prod`**, c'est le lot de la #114. Tant qu'elles ne le sont pas, les huit cas d'intégration base se sautent et les deux preuves manquantes ne peuvent pas être produites. 3. **La mise en cron** des deux lanceurs par le rôle Ansible `app` : #113. 4. **Les deux preuves à coller ici** une fois la base joignable : le p95 de latence sur la zone or chargée, et la trace de remontée d'un échantillon depuis la base. ### Documentation - `docs/data/etl-pipeline.md` §12 réécrit sur ce qui est posé, §2 remis à l'état réel, et **le point ouvert « chargement gold → PostgreSQL non spécifié » du §14 est refermé** ; - `docs/POSTGRESQL.md` : les deux objets dérivés passés au même crible d'exposition Grafana, table par table, que les sept du #105 ; - `db/migrations/README.md` : le motif de l'agrégat continu, le `WITH NO DATA` et pourquoi un `WITH DATA` matérialiserait 352 807 lignes au démarrage du serveur ; - `services/etl/README.md` : les entrées, les codes de sortie que lit cron, et les deux pièges ; - `tests/integration/README.md` : ce que chaque test d'intégration exige, et le fait qu'un test sauté ne prouve rien. ### 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. - **Le PRD rattache l'ENF-04 aux tickets #38 et #40**, pas à celui qui en porte le critère, et ne reprend pas le p95 déjà mesuré au #29.
justine added reference olivier/35-etl-zone-or 2026-09-04 11:41:17 +00:00
Sign in to join this conversation.
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".
2026-09-09
Reference
g2/enervision#35
No description provided.