# ETM-PowerSync — Contrat de données UI (qui calcule quoi) > Compagnon de `DASHBOARD_SPEC.md`. Tranche la question récurrente > « **qui calcule les valeurs affichées ?** » pour chaque écran. > Rev. 5 — sonde `.75` : réserve décision-2 **levée** (cumuls séparés) ; ajout de > 3 garde-fous : 2 conventions de signe (§7.1), buckets par Δ-de-cumuls (§7.3), > gestion reset compteurs (§2.1/§8.1) ; RP calquées sur le template nymea, Influx > v1.6.7 confirmée. > Rev. 4 — §8.3 corrigé : **réutiliser l'InfluxDB nymea** (pas SQLite), DB ETM > dédié + rollups natifs (retention policies) + checkpoint local pour le contrôle > vivant. Alerte : tag Docker `latest` → InfluxDB 3 Core (72 h de rétention). > Rev. 3 — ajout §7 (Héos data inputs & metering map) + §8 (persistance & > historique : mesures brutes stockées / ratios dérivés / plan stocké). > Rev. 2 — §2 corrigé après sonde terrain sur `.75` (nymead 1.15.2) : > `GetPowerBalanceLogs` n'expose **aucun ratio**, seulement > production/consumption/acquisition/storage + cumuls. La source canonique > des ratios est donc le **state du plugin energymanager**, pas l'app, > pas l'Energy experience nymea. --- ## 0. Principe directeur **L'app Flutter est un moteur de rendu, pas un moteur de calcul.** Source de vérité unique, comme `INTERFACE_etmvariableload.md` côté plugins. Si l'app refait les maths de son côté, elle finit par **contredire l'arbitre** (cf. Invariant 3 — charges pilotées au Grid mais hors surplus d'arbitrage ; cf. phantom EV reservation — commandé vs mesuré). Un seul endroit calcule. ### Frontière | Catégorie | Où ça se calcule | Raison | |---|---|---| | Sens / couleur des flèches du flow | **App** (dérivé du signe d'un état) | trivial, non ambigu, instantané | | Surplus d'arbitrage | **energymanager plugin** | respecte l'Invariant 3 | | Auto-conso / autonomie / totaux jour | **energymanager plugin (state)** | nymea 1.15.2 ne logge pas les ratios (sonde `.75`) | | Décisions / raisons en cours | **energymanager** (`loadActions[]`) | l'arbitre est seul à savoir *pourquoi* | | Plan de la journée (futur) | **Héos** (etm-powersync-eos) | seul à avoir forecast + MILP | | Plan de la journée (passé) | **nymea energy logs** | historique réalisé | ### Couches (rappel) 1. **nymea things** → états instantanés bruts (P par compteur) + energy logs (historique). 2. **energymanager plugin** → surplus (Invariant 3), décisions+raisons, ratios live. 3. **etm-powersync-eos (Héos)** → forecast + plan + raisonnement tarifaire. 4. **App Flutter** → rend 1+2+3. Ne dérive QUE le sens des flèches. --- ## 1. Flux énergétique (flow-card) ### 1.1 Direction & couleur — règles figées | Nœud | Comportement | Sens des particules | Couleur | |---|---|---|---| | **Solaire (PV)** | producteur pur | toujours PV → hub | jaune `#FEC113`, jamais inversé | | **Maison** | consommateur pur | toujours hub → Maison | bleu `#31A3DD`, jamais inversé | | **Réseau** | bidirectionnel | import = Réseau→hub / export = hub→Réseau | import **rouge** (coût) · export **vert atténué** (revente faible) | | **Batterie** | bidirectionnel | charge = hub→Bat / décharge = Bat→hub | décharge **vert** (aide) · charge **neutre/bleu** | > La convention de signe doit être **explicite et documentée** (un seul endroit). > Sur la capture actuelle : Réseau en rouge vers le centre = import 2.4 kW ✓. > Vérifier que le sens batterie reflète bien charge vs décharge. ### 1.2 Héos au centre — décision à prendre Physiquement **faux** : Héos est le contrôleur, pas un nœud électrique. Le vrai point de couplage AC est au centre, Héos est à côté. - **Option A** — garder Héos au centre comme « le cerveau ». Les lignes = la conscience/le contrôle, pas le câble. Métaphore assumée. Fort en branding. Acceptable car la carte parle *de pilotage*. - **Option B** — nœud central = point de couplage (busbar AC neutre), Héos en badge/overlay. Physiquement correct, plus sobre. → **Reco** : A est défendable tant que l'animation respecte 1.1. Ne pas laisser les particules « traverser » Héos dans un sens physiquement impossible. ### 1.3 Mono vs multi-onduleur - Le **nœud Solaire du diagramme affiche TOUJOURS l'agrégat** (somme). Ne jamais multiplier les nœuds dans le schéma — ça le casse visuellement. - Le détail (tap sur `+`) gère la multiplicité : - 1 onduleur → prod + strings si dispo. - N onduleurs → agrégat en tête + liste par onduleur. ### 1.4 Détails Maison & compteur non mesuré (ex. PAC) - Nœud Maison = consommation **totale**, dérivée : `Maison = PV + import_grid + décharge_bat − export_grid − charge_bat`. - Le détail ne montre des sous-postes **que pour les compteurs qui existent**. - **Charge connue mais non mesurée (PAC sans compteur dédié)** : - ne **jamais** afficher un faux 0 ni une estimation inventée (anti-oversell) ; - soit on l'omet, soit on affiche « non mesuré » explicitement. - Décision implicite à acter : la décomposition par appareil **nécessite un compteur par appareil**. Pas de compteur PAC = pas de ligne PAC. --- ## 2. Cartes Auto-conso / Autonomie - **Agrégats sur une période** → **jamais** calculés par l'app (sinon dérive selon le rythme de polling, et désaccord avec le bilan/logs). - **Réalité 1.15.2 (sonde `.75`)** : `GetPowerBalanceLogs` n'expose **aucun ratio ni décomposition par bande** — seulement production / consumption / acquisition / storage + **cumuls**. La prémisse « ratios déjà calculés backend » est donc fausse littéralement. - **Source canonique = state du plugin energymanager** (une seule source). Pas l'app, pas l'Energy experience. Justification : ce n'est pas un pis-aller, c'est le **seul composant capable de calculer juste** (voir ci-dessous). ### 2.1 Formules déterministes (depuis cumuls, zéro drift de polling) ``` autonomie = (consommation − import) / consommation autoconsommation = (production − export) / production ``` - **autonomie** : `import` = Δ `totalAcquisition`. ✅ - **autoconsommation** : `export` = Δ `totalReturn`. ✅ **RÉSERVE LEVÉE (sonde `.75`)** : le réseau *instantané* est net-signé (un seul champ `acquisition`), MAIS les **cumuls `totalAcquisition` / `totalReturn` sont séparés**. Donc les deux ratios sont dérivables des logs par **deltas de cumuls** — pas besoin que le plugin intègre import/export pour l'historique. - ⚠️ **Compteurs = monotones requis.** Δ d'un cumul n'est valide que si le compteur ne reset jamais. Sur reset (restart nymead, re-add thing, rollover) → Δ < 0 : détecter (`Δ < 0` ⇒ reset, reseed sur la nouvelle valeur, ne pas créer un bucket négatif). À gérer côté plugin **et** dans les CQ de rollup. - Ces formules sont **correctes avec batterie** : import/export au compteur réseau nettent déjà les flux batterie ; PV→batterie→maison compte comme autoconsommé (pas exporté). Pas de correction round-trip. ### 2.2 Bande autoconso instantanée (graphe Puissances) - Même propriétaire : `selfConsumptionPower = max(production − max(export,0), 0)`. - À exposer en **state plugin**, pas dérivé app — sinon re-bug de signe à chaque refonte (le `.abs()` interim n'était qu'un pansement sur un calcul app-side). --- ## 3. Carte décisions — AVEC vs SANS Héos (frontière open-core) La carte actuelle parle **au futur** : « coût réseau ce soir 0,22 € », « objectif 80 % pour 18h ». Ça suppose un **forecast + un plan** = Héos. ### 3.1 Avec Héos (tier Predict/AI) Inchangé : raisonnement tarifaire, objectifs horaires, plan. Texte = sortie Héos. ### 3.2 Sans Héos (tier Community, rule-based) Le rule-based **n'a ni forecast ni plan**. Il ne peut donc pas tenir ce discours. - En-tête : « **Pilotage automatique** » (pas « Héos pilote »). - Décisions exprimées **au présent, réactives** : - « Surplus solaire 2.0 kW → chauffe-eau activé » - « Pas de surplus → voiture en pause » - **Zéro promesse au futur** (pas de « ce soir », pas de « objectif 18h »). Honnêteté : ne pas faire croire à une intelligence prédictive absente. - Source du texte : `loadActions[]` de l'energymanager (la *raison* additive qu'on a déjà figée), pas une reconstruction côté app. ### 3.3 Conséquence sur « Plan de la journée » Voir §4 : sans Héos, la partie future (hachurée) disparaît. Surface d'upsell naturelle : « Activez Héos pour planifier la journée ». --- ## 4. Plan de la journée - **Passé (barres pleines)** = énergie réalisée → **nymea energy logs**. - **Futur (barres hachurées)** = **Héos** (etm-powersync-eos → socket → energymanager → state/endpoint exposé). L'app ne projette **rien** elle-même. - Sans Héos : n'afficher **que le passé**. Pas de projection bricolée côté app. - Légende (Maison / ECS / VE / Batterie / Réseau / Batt→maison) = catégories de l'allocation Héos ; mêmes clés que le plan, pas de remapping côté app. --- ## 5. Config — Things (placeholder actuel) L'app n'est **pas** un fork de nymea-app → il faut réimplémenter les RPC (`Integrations.*` : GetThings, GetThingClasses, GetVendors, DiscoverThings, AddThing/PairThing/ConfirmPairing, EditThing, RemoveThing). C'est gros, et nymea-app le fait déjà bien. **Décision de périmètre à acter explicitement** : - **Option MVP** — liste read-only des things + actions simples (renommer, activer/désactiver, supprimer). 80 % de la complexité du wizard est dans l'ajout avec discovery/pairing/paramètres — on l'évite. - **Option ciblée** — réimplémenter le wizard **seulement pour TES classes** (eastron, abbterra, v2c… dont tu maîtrises les params). - **Option hand-off** — la config avancée reste dans nymea-app (installateur), l'app ETM se concentre sur l'experience énergie (client final). → Question vraie : le client final a-t-il **besoin** d'ajouter des things, ou c'est un acte installateur (toi/Mathieu) ? Si installateur-only, ne pas construire un demi-wizard. --- ## 6. Config — Protocoles → ModbusRTU master Surface **finie et bien définie**, dont **tes propres plugins compteurs dépendent** (SDM, abbterra) → vaut le coup en natif. - `ModbusRtu.GetSerialPorts` → lister les ports. - `GetModbusRtuMasters` / `AddModbusRtuMaster` / `Reconfigure…` / `Remove…`. - Params : serialPort, baudrate, parity, stopBits, dataBits. → Petit, fermé, et bloquant pour tes meters. Le construire avant le wizard Things. --- ## 7. Héos — data inputs & metering map ### 7.1 Règle de granularité **nymea ne stocke que ce qui est compté.** Un consommateur n'a de série de puissance que s'il est un *thing avec un state `currentPower`* (interfaces `smartmeter`/`energymeter`/consumer/producer, `logged: true`). → **La granularité d'optimisation Héos = la granularité de comptage.** ⚠️ **DEUX conventions de signe coexistent — ne pas les confondre** (piège « fix » d'un faux bug) : 1. **Thing-level `currentPower`** : point de vue **consommateur** → **négatif pour un producteur** (le PV a `currentPower` < 0). C'est la cause du bug autoconso / `.abs()`. 2. **Power balance agrégé** (`production` / `consumption` / `acquisition`) : `production` **positif**, `consumption` positif, `acquisition` **net signé** (+ import / − export). (Sonde `.75` : `acquisition = -231`, et un `currentPowerProduction` **positif** au niveau balance — normal, ce n'est PAS le `currentPower` thing-level.) Le signe est **canonique des deux côtés** — l'app ne le devine jamais ; elle ne dérive que le *sens* d'un flux à partir du signe (§1.1), rien d'autre. ### 7.2 Carte de comptage (état du parc) | Consommateur | Thing ? | `currentPower` ? | Héos optimise finement ? | |---|---|---|---| | Réseau (GRID) | ✅ | ✅ | référence (root meter) | | PV / onduleur | ✅ | ✅ (négatif) | oui, **par onduleur** | | Batterie | ✅ | ✅ | oui | | VE (wallbox) | ✅ | ✅ | oui | | ECS (relais SDM) | ✅ | ✅ ou déduit du niveau | oui | | **PAC** | SG-Ready | ❌ `sgReadyMode` seul | **non — pilotable, pas mesurable** | | **Clim (Zenkeo/Tuya)** | bridge | ⚠️ seulement si DP Tuya puissance | partiel | | Maison (base) | dérivé | résidu = total − charges pilotées | `Maison_base` | - **Trou PAC** : SG-Ready n'expose que `sgReadyMode` (Off/Low/Standard/High), aucune puissance. Sans compteur dédié, Héos **pilote** la PAC mais ne la **mesure** pas ; sa conso reste noyée dans `Maison_base`. - **Clim** : seulement si `clim_bridge.py` remonte un state puissance. - **Production par panneau de toit : non.** Par onduleur oui ; par string seulement si le plugin expose les states MPPT. Ne pas concevoir Héos en supposant une granularité panneau. - **Conséquence** : toute optim PAC/clim fine est une décision **matérielle** (poser un compteur / exposer un DP), pas logicielle. ### 7.3 Alimentation de Héos — Route B (tranché) - **States = source live**, lus en in-process par le plugin. - **energymanager = série historique canonique** par consommateur/générateur, échantillonnée à la résolution du MPC (buckets 15 min), Invariant-3-correcte. - **logs nymea = filet / contrôle croisé**, pas l'entrée primaire de Héos. - **Construire les buckets par Δ de compteurs, pas par intégration du signal live** (sonde `.75` : les cumuls sont séparés) : - grid import/export du bucket = Δ `totalAcquisition` / Δ `totalReturn` ; - conso par appareil = Δ `thing.totalEnergyConsumed` ; - exact, pas de drift d'intégration. L'intégration du signal net-signé ne sert qu'au temps réel sous le bucket (affichage), pas à la série stockée. - ⚠️ même gestion de reset qu'en §2.1/§8.1 (Δ < 0 ⇒ reset). - Justification Route B : la Route A (logs nymea bruts) impose le downsampling/ rétention de nymea sans contrôle du schéma ; le plugin reste seul à figer la décomposition Invariant-3 dans la série curée. --- ## 8. Persistance & historique > Deux classes de données aux cycles de vie **opposés**. C'est la règle centrale. ### 8.1 Mesures — stocker le brut, dériver les ratios - On stocke le **brut intégrable** par bucket : production, consommation, import, export (séparés !), + par charge pilotée, + charge/décharge batterie. → obtenu par **Δ de cumuls** (`totalProduction`, `totalReturn`, `totalConsumption`, `totalAcquisition`, `thing.totalEnergyConsumed`), pas par intégration du signal live (§7.3). **Gérer le reset** (Δ < 0 ⇒ reseed). - Les **ratios ne sont JAMAIS stockés** comme donnée primaire — vues dérivées : ``` autonomie = (Σ conso − Σ import) / Σ conso autoconsommation = (Σ prod − Σ export) / Σ prod ``` → recalculables à la lecture pour **n'importe quelle période** (jour / semaine / mois / plage custom). Stocker des ratios figés = verrouillage de granularité. ### 8.2 Plan — stocker explicitement (non recalculable) - Un **ratio se reconstruit à l'infini** depuis le brut. Un **plan ne se recalcule jamais après coup** : il dépendait des prévisions PV/conso/tarif **à T0** et de la solution MILP de cet instant. → **S'il n'est pas persisté au moment où il est produit, il est perdu.** - Schéma : `(produced_at, bucket_start, asset, planned_value [, inputs_hash])`. → stockable dans Influx tagué par `plan_run_id` ; le plan **en cours d'exécution** est en plus checkpointé localement (§8.3). - Cadence limitée : ne pas stocker chaque tick MPC. Garder un **plan-of-record** (ex. 1/h ou snapshot début+fin de journée), pas chaque itération. - Débloque **plan vs réalisé** : overlay prévu/réalisé → confiance client + matière première du federated-learning-lite (erreur de prévision). ### 8.3 Store & rétention (réalité embarquée) **Réutiliser l'InfluxDB déjà présente** (nymea s'en sert). L'argument « service en plus » ne tient pas : le coût est déjà payé. Influx est faite pour les séries ; ses **retention policies + downsampling (CQ 1.x / tasks 2.x)** font les rollups **nativement** → moins de code qu'un SQLite hand-rollé. Et configurer la rétention **corrige au passage le « shard accumulation »** qui faisait crash-looper l'instance (les shards s'empilaient faute de purge). - **DB / bucket ETM dédié** — pas dans la base `nymeatest` de nymea. Isole ta donnée des upgrades/migrations nymea et te donne ta propre rétention. - **Calquer les paliers sur le template RP nymea existant** (sonde `.75`) plutôt que d'inventer : `live 24h · minutes 7j · hours 3 ans · days 20 ans` (+ `discrete 1 an`). Cohérence opérationnelle, CQ alignées. - ⚠️ **Ne pas écrire dans `autogen` (DEFAULT, rétention ∞).** C'est le vrai risque de croissance (pas un shard runaway : `nymeatest` n'a que 2 shards aujourd'hui). Router explicitement les writes ETM vers les RP tiers. - ⚠️ **Version Influx** : `.75` est en **v1.6.7** ✓ (pas 3 Core, pas de piège 72 h). Le tag Docker `latest` pointe désormais vers **InfluxDB 3 Core** (rétention 72 h + limite 5 DB en OSS) → **pinner**, ne jamais laisser un upgrade auto basculer. - **Checkpoint local minuscule** (fichier / petit SQLite) pour l'**état de contrôle vivant** (plan en cours d'exécution, dernier setpoint). Si Influx tombe, Héos ne doit **ni hard-fail ni perdre le plan exécuté** — dégradé propre, cohérent avec le L2 watchdog. ***Logging store down ≠ arbitrage down.*** - Distinct du log engine nymea (filet brut générique) : la série dans le DB ETM est la **série curée** (Invariant-3, import/export scindés) dont dérivent Héos *et* l'historique client. ### 8.4 Read model de l'historique client ``` période choisie → agrège les mesures au palier de rollup correspondant → dérive autoconso / autonomie (§8.1) → overlay plan-of-record (§8.2) si dispo → vue « prévu vs réalisé » ``` Même donnée parcourue dans les deux sens du temps : passé = mesures réalisées, futur = plan Héos. La metering map §7.2 est le socle commun (entrée Héos + bandes du « Plan de la journée »). --- ## Récap des décisions 1. Héos au centre du flow : **Option A — métaphore « cerveau » assumée** (condition : animation respecte les sens figés §1.1, jamais de particule à contre-sens à travers Héos). ✅ tranché. 2. Source des ratios : **state du plugin energymanager** (ni app, ni Energy experience). Dérivation depuis cumuls via §2.1. **Réserve levée** (sonde `.75` : `totalAcquisition`/`totalReturn` séparés) — dérivable des logs, le plugin n'a pas à intégrer pour l'historique. ✅ tranché. 3. Périmètre Things app : **hand-off nymea-app + MVP read-only client** ; **ModbusRTU master en premier** (§6, bloquant pour SDM/abbterra). Wizard ciblé seulement si vraiment nécessaire. ✅ tranché. 4. Carte sans Héos : wording **présent/réactif** + masquage du futur, texte issu de `loadActions[]`. ✅ tranché. 5. Alimentation Héos : **Route B** — states (live) + série canonique energymanager (15 min, Invariant-3), logs nymea en filet. ✅ tranché. 6. Persistance : **mesures brutes stockées + ratios dérivés** ; **plan stocké explicitement** (non recalculable) ; **réutiliser l'InfluxDB existante** (DB ETM dédié, rollups via retention policies) + **checkpoint local** pour l'état de contrôle vivant. ⚠️ pinner Influx en **1.x/2.x** (pas 3 Core / 72 h). ✅ tranché. ### Vérifié sur sonde (`.75`) - ✅ **PowerBalance** : instantané net-signé (`acquisition`), cumuls `totalAcquisition`/`totalReturn` **séparés** → ratios dérivables par Δ (§2.1). - ✅ **Influx v1.6.7** (pas 3 Core). DB nymea = `nymeatest`, RP existantes (`live/minutes/hours/days/discrete`), DEFAULT = `autogen` ∞ (le vrai risque). ### Reste ouvert (vrai) - **Confirmer les signes sur box réelle** (`.120`) — `.75` est statique/factice (totaux à 0). L'archi plugin=source rend l'app sign-agnostic, mais valider. - **Carte de comptage §7.2** : décider produit par produit quels consommateurs non mesurés (PAC, clim) justifient un compteur matériel pour une optim fine. - **Gestion reset des compteurs** (§2.1/§7.3/§8.1) : implémenter la détection Δ < 0 côté plugin **et** dans les CQ de rollup.