Flux R67 : énergie par période livrée par le distributeur — asset parallèle, hors union releves
Statut
Accepté — grill /grill-with-docs du 27/06/2026 (sur #214, brique de l'épique #191), adossé à 4 demandes M023 R67 réelles (pilotes Linky/non-Linky/MES/PMES).
- Prolonge ADR-0020 (landing JSON-colonne + motif staging/flux), ADR-0034 (Wh→kWh floor au boundary), ADR-0042 (instant
TIMESTAMPTZvs jour civilDATE), ADR-0035 (cadrans SSOT, parité de frontière). - Amende ADR-0029 (R67 exclu de l'union des relevés index) et ADR-0028 (R67 hors de l'espace de nommage
releve_idet de la table de priorité C15>R64>R151). - Respecte sans réserve ADR-0016 (cœur ERP-agnostique) / ADR-0027 (electricore = physique/réseau, Odoo = comptable) : R67 est de la donnée distributeur pure.
Contexte
Le flux R67 (« mesures facturantes ») est publié par Enedis à la demande (prestation M023, ponctuelle, JSON zippé sur le même SFTP que R64). Il porte l'historique des consommations retenues pour la facturation, par cadran, sur un horizon glissant max(aujourd'hui − 36 mois, dernière MES). Son débouché tranché en amont (#214) : amorcer (cold-start) la provision d'un mensualisé dès le mois 1, avant qu'EDN ait accumulé ~12 mois de relevés propres (R151/R64). Rôle mince, pas un flux du quotidien.
La tentation est de le traiter « comme R64 » (autre flux JSON timeseries à cadrans). C'est un piège. La dissection des 4 fichiers réels établit une différence de nature, pas de surface :
| R64 (et R151, R15, C15) | R67 | |
|---|---|---|
| Feuille JSON | valeur[] = un index cumulé à un instant (d, v, iv) |
quantite[] = une énergie consommée sur une période (dbtMesure, finMesure, codeNature, codeStatut, quantite en Wh) |
| Grain | 1 relevé = 1 instant | 1 ligne = 1 période [debut, fin) |
| Grandeur | odomètre (cumul) → l'énergie se calcule en différenciant deux index (pipeline_energie) |
énergie déjà différenciée par le distributeur |
Autrement dit R67 arrive en aval du travail que pipeline_energie fait sur les index : c'est de l'énergie pré-calculée, parallèle au chemin index→énergie, pas un 5ᵉ relevé. Le forcer dans la chaîne des relevés (mart releves, ADR-0029) casserait cette chaîne (cf. Raison).
La dissection a aussi remonté quatre faits structurants que le modèle doit encaisser :
- Les
contexte[]partitionnent le temps par motif (CYCLcyclique,CFNSentrée/sortie fournisseur,MCTchangement FTA/puissance,AUTRErégularisation). Chaque motif a des trous que les autres comblent ; il faut unioner tous les contextes pour une couverture continue. Zéro recouvrement temporel inter-motif (vérifié 35/35, 23/23, 8/8 adjacencesfin[i]==dbt[i+1], 0 doublon de clé) → l'union ne double-compte jamais. - La grille distributeur peut être absente (pilote non-Linky : seulement
F/HoroF1) ou dégénérée (DI000001BASE seul, en fenêtre d'entrée/MCT, avant basculeDI0000034 cadrans — qui peut survenir en cours de mesure). - Somme 4-cadrans distributeur ≠ total fournisseur de ~1 kWh sur la moitié des intervalles (arrondi par cadran ; reconstructions indépendantes). Mais
D/DI000003est un raffinement strict du fournisseur :F-BASE = Σ4 cadrans,F-HP/HC = HPB+HPH / HCB+HCH(vérifié exact sur intervalle propre). - Quantités négatives légitimes :
codeNature=C« Régularisé » est une régularisation physique (révision d'une conso souvent estimée),quantite < 0quand elle réduit une sur-estimation. Donnée réseau valide — sans rapport avec les conventions de signe comptables.
Décision
flux_r67 est un asset parallèle : il réutilise toute la plomberie JSON de R64 (ADR-0020) mais sa linéarisation est propre, et il ne rejoint jamais la chaîne des relevés index.
-
Landing α réutilisé (ADR-0020). Bloc
R67dansconfig/flux.yaml(format: json, glob**/R63_R64_R65_R66_R67_C68/*_R67_*.zip), tableraw_r67(JSON-colonne), modèlesstg_r67+flux_r67.raw_r67 → flux_r67dansMODELES_PAR_RAW; jamais ajouté aux gardes des marts périodiques (releves,chronologie_releves). -
Grille coalescée — 1 grille par période, priorité
D/DI000003 ≻ D/DI000001 ≻ F.energie_<cadran>_kwhest rempli depuis la grille la plus fine disponible pour chaque période ;code_grille/code_calendriergardés en provenance. Justification :DI000003étant un raffinement strict, le total fournisseur est re-dérivable par agrégation (« on ne perd rien avec le DI ») ; le repliFn'intervient que quand le distributeur manque (non-Linky) ou est dégénéré. -
Union de tous les
contexte[]. Concaténation des quantités de tous les motifs (l'union tuile sans trou ni recouvrement) ;id_motif_relevegardé en annotation (le grain(pdl, debut, fin)est préservé — motif 1:1 par intervalle, zéro overlap). Le motif est le pourquoi d'un segment (entrée/sortie/changement/cyclique/régul), pas une partition à sommer. -
Format wide
energie_<cadran>_kwhviapivot_cadransparamétré : la macro gagne un argumentgrandeur='index'(défaut inchangé) ; R67 l'appelle avecgrandeur='energie'surquantite. Réutilise le SSOT cadrans (cadrans_releve/CADRANS, ADR-0035) — pas de re-triplication de la liste des 7 cadrans. -
Wh→kWh par
floorexplicite (cast(floor(quantite / 1000.0) as bigint), ADR-0034) — pas// 1000: l'opérateur//de DuckDB tronque vers zéro (≠ floor) et R67 porte des négatifs, doncfloorexplicite (vers −∞). Lossless sur la donnée observée (249/249 quantités multiples de 1000 Wh → résultat identique) ; le floor explicite honore le contrat si une régul négative non-multiple survient un jour. Négatif préservé (régul physique). -
Bornes
debut/finen jour civilDATE, intervalle demi-ouvert[debut, fin)(ADR-0042 : ce sont des bornes de période, pas des instants). Aucune harmonisation +1j (R67 est un asset autonome, jamais joint à R151/R64). Laperiode(fenêtre d'éligibilité M023, plus large que la couverture data) est gardée à part et n'est jamais utilisée comme borne d'énergie. -
Nature depuis
codeNature(R/E/C → réel/estimé/régularisé, défaut prudentestimé) — pasnature_depuis_etape_metier(l'etapeMetierest toujoursFACT).code_statutconservé en passthrough. -
Clé propre
periode_id = md5(flux_R67|pdl|debut|fin)[:16], mintée sur dates naïves (stabilité tz, cf. ADR-0028), dans un espace de nommage distinct dereleve_id. Dédup parqualify row_number() over (partition by pdl, debut, fin, grille order by modification_date desc, date_creation desc) = 1(patron R64 : la livraison la plus récente gagne ; protège une éventuelle re-publication restatée même si l'échantillon est 100 %Initial). -
flux_r67n'est jamais unioné dansreleves/chronologie_releves. Il est requêtable directement (loaderr67()). Sa frontière Pandera (grain énergie-par-période, pasRelevéIndex) est différée hors du présent périmètre d'ingestion (le loader expose un LazyFrameSELECT *,validator=None, comme R64 aujourd'hui). -
La provision reste hors
core/hors ce flux.flux_r67livre l'énergie physique par cadran ; la dérivation de provision (estimation annuelle/cadran, total ÷ 12 plate) et son écriture sont une brique séparée de #191, côté core→Odoo (ADR-0016/0027). Même partage que l'accise : electricore = grandeur physique, Odoo = politique.
Raison
Pourquoi R67 ne peut pas entrer dans releves (les 5 ruptures). Le contrat releves (contrat_releve(), ADR-0029/0045) est 1 ligne = 1 index relevé à un instant :
- Grain :
relevesest clé sur undate_releveunique (TIMESTAMPTZ) ; R67 a deux bornes (debut,fin). Fabriquer un instant unique jette une borne et la sémantique de période. - Nature de la valeur :
relevesporte des colonnesindex_<cadran>_kwh(cumul/odomètre) ; R67 porte de l'energie_<cadran>_kwh(déjà différenciée). Verser l'une dans l'autre fait calculer àpipeline_energieune « énergie d'énergie » — du faux. - Identité & priorité : R67 n'a pas de place dans l'espace
releve_idni dans leCASEde prioritéC15→R64→R151(ADR-0028) ; il tomberait au bucketelseou entrerait en collision sur(rsc, date_releve, ordre_index)avec un vrai relevé index — exactement l'« accident lexical » qu'ADR-0028 tue. - SSOT index :
relevesest la source unique des valeurs d'index (ADR-0045) ; y injecter du non-index casse l'invariantreleve_idUNIQUE et le star-join 1:1 dechronologie_releves. - Frontière Pandera :
RelevéIndexdevrait s'élargir pour admettre bornes de période + énergie — ce qui ferait sauter le test de parité ADR-0035, justement le garde-fou contre cette dérive.
Pourquoi coalescer plutôt que garder les deux grilles. D/DI000003 est un raffinement strict de F (re-dérivation vérifiée exacte) : garder F en plus serait redondant et un footgun (sommer sans filtrer code_grille double-compte). L'écart D-vs-F (~1 kWh/période, arrondi) est du bruit immatériel sur une provision ÷12 (déjà réconcilié au spike : FC022034 BASE 2513 kWh/an ↔ détail 4 cadrans).
Pourquoi floor explicite (et pas // 1000). Cohérence avec la conversion Wh→kWh de tout le boundary dbt (ADR-0034). Mais // de DuckDB tronque vers zéro (≠ floor), et R67 porte des négatifs : on écrit donc floor(x / 1000.0) (vers −∞), pas // 1000 comme R64 (qui n'a que des cumuls ≥ 0). À noter (seule nuance vs ADR-0034) : la preuve « l'erreur de troncature télescope » vaut pour des index cumulés ; sur des énergies par période chaque ligne tronque indépendamment, l'erreur ne s'annule pas d'une période à l'autre. Sans objet ici (data lossless : multiples de 1000 → // et floor coïncident), mais on ne s'appuie pas sur la preuve de télescopage.
Alternatives écartées
- Unioner R67 dans
releves(le « 5ᵉ relevé »). Casse grain + valeur + priorité + SSOT + frontière (les 5 ruptures). C'est la confusion centrale que cet ADR existe pour interdire. - Garder les deux grilles
DetFen lignes séparées (code_grilledimension). Servirait la provision « au kWh facturé près » et l'optim tarifaire sans re-dériver — mais footgun de double-compte et choix de grille remonté chez chaque consommateur ; rejeté car le raffinement strict deDI000003rendFre-dérivable et l'écart d'arrondi est immatériel. - Round / float pour Wh→kWh. Plus « précis » par période — rejeté pour cohérence maison (ADR-0034 floor) ; lossless de toute façon.
- Minter un
releve_idpour R67. Pollue l'identité des relevés index (ADR-0028). ge ≥ 0sur l'énergie. Rejetterait les régularisations physiques négatives (donnée prod légitime) — même classe de bug que les schémas trop étroits (accise rc14), mais ici la cause est physique, pas comptable.- CAR (Consommation Annuelle de Référence) au lieu de R67. Source idéale (auto-recalculée par Enedis) mais absente de tout flux ingéré ; exigerait l'API SGE web-service (#207) — plus gros chantier que R67.
Conséquences
- Issues à cuter (une PR, brique de #191 / sous-tâche de #214) : (1)
config/flux.yamlR67+raw_r67+MODELES_PAR_RAW; (2)stg_r67+flux_r67.sql(linéarisation énergie-par-période) +schema.yml; (3) golden depuis échantillon réel anonymisé (tests/fixtures/flux/r67.json+golden/flux_r67.json+test_dbt_r67_golden.py) ; (4) loaderr67()(helpers.py+DESCRIPTOR_R67+ export) ; (5) doc recettedocs/ingestion.md+ procédure M023. - ADR amendés : ajouter à ADR-0029 une note « R67 intentionnellement exclu (énergie-par-période) » ; à ADR-0028 « R67 hors espace
releve_id» ; à ADR-0035 «pivot_cadransparamétrégrandeur». - Macro :
pivot_cadrans(etcadrans_index_noms/_cols/_renamesi un contrat les exige) gagnent un argumentgrandeur='index'; le test de paritécadrans_releve/CADRANSreste couvrant. - Hors périmètre : la dérivation de provision (#191) ; la frontière Pandera propre de R67 ; l'automatisation de la demande M023 (portail manuel, #216 wontfix). La voie M023 batch est recevable aussi pour les non-communicants (démenti du préjugé « niveau 0 → rejet »), mais le contenu y est grossier (BASE, bimestriel, estimé).
- Limite à connaître avant un changement d'échelle : la dédup
code_statutn'est pas exercée (échantillon 100 %Initial) ; la clé(pdl, debut, fin, grille)+modification_date/date_creationest posée par précaution pour la re-publication. Ne pas supposer un statut unique. - Glossaire :
electricore/ingestion/CONTEXT.md— entrées Mesures facturantes (R67) (préciser : énergie par période, horsreleves; régulC= physique, peut être négative) + CAR (déjà esquissées en #481, à consolider avec cette nuance).