Patrick Schurig baf267a963 feat(3g-2): une charge, un commandeur
Le relevé machine du 2026-08-26 l'énonçait sans détour : « tant que ceci n'est
pas traité, la transplantation ne commande rien ». L'arbitre décidait,
publiait un motif juste, et adjustEvChargers() commandait autre chose une
milliseconde plus tard. Ce lot ferme les trois points que le banc a ouverts.

1. UNE CHARGE, UN COMMANDEUR

Les actions EV du waterfall sont enfin DISPATCHÉES — elles ne l'étaient pas du
tout : m_loadAdapters ne contient pas les bornes, et applyActionsToAdapters()
s'arrêtait là. Et les bornes ainsi commandées sortent du chemin proxy par deux
crochets virtuels posés dans l'amont, interrogés à deux moments du cycle où la
réponse n'est pas la même : evSurplusPlannedByWaterfall() avant le plan (à qui
la borne appartient-elle), evCommandedByWaterfall() après (qu'a-t-il commandé).
Sans cette distinction, une borne servie par l'échéance ne serait plus
commandée du tout.

Le jeu des bornes commandées est NOMMÉ par le scheduler (Plan::waterfallEvIds).
Le redéduire ailleurs — « motif différent d'EV_DEADLINE », « financement au
surplus » — dériverait du vrai au premier motif ajouté, et EV_GRID_START porte
justement funding=Grid tout en venant du waterfall. Il est vidé à l'entrée du
mode dégradé : le waterfall ne commandant plus, il ne peut plus revendiquer.

chargingState, que posait adjustEvChargers(), est repris par l'arbitre pour
les bornes qui n'y passent plus — sinon l'app afficherait « en charge » une
borne que l'arbitre vient d'éteindre.

2. evFloorW() REND L'EXIGENCE RÉELLE, ET LA TOLÉRANCE DEVIENT VISIBLE

Le plancher valait `min × acquisitionTolerance × 230 × phases` : 2 070 W
annoncés pour une borne qui en exige 4 140. Le waterfall allouait 2 828 W,
l'adaptateur refusait d'enclencher sous 6 A, et 2 828 W étaient retirés du
budget pour personne. Ce sont deux grandeurs différentes : le plancher est ce
que la borne EXIGE, la tolérance est la part qu'on accepte de ne pas couvrir.

La tolérance n'est pas supprimée pour autant — un réglage documenté (défaut
0,5) qui devient inerte sans le dire est un silence ambigu, et tout le parc
verrait son VE démarrer deux fois plus tard sans explication. Elle devient un
seuil de démarrage EXPLICITE : motif EV_GRID_START {budgetW, floorW, gridW},
funding=Grid, et une comptabilité qui se partage — budgetW dans
budget.allocatedW, gridW dans budget.evReservedW, budgetW + gridW ==
allocatedW.

C'est une brèche assumée dans le financement réseau que DESIGN_3g §3.1 déférait
à 3f. Elle est bornée par construction, pas par discipline : surplus réel exigé
(budgetW > 0), tout le budget restant consommé donc AU PLUS UNE borne par
cycle, et le plafond de la protection de surcharge s'applique avant. Écrite en
toutes lettres dans DESIGN_3g §3.1-bis.

3. LE RANG D'UNE BORNE EST CONFIGURABLE

Il vit là où vivent tous les rangs : LoadConfig.priority (LM-1206/LM-1207). Pas
de second système de priorité — un classement propre aux bornes ne saurait pas
exprimer « VE1 > ECS > VE2 », et deux classements qui ne peuvent pas
s'interclasser sont un défaut, pas deux fonctionnalités. ChargingInfo ne porte
donc aucun champ de rang.

Toute borne détectée reçoit d'office une entrée « evcharger », portant le rang
et RIEN d'autre : ses limites viennent du Thing et changent avec la voiture
branchée. Création journalisée, une ligne par borne, à la création seulement —
une écriture que personne n'a demandée doit laisser une trace. Le tri devient
TOTAL : à rang égal, l'identifiant départage, donc laquelle démarre est
reproductible même avant tout réglage.

L'entrée SURVIT à la disparition de son Thing : borne remplacée, ThingId
changé, appareil déposé. Elle reste publiée available: false / faultCode:
THING_MISSING / motif LOAD_UNAVAILABLE, et ne retient aucun budget. La
supprimer d'office rendrait « borne remplacée » indistinguable de « borne
jamais configurée ».

