LM-1013-b-v. Tranché le 2026-09-01, jamais écrit — la décision a passé au second
plan quand sessionWh a été alimenté depuis le compteur de charge et que le régime
`unmeasurable` s'est réduit aux trois cas matériels. C'est la première classe
d'erreur d'AGENTS.md : une règle qui expire sans être exécutée. Elle n'était ni
dans le code, ni dans la spec, ni au contrat.
LE PROBLÈME. L'étalement `reste / temps restant` suppose que le NUMÉRATEUR bouge.
Sous `unmeasurable` il ne bouge pas : `remainingWh` est figé à la cible entière,
par contrat, parce qu'on ne sait pas ce qui a été livré et que le rebaser serait
affirmer sur ce qu'on ignore (LM-1009 C1). Un rapport dont seul le dénominateur
décroît ne suit plus un retard : IL SUIT L'HORLOGE. On achetait davantage parce
que le temps passait, jamais parce que quelque chose était en retard.
CHIFFRÉ, et le chiffre a été refait plutôt que repris. Obligation 4 kWh, plafond
7 kW, fenêtre d'un jour : l'ancienne loi achetait 19 010 Wh, soit ×4,75
l'obligation. Le facteur suit ln(T·P/E) + 1 — il grandit avec la fenêtre, ce qui
est le pire sens possible : plus on laisse de temps pour bien faire, plus on paie.
Une charge qu'on mesure mal coûtait ainsi structurellement plus cher qu'une charge
qu'on mesure, l'incitation exactement à l'envers.
(Le ×2,5 retenu au moment de trancher valait pour une fenêtre bien plus courte.
Sur la fenêtre quotidienne, qui est celle du modèle, c'est ×4,75. La conclusion ne
change pas ; le chiffre, si — et un chiffre faux dans une spec finit par servir de
prémisse à quelqu'un.)
LA LOI. Cible ÷ fenêtre de l'obligation, constante, et `atMaximum` jamais vrai. La
fenêtre est le JOUR parce que l'obligation est déclarée par jour — ce n'est pas un
réglage, c'est l'unité de la grandeur qu'on étale. Le total acheté sur la fenêtre
vaut alors EXACTEMENT la cible. On garde « viser l'obligation entière », le choix
qui sert ; on perd l'escalade, qui ne reposait sur rien.
CE QUE LE TEST TIENT, et il a été vu mordre — désarmé, il rend 667 W au lieu de
167 W à six heures de l'échéance :
· la cadence est identique à 6 h, 2 h, 1 h, 30 min et 5 min de l'échéance ;
· `atMaximum` reste faux jusqu'à la dernière minute ;
· l'intégrale sur les 1440 minutes de la fenêtre vaut la cible à 5 Wh près —
la propriété qui justifie la loi ne se lit sur aucune valeur isolée ;
· CONTRE-ÉPREUVE : même obligation, même instant, même numérateur (4000 non
livrés) sous `measured` — durcissement et point de non-retour intacts. Sans
elle, avoir supprimé l'étalement POUR TOUT LE MONDE passerait inaperçu.
AU CONTRAT. La loi de commande dépend désormais du régime, et un écran qui
suppose la même dans les deux se trompera sur la trajectoire — qui est ce qu'il
vend. INTERFACE.md porte la table des deux lois, et dit ce que l'écran ne peut
plus affirmer sous `unmeasurable` : ni que l'achat va s'intensifier, ni que
`atMaximum: false` veut dire que tout va bien — il veut dire qu'il n'y a pas
d'étalement du tout, donc rien qui puisse cesser.
Suites : loadmodel 23/23, simulation 66/66, charging 48/48, spotmarket 32/32.
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 — 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'optimisationupdate(), ~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 surpowerBalanceChanged. Elle lit le courant par phase du root meter ; si une phase dépassephasePowerLimit × 230 W, elle throttle immédiatement (issuerOverloadProtection).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) :
- Handshake — le backend annonce
{ protocolVersion, name }. Le plugin vérifie la version ; incompatible → refus + repli règles. - 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 unLoadAction[]({ loadId, action, targetPowerW, reason }) ouabstain. - 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 :
QDateTimeISO 8601 avec timezone. - Puissances en Watts, courants en Ampères.
phasePowerLimitest en Ampères par phase. currentPowerroot meter négatif = surplus (export).phasePowerLimit = 0→ smart charging désactivé.weightingd'unScoreEntry: 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
requestOptimizationposée, stratégie règles par défaut. Modecommunity: 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.