ADR-013 — Extension du domaine hydrologique au domaine énergétique (tarifs ElCom)¶
Date : 2026-09-27
Statut : Acceptée — implémentée et en production. Mergée sur main le 2026-09-27 (merge 6a9ce9b, tag v2.0.0-energy) après rejeu des migrations et de l'import sur une copie de la base de production ; les deux migrations additives s'appliquent au démarrage du conteneur api, et l'import ElCom a été exécuté en production (GET /api/v1/energy/positioning sert les tarifs 2027).
Références : audit préalable du brief (archivé hors du dépôt, fait autorité sur le brief initial), ADR-004 (Prisma), ADR-007 (LINDAS SPARQL), ADR-012 (couche IA).
Contexte¶
AlpiMonitor est un tableau de bord hydrologique : un schéma plat et spécialisé (Catchment → Station → Sensor → Measurement), une ingestion LINDAS toutes les 10 minutes et une page unique. Le brief « extension énergie » propose d'y ajouter un comparateur des tarifs d'électricité par gestionnaire de réseau de distribution (GRD), à partir des données publiques de l'ElCom.
Faits établis par l'audit (requêtes du 2026-09-27) :
- L'ElCom est servi par le même endpoint LINDAS que l'hydrologie (
https://lindas.admin.ch/query, graphehttps://lindas.admin.ch/elcom/electricityprice). Seuls le graphe et la requête changent. - Une observation ElCom = commune × GRD × catégorie × produit × année (~41 300 observations par an au niveau national), avec les composantes
gridusage,energy,charge,aidfeeet untotal. - Un GRD n'a pas un tarif unique : la redevance communale (
charge) varie selon la commune. Une commune peut avoir plusieurs GRD la même année (Orsières, 2017–2023). - Les communes fusionnent : Bagnes (6031) et Vollèges (6036) sont devenues Val de Bagnes (6037) en 2021. Une série pluriannuelle doit donc pouvoir référencer des communes qui n'existent plus.
- Le GRD de référence pour la démonstration est SEDRE SA (
operator/689).
Le brief proposait un modèle dimensionnel : des dimensions partagées ouvrage (station, centrale, GRD, bâtiment), territoire et temps, et des tables de faits séparées, dont la table de mesures hydrologiques existante.
Décision¶
1. Rejet du modèle ouvrage / territoire / temps¶
Ce modèle n'est pas un ajout, c'est une refonte du schéma en production :
- faire de
Stationun sous-type d'ouvrageimpose de migrer ses clés et ses relations (Sensor,Threshold,Alert,Insight,Withdrawal,StationGlacier,ThresholdAudit) ; - 45 fichiers source et test référencent
Station,stationIdouofevCode(API,packages/shared, web), dont leQueryPortdu chat (ADR-012) et l'interface Prisma structurelle de l'ingestion ; - le seed de 515 lignes, rejoué à chaque boot en production (
SEED_ON_BOOT=true), serait à réécrire ; - la CI ne lance aucune base de données : la migration de refonte ne serait testée qu'au premier boot en production (
prisma migrate deploydansentrypoint.sh).
Le domaine hydrologique et le domaine tarifaire n'ont aucune donnée en commun : ni identifiant, ni granularité temporelle (10 minutes contre une année), ni entité. Une dimension ouvrage qui unifierait une station de mesure et un GRD serait une abstraction sans usage. Une table temps en base transactionnelle n'apporte rien qu'une colonne period Int ne fasse déjà.
2. Tables énergie séparées, la commune comme seule dimension partagée¶
Cinq modèles additifs, sans aucune modification des modèles existants :
| Modèle | Rôle | Clé métier (unique) |
|---|---|---|
Municipality |
Dimension commune, clé = numéro OFS. Historise les communes fusionnées (validFrom / validTo). Porte le canton (cantonId, obligatoire). Seule dimension réellement partagée, réutilisable si un module territorial est engagé plus tard |
bfsNumber |
GridOperator |
GRD, clé = identifiant ElCom (689 = SEDRE SA) | elcomId |
ElectricityTariff |
Faits annuels, grain de l'observation ElCom | (period, operatorId, bfsNumber, category, product) |
SwissTariffMedian |
Médiane suisse publiée par l'ElCom (cube -swiss), stockée telle quelle |
(period, category, product) |
CantonTariffMedian |
Médiane cantonale publiée par l'ElCom (cube -canton), stockée telle quelle |
(period, cantonId, category, product) |
Plus une valeur d'enum : IngestionSourceKind.LINDAS_ELCOM, pour tracer les imports dans IngestionRun avec la même discipline que l'hydrologie.
Choix de conception :
sourceIri @uniquesur chaque fait : l'IRI ElCom de l'observation. Elle rend l'import idempotent et garantit la traçabilité de toute valeur affichée jusqu'à son enregistrement source.- Clé métier unique en plus de l'IRI. L'IRI garantit l'idempotence de l'import, pas l'unicité métier : si l'ElCom republiait la même observation sous un autre IRI, on aurait un doublon silencieux qui fausserait toute distribution. Chaque table de faits porte donc une contrainte unique sur sa clé métier, vérifiée discriminante sur les données réelles (requêtes du 2026-09-27) :
| Cube | Périmètre testé | Observations | Clés distinctes |
|---|---|---|---|
electricityprice |
2011 | 42 927 | 42 927 |
electricityprice |
2018 | 59 550 | 59 550 |
electricityprice |
2026 | 41 306 | 41 306 |
electricityprice-swiss |
tous millésimes | 405 | 405 |
electricityprice-canton |
tous millésimes | 9 951 | 9 951 |
- Deux tables de médianes plutôt qu'une table à
scope. Une table unique aurait uncantonIdnul pour la médiane suisse. Or Postgres considère deuxNULLcomme distincts dans une contrainte unique : les lignes suisses ne seraient pas protégées contre les doublons. Deux tables gardent chaque clé entièrement non nulle. - Canton : seconde source obligatoire, sur le même endpoint. Le cube par GRD ne porte pas le canton : aucune dimension
canton, et aucune triple sur les IRI de commune dans le graphe ElCom. Le cube-canton, lui, le porte (dimension/canton→https://ld.admin.ch/canton/23). Le rattachement commune → canton vient du registre OFS sur LINDAS (graphehttps://lindas.admin.ch/fso/register,schema:containedInPlace), qui couvre aussi les communes fusionnées (Bagnes 6031 et Vollèges 6036 → canton 23). Le même numéro OFS de canton (23 = VS,schema:alternateName "VS") sert dansMunicipality.cantonIdetCantonTariffMedian.cantonId, ce qui rend les deux joignables. - 4 numéros de commune ElCom sur 2 715 n'ont pas de canton dans le registre. Leur nature a été vérifiée un par un, sur les GRD rattachés et la continuité des séries (requêtes du 2026-09-27). Aucun n'est valaisan.
| N° ElCom | Nature | Preuve | Traitement |
|---|---|---|---|
| 30 | Ancien numéro d'Andelfingen (ZH), renuméroté après fusion | Présent dans le registre OFS comme identité sans version (ni nom ni canton). Même GRD (367, « Gemeinde Andelfingen ») sous le n° 30 de 2011 à 2023, puis sous le n° 291 (Andelfingen, canton 1 = ZH) de 2024 à 2027 | Canton 1 (ZH), repris du successeur |
| 3378 | Ancien numéro de Neckertal (SG), renuméroté | Identité sans version dans le registre. Mêmes GRD (727, 497, 263) sous le n° 3378 de 2011 à 2023, puis sous le n° 3396 (Neckertal, canton 17 = SG) de 2024 à 2027 | Canton 17 (SG), repris du successeur |
| 700 | Ancien numéro de Moutier, commune ayant changé de canton (BE → JU) | Identité sans version dans le registre. GRD 676 (« Municipalité de Moutier ») sous le n° 700 de 2011 à 2026, puis sous le n° 6831 (Moutier, canton 26 = JU) en 2027 | Canton 2 (BE). Limite : l'ElCom publie encore sous 700 en 2026 alors que Moutier est jurassienne depuis cette année-là ; une valeur statique ne peut pas refléter ce changement |
| 9998 | Code technique hors nomenclature | Absent de tout le registre OFS. Un seul GRD (426, EKS Schaffhausen), deux millésimes (2011–2012), total H4 identique les deux années | Écarté à l'import, compté comme ignoré dans IngestionRun |
L'import actuel n'en rencontre aucun, y compris sur son millésime national (§3) : vérifié le 2026-09-27, aucun des quatre codes n'a d'observation en 2027, et les 2 107 communes de 2027 ont toutes un canton et un nom dans le registre. La table de correspondance de 3 entrées n'est donc pas implémentée : elle n'aurait aucun cas à traiter. Elle le deviendrait si l'import national remontait avant 2027 (30 et 3378 jusqu'en 2023, 700 jusqu'en 2026). Dans ce cas, 9998 serait écarté comme code technique.
- Le canton d'une commune n'est pas immuable (cas de Moutier). Municipality.cantonId reste une valeur unique par numéro OFS : c'est suffisant, parce que l'ElCom attribue un nouveau numéro au changement de canton (700 → 6831). Le seul écart est l'année 2026 de Moutier, documentée ci-dessus. Moutier n'est pas dans le périmètre du module.
- Decimal(8,4), pas Float : le critère d'acceptation « total recalculé = total publié, à l'arrondi près » échouerait sur des artefacts d'arrondi binaire.
- Composantes nullables, jamais remplacées par 0 : une donnée manquante reste manquante.
- Composante comptage (meteringRate) depuis 2026. L'ElCom publie depuis 2026 le comptage séparément, et le total l'inclut ; avant 2026, il est compris dans l'acheminement et la colonne reste nulle. Sans elle, le critère de réconciliation échouerait sur 2026–2027 (SEDRE H4 2026 : 12,693 + 13,357 + 0,5 + 2,3 + 1,853 = 30,703). Ajoutée par une seconde migration additive.
- category et product en String, pas en enum : l'ElCom définit ces valeurs (H1–H8, C1–C7 ; standard, cheapest) et peut en ajouter sans que nous ayons à migrer.
- Médianes stockées, pas recalculées : l'ElCom calcule ses médianes sur une base qui lui est propre. Les recalculer produirait un chiffre maison là où le principe du module est de n'afficher que des chiffres du régulateur.
3. Import manuel, périmètre restreint à ce que les écrans affichent¶
Les tarifs changent une fois par an. L'import est une commande lancée manuellement, jamais déclenchée par un cron, au boot ou par le seed :
- dev :
pnpm --filter @alpimonitor/api import:elcom - prod :
node dist/energy/import-elcom-cli.js, dans le conteneurapi
Périmètre : les cinq derniers millésimes publiés (déterminés depuis le cube de médiane suisse) :
- millésime le plus récent : tous les GRD suisses (toutes les communes), pour la distribution nationale de l'écran Positionnement ;
- quatre millésimes précédents : communes valaisannes seulement, donc tous les GRD valaisans, SEDRE compris, pour l'écran Trajectoire ;
- les médianes suisse et valaisanne publiées, sur les cinq millésimes.
Pas l'exhaustivité du graphe (~600–700 k lignes, qu'aucun écran n'utilise). Le canton et le nom de chaque commune viennent du registre OFS, joint dans la requête. En périmètre national, cette jointure est facultative : une commune absente du registre produit une ligne ignorée, comptée contre le seuil, jamais une ligne silencieusement perdue.
Garanties :
- Idempotence : chaque fait est identifié par son IRI. Une valeur corrigée à la source est mise à jour ; une valeur identique est comptée « inchangée ».
IngestionRun(sourceLINDAS_ELCOM) trace créé / inchangé / ignoré avec le même sens que l'hydrologie. Les mises à jour sont journalisées mais pas persistées :IngestionRunn'a pas de colonne pour elles, et en ajouter une toucherait une table partagée avec l'hydro. - Échec bruyant : si plus de 1 % des lignes d'un millésime sont ignorées (total absent, champ obligatoire manquant), l'import échoue au lieu d'absorber ce qui ressemble davantage à un changement de schéma chez l'ElCom qu'à des données manquantes. Il échoue aussi si SEDRE SA est absente d'un millésime, si les médianes manquent, ou si un fait réapparaît sous un nouvel IRI (collision de la clé métier). Dans tous les cas, un
IngestionRunFAILUREest écrit. - Réconciliation : l'import vérifie, pour chaque tarif, que
gridUsage + energy + charge + aidFee (+ meteringRate)égale le total publié, à l'arrondi près (0,0005 par composante). Cette vérification ne s'applique pas aux médianes : une médiane des totaux n'est pas la somme des médianes des composantes.
Volume ingéré (import du 2026-09-27, millésimes 2023–2027) : 50 320 tarifs, soit 2 308 / 2 155 / 2 320 / 2 328 pour le Valais de 2023 à 2026, puis 41 209 pour toute la Suisse en 2027 ; 150 médianes suisses, 150 médianes valaisannes ; 2 107 communes dans 26 cantons ; 542 GRD. 110 Mo téléchargés, en environ 60 s ; table des tarifs : 26 Mo. Réconciliation : 50 320 sur 50 320, écart maximal de 0,001. 91 lignes ignorées en 2027 (0,22 %, sous le seuil de 1 %) : observations sans total publié, dont Zeneggen (VED Visp) H6 et H8 pour le Valais. Une seconde exécution crée 0 ligne et en compte 50 620 inchangées.
4. Écran Positionnement : afficher la robustesse, pas une position unique¶
Historique de la décision. Une première version retenait une base unique : un GRD, une valeur, une voix. Un contrôle adverse l'a invalidée comme base unique. 418 GRD sur 536 (78 %) ne desservent qu'une seule commune (2027, toutes catégories). « Un GRD, une voix » place donc la médiane au niveau des petits réseaux communaux, et la position de SEDRE bascule selon le périmètre retenu : au-dessus dans 4 catégories sur 15 avec tous les GRD, dans 9 sur 15 avec les seuls GRD d'au moins 5 communes. SEDRE elle-même, avec 3 communes, ne passerait pas un seuil à 5.
Décision : l'écran affiche la robustesse de la position de SEDRE sur quatre bases, calculées et consultables, dont aucune n'est présentée comme la bonne.
| Base | Unité | Effectif (H4, 2027) |
|---|---|---|
| Tous les GRD | un GRD | 535 |
| GRD d'au moins 2 communes | un GRD | 117 |
| GRD d'au moins 5 communes | un GRD | 44 |
| Pondérée par communes | une ligne commune × GRD (chaque GRD pèse son nombre de communes) | 2 241 |
Le nombre de communes d'un GRD est le nombre de communes distinctes pour lesquelles il publie au moins un tarif dans le millésime.
Principe de classement. Une catégorie n'est déclarée au-dessus (ou en dessous) de la médiane que si SEDRE est strictement au-dessus (ou strictement en dessous) de la médiane dans les quatre bases. Sinon, elle est déclarée non discriminante, avec l'amplitude de l'écart entre les bases.
Règle d'agrégation : quand un GRD publie plusieurs tarifs pour une même catégorie (un par commune desservie), sa valeur est la médiane de ses tarifs communaux ; quand ils sont identiques, c'est cette valeur unique. En 2027, pour H4, 115 GRD desservent plusieurs communes, et 53 y publient des tarifs différents (écart maximal : 4,3 ct./kWh). La règle ne change pas le classement : dans la base « tous les GRD », médiane, moyenne, minimum et maximum donnent les mêmes catégories au-dessus de la médiane.
Conditions d'affichage, non négociables :
- la règle d'agrégation est écrite à l'écran, pas seulement ici ;
- l'effectif n est affiché sur chaque vue : il varie selon la catégorie et la base ;
- la médiane ElCom est un repère distinct et étiqueté, jamais le milieu d'une boîte : deux bases, deux objets graphiques.
Classement de SEDRE en 2027 (produit standard ; écart de SEDRE à la médiane de chaque base) :
| Cat. | SEDRE | Tous les GRD | ≥ 2 communes | ≥ 5 communes | Pondérée par communes | Verdict | Amplitude |
|---|---|---|---|---|---|---|---|
| H1 | 35,300 | +1,6 % | +3,1 % | +8,1 % | +4,6 % | au-dessus | 6,5 pts |
| H2 | 30,494 | −2,1 % | 0,0 % | +4,2 % | +2,8 % | non discriminante | 6,3 pts |
| H3 | 26,623 | −4,5 % | −0,4 % | +2,7 % | +2,2 % | non discriminante | 7,2 pts |
| H4 | 27,363 | −2,9 % | 0,0 % | +2,4 % | +3,0 % | non discriminante | 5,9 pts |
| H5 | 24,656 | −6,5 % | −2,6 % | +0,6 % | +0,2 % | non discriminante | 7,0 pts |
| H6 | 20,756 | −15,8 % | −12,4 % | −8,4 % | −9,2 % | en dessous | 7,4 pts |
| H7 | 23,719 | −7,5 % | −4,3 % | −1,3 % | −0,04 % | en dessous | 7,5 pts |
| H8 | 25,198 | −5,9 % | −3,2 % | −0,8 % | −0,9 % | en dessous | 5,1 pts |
| C1 | 24,620 | −8,8 % | −5,0 % | −2,8 % | −2,4 % | en dessous | 6,4 pts |
| C2 | 22,762 | −10,5 % | −8,0 % | −6,2 % | −5,3 % | en dessous | 5,2 pts |
| C3 | 24,604 | −0,9 % | +1,6 % | +3,0 % | +6,5 % | non discriminante | 7,4 pts |
| C4 | 23,399 | −2,4 % | −0,4 % | +1,7 % | +5,1 % | non discriminante | 7,5 pts |
| C5 | 23,399 | +10,9 % | +15,2 % | +22,6 % | +25,8 % | au-dessus | 14,9 pts |
| C6 | 24,162 | +17,5 % | +22,2 % | +26,3 % | +31,5 % | au-dessus | 14,1 pts |
| C7 | 23,060 | +17,5 % | +20,8 % | +24,1 % | +30,4 % | au-dessus | 12,9 pts |
Amplitude = écart le plus haut moins écart le plus bas entre les quatre bases, en points de pourcentage. H2 et H4 sont à égalité exacte avec la médiane de la base « ≥ 2 communes ».
Constat retenu : SEDRE est au-dessus de la médiane dans 4 catégories (H1, C5, C6, C7), en dessous dans 5 (H6, H7, H8, C1, C2), et la position dépend de la base dans 6 (H2 à H5, C3, C4). Face à la médiane publiée par l'ElCom, pondérée par la population et non reproductible (voir ci-dessous), SEDRE est au-dessus dans 10 catégories sur 15 : ce repère est affiché à part.
Encart H1 : le comptage, un coût fixe. En H1 (1 600 kWh/an), SEDRE facture un comptage de 81,60 CHF/an, soit 5,10 ct./kWh, contre 4,50 pour la médiane des GRD (base « tous les GRD »). Ce montant fixe pèse d'autant plus que la consommation est faible : l'écart de comptage passe de +0,60 ct. en H1 à +0,38 en H2 et +0,21 en H4. Écarts de SEDRE à la médiane GRD par composante, en H1 : comptage +0,600, acheminement +0,248, énergie +0,218, redevance communale −0,500 (SEDRE : 0). Limite de méthode : les médianes par composante ne s'additionnent pas exactement en une médiane du total ; la décomposition indique la cause, ce n'est pas une identité comptable.
Méthode de la médiane ElCom — confirmée par la source primaire. Wegleitung zur Tarifdeklaration, tarifs 2026 (ElCom, réf. ElCom-D-F2893401/4), §11.2 :
« Der Tarif des Mediankunden wird aus allen Tarifen der entsprechenden Kategorie unter Berücksichtigung der Einwohnerzahlen ermittelt. Der Tarif des Mediankunden ist der Wert, welcher in der Mitte des gelben Intervalls liegt. Würde also jeder Einwohner die gewählte Verbrauchskategorie aufweisen, hätte die eine Hälfte der Einwohner einen günstigeren, die andere Hälfte einen teureren Tarif zu bezahlen. »
§11.3 donne la procédure : communes triées par tarif croissant, cumul des habitants, « Mediankunde » = habitant de rang (somme des habitants + 1) / 2, dont le tarif est la médiane ; seuls les tarifs « grösser Null » sont pris en compte. Le §11.1 précise que les GRD ne déclarent que les communes où ils desservent « mindestens 25 Prozent der Endverbrauchenden ».
Reproduction — non résolue. La médiane publiée ne peut pas être recalculée à partir des sources de ce projet :
- la population communale n'est pas publiée sur LINDAS (ni dans
fso/register, ni dansterritorial, ni comme cube) ; - la Wegleitung ne décrit pas comment une commune desservie par plusieurs GRD entre dans le cumul des habitants. LINDAS publie une part de desserte par GRD et par commune (
elcom:coverageRatio, sur les objetsschema:Offer), renseignée à partir de 2025, mais aucun texte de l'ElCom consulté ne la relie au calcul de la médiane.
L'écran dit donc seulement que la médiane ElCom est pondérée par la population selon la méthode de l'ElCom, et que les données ouvertes ne permettent pas de la reproduire.
Comptage et médiane publiée. La Wegleitung (§11.3) énumère quatre composantes pour le calcul des médianes (Netznutzung, Energie, Abgaben an das Gemeinwesen, Netzzuschlag), sans mentionner le comptage, publié séparément depuis 2026. Le cube de médiane suisse publie pourtant sa propre composante comptage (1,744 en H4 2027), et son total (26,530) est de l'ordre des totaux comptage compris (médiane brute des lignes : 26,557 avec, 24,878 sans). SEDRE est donc comparée à ce repère sur des totaux, de part et d'autre.
5. Module B remplacé par une note de méthode¶
La carte d'éligibilité au regroupement (module B du cadrage) n'est pas construite : sans la topologie du réseau, elle prétendrait savoir ce qu'elle ne sait pas. Elle est remplacée par une page statique (/energie/regroupement) qui sépare ce que les données ouvertes établissent (RegBL, registre des installations de l'OFEN, GRD par commune de l'ElCom), ce qu'elles approximent (potentiel solaire sonnendach, « même site » par la parcelle, installations sans EGID) et ce qui est hors de portée : le rattachement de chaque point de mesure au réseau, donnée interne du gestionnaire. Les chiffres sont un instantané daté (2026-09-27, trois communes de SEDRE SA), pas une ingestion : aucun modèle ni migration.
Les critères juridiques des régimes de regroupement (CEL, RCP, RCPv) n'ont pas été vérifiés dans les textes légaux. La page les affiche comme tels et ne les associe à aucune donnée ; un test vérifie qu'aucun régime n'apparaît dans les blocs de données.
Conséquences¶
Positives¶
- Zéro impact sur l'existant : aucun modèle hydrologique modifié, les 344 tests restent verts par construction.
- Migration purement additive : une image antérieure tourne sur le nouveau schéma, le rollback Coolify reste possible.
- Réutilisation de l'endpoint LINDAS et de la discipline d'ingestion (
IngestionRun, hash, archive).
Négatives¶
- Le client SPARQL doit être généralisé (le parser actuel est couplé à l'ontologie hydro) : environ 1,5 jour, chiffré dans l'audit.
- Deux domaines sans lien cohabitent dans le même schéma. Le récit du projet (vitrine hydrologique) devra le justifier.
- L'import dépend de deux graphes LINDAS (
elcom/electricitypriceetfso/register), tous deux sur le même endpoint.
Neutres¶
- Pour le volume attendu (~600 à 700 k lignes au plus sur 2011–2027), Postgres ne pose aucun problème.
Hors scope¶
- Module B (carte d'éligibilité au regroupement) : remplacé par une note de méthode statique (§5), sans modèle ni ingestion.
- Toute modification de
Stationou d'un modèle hydrologique. - Toute extension du chat (ADR-012) aux données tarifaires.
- Tout calcul de médiane, d'interpolation ou d'estimation.
Alternatives écartées¶
Modèle dimensionnel ouvrage / territoire / temps (brief initial)¶
Refonte de 45 fichiers pour unifier des entités sans donnée commune. Voir la décision §1.
Stocker les tarifs dans Measurement¶
Measurement est rattachée à Sensor → Station, avec une unicité [sensorId, recordedAt]. Un tarif n'a ni capteur ni station ; il faudrait des stations fictives. Rejeté.
Application séparée¶
Il faudrait recréer CI, déploiement, Prisma, design system et observabilité pour un gain nul. Rejeté au profit d'un module isolé dans le monorepo.