CORRIGE UNE RUPTURE D'INVARIANT DE 3g-1, non signalée à l'époque : en faisant
entrer les bornes dans loads[], 3g-1 publiait des charges qui ne figuraient
dans AUCUNE GetLoadConfig — la charge fantôme que le lot B-bis avait
supprimée, réapparue par l'autre bout. L'app s'appuie sur cet invariant pour
résoudre libellé, domaine et rang. L'auto-provisionnement le rétablit par
construction.

ÉPROUVÉ

  - testEvChargerRankDecidesWhoCharges : deux bornes, un budget qui ne paie
    qu'un plancher, la seconde refusée avec BELOW_MIN_POWER {budgetW: 620,
    minPowerW: 1380}, et l'inversion du rang change la borne servie. C'est la
    moitié ARBITRAGE de LM-1204-b. Couvre aussi l'aller-retour neutre par la
    frontière RPC et l'inclusion loads[] ⊆ GetLoadConfig.
  - testEvGridStartIsAnnouncedAndBounded : le motif chiffre le soutirage, la
    comptabilité se réconcilie, une seule borne soutire par cycle.
  - testEvChargerConfigOutlivesItsThing : l'entrée survit, publiée en défaut.
  - testEvChargerConfigIsRankOnly / RefusesPayloads / ClaimsItsThing.

Deux points de contrôle de `run` bougent, dans un seul scénario et par une
cause unique : la conversion watts→ampères se fait désormais après
l'allocation, et l'arrondi tombe parfois de l'autre côté. 14 des 16 scénarios
sont identiques au bit près, et ce qui compte est inchangé dans les deux —
la voiture est à 100 % à l'échéance.

Suite complète : simulation 27/27, charging 16/16, loadmodel 16/16,
spotmarket 7/7. Build amd64 0/0.

RIEN N'A TOURNÉ SUR MACHINE. docs/RECAP_3g2.md dit ce qui reste à éprouver au
banc — dont LM-1204-b avec deux bornes réelles, la Terra AC étant vérifiée
absente du réseau (ARP INCOMPLETE depuis le banc lui-même).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017TVywtayEeoRJ2yWUW4iZj
2026-08-27 10:43:25 +02:00
2026-01-11 11:09:23 +01:00
2026-01-11 11:09:23 +01:00
2026-01-11 11:09:23 +01:00
2026-01-11 11:09:23 +01:00

etm-powersync-energy-etm

Moteur HEMS pour l'expérience énergie de nymea. Fork de nymea-energy-plugin-nymea (GPL-3.0), étendu d'un optimiseur EV vers un gestionnaire d'énergie complet : bornes, ECS, PAC, batterie, relais — pilotés sous une seule logique d'arbitrage, en local, sans dépendance cloud.

Ce plugin est un EnergyPlugin (il hérite de libnymea-energy, ce n'est pas un IntegrationPlugin). Il n'expose aucune ThingClass propre : il orchestre les Things créées par d'autres plugins, détectées par interface (evcharger, electricvehicle, rootmeter/energymeter, energystorage, et à terme heatingelement, heatpump, relais).

Produit : libnymea_energypluginetm.so → /usr/lib/nymea/energy/


Statuts honnêtes

Chaque bloc affiche son état réel. On ne documente pas une promesse.

Statut Sens
STABLE Hérité de l'amont, fonctionne en production
EN COURS En industrialisation pour la beta
ROADMAP Planifié, non implémenté

Modèle open-cœur

Deux couches, frontière nette. Le plugin de ce repo est entièrement GPL-3.0.

  • Couche A — ce repo (GPL-3.0). Toute la mécanique HEMS : ingestion d'état, sécurité, arbitrage, stratégie par défaut basée sur règles, adaptateurs de charge. Tourne seul, sans aucun composant externe. C'est aussi le candidat à une contribution amont chez nymea.
  • Couche B — optimiseur externe (hors de ce repo). Un processus séparé (propriétaire Héos, ou tiers communautaire) qui se branche par un socket local documenté. Il propose des décisions ; il ne peut jamais contourner la sécurité.

Principe directeur : le plugin est agnostique à l'optimiseur. Héos et un optimiseur tiers sont des backends interchangeables derrière le même contrat. Aucun code propriétaire ne vit dans ce repo — la séparation se fait par processus + protocole publié, pas par linkage.

Conséquence pratique, et invariant testé : socket absent ou muet → repli automatique sur la stratégie de règles intégrée. En mode Community, le plugin fonctionne intégralement sans optimiseur.


Architecture ETM PowerSync


Architecture — les blocs

1. Ingestion d'état — STABLE

Le plugin lit l'état du système via les interfaces nymea, jamais par ThingClassId.

Interface États lus Actions émises
evcharger chargingEnabled, maxChargingCurrent, pluggedIn, charging, currentPhaseA/B/C, currentPowerPhaseA/B/C setChargingEnabled, setMaxChargingCurrent
electricvehicle batteryLevel, maxChargingCurrent, capacity, minChargingCurrent, phaseCount —
rootmeter / energymeter currentPowerPhaseA/B/C, currentPhaseA/B/C —
energystorage currentPower, batteryLevel —
heatingelement, heatpump, relais à définir à définir — ROADMAP

Le currentPower du root meter est négatif en cas de surplus (export réseau). C'est la grandeur d'entrée centrale du moteur.

Deux déclencheurs distincts alimentent les deux boucles (voir §2) :

  • PowerBalanceEntryAdded (SampleRate1Min) → le cycle d'optimisation update(), ~1×/min.
  • powerBalanceChanged (temps réel, à chaque changement) → la boucle de sécurité, découplée du cycle.

2. Les deux boucles — STABLE

C'est la décision d'architecture la plus importante du moteur : sécurité et optimisation sont deux boucles séparées, à cadences différentes, avec des priorités différentes.

  • Boucle de sécurité (verifyOverloadProtection()) tourne en temps réel sur powerBalanceChanged. Elle lit le courant par phase du root meter ; si une phase dépasse phasePowerLimit × 230 W, elle throttle immédiatement (issuer OverloadProtection). verifyOverloadProtectionRecovery() ré-active quand la marge revient.
  • Boucle d'optimisation (update()) tourne ~1×/min sur le cycle lent. C'est elle, et elle seule, que l'optimiseur externe peut influencer.

L'optimiseur ne touche jamais la boucle de sécurité. Quoi qu'il propose, le plafond de puissance (overload, et à terme la contrainte réseau §14a) borne la sortie. C'est ce qui rend l'optimiseur débranchable, et autorise du code tiers non audité à se brancher sans risque pour l'installation.

3. Arbitrage central — EN COURS

L'amont calcule un budget de surplus pour les bornes EV uniquement, via le terme addedPower de planSurplusCharging() (« puissance déjà engagée dans ce cycle »). Le fork généralise ce budget en allocation centrale : un coordinateur unique tient le budget de surplus disponible, interroge chaque charge par priorité décroissante, et décrémente le budget à chaque engagement.

C'est la pièce qui évite le sur-engagement : sans budget central, un manager ECS, un manager batterie et le manager EV verraient chacun « 2 kW de surplus » et tireraient 6 kW. L'allocation reste unique et centrale ; les charges ne sont que des consommatrices du budget.

4. Adaptateurs de charge — EN COURS

L'application d'une décision à un appareil passe par un adaptateur par type de charge. L'amont a executeChargingAction() → Thing::executeAction(setChargingEnabled, setMaxChargingCurrent) pour l'EV. Le fork généralise : un LoadAdapter traduit une allocation de puissance en action device selon le type — ampères pour la borne, palier de relais pour l'ECS, décalage de consigne pour la PAC.

L'EV devient ainsi un type de charge parmi d'autres, et non plus le centre du moteur.

Adaptateur Statut
evcharger (hérité) STABLE
ECS / relais EN COURS (cible beta)
PAC / HVAC ROADMAP
Batterie ROADMAP

5. Stratégie par défaut basée sur règles — EN COURS

La logique de décision locale (planSurplusCharging(), planSpotMarketCharging()) devient la stratégie par défaut, intégrée au plugin, GPL, sans aucune dépendance externe. C'est elle qui pilote en mode Community et qui prend le relais quand l'optimiseur est absent. Glouton par priorité statique avec décrément de budget — simple, déterministe, vérifiable.

6. Frontière A↔B — requestOptimization() — EN COURS

Point d'injection unique entre la couche GPL et l'optimiseur externe, entre prepareInformation() et adjustEvChargers() dans le cycle. Le plugin construit un contexte (SurplusData/SurplusContext) et appelle PowerSyncClient::requestOptimization() ; l'optimiseur retourne une allocation (ChargingAction aujourd'hui, LoadAction généralisé en cible) qui remplace ou complète les décisions locales.

Ce contrat sert trois usages d'un seul artefact : interface de stratégie interne, protocole du socket (Couche B), et spécification publiée pour la communauté.


Comment ça fonctionne ensemble — le cycle update()

Exécuté à chaque entrée du bilan de puissance (~1 min). Pipeline hérité de l'amont, généralisé par le fork :

update(currentDateTime)
  │
  ├─ 1. updateManualSoCsWithoutMeter()   estime le SoC si pas de compteur sur le VE
  │
  ├─ 2. prepareInformation()             filtre les charges actives, calcule phases/SoC/temps restant,
  │                                       reset des actions par issuer
  │                              ┌───────────────────────────────────────────────┐
  │   ── FRONTIÈRE A↔B ──────────┤ requestOptimization(SurplusContext)            │
  │                              │   optimiseur vivant → LoadAction[]             │
  │                              │   absent / muet / abstain → stratégie défaut   │
  │                              └───────────────────────────────────────────────┘
  │
  ├─ 3. verifyOverloadProtection()       SÉCURITÉ — borne la sortie, quoi qu'ait proposé l'optimiseur
  ├─ 4. verifyOverloadProtectionRecovery()
  │
  ├─ 5. planSpotMarketCharging()         créneaux bon marché (SpotMarketManager) → schedules
  │
  ├─ 6. planSurplusCharging()            currentLoad = rootMeter.currentPower()
  │                                       + correction batteries + addedPower (budget)
  │                                       allowanceInAmpere = −currentLoad / 230
  │
  └─ 7. adjustEvChargers() / applyAllocation()
         décision finale par priorité :
           1. TimeRequirement   (deadline imminente)
           2. SurplusCharging   (courant surplus)
           3. SpotMarketCharging (créneau bon marché)
           4. fallback courant minimum (6 A)
           5. Idle (OFF)
              │
              ├─ executeChargingAction() → Thing::executeAction(...)
              ├─ emit chargingInfoChanged()       (ChargingState lu en JSON-RPC)
              └─ emit chargingSchedulesChanged()  (planning lu en JSON-RPC)

En parallèle, hors de ce cycle, la boucle de sécurité tourne en temps réel sur powerBalanceChanged et peut throttler à tout instant, indépendamment de l'optimiseur.


Couche B — brancher un optimiseur sur le socket

L'optimiseur est un serveur ; le plugin est le client. L'optimiseur ouvre /run/powersync/optimizer.sock, le plugin s'y connecte.

Cycle de vie d'un backend (Héos ou tiers) :

  1. Handshake — le backend annonce { protocolVersion, name }. Le plugin vérifie la version ; incompatible → refus + repli règles.
  2. Boucle — à chaque cycle le plugin pousse un SurplusContext (surplus disponible, charges avec type/puissance/min-max/priorité/état, batterie SoC, tarif, contrainte réseau) ; le backend renvoie un LoadAction[] ({ loadId, action, targetPowerW, reason }) ou abstain.
  3. Heartbeat — la cadence des requêtes est le heartbeat. Pas de réponse dans le délai → optimizerAlive=false → repli règles, sans interruption du pilotage.

Le contrat est générique-domaine, pas spécifique à Héos : n'importe qui peut écrire un serveur (Python+ML, Rust, etc.) qui répond au même protocole. La spécification est publiée sous licence libre — voir docs/OPTIMIZER_PROTOCOL.md (ROADMAP).

Sécurité : quelle que soit la sortie du backend, la boucle de sécurité et les bornes min/max par charge plafonnent l'allocation. Un optimiseur défaillant ne peut, au pire, que proposer une mauvaise répartition — bornée et clampée.


Interface JSON-RPC

Namespace NymeaEnergy (hérité du fork ; migration vers un namespace EMS générique à l'étude pour l'amont). Transport WebSocket JSON-RPC 2.0, port nymea standard 4444. Voir INTERFACE.md pour la référence complète (méthodes, types, notifications).

Surfaces principales : overload (Get/SetPhasePowerLimit), paramètres de charge (acquisitionTolerance, batteryLevelConsideration, lockOnUnplug), configuration EV (Get/SetChargingInfo), spot market.


Configuration

Deux étages, à ne pas confondre :

Tuning intégrateur — EnergyManagerConfiguration (read-only, chargé une seule fois au démarrage ; un changement exige un redémarrage du daemon). Chemin : $NYMEA_ENERGY_MANAGER_CONFIG, sinon /var/lib/nymea/energy-manager-configuration.json, sinon les défauts (recommandé).

{
  "chargingEnabledLockDuration": 300,
  "chargingCurrentLockDuration": 5,
  "minimumScheduleDuration": 15,
  "spotMarketChargePredictableEnergyPercentage": 0.5
}
Paramètre Défaut Unité Rôle
chargingEnabledLockDuration 300 s Anti-flapping après ON/OFF
chargingCurrentLockDuration 5 s Anti-flapping après changement de courant
minimumScheduleDuration 15 min Durée minimale d'un créneau spot market
spotMarketChargePredictableEnergyPercentage 0.5 ratio Fraction d'énergie spot considérée prédictible

Cette configuration sert à tester des comportements en environnement de test. Elle n'est pas destinée à être modifiée par l'utilisateur.

Settings utilisateur (runtime, via JSON-RPC, persistés dans EnergySettings / QSettings INI energy.conf) : phasePowerLimit, acquisitionTolerance, batteryLevelConsideration, les ChargingInfo par charger, l'état spot market. lockOnUnplug vit dans un QSettings séparé. Les seuils utilisateur ajoutés par le fork (seuil surplus par charge, priorités) suivent ce chemin runtime, jamais le JSON read-only.


Conventions

  • Timestamps : QDateTime ISO 8601 avec timezone.
  • Puissances en Watts, courants en Ampères. phasePowerLimit est en Ampères par phase.
  • currentPower root meter négatif = surplus (export).
  • phasePowerLimit = 0 → smart charging désactivé.
  • weighting d'un ScoreEntry : 1.0 = créneau le moins cher, 0.0 = le plus cher.

Build, tests, simulations

Build standard nymea (qmake6, cross-compilation arm64 supportée ; publication sur le canal APT powersync-testing).

Couverture de tests :

qmake CONFIG+=coverage
make -j$(nproc)
# tests dans tests/auto :
#   nymeaenergytestcharging
#   nymeaenergytestspotmarket
./build-coverage-report.sh -b builddir

Simulations (plots de vérification de la logique métier dans results/) :

./docker-simulation.sh   # docker requis

Roadmap

  • Beta : arbitrage central généralisé, adaptateur ECS/relais, frontière requestOptimization posée, stratégie règles par défaut. Mode community : tout fonctionne sans optimiseur.
  • Post-beta : adaptateurs PAC/HVAC et batterie ; socket + protocole optimiseur documenté (GPL) ; backend propriétaire Héos ; contrainte réseau §14a EnWG comme source de plafond ; tarification FR (Linky TIC, Tempo) ; namespace EMS générique.
  • Amont : une fois la beta opérationnelle, discussion avec l'équipe nymea sur une intégration possible dans l'arbre upstream.

Licence

etm-powersync-energy-etm est sous GNU General Public License v3.0 ou ultérieure, comme l'amont dont il dérive. Tout le code de ce repo est GPL — il n'y a, et il n'y aura, aucun composant propriétaire ici. La valeur propriétaire (l'optimiseur Héos) vit dans un processus séparé, derrière le socket documenté, et n'est pas distribuée dans ce dépôt.

Une copie de la licence est incluse dans ce repo ; si elle manque : https://www.gnu.org/licenses/gpl-3.0.html.

Description
Plugin nymea Community pour le HEMS ETM PowerSync — recharge EV surplus, spot market, overload protection, pont vers powersync-optimizer (GPL-3.0)
Readme 41 MiB
Languages
C++ 97.6%
Shell 1.2%
QMake 0.8%
Makefile 0.2%
C 0.2%