Compare commits

...

29 Commits

Author SHA1 Message Date
Patrick Schurig
6670bed6cc [3e+doc] clôture 3e + TEST_TERRAIN.md + point d'étape
docs/TEST_TERRAIN.md : procédure Palier 1 (14 tests T1-T14) pour le banc nymea-dev arm64
— §0 pré-vol (forçables [À LIRE SUR LA BOX]), §1 déploiement (cross-arm64→scp→dpkg,
logging NymeaEnergy.debug, déclaration adaptateurs codée dans energypluginnymea.cpp),
§2-3 ECS 1/3 relais (dont transition non-cascadée 1500→2000), §4 SG-Ready (montée/
atomicité/hystérésis), §5 watchdog L2, §6 interaction priorités, §7 EV optionnel.

AGENTS.md ÉTAT : 3e clôturée (testEcsRelayTopologies dfdd988), audit Doxygen (5→0),
TEST_TERRAIN créé ; déféré = passe README+contrats (force/minStage-maxStage/min-maxState/
degradedMode pas encore dans le protocole publié), Waveshare, V2C, 3f, 3g, config priorités,
arm64 CI, Doxyfile+CI. Prochaine action : test terrain vendredi puis passe contrats.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 00:31:47 +02:00
Patrick Schurig
dfdd9884d0 [3e+] ECS non-cascadé : applyRelayStage off-before-on + tests 1 et 3 relais
applyRelayStage faisait déjà du set-cible complet (delta correct, gère le non-cascadé) :
durcissement off-before-on (anti sur-puissance transitoire quand monter de palier éteint
des relais, ex. 3 résistances 500/1000/2000 : 1500→2000 commute 3 relais) + intention
documentée (comme SG-Ready).

testEcsRelayTopologies : ECS simple 1 relais (on/off) + ECS 3 relais non-cascadé
(transition 1500→2000 → set final r2000 SEUL, r500/r1000 coupés). Couvre les 2 topologies
du test terrain vendredi. Suite simulation 20/20, plugin prod 0/0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 00:09:24 +02:00
Patrick Schurig
51760a7f61 [doc] audit Doxygen : \param 'now' + docs périmées après refactors 3c/3e
Audit manuel (doxygen non installé, pas de Doxyfile). 5 findings corrigés :
- EvAdapter::applyAction : \param now manquant (param partiellement documenté → warning) ;
  toLoadContext : \param now ajouté.
- EnergyArbitrator::buildContext : mention SG-Ready + \param now (source unique verrous).
- applyActionsToAdapters : dispatch State→SG-Ready documenté (était ECS/Stage seul).
- onMeterWatchdogTick : doc alignée sur le refactor 7c (délègue à evaluateMeterFreshness,
  QTimer sous #ifndef ENERGY_SIMULATION).
- RuleBasedScheduler (classe + getPlan) : décrivait seulement le proxy EV → ajout du
  waterfall non-EV (budget net signé, priorité ASC, recrédit, clamp lock-aware) et
  correction "seul ctx.timestamp utilisé" (faux : meter + loads aussi).

Concepts 3c/3e vérifiés documentés : seam de temps/lockWindow, minStage/maxStage,
atomicité 2 bits (transientHarm), mode dégradé L2, waterfall unifié + ordre EV→ECS/SG-Ready,
hystérésis SG-Ready. Build 0/0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 23:55:31 +02:00
Patrick Schurig
e641f289db [3e-étape] ÉTAT : 3e FAITE (suite 19/19) + récap capacités moteur + liste différé
Point d'étape : EV proxy + ECS + SG-Ready sur budget unifié trié par priorité,
protection compresseur, watchdog L2/mode dégradé. Différé : Waveshare (session
dédiée Modbus), 3d/3f/3g, config priorités API+UI, arm64 CI, test terrain vendredi.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 23:46:27 +02:00
Patrick Schurig
d8079e84e0 [3e-5] testSgReadySurplus : montée + hystérésis + court-cycling + budget partagé ECS↔PAC
Test simulation autonome (mock powerSwitch 2 relais, encodage 2 bits). 4 volets :
(1) montée 2→3→4 ; (2) hystérésis 3↔4 (zone morte P4×1,0–1,2, budget oscillant →
reste 4) ; (3) court-cycling (gelé sous minStateHold, bascule au-delà via temps simulé) ;
(4) budget PARTAGÉ ECS↔PAC : ordre priorité → service inverse (preuve waterfall unifié 3e).
Suite simulation 19/19, 0 régression.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 23:43:02 +02:00
Patrick Schurig
b06ac15714 [3e-4] arbitre : registerSgReadyAdapter + dispatch State + mode dégradé → état 2
registerSgReadyAdapter + m_sgReadyAdapters ; buildContext inclut les PAC ;
applyActionsToAdapters dispatche kind==State → m_sgReadyAdapters. Mode dégradé L2 :
SG-Ready → état 2 (NORMAL, mains off, force=true), JAMAIS état 1 (blocage). SAFETY.md
table L2 corrigée (état 2, pas 1). Build 0/0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 23:25:09 +02:00
Patrick Schurig
093fa09b5e [3e-3] mapping sémantique SG-Ready + waterfall unifié ECS/SG-Ready
getPlan : cascade unique sur charges non-EV (relay-stages + sg-ready) triées par
priorité — budget de surplus partagé. buildSgReadyStateAction : mapping qualitatif
(4 forcé hyst. 1,2/1,0 ; 3 reco ≥P3 ; 2 normal mains off ; 1 jamais via surplus),
recrédit sur puissance allouée déclarée, clamp lock-aware minState/maxState.

AGENTS.md : ROADMAP config priorités utilisateur (acquis tri unifié ; manque 3g VE
+ couche config JSON-RPC/UI Flutter pour drag-and-drop à chaud). Build 0/0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 23:19:54 +02:00
Patrick Schurig
c6d7831df9 [3e-2] SgReadyAdapter : encodage 2 bits → 4 états + atomicité de transition
Adaptateur sg-ready (kind:State) : pilote N relais signal (stateRelays par état),
lockWindow symétrique (minStateHold, gel total — protection court-cycling), seam de
temps unifié (toLoadContext(now)/applyAction(now)). currentPowerW = puissance allouée
déclarée (pas mesurée → recrédit correct, anti double-comptage état 2).

Atomicité 2 bits : applyStateRelays commute d'abord le relais au transitoire le plus
doux (neutre/reco) puis les autres → jamais de blocage/forcé parasite. Contrat documenté
(transport déporté Shelly/Modbus). État initial = 2 (mains off). Build 0/0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 22:53:07 +02:00
Patrick Schurig
83d5ad9ed7 [3e-1] types SG-Ready : declared.states + estimatedPowerW(état) + telemetry min/maxState
LoadDeclared : states[1-4] + estimatedPowerW QHash<int,double> (déclaré installateur,
approximatif ; états 1/2 ≈ 0 pour l'allocation surplus, invariant 8).
LoadContextTelemetry : minState/maxState (fenêtre verrou minStateHold, parallèle à
minStage/maxStage ECS). Build 0 erreur / 0 warning.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 22:42:33 +02:00
Patrick Schurig
cbee13e455 [3c-clôture] ÉTAT : 3c FAITE (suite 18/18 + charging 46/46), arm64 cross délégué CI
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 22:27:30 +02:00
Patrick Schurig
54ba2296fa [3c-7c] testMeterSilentFallback + seam watchdog injectable
Extraction recordMeterUpdate(now)/evaluateMeterFreshness(now) : logique L2 injectable
(temps en paramètre), déclencheurs réels (QTimer + powerBalanceChanged) sous
#ifndef ENERGY_SIMULATION (pattern amont). onMeterWatchdogTick délègue.

testMeterSilentFallback : compteur muet >90s → dégradé (ECS off force=true, bypass
minOn) → STABILITÉ (ECS reste 0 sur 4 cycles, planif suspendue) → REPRISE (recalcul
depuis le surplus, pas de restauration d'ancienne consigne). Suite simulation 18/18,
charging 46/46, plugin prod 0/0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 22:21:22 +02:00
Patrick Schurig
dde967da41 [3c-7b] testEcsSurplusPV : waterfall ECS + protection compresseur (seam de temps prouvé)
Test simulation autonome (arbitre frais via initTestCase) : 2 relais powerSwitch +
EcsRelayAdapter minOn=300. 4 régimes pilotés par le temps simulé :
cascade export 0→1→2 ; anti-clignotement (recrédit, hors verrou) ; import<minOn → RESTE
(protection compresseur) ; import>minOn → déleste. Seul le temps simulé change entre les
2 derniers → prouve le seam de temps unifié ET la protection.

Renommage ThingClass mockPowerSwitch→powerSwitch (collision symbole plugininfo vs
energytestbase dans le binaire simulation). Suite simulation : 17 passed, 0 failed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 21:52:14 +02:00
Patrick Schurig
3a8eb5da86 [3c-docs] défauts minOn/minOff par type + séparation budget/verrou (protection compresseur)
Décision Patrick : délestage strict au budget, MAIS minOn/minOff (protection
compresseur, anti court-cycling) bornent le palier via l'adaptateur, pas le budget.
Paramètres par charge (config installateur, jamais hardcodé) + défauts indicatifs
par type (résistif / thermodynamique-PAC / SG-Ready). Note seam de temps unifié.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 21:26:38 +02:00
Patrick Schurig
5d67dc943d [3c-3-fix] waterfall ECS : surplus net signé + clamp lock-aware (protection compresseur)
Bug : exportW clampé à max(0,-p) AVANT recrédit → sur-crédit en import (ECS
restait allumé sur le réseau, ne délestait jamais). Fix : surplus net SIGNÉ
(exportW - importW). Régime export inchangé.

Le délestage strict est borné par minOn/minOff (protection compresseur, pas confort) :
l'adaptateur expose minStage/maxStage (fenêtre de verrou évaluée au temps de cycle),
le scheduler clampe bestStage et décrémente au palier réel → budget correct pour les
charges suivantes (puissance verrouillée = engagée non-coupable).

Seam de temps unifié : now=ctx.timestamp partagé par toLoadContext()/applyAction() ;
lockWindow() est l'unique calcul, lockActive() en dérive (décision==exécution).
Interface ILoadAdapter étendue (now) + contrat "temps=paramètre, jamais l'horloge"
documenté pour les futurs adaptateurs. EvAdapter aligné. Build 0 erreur / 0 warning.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 21:25:22 +02:00
Patrick Schurig
a471a23aeb [3c-7a] mock powerswitch + helpers energytestbase
ThingClass mockPowerSwitch (interface power : power bool writable + currentPower
double, params port/nominalPower) pour piloter l'EcsRelayAdapter en test.
setupThing + executeAction (power → état + currentPower dérivé du nominal, override
HTTP possible). energytestbase : mockPowerSwitchThingClassId + addPowerSwitch()
+ setPowerSwitchStates(). Cascade N paliers = N instances (matériel réel).
Build mock (-Werror) + binaire de test : 0 erreur / 0 warning.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 18:11:01 +02:00
Patrick Schurig
f71e0405b4 [3c-6] degradedMode() + notification ChargingSchedulesChanged + invariant zéro-cloud
virtual degradedMode() dans SmartChargingManager (base false, [ETM] additif),
override EnergyArbitrator. Champ o:degradedMode (additif) dans la notification
NymeaEnergy.ChargingSchedulesChanged, émise aussi aux transitions du mode dégradé
(planif suspendue → push du flag via emit chargingSchedulesChanged()).
INTERFACE.md : champ degradedMode documenté.

SAFETY.md : notification réconciliée (ChargingSchedulesChanged, pas EnergyManagerChanged)
+ limite "valeur figée non détectée". Correction ZÉRO CLOUD : suppression de la section
"Alertes externes" / mécanisme n8n, remplacée par une signalisation 100% locale
(notification nymea in-app + buzzer/relais via règle nymea, aucun canal réseau sortant).
Invariant 10 "ZÉRO cloud" gravé dans AGENTS.md.

Build 0 erreur / 0 warning.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 17:04:09 +02:00
Patrick Schurig
312a2484ae [3c-5] watchdog L2 : QTimer fraîcheur compteur + mode dégradé conservateur
QTimer 30s indépendant des signaux ; m_lastMeterUpdate picoté sur powerBalanceChanged.
Silence >90s → mode dégradé (appliqué à la TRANSITION uniquement) :
  - ECS palier 0 force=true ;
  - EV : clamp courant minimum SEULEMENT si déjà en charge (pas d'activation forcée ;
    "jamais 0 A si branché" relève du failsafe L1, pas du repli logiciel).
update() suspend la planification + le dispatch tant que m_degradedMode (sécurité L4
en position 3 reste active) → pas de rallumage sur le cache d'un compteur mort, pas
d'oscillation. Reprise au retour du compteur.

SAFETY.md §L2 : nuance maintenu/démarré + suspension planification. AGENTS.md morceau 7 :
exiger ECS reste à 0 sur plusieurs cycles. SG-Ready/Batterie déférés 3e/3f ;
flag degradedMode exposé en 3c-6. Build 0 erreur / 0 warning.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 16:43:28 +02:00
Patrick Schurig
0615e5f39d [3c-4] dispatch ECS : applyActionsToAdapters(Slot) dans update()
Itère slot.actions, dispatche les kind==Stage vers m_ecsAdapters (position 7,
avant adjustEvChargers). EV (Setpoint) reste sur le proxy amont jusqu'à 3g.
Build 0 erreur / 0 warning.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 16:26:49 +02:00
Patrick Schurig
6298d5d42f [3c-3] waterfall ECS dans RuleBasedScheduler::getPlan() + tri priorité ASC
Corrections A (déduction EV unique) et B (anti-clignotement) intégrées.
Tri priorité ascendant (rang 1 = premier servi, OPTIMIZER_PROTOCOL §5/annexe C) —
corrige l'inversion du PLAN 3C et 3 doc-comments (plan.h, loaddescriptor.h,
ecsrelayadapter.h). Build 0 erreur / 0 warning.

telemetry() ECS : currentPowerW MESURÉE si au moins un relais expose "currentPower"
(thermostat coupé → 0, pas de fantôme), DÉCLARÉE en repli seulement sans comptage.
Dette evadapter.cpp priority=100 (ancienne convention) inscrite en 3g.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 16:20:08 +02:00
Patrick Schurig
7709057335 [wip] 3c morceaux 0-2 compilés + plan 3c validé dans AGENTS.md
Morceaux 0-2 implémentés et compilés (0 erreur / 0 warning) :
- M0 : LoadAction.force=false (bypass verrous anti-rebond sécurité)
- M1 : EcsRelayAdapter (.h+.cpp) — N paliers powerswitch, anti-rebond, etm.pri
- M2 : buildContext() — SurplusMeter brut, loads EV+ECS, registerEcsAdapter()

AGENTS.md : section PLAN 3C ajoutée avec corrections A+B intégrées.
Corrections A (déduction EV unique dans scheduler) et B (recrédit conso
propre anti-clignotement) documentées avant implémentation morceau 3.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 13:34:42 +02:00
Patrick Schurig
19951a1e3e [3c-prep] AGENTS.md : ajout watchdog L2 + testMeterSilentFallback à la DoD 3c
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 11:36:12 +02:00
Patrick Schurig
5bb6da0e9f [3b-iv] ETM_ARBITRATOR actif — iso-fonctionnalité prouvée (simulation + tests charging, diff décisions zéro écart)
- energyplugin/energyplugin.pri : décommente DEFINES += ETM_ARBITRATOR (flip actif)
- energyplugin/etm/energyarbitrator.cpp : ajoute qCDebug "Updating smart charging" en tête
  de update() — comparabilité des logs avec l'amont garantie
- AGENTS.md : 3b →  FAITE, chiffres de preuve, prochaine action 3c

Preuve iso-fonctionnalité :
- Simulation : 226 lignes décisions (Theoretically/Surplus/Current load) — diff = 0
- Tests charging : 57 lignes décisions — diff = 0 ; 46/46 PASS ref ET ETM
- [Arbitre] présents avec raisons françaises (idle, surplus PV, aWATTar, deadline)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 11:29:34 +02:00
Patrick Schurig
d8ebd65eba [upstream-fix] maxChargingCurrent uint→double + INCLUDEPATH energyplugin.pri + flip ETM vers pri
- tests/mocks/plugins/energymocks/integrationpluginenergymocks.json : maxChargingCurrent
  "type": "uint" → "type": "double" sur les 3 Thing classes (charger, chargerPhaseSwitching,
  simpleCharger) — nymea 1.15 a changé le type de l'interface evcharger.
- energyplugin/energyplugin.pri : INCLUDEPATH += $$PWD — rend energyplugin/ accessible
  aux consommateurs du .pri (simulation, tests) qui compilent les sources ETM.
- energyplugin/energyplugin.pro : le toggle ETM_ARBITRATOR déplacé dans energyplugin.pri
  (unique point de contrôle propagé à plugin ET simulation).
- tests/auto/simulation/experience/energyexperienceenergymock.cpp : même flip que
  energypluginnymea.cpp — instancie EnergyArbitrator si ETM_ARBITRATOR est défini.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 10:39:31 +02:00
Patrick Schurig
c3fedfe36b [3b] décision B + modèle sécurité (AGENTS + SAFETY.md) + Doxygen proxy/inactif
- AGENTS.md : nouvelle entrée "3b révisé — délégation EV à l'amont" (beta hybride
  assumée, ETM réel en 3c, transplantation EV en 3g) ; modèle sécurité L0-L4
  avec double déclenchement verifyOverloadProtection documenté (signal ligne 127 +
  appel cyclique ligne 313 SCM.cpp).
- docs/SAFETY.md : document normatif 5 couches + signalisation locale optionnelle ;
  Variante B confirmée pour le repli L2 (EV au minimum + notification nymea +
  risque 1,4 kW accepté) ; table défaillances/couches corrigée (L1 ne couvre pas
  compteur hors ligne).
- energyarbitrator.cpp update() : commentaire explicitant la correspondance exacte
  avec l'ordre SCM (1-4 parent, ETM entre 4 et 7, planSpot+planSurplus via getPlan).
- rulebasedscheduler.h : Doxygen getPlan() marqué "PROXY AMONT POUR L'EV (beta)".
- evadapter.h : Doxygen applyAction() marqué "Inactif jusqu'à 3g".

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 07:41:12 +02:00
Patrick Schurig
7d3fc6e5ea add adresse IP dans AGENTS.md 2026-06-08 07:23:55 +02:00
Patrick Schurig
08039a3542 [wip] état session — phases 0-3b, prochaine action 3b-iv, topologie remotes 2026-06-07 23:34:18 +02:00
Patrick Schurig
5f49e4ca3c [3b-wip] EnergyArbitrator + RuleBasedScheduler + EvAdapter (dispatch amont, ETM_ARBITRATOR désactivé)
- EnergyArbitrator : public SmartChargingManager — raison documentée dans AGENTS.md §DÉCISIONS DE DESIGN
- SmartChargingManager : protected slots + virtual update() + 3 accesseurs inline [ETM]
- RuleBasedScheduler::getPlan() wraps planSurplusCharging/planSpotMarketCharging, annote chaque action d'un reason français
- EvAdapter : ILoadAdapter concret pour evcharger — applyAction() implémenté, NON appelé en 3b (dispatch via adjustEvChargers() amont, iso-fonctionnel)
- ETM_ARBITRATOR : commenté dans .pro — ne s'active qu'après preuve iso-fonctionnelle (3b-iv)
- Doxygen \brief + invariants + contrats sur toutes les classes/méthodes publiques etm/ (DoD §5)
- plan.h : timeSlots (pas slots, mot-clé Qt) ; commentaire JSON sérialisation "slots" OPTIMIZER_PROTOCOL §6
- .clangd : flags de repli Qt/nymea pour clangd via symlink ~/Schreibtisch/
- compile_commands.json gitignore (chemins absolus locaux)
- Build : 0 erreurs, 0 warnings — libnymea_energypluginnymea.so 914 KB

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 23:16:49 +02:00
Patrick Schurig
9017a880ac [brief] AGENTS.md = version définitive (architecture+workflow), statut phases 0-3a, décision TARGET drop-in actée 2026-06-07 22:06:50 +02:00
Patrick Schurig
4ae1939f93 [3a] structs protocole + interfaces LoadAdapter/Scheduler (zéro comportement)
LoadAction (kind+funding+§6 fields), LoadDescriptor, SurplusContext (§5),
Plan/Slot, ILoadAdapter, IScheduler — noms de champs = OPTIMIZER_PROTOCOL.md.
energyplugin.pri inclut etm/etm.pri. Build Qt6 vert, aucun fichier upstream touché.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 22:02:18 +02:00
34 changed files with 3791 additions and 35 deletions

20
.clangd Normal file
View File

@ -0,0 +1,20 @@
# Flags de repli pour les headers ouverts hors contexte compile_commands.json
# (notamment quand le repo est ouvert via le symlink ~/Schreibtisch/ alors que
# compile_commands.json contient les chemins réels ~/projects/).
# Ces flags s'appliquent en fallback ; les entrées compile_commands.json ont priorité.
CompileFlags:
Add:
- -std=c++17
- -Ienergyplugin
- -I/usr/include/nymea
- -I/usr/include/nymea-energy
- -I/usr/include/x86_64-linux-gnu/qt6
- -I/usr/include/x86_64-linux-gnu/qt6/QtCore
- -I/usr/include/x86_64-linux-gnu/qt6/QtGui
- -I/usr/include/x86_64-linux-gnu/qt6/QtNetwork
- -I/usr/lib/x86_64-linux-gnu/qt6/mkspecs/linux-g++
- -DQT_CORE_LIB
- -DQT_NETWORK_LIB
- -DQT_GUI_LIB
- -DQT_PLUGIN
- -D_REENTRANT

3
.gitignore vendored
View File

@ -8,3 +8,6 @@ Makefile
builddir/
*_moc.cpp
autogenerated/
# clangd — chemins absolus du poste local, ne pas versionner
compile_commands.json

441
AGENTS.md
View File

@ -1,21 +1,436 @@
# AGENTS.md — etm-powersync-energy-plugin-etm
Moteur HEMS. Fork GPL de `nymea-energy-plugin-nymea`, étendu de l'optimisation EV
vers un gestionnaire d'énergie complet (ECS, PAC, batterie, relais).
vers un gestionnaire d'énergie complet (EV, ECS, PAC SG-Ready, batterie).
- **Licence** : GPL-3.0 · **Miroir public** : OUI
- **Agent** : energy-etm · **Branche** : feature/beta-rulebased · **Scope** : energyplugin/
- **Branche de travail** : `feature/beta-rulebased`
- **Document d'interface faisant autorité** : `docs/OPTIMIZER_PROTOCOL.md` (le contrat
stratégie/arbitrage — interne ET socket). `INTERFACE.md` fait autorité sur l'API JSON-RPC.
## Invariants locaux
1. Tourne SANS `etm-powersync-optimizer` (socket absent → repli stratégie règles).
2. Sécurité jamais déléguée : `verifyOverloadProtection()` (temps réel) borne toute sortie de l'optimiseur.
3. Pas de boucle de feedback : surplus = PV mesurée + compteur, jamais le net.
4. `decisionReason` non vide, en français, sur chaque décision.
5. Aucun composant propriétaire ici (Héos vit dans `etm-powersync-optimizer`).
6. Première tâche (revue) : renommer `nymea-energy-plugin-nymea.pro``.pro` ETM
(+ TARGET, debian/). NE PAS toucher aux noms de paquets publiés.
## ÉTAT
## Références
- `README.md` (architecture), `INTERFACE.md` (fait autorité sur l'API), `etm_powersync_energy.svg`.
| Phase | Statut | Commit(s) |
|-------|--------|-----------|
| 0 — analyse fork / structure | ✅ FAITE | `f4d5b20` |
| 1 — renommage .pro + métadonnées debian | ✅ FAITE | `f4d5b20` |
| 2 — design arbitre validé | ✅ FAITE | `074fa71` |
| 3a — structs protocole + interfaces | ✅ FAITE | `4ae1939` |
| 3b — EnergyArbitrator + scheduler + adapter | ✅ FAITE — iso-fonctionnalité prouvée | `5f49e4c`, `d8ebd65`, `[3b-iv]` |
| 3c — EcsRelayAdapter + waterfall ECS | ✅ FAITE — suite 18/18 + charging 46/46 | `6298d5d``54ba229` |
| 3e — SgReadyAdapter | ✅ FAITE — suite 19/19 | `83d5ad9``d8079e8` |
Carte globale et frontières : voir `../AGENTS.md`.
**Détail 3b** :
- `EnergyArbitrator : public SmartChargingManager` — justification dans `## DÉCISIONS DE DESIGN`
- `EvAdapter` + `RuleBasedScheduler` implémentés
- Build : **0 erreur / 0 warning**
- `ETM_ARBITRATOR` **actif** dans `energyplugin.pri`
- Iso-fonctionnalité prouvée :
- Simulation : 226 lignes décisions identiques (Theoretically / Surplus / Current load), diff = 0
- Tests charging : 57 lignes décisions identiques, diff = 0 ; 46/46 PASS ref ET ETM
- [Arbitre] présents avec raisons françaises pour les 4 cas (idle, surplus PV, aWATTar, deadline)
**Détail 3c (morceaux déjà compilés, 0 erreur / 0 warning)** :
- **Morceau 0**`LoadAction.force=false` (bypass verrous sécurité) ✅
- **Morceau 1**`EcsRelayAdapter` (.h + .cpp) : pilote N Things powerswitch,
`applyRelayStage()`, verrous `minOnS/minOffS`, bypass si `force==true`
- Enregistrement explicite via `EnergyArbitrator::registerEcsAdapter()` (tests + config)
- **Morceau 2**`buildContext()` : `SurplusMeter` brut (`exportW = max(0, -meter->currentPower())`),
`loads[]` EV + ECS, `SurpusPv` déféré 3d ✅
**3c CLÔTURÉE** (commits `6298d5d` waterfall → `54ba229` testMeterSilentFallback) :
- Morceaux 3-7 faits. Waterfall ECS (tri priorité ASC = rang) + dispatch + watchdog L2
(mode dégradé conservateur, planif suspendue) + degradedMode/notification + tests.
- **Correctif clé** `[3c-3-fix]` : surplus **net signé** (délestage en import) + **clamp
lock-aware** `minStage/maxStage` (protection compresseur) ; **seam de temps unifié**
(`now=ctx.timestamp`, `lockWindow()` source unique) ; watchdog injectable
(`recordMeterUpdate`/`evaluateMeterFreshness`, déclencheurs sous `#ifndef ENERGY_SIMULATION`).
- Tests : `testEcsSurplusPV` (4 régimes) + `testMeterSilentFallback` (stabilité + reprise).
Suite simulation **18/18**, charging **46/46**, plugin prod 0/0.
- **arm64 cross : NON vérifié dans le sandbox dev** (pas de toolchain/Qt6-aarch64/docker)
→ relève de l'infra de build CI (`etm-powersync-deploy`). À confirmer là-bas.
**3e CLÔTURÉE** (commits `83d5ad9` types → `d8079e8` testSgReadySurplus) :
- `SgReadyAdapter` : 4 états normés (kind:State), encodage 2 bits, `lockWindow` symétrique
(`minStateHold`, protection court-cycling PAC), **atomicité de transition** (`transientHarm` :
passe par le neutre/reco, jamais par blocage/forcé — contrat transport déporté).
- Scheduler : **mapping sémantique** (≥P4×1,2→forcé, hystérésis 1,2/1,0 ; ≥P3→reco ; sinon
normal ; état 1 jamais via surplus) + **waterfall UNIFIÉ** ECS+SG-Ready (un seul budget,
trié par priorité). Mode dégradé L2 → état 2 (mains off, jamais blocage ; SAFETY.md corrigé).
- Tests : `testSgReadySurplus` (montée · hystérésis · court-cycling · **budget partagé ECS↔PAC
avec inversion de priorité**) + `testEcsRelayTopologies` (ECS 1 relais + 3 relais
**non-cascadé** 1500→2000, off-before-on) — commit `dfdd988`.
- **DoD 3e** : amd64 0/0 ✓ · simulation **20/20** ✓ · `decisionReason` français (forcé/reco/normal/
verrou) ✓ · arm64 → CI (idem 3c).
- **Audit Doxygen** fait (5 findings → 0 : `\param now`, docs périmées 3c/3e) — commit `51760a7`.
- **`docs/TEST_TERRAIN.md`** créé : procédure Palier 1 (14 tests) pour le banc nymea-dev arm64.
### Ce que le moteur sait faire aujourd'hui
- **Arbitrage central unique** : un budget de surplus net signé, cascade par **priorité** (rang).
- **Charges** : EV (proxy amont, décision B), **ECS** (paliers, `Stage`), **SG-Ready PAC**
(4 états, `State`) — ECS+PAC classables ensemble sur le budget partagé.
- **Sécurité** : protection compresseur (verrous lock-aware `minStage/maxState`, seam de temps
unifié) ; watchdog L2 (compteur muet >90 s → mode dégradé conservateur, planif suspendue,
reprise par recalcul) ; `verifyOverloadProtection()` amont intacte ; `degradedMode` notifié.
- **Local-first** : zéro cloud (invariant 10).
### DÉFÉRÉ (ordre indicatif)
- **Passe README + contrats** : `OPTIMIZER_PROTOCOL.md` ne reflète PAS encore plusieurs ajouts
3c/3e — `LoadAction.force` (bypass sécurité L2), `telemetry.minStage/maxStage` et
`minState/maxState` (fenêtres de verrou), `degradedMode` (notification). À documenter dans
le protocole publié + README architecture. **Prochaine session doc** (pas avant le terrain).
- **Waveshare D8** : plugin DEVICE (8 powerswitch) sous l'adaptateur — **session dédiée**
(chantier transport Modbus RTU/RS485).
- **V2C** (borne EV) : intégration — **session dédiée**.
- **3d** SocketScheduler (handshake/heartbeat/repli optimiseur).
- **3f** BatteryAdapter (constraints + charge réseau plafonnée) + waterfall grid-funding.
- **3g** transplantation EV dans le waterfall unifié → toutes charges classables ensemble.
- **Couche config priorités** (API JSON-RPC + UI Flutter drag-and-drop) — cf. `## ROADMAP`.
Note : la déclaration des adaptateurs est aujourd'hui **codée** (`energypluginnymea.cpp`),
pas configurable à chaud — cf. `docs/TEST_TERRAIN.md` §1.d.
- **arm64** cross-compile validé en pré-déploiement (infra CI `etm-powersync-deploy`).
- **`Doxyfile` + job CI `doxygen -W`** (automatiser l'audit doc).
**PROCHAINE ACTION** : **test terrain vendredi** (`docs/TEST_TERRAIN.md`, Palier 1), puis
**passe contrats** (OPTIMIZER_PROTOCOL + README).
**Remotes git** :
- `origin` (`https://git.etm-powersync.fr/...`) = remote de travail — push normal
- `etm-public` (`gitea-lan:...powersync-energy-plugin-etm`) = miroir public GPL → push **MANUEL par Patrick uniquement** (`sync-public.sh`)
- `etm-pro` = reliquat historique — ne pas utiliser, cartographie à clarifier
---
## PLAN 3C (validé, morceaux 0-2 déjà compilés)
Plan approuvé par Patrick. Corrections A (double déduction EV) et B (anti-clignotement ECS)
**intégrées** dans le design ci-dessous.
### Morceaux déjà compilés (0 erreur/warning)
| # | Fichier(s) | Ce qui a été fait |
|---|-----------|-------------------|
| 0 | `types/loadaction.h` | `bool force = false` — bypass verrous sécurité |
| 1 | `adapters/ecsrelayadapter.h/.cpp` | Adaptateur N-paliers powerswitch, anti-rebond, `applyRelayStage()`, `etm.pri` |
| 2 | `energyarbitrator.h/.cpp` | `buildContext()` : `SurplusMeter` brut, `loads[]` EV+ECS, `registerEcsAdapter()`, `m_ecsAdapters` |
### Morceaux à venir
**3 — Waterfall ECS dans `RuleBasedScheduler::getPlan()`**
Après la boucle EV proxy, ajouter :
```
// Déduction unique (correction A) — ctx.meter.exportW = mesure brute
evReservedW = Σ EV en charge dans slot.actions : max(0, commandedA×phases×230 ev->currentPower())
remainingSurplusW = max(0, ctx.meter.exportW evReservedW)
// Tri loads ECS par priorité ASC (priority 1 = servi en premier ; protocole §5 + annexe C)
pour chaque LoadContext lc où lc.adapter == "relay-stages" :
budgetCharge = remainingSurplusW + lc.telemetry.currentPowerW // correction B anti-clignotement
bestStage = palier le plus haut dont stages[i] ≤ budgetCharge
reason = "Surplus PV — ECS palier N (W)" ou "Surplus insuffisant — ECS éteint"
remainingSurplusW = remainingSurplusW + lc.telemetry.currentPowerW stages[bestStage]
// Grid funding : dormant jusqu'à 3f (commente, n'implémente pas)
```
**4 — `syncAdapters()` extension + `applyActionsToAdapters(Slot)` dans `update()`**
- `syncAdapters()` : commentaire "découverte ECS via interface 'ecsrelay' déférée 3g"
- `applyActionsToAdapters(Slot)` : itère slot.actions, dispatche via `m_ecsAdapters` pour kind==Stage
**5 — Watchdog L2** (cf. SAFETY.md §L2)
- `QTimer m_meterWatchdog` 30 s — picoté sur `powerBalanceChanged` SIGNAL pour `m_lastMeterUpdate`
- `onMeterWatchdogTick()` : si `now m_lastMeterUpdate > 90 s``applyDegradedMode()`
- `applyDegradedMode()` : ECS stage 0 force=true + EV courant minimum, reason="Compteur muet..."
**6 — `degradedMode()` + notification + INTERFACE.md**
- `virtual bool degradedMode() const` dans `SmartChargingManager` (retourne false, `// [ETM]`)
- Override dans `EnergyArbitrator`
- Champ `"degradedMode": bool` dans `ChargingSchedulesChanged` (additif, rétro-compatible)
- Mise à jour `docs/INTERFACE.md` + limite dans `docs/SAFETY.md` : "valeur strictement constante non détectée"
**7 — Mock powerswitch + tests**
- Mock JSON : ThingClass `mockPowerSwitch` (état `power` bool, état `currentPower` double)
- `energytestbase.h` : `mockPowerSwitchThingClassId`
- `testEcsSurplusPV` : cas normal + cas "ECS déjà au palier 1, surplus stable → Y RESTE" (anti-clignotement)
- `testMeterSilentFallback` : compteur muet 90 s → mode dégradé → ECS off ; **vérifier que
l'ECS RESTE à 0 sur plusieurs cycles `update()` pendant le silence** (planification
suspendue, pas de rallumage sur cache mort) ; dégel → reprise normale + `degradedMode=false`.
Repli EV : seul un VE *déjà en charge* est clampé au minimum (pas d'activation forcée).
Seam de test : piloter `onMeterWatchdogTick()` / `m_lastMeterUpdate` sans 90 s d'horloge réelle.
### Arithmétique du budget (corrections A + B)
**Correction A — déduction EV unique, dans le scheduler** :
```
exportW dans ctx.meter = mesure brute (invariant 8, protocole §5)
evReservedW = déduction dans getPlan() APRÈS proxy EV, avec m_chargingActions fraîches
Pas de déduction dans buildContext().
```
Exemple : PV 9 kW, EV stable 7360 W, export mesuré 1140 W → evReservedW=0, budget ECS=1140 W ✓
**Correction B — anti-clignotement par recrédit de la conso actuelle** :
```
budgetCharge = remainingSurplusW + lc.telemetry.currentPowerW
```
Identique à SCM ~l.1245 pour l'EV. Sans ce recrédit : ECS palier 1 → export chute → palier 0 → oscillation.
---
> ⚠️ Tout plan antérieur mentionnant « créer etm/ avec PowerSyncClient et
> StaticHcHpProvider comme première étape » ou « injecter l'optimiseur dans
> SmartChargingManager » est **INVALIDE et ABANDONNÉ**. Ne pas le reprendre,
> quelle qu'en soit la source (fichier, mémoire de session, contexte).
---
## ARCHITECTURE CIBLE (non négociable)
```
┌──────────────────────────────┐
│ ARBITRAGE CENTRAL │ ← généralisation du
│ budget de surplus UNIQUE │ SmartChargingManager amont
│ waterfall par priorités │
└──────┬───────────────────────┘
│ IScheduler (= contrat OPTIMIZER_PROTOCOL)
┌───────────┴───────────┐
RuleBasedScheduler SocketScheduler
(in-process, V1, GPL) (client unix://|tcp://, V1 aussi —
plan à 1 créneau personne en face en beta : repli rules)
│ distribue le budget en LoadAction typées
┌──────────┬────┴─────┬──────────────┐
EvAdapter EcsRelayAdapter SgReadyAdapter BatteryAdapter
(setpoint, (stage 0/1/2) (state 1-4) (constraint +
iface setpoint W réseau)
evcharger
nymea)
```
Règles absolues :
1. **UN seul arbitre.** Le budget de surplus est une ressource unique, arbitrée à UN
endroit. **INTERDIT : managers frères par type de charge** (EcsManager,
BatteryManager à côté du SmartChargingManager) — deux décideurs sur le même surplus
= sur-engagement et oscillations.
2. **Les LoadAdapters exécutent, ils ne décident pas.** Un adaptateur : parle à son
matériel, déclare ses capacités/contraintes (`declared`, `limits`, types d'action),
expose sa télémétrie, applique les `LoadAction` reçues. Aucune logique de
répartition dedans.
3. **Le SmartChargingManager amont est EV-spécifique** : il se GÉNÉRALISE en arbitrage
multi-charges (`ChargingAction``LoadAction`, bornes EV → adaptateurs). On ne
branche PAS l'optimiseur dans le manager EV tel quel.
4. **La boucle de sécurité est intouchable** : `verifyOverloadProtection()` (temps
réel) + bornes par adaptateur écrêtent TOUTE sortie de stratégie, interne ou socket.
5. **Plan par créneaux** (OPTIMIZER_PROTOCOL §6) : seul le créneau courant est exécuté.
Le rule-based répond un plan à 1 créneau. Modèle async = **cache** : le plan du
cycle précédent s'applique, le recalcul se fait en fond. Jamais d'attente dans
`update()`.
6. **Repli toujours fonctionnel** : optimiseur absent/mort/abstain → rule-based.
Capabilities (`tier`, `optimizerExpected`, `optimizerAlive`, `activeStrategy`)
reflètent l'état en continu.
7. **`decisionReason` non vide, en français, sur chaque action.** Action sans reason
= rejetée.
8. **Pas de boucle de feedback** : surplus = PV mesurée + compteur, jamais le net
après pilotage.
9. **Aucun composant propriétaire ici** (Héos = repo privé `etm-powersync-optimizer`).
Ce repo doit compiler et tourner seul, GPL pur.
10. **ZÉRO cloud** — aucun appel réseau sortant vers un service distant (ni n8n, ni mail,
ni push tiers). Le système fonctionne sans internet (autoconsommation, local-first).
Toute alerte est **locale** : notification nymea in-app + signalisation physique
(buzzer/relais via règle nymea). Le moteur expose l'état, il ne contacte personne.
Exception : le plugin est CLIENT d'un optimiseur sur socket local/LAN (OPTIMIZER_PROTOCOL,
`unix://` ou `tcp://` du réseau de l'installation) — jamais un service cloud externe.
## RÉPONSES FIGÉES (ne plus poser ces questions)
- Plages HC/HP et tarifs : **configuration JSON**, jamais hardcodé. Prévoir Tempo
(6 types de jours), pas seulement HC/HP.
- Async : **modèle cache** (cf. règle 5).
- Bugs upstream : **commits séparés** du code ETM, message préfixé `[upstream-fix]`.
Candidats PR nymea (fix phases EV, Keba) = patchs isolés, propres, upstreamables.
- `protocolVersion` : **constante `"1.0"`**, pas un paramètre de config.
- Renommage : FAIT (Phase 1, commit f4d5b20). TARGET et noms de paquets debian
INCHANGÉS (.so drop-in remplaçant l.amont — garantit un seul plugin énergie chargé).
## WORKFLOW OBLIGATOIRE
Chaque phase produit un livrable VALIDÉ PAR PATRICK avant la suivante. Jamais de code
avant validation du design de la phase.
- **Phase 0 — Analyse (en cours)** : répondre par écrit, code lu à l'appui :
(a) quelles charges SmartChargingManager pilote-t-il (types manipulés) ;
(b) ChargingAction peut-il exprimer « ECS palier 1 » / « batterie décharge
interdite » — citer ses champs ; (c) avec des managers séparés, où vivrait le
budget unique. Zéro code, zéro plan d'implémentation.
- **Phase 1 — Renommage** : `git mv` du `.pro`, TARGET, debian/. Un commit, revue.
- **Phase 2 — Design de l'arbitrage généralisé** : interface `LoadAdapter` (méthodes,
ce qu'un adaptateur déclare), flux du budget, mapping `LoadAction`→adaptateurs,
où vit `IScheduler`. Texte + signatures, pas d'implémentation. Validation Patrick.
- **Phase 3 — Implémentation par étapes** (chacune : compile amd64 + cross arm64,
et un scénario `docker-simulation.sh` qui la prouve = DoD) :
3a. structs du protocole (contexte, plan, actions) ;
3b. arbitre + RuleBasedScheduler + EvAdapter (iso-fonctionnel avec l'amont sur EV) ;
3c. EcsRelayAdapter (paliers) ; 3d. SocketScheduler (handshake/heartbeat/repli,
testé contre un optimiseur factice ~50 lignes) ; 3e. SgReadyAdapter ;
3f. BatteryAdapter (constraints + charge réseau plafonnée).
- **Bugs upstream** : au fil de l'eau, commits `[upstream-fix]` séparés.
## DÉCISIONS DE DESIGN (écarts et justifications)
### 3b révisé — délégation EV à l'amont (beta assumée)
**Décision Patrick** : hybride étagé pour la beta.
**En beta** : les décisions EV restent dans les méthodes amont
`planSurplusCharging` / `planSpotMarketCharging` (`SmartChargingManager`), inchangées.
`RuleBasedScheduler::getPlan()` les appelle en **proxy** et reformate leurs sorties
(`ChargingActions`) en `LoadAction` pour le log `[Arbitre]`.
`EvAdapter::applyAction()` est **inactif** jusqu'à 3g — mais `descriptor()` et
`telemetry()` sont utilisés dès maintenant pour le `SurplusContext`.
**Pipeline ETM réel** (waterfall budget Surplus/Grid, `applyAction`) arrive en **3c**
pour les charges non-EV (ECS, SG-Ready), alimenté par le surplus *restant* après
déduction de l'`addedPower` des consignes EV du cycle courant (pas encore visible
au compteur).
**Limitations beta assumées** :
- EV toujours prioritaire ; waterfall appliqué uniquement aux charges non-EV.
- Le classement drag-and-drop (priorités) ne portera que sur les charges non-EV.
**Étape 3g (post-beta)** : transplantation réelle de la logique EV dans
`RuleBasedScheduler` → priorités libres entre toutes les charges (EV, ECS, SG-Ready,
batterie).
**Dette 3g — convention de priorité EV** : `EvAdapter::descriptor()` met
`priority = 100` (`evadapter.cpp:24`), reliquat de l'ancienne convention « poids,
valeur haute = premier ». Inoffensif en beta : l'EV est servi par le proxy *avant* le
waterfall ECS et n'entre pas dans le tri ECS (ascendant, rang 1 = premier servi,
protocole §5). À reconcilier quand l'EV rejoindra le waterfall unifié : la priorité
devient un **rang** (1, 2, 3…), pas un poids — sinon `priority=100` placerait l'EV en
dernier d'un tri ascendant.
---
### 3b-iii — EnergyArbitrator hérite de SmartChargingManager
**Design validé en session** : "nouvelle classe dans etm/, n'étend pas SmartChargingManager".
**Écart implémenté** : `EnergyArbitrator : public SmartChargingManager`.
**Justification** :
1. **Contrainte NymeaEnergyJsonHandler** : ce handler amont prend un
`SmartChargingManager*` dans son constructeur.
Sans héritage, toute solution propre (interface commune, pointeur générique)
nécessiterait de modifier `nymeaenergyjsonhandler.h/.cpp` — violation de la règle
"Modifier le code amont uniquement pour corriger des bugs".
2. **verifyOverloadProtection() intacte** : héritée bit-pour-bit, connectée aux mêmes
signaux via le constructeur du parent. Zéro risque de régression sur la sécurité.
3. **simulationCallUpdate() polymorphe** : appelle `update()` virtuel → redirige
automatiquement vers `EnergyArbitrator::update()`. Les tests amont passent sans
modification.
4. **Minimal upstream diff** : seuls les attributs `protected`/`virtual` changent dans
`smartchargingmanager.h` (marqués `// [ETM]`). Zéro logique upstream modifiée.
**Risque accepté** : `EnergyArbitrator` a accès à l'état privé de SCM via les
accesseurs `internal*`. La discipline AGENTS (LoadAdapters exécutent, ne décident pas ;
un seul arbitre) compense. Si SCM était refactorisé en amont pour exposer une interface
publique propre, l'héritage pourrait être remplacé par composition.
---
### Verrous minOn/minOff — protection compresseur (décision Patrick)
Le délestage du waterfall est **strict au niveau budget** (surplus net signé : en import,
budget négatif → palier 0). Mais une charge à compresseur (PAC, ballon thermodynamique)
ou un VE ont un **temps de fonctionnement minimum incompressible** : ce n'est pas du
confort, c'est de la **protection matérielle** (le court-cycling détruit le compresseur).
**Séparation des responsabilités** :
- Le **scheduler** décide le palier idéal selon le budget (peut vouloir « palier 0 »).
- L'**adaptateur** borne ce choix via `minStage`/`maxStage` (fenêtre `lockWindow()` évaluée
au temps de cycle) : une charge verrouillée ON garde son palier ; l'import transitoire
est **borné par minOn**, pas illimité. Le scheduler clampe et décrémente le budget au
palier réel (puissance engagée non-coupable) → budget correct pour les charges suivantes.
- `minOnS`/`minOffS` sont des **paramètres par charge** (constructeur `EcsRelayAdapter`,
config installateur) — **jamais codés en dur**.
**Défauts indicatifs par type** (à affiner à la mise en service) :
| Type de charge | minOn | minOff | Raison |
|----------------|-------|--------|--------|
| Ballon résistif (ECS simple) | ~60 s | ~60 s | anti-rebond relais seul |
| Ballon thermodynamique / PAC | ~300600 s | ~300 s | **protection compresseur** (anti court-cycling) |
| SG-Ready PAC (3e) | `minStateHoldS` ~900 s | — | maintien d'état imposé constructeur |
**Seam de temps** : `minStage`/`maxStage` (décision) ET le verrou de `applyAction`
(exécution) partagent le **même `now = ctx.timestamp`** via `lockWindow()` — source unique,
divergence impossible par construction, injectable en simulation. Voir `iloadadapter.h`
(contrat « temps = paramètre, jamais l'horloge »).
---
## MODÈLE DE SÉCURITÉ (décision Patrick — immuable)
Cinq couches indépendantes. Chacune est conçue pour qu'une défaillance des couches
supérieures n'affecte pas les couches inférieures. Voir `docs/SAFETY.md` pour le détail.
| Couche | Qui | Quoi |
|--------|-----|------|
| **L0** | Disjoncteur / Linky matériel | Coupure physique — hors logiciel |
| **L1** | Failsafe natif des bornes | Config installateur, checklist ETM |
| **L2** | Watchdog fraîcheur compteur (à coder en 3c) | `QTimer` piloté : si `lastMeterUpdate > 90 s` → mode dégradé (EV min/off, ECS off, pas de charge réseau batterie), `decisionReason` explicite, notification nymea. Scénario simulation dédié : "compteur muet → repli". |
| **L3** | Watchdog systemd sur nymead | Repo `etm-powersync-deploy`, hors scope ici |
| **L4** | Logique signal-driven existante | Boucle `update()` déclenchée par événements |
**Règles de code** :
- Le watchdog L2 est piloté par **`QTimer`** (pas par signal `meterChanged`) pour
rester actif même si le signal ne fire plus.
- Mode dégradé = consignes **de repli** (EV au minimum si pluggedIn, ECS off, etc.)
+ `decisionReason` non vide + notification `EnergyManagerChanged` avec `degradedMode`.
- `verifyOverloadProtection()` (L4) est déclenchée par **deux mécanismes** :
(a) signal `powerBalanceChanged` (temps réel — SCM.cpp ligne 127, mécanisme principal) ;
(b) appel cyclique en position 3 d'`update()` (SCM.cpp ligne 313, filet périodique).
La position dans `update()` est **INTOUCHABLE** — même dans `EnergyArbitrator::update()`.
---
## DÉFINITION DE FAIT (par étape de phase 3)
1. Compile amd64 et cross arm64.
2. Scénario de simulation ajouté/étendu qui démontre le comportement (le harnais
`docker-simulation.sh` + `tests/auto` hérités sont le banc de test).
3. `decisionReason` visibles dans les logs de simulation.
4. Aucune régression des tests amont existants.
5. Toute classe/méthode **publique** de `etm/` porte un commentaire Doxygen :
`\brief`, `\param`, `\return`, et surtout le **contrat de comportement**
(invariants, écrêtage, hypothèses que l'appelant peut faire).
Les headers 3a servent de modèle — les convertir au format Doxygen lors du passage 3b.
## ROADMAP — configuration des priorités par l'utilisateur (post-beta)
- **ACQUIS (3e)** : le waterfall trie déjà ECS + SG-Ready ensemble par `priority` (rang
ASC, 1 = servi en premier), **budget unifié** qui cascade à travers toutes ces charges.
- **LIMITE beta** : le VE reste **hors du tri** (décidé par l'amont *avant* le waterfall,
décision B) → on ne peut pas classer le VE derrière l'ECS.
- **MANQUE pour des priorités réglables par le client** :
- (a) **3g** : transplanter le VE dans le waterfall unifié → toutes les charges
(VE, ECS, SG-Ready, batterie) classables ensemble.
- (b) **Couche « config priorités »** : exposer et persister le `priority` de chaque
charge via l'API JSON-RPC, modifiable depuis l'app Flutter (drag-and-drop des
priorités de l'UI). C'est le **pont moteur↔UI**, un morceau à part entière
(ni 3e ni 3g).
- **État actuel** : `priority` est fixé à la création de l'adaptateur (`register*Adapter`),
pas d'interface de réglage à chaud.
- **Argument démo nymea** : le client réordonne ses charges, le surplus suit.
## RÉFÉRENCES
- `docs/OPTIMIZER_PROTOCOL.md` — le contrat. §5 (SurplusContext), §6 (plan/actions),
§7 (repli), annexe C (priorités).
- `README.md` — architecture (deux boucles, frontière), `etm_powersync_energy.svg`.
- `INTERFACE.md` — API JSON-RPC existante (`NymeaEnergy`, cible future `Ems`).
- Carte globale du workspace : `../AGENTS.md`.

View File

@ -453,10 +453,18 @@ S'abonner via `JSONRPC.SetNotificationStatus` avec le namespace `"NymeaEnergy"`.
---
### `NymeaEnergy.ChargingSchedulesChanged`
Émis à chaque recalcul du planning (cycle ~1 min).
Émis à chaque recalcul du planning (cycle ~1 min), **et** à chaque transition du mode
dégradé L2 (watchdog fraîcheur compteur).
```json
{ "chargingSchedules": [ ... ] }
{
"chargingSchedules": [ ... ],
"o:degradedMode": false
}
```
- `degradedMode` *(bool, optionnel — [ETM])* : `true` quand le compteur est muet depuis
> 90 s et que les consignes de repli L2 sont actives (planification suspendue, ECS coupé,
EV en charge clampé au minimum). Repasse à `false` au retour du compteur. Champ additif :
les clients antérieurs l'ignorent. Détail : `docs/SAFETY.md` §L2.
---

209
docs/SAFETY.md Normal file
View File

@ -0,0 +1,209 @@
# SAFETY.md — Modèle de sécurité ETM PowerSync Energy
Décision Patrick Schurig, validée en session 2026-06-08. Ce document est normatif.
## Principes
Cinq couches indépendantes + signalisation locale optionnelle. Une couche supérieure
peut tomber sans impacter les couches inférieures. Jamais de contournement logiciel
d'une couche matérielle.
---
## L0 — Disjoncteur (matériel)
**Protège contre** : surintensité physique, défaut d'isolement.
**Responsable** : installateur électricien, norme C15-100.
**ETM ne touche pas à cette couche.** Elle fonctionne que nymead soit vivant ou non.
---
## L1 — Failsafe natif des bornes EV et charges pilotées
**Protège contre** : perte de communication borne ↔ nymead.
**Ne couvre PAS** : perte de communication compteur ↔ nymead (c'est le rôle de L2).
**Responsable** : configuration installateur sur chaque borne/appareil.
**Checklist ETM à la mise en service** :
- Borne EV : configurer le timeout de communication → repli sur courant minimum
défini par l'installateur (jamais 0 A si le VE est branché, jamais maximum abonnement).
- Relais ECS : désactivation si absence de commande > N minutes (selon chauffe-eau).
- SG-Ready PAC : état 1 (normal, non piloté) si silence > N minutes.
Cette configuration est documentée dans la checklist de déploiement
(`etm-powersync-deploy`), pas ici.
---
## L2 — Watchdog fraîcheur compteur (à implémenter en phase 3c)
**Protège contre** : compteur hors ligne, plugin gelé, nymead bloqué.
**Responsable** : code ETM dans `EnergyArbitrator`.
### Comportement
Un `QTimer` (pas un signal `meterChanged`) tourne en permanence avec une période de 30 s.
À chaque tick : si `QDateTime::currentDateTime() - m_lastMeterUpdate > 90 s`
**mode dégradé** déclenché.
**Choix de conception :** `QTimer` et non signal `powerBalanceChanged`, car si le
compteur est muet, le signal ne fire plus — le watchdog doit rester actif précisément
dans ce cas.
### Mode dégradé — consignes de repli (**Variante B — décision Patrick**)
À la **transition** vers le mode dégradé, les charges pilotées reçoivent une consigne de
repli **conservatrice** : le repli n'INITIE rien, il borne ce qui tourne déjà.
| Charge | Consigne de repli |
|--------|------------------|
| EV **en charge** | Clamp au courant minimum borne (`maxChargingCurrentMinValue`) |
| EV branché mais **pas en charge** | Inchangé — reste off (off possiblement volontaire : HC/spot à venir) |
| EV débranché | Aucune action |
| ECS | Relais coupé (palier 0, `force=true`) |
| SG-Ready PAC | État **2** (normal — mains off, la PAC chauffe selon son thermostat). JAMAIS état 1 (blocage) : bloquer une PAC sous compteur muet = maison qui ne chauffe plus sans raison visible. |
| Batterie | Aucune charge réseau (surplus uniquement, plafonné à 0 si compteur muet) |
« Maintenu » ≠ « démarré » : le mode dégradé ne force jamais l'activation d'une charge.
La garantie *"jamais 0 A si le VE est branché"* appartient au **failsafe L1 de la borne**
(timeout communication → courant minimum installateur), pas au repli logiciel.
Chaque consigne porte un `decisionReason` explicite :
`"Compteur muet depuis >90 s — consigne de repli (L2 watchdog)"`.
**Suspension de la planification** : tant que le mode dégradé est actif, `update()`
exécute la sécurité L4 (position 3, intouchable) puis **retourne immédiatement** — ni
`getPlan()` ni dispatch. Sinon `update()` replanifierait sur le **cache** d'un compteur
mort et rallumerait les charges que le watchdog vient de couper, que le tick suivant
recouperait 30 s plus tard (oscillation). Le repli est donc appliqué **une seule fois à
la transition** ; les consignes tiennent jusqu'au retour du compteur.
**Risque accepté** : ~1,4 kW de tirage EV non supervisé pour un VE déjà en charge au
moment de la bascule (minimum borne typique ≈ 6 A × 230 V × 1 phase). Ce tirage est
atténué par L0 (disjoncteur) et L1 (failsafe borne). Le client est informé (voir
Notification ci-dessous).
**Limite** : la notification part du contrôleur dégradé lui-même (best-effort). L'alerte
indépendante de la défaillance du contrôleur est le rôle de L3 (watchdog systemd).
### Notification client (dans ce repo)
- La notification JSON-RPC `NymeaEnergy.ChargingSchedulesChanged` porte un champ additif
`degradedMode` (bool). Elle est émise aux **transitions** du mode dégradé (entrée/sortie),
en plus des recalculs de planning. Voir `INTERFACE.md`.
- L'application affiche : *"Supervision compteur perdue — charge EV maintenue au minimum"*.
### Limite — détection par fraîcheur uniquement
Le watchdog L2 détecte l'**absence de mise à jour** du compteur (plus de signal
`powerBalanceChanged` depuis > 90 s), pas une **valeur figée**. Un compteur qui continue
d'émettre une valeur strictement constante (capteur bloqué mais lien vivant) n'est **pas**
détecté par cette couche — `m_lastMeterUpdate` reste frais. Détecter une valeur figée
(variance nulle sur fenêtre) est hors scope L2 ; le cas est couvert au niveau matériel/L0
et par la supervision externe.
### Signalisation locale (zéro cloud)
ETM PowerSync est **100 % autonome, zéro cloud** : aucune alerte ne sort vers un service
distant (ni n8n, ni mail, ni push tiers). Le système est conçu pour fonctionner **sans
internet** (argument produit : autoconsommation, local-first). Le `degradedMode` est
signalé par deux canaux strictement locaux :
- **Notification nymea in-app** (déjà implémentée : champ `degradedMode`) — canal principal
vers le client connecté à l'application.
- **Signal sonore local optionnel** (buzzer GPIO ou canal relais) piloté par une **Règle
nymea** déclenchée sur `degradedMode` — pour le client sur place, sans application.
Aucun code buzzer dans ce repo : le moteur **expose** l'état, la signalisation est une
Thing nymea + une règle (configuration d'installation, cf. `## Signalisation locale`).
**Aucun canal sortant réseau.** Voir l'invariant « ZÉRO cloud » dans `AGENTS.md`.
### Sortie du mode dégradé
Dès que le compteur fournit une nouvelle mesure (`m_lastMeterUpdate` remis à jour)
→ reprise normale au cycle suivant, `degradedMode: false`.
### Scénario de simulation obligatoire (DoD 3c)
Scénario `testMeterSilentFallback` dans `tests/auto/simulation/` :
1. Charger branché, surplus PV → charge en cours.
2. Geler les updates du compteur (mock).
3. Vérifier qu'après 90 s simulés, les consignes de repli sont émises avec le bon `reason`
et que `degradedMode` est à `true`.
4. Dégeler le compteur → vérifier la reprise normale et `degradedMode: false`.
---
## L3 — Watchdog systemd sur nymead
**Protège contre** : crash de nymead, deadlock process, défaillance de L2.
**Responsable** : `etm-powersync-deploy` (hors scope de ce repo).
Nymead enregistre `sd_notify(WATCHDOG=1)` ; systemd le relance si absent > timeout.
À la reprise, L1 garantit la sécurité matérielle pendant le redémarrage.
---
## L4 — Logique signal-driven existante (amont)
**Protège contre** : surcharge transitoire sur phases.
**Responsable** : `SmartChargingManager::verifyOverloadProtection()`.
### Double déclenchement (code amont vérifié)
`verifyOverloadProtection()` est déclenchée par **deux mécanismes distincts** :
1. **Signal temps réel** (`smartchargingmanager.cpp` ligne 127) :
```cpp
connect(m_energyManager, &EnergyManager::powerBalanceChanged, this, [this]() {
verifyOverloadProtection(QDateTime::currentDateTime());
});
```
C'est le mécanisme **principal** : déclenché à chaque nouveau bilan de puissance
émis par le compteur, sans attendre le prochain cycle `update()`.
2. **Appel cyclique** (`smartchargingmanager.cpp` ligne 313, dans `update()`) :
```cpp
verifyOverloadProtection(currentDateTime); // position 3 du cycle
```
Filet périodique — garantit une vérification même si le signal précède la mise à
jour des états internes.
Note : en mode simulation, `simulationCallUpdate()` (ligne 295-299) appelle `update()`
puis `verifyOverloadProtection()` une seconde fois — double appel intentionnel dans
les tests, pas dans la production.
### Règle de code ETM
`EnergyArbitrator::update()` appelle `verifyOverloadProtection()` en **position 3**,
identique à l'amont. INTERDIT de déplacer cet appel ou de le conditionner.
La connexion signal est héritée via le constructeur parent — aucune reconnexion ETM.
---
## Signalisation locale (optionnelle — hors de ce repo)
**Protège contre** : alarme silencieuse non détectée, exigeant une action humaine immédiate.
**Ce plugin** émet les événements (`degradedMode`, alarmes overload, etc.).
**La signalisation** est une Thing nymea (buzzer GPIO ou canal relais) + une **Règle**
déclenchée sur ces événements — configuration installation, documentée dans
`etm-powersync-deploy`.
Aucun code buzzer/relais dans ce repo. Principe : le moteur émet, la configuration
d'installation décide quoi signaler.
**Zéro cloud** : toute la signalisation est locale (notification nymea in-app +
signalisation physique). Aucun appel réseau sortant vers un service distant — le système
fonctionne sans internet. Invariant gravé dans `AGENTS.md`.
---
## Correspondance couches / scénarios de défaillance
| Défaillance | L0 | L1 | L2 | L3 | L4 | Signal local |
|-------------|----|----|----|----|-----|------|
| Surintensité réseau | ✅ | ✅ | — | — | ✅ | optionnel |
| Borne perd le réseau IP | — | ✅ | — | — | — | — |
| Compteur hors ligne > 90 s | ✅ | — | ✅ | — | — | optionnel |
| Crash nymead | — | ✅ | — | ✅ | — | — |
| Bug dans `update()` | — | ✅ | ✅ | ✅ | ✅ | — |
| Optimiseur externe mort | — | — | — | — | — | repli rule-based (AGENTS §6) |

248
docs/TEST_TERRAIN.md Normal file
View File

@ -0,0 +1,248 @@
# TEST_TERRAIN.md — Procédure de test Palier 1 (nymea-dev arm64)
Test terrain du moteur **etm-powersync-energy-plugin-etm** sur banc : compteur **mock
forçable** (puissance injectée par HTTP) + **relais GPIO réels** (vérifiés par `gpioget`).
14 tests (T1T14). Chaque test : **inject → log attendu → vérif GPIO → case ✓/✗**.
> Convention puissance compteur : `currentPower < 0` = **export** (surplus PV) ; `> 0` = **import**.
> Le moteur calcule un surplus net SIGNÉ `(exportW importW)` qui cascade par priorité.
---
## §0 — Pré-vol (à remplir SUR LA BOX)
| Élément | Valeur | Source |
|---|---|---|
| IP nymea-dev | `__________` | **[À REMPLIR]** (réseau banc) |
| Port JSON-RPC | `nymeas://<IP>:2222` (TCP+TLS) · `wss://<IP>:4444` | connu |
| Auth requise ? | `__________` | **[À LIRE SUR LA BOX]** `JSONRPC.Hello` → champ `authenticationRequired` |
| Token (si auth) | `__________` | `Users.Authenticate {username,password,deviceName}``token` |
| ThingClassId compteur mock | `2721a051-6e12-471a-baba-21d87c4cebc9` | connu (energymocks) |
| ThingId compteur mock | `__________` | **[À LIRE]** après `Integrations.AddThing`, ou `Integrations.GetThings` |
| Port HTTP compteur mock | `26655` (param `port` par défaut) | connu |
| ThingId relais R500 / R1000 / R2000 | `__________` | **[À LIRE]** `Integrations.GetThings` (plugin GPIO relais réel) |
| ThingId relais SG-Ready K1 / K2 | `__________` | **[À LIRE]** idem |
| gpiochip + offsets relais | `__________` | **[À LIRE]** `gpiodetect` puis `gpioinfo gpiochip0` |
| Conteneur build cross-arm64 | `__________` | **[À LIRE DANS `etm-powersync-deploy`/DEPLOY.md]** |
> `[À LIRE SUR LA BOX]` = dépend du déploiement, pas inventé ici. Tout le reste est figé.
---
## §1 — Déploiement (option a : build cross-arm64 → scp → dpkg)
### a. Build cross-arm64 (depuis le poste dev)
```bash
# Dans le conteneur de build cross-arm64 (cf. DEPLOY.md du repo etm-powersync-deploy).
# Produit le .deb arm64 : nymea-energy-plugin-nymea_<ver>_arm64.deb
# (TARGET inchangé = libnymea_energypluginnymea.so, drop-in remplaçant l'amont).
```
> Le nom de paquet/TARGET est **inchangé** (décision Phase 1) → le `.deb` ETM **remplace**
> le plugin énergie amont. Un seul plugin énergie chargé.
### b. Déploiement sur la box
```bash
BOX=<IP> # [À REMPLIR]
scp nymea-energy-plugin-nymea_*_arm64.deb root@$BOX:/tmp/
ssh root@$BOX 'dpkg -i /tmp/nymea-energy-plugin-nymea_*_arm64.deb && systemctl restart nymead'
ssh root@$BOX 'journalctl -u nymead -f' # suivre les logs
```
> `.so` installé dans `/usr/lib/<multiarch>/nymea/energy/libnymea_energypluginnymea.so`.
### c. Activer le logging `[Arbitre]` (SINON aucune trace de décision)
La catégorie est exactement **`NymeaEnergy`** (`NYMEA_LOGGING_CATEGORY(dcNymeaEnergy, "NymeaEnergy")`).
Méthode robuste — drop-in systemd (Qt logging rules) :
```bash
ssh root@$BOX 'mkdir -p /etc/systemd/system/nymead.service.d && \
printf "[Service]\nEnvironment=QT_LOGGING_RULES=NymeaEnergy.debug=true\n" \
> /etc/systemd/system/nymead.service.d/etm-logging.conf && \
systemctl daemon-reload && systemctl restart nymead'
```
> Alternative selon la version : `nymead --logging <règles>` ou la section logging de
> `/etc/nymea/nymead.conf`. **[VÉRIFIER `nymead --help` SUR LA BOX]** si le drop-in ne suffit pas.
> Les lignes attendues commencent par `[Arbitre]` et `[EcsRelayAdapter]`/`[SgReadyAdapter]`.
### d. Déclarer les adaptateurs de test — LA VRAIE MÉTHODE
⚠️ **Il n'existe pas encore de config runtime des adaptateurs** (déféré : couche « config
priorités »). En production, `energypluginnymea.cpp::init()` crée l'`EnergyArbitrator` mais
**n'enregistre aucun adaptateur** ECS/SG-Ready. La déclaration se fait donc par un **bloc de
code** ajouté à `energypluginnymea.cpp` (juste après la création de `chargingManager`,
ligne ~54), recompilé dans le `.deb` de test.
Workflow : déployer une 1re fois → `Integrations.AddThing` le compteur mock + les relais GPIO
→ relever leurs ThingId (`GetThings`) → coller le bloc ci-dessous avec ces ThingId → rebuild → redéployer.
```cpp
// energypluginnymea.cpp, init(), APRÈS la ligne :
// EnergyArbitrator *chargingManager = new EnergyArbitrator(...);
#ifdef ETM_ARBITRATOR
{
ThingManager *tm = thingManager();
// --- ECS 3 relais (8 niveaux binaires) — ThingId réels [À REMPLIR] ---
const QString R500 = "{__________}"; // relais GPIO 500 W
const QString R1000 = "{__________}"; // relais GPIO 1000 W
const QString R2000 = "{__________}"; // relais GPIO 2000 W
auto *ecs = new EcsRelayAdapter(
tm, "ecs-terrain", "ECS banc",
QList<int>({0, 500, 1000, 1500, 2000, 2500, 3000, 3500}),
QList<QList<QString>>({ {}, {R500}, {R1000}, {R500,R1000},
{R2000}, {R500,R2000}, {R1000,R2000}, {R500,R1000,R2000} }),
/*minOnS*/ 60, /*minOffS*/ 60, /*priority*/ 1, chargingManager);
chargingManager->registerEcsAdapter(ecs);
// --- SG-Ready (2 bits K1/K2) — ThingId réels [À REMPLIR] ---
const QString K1 = "{__________}";
const QString K2 = "{__________}";
auto *pac = new SgReadyAdapter(
tm, "pac-terrain", "PAC banc",
QHash<int,QList<QString>>({ {1,{K1}}, {2,{}}, {3,{K2}}, {4,{K1,K2}} }),
QHash<int,double>({ {1,0.0}, {2,0.0}, {3,1500.0}, {4,3000.0} }),
/*minStateHoldS*/ 300, /*priority*/ 2, chargingManager);
chargingManager->registerSgReadyAdapter(pac);
}
#endif
```
> Inclure `#include "etm/adapters/ecsrelayadapter.h"` et `etm/adapters/sgreadyadapter.h`.
> Le **root meter** = le compteur mock : le déclarer via l'expérience énergie nymea
> (`Energy.SetRootMeter` / config) avec le ThingId du compteur mock.
> Pour l'ECS simple (T1/T2), utiliser un seul relais : `stages {0,2000}`, `mapping {{},{R2000}}`.
---
## Helpers bash (poste dev ou box)
```bash
BOX=<IP>; MPORT=26655 # [À REMPLIR]
# Injecter une puissance compteur (W). Négatif = export (surplus PV), positif = import.
inject(){ curl -s "http://$BOX:$MPORT/setstates?connected=true&currentPowerPhaseA=$(($1/3))&currentPowerPhaseB=$(($1/3))&currentPowerPhaseC=$(($1/3))" >/dev/null; echo "compteur = $1 W"; }
# Compteur MUET : on cesse d'injecter (>90 s) → watchdog L2 bascule (QTimer 30 s, seuil 90 s).
mute(){ echo "NE PLUS injecter pendant >90 s…"; sleep 95; }
# Lire un relais GPIO réel (0/1) : relay <gpiochip> <offset>
relay(){ ssh root@$BOX "gpioget $1 $2"; } # ex. relay gpiochip0 17
# Suivre les décisions
logs(){ ssh root@$BOX "journalctl -u nymead -f | grep -E 'Arbitre|EcsRelay|SgReady'"; }
```
> Le compteur mock pose `currentPower = somme des 3 phases`. `RootMeter::currentPower()` le relit.
---
## §2 — ECS simple (1 relais, paliers {0, 2000})
### T1 — Montée sur surplus
- **inject** : `inject -2500`
- **log attendu** : `[Arbitre] … Surplus PV … ECS palier 1` puis `[EcsRelayAdapter] … → stage 1`
- **vérif GPIO** : `relay <chip> <R2000>`**1** (ON)
- ✓ / ✗ : `____`
### T2 — Délestage (import)
- **inject** : `inject 1000` (import → surplus net négatif)
- **log attendu** : `Surplus insuffisant … ECS éteint` ; `→ stage 0`
- **vérif GPIO** : relais → **0** (OFF)
- ✓ / ✗ : `____`
---
## §3 — ECS 3 relais (8 niveaux binaires R500/R1000/R2000)
### T3 — Cascade montante
- **inject** : `inject -1700` (budget 1700 → palier **1500**)
- **log** : `ECS palier 3 (1500 W)`
- **vérif GPIO** : R500=**1**, R1000=**1**, R2000=**0**
- ✓ / ✗ : `____`
### T4 — Transition NON-CASCADÉE 1500 → 2000 (le test clé)
- **contexte** : on part de T3 (palier 1500, l'ECS mesure 1500 W).
- **inject** : `inject -700` (budget = 700 + 1500 recrédit = 2200 → palier **2000**)
- **log** : `ECS palier 4 (2000 W)` ; commutation **off-before-on** (R500/R1000 coupés avant R2000)
- **vérif GPIO** : R500=**0**, R1000=**0**, R2000=**1** ← *set final R2000 SEUL, pas un état parasite durable*
- ✓ / ✗ : `____`
### T5 — Protection minOn (anti court-cycling)
- **contexte** : ECS vient de commuter (< minOn 60 s).
- **inject** : `inject 1200` (import → le budget voudrait éteindre)
- **log attendu** : `Verrou minOn — … maintenu palier …`
- **vérif GPIO** : relais **inchangés** (maintien). Attendre > 60 s puis re-`inject 1200` → délestage.
- ✓ / ✗ : `____`
### T6 — Délestage complet
- **inject** : `inject 2000` (import franc, > minOn écoulé)
- **vérif GPIO** : R500=R1000=R2000=**0**
- ✓ / ✗ : `____`
---
## §4 — SG-Ready (PAC, 2 bits K1/K2 ; P3=1500, P4=3000)
### T7 — Montée d'états 2 → 3 → 4
- **inject** `-1000` → état **2** (K1=0,K2=0) ; **inject** `-2000` → état **3** (K2=1) ;
attendre > minStateHold (300 s) puis **inject** `-2500` → état **4** (K1=1,K2=1).
- **log** : `… recommandée (état 3 …)` puis `… forcée (état 4 …)`
- **vérif GPIO** : état 3 → K1=0,K2=1 ; état 4 → K1=1,K2=1
- ✓ / ✗ : `____`
### T8 — Atomicité (transitoire bénin)
- **contexte** : transition 2→4 (00→11) commute K1 ET K2.
- **vérif GPIO** : set **final** K1=**1**, K2=**1** (état 4). Le transitoire passe par K2 d'abord
(01=reco), **jamais** 10=blocage — sub-ms, non observable à `gpioget` mais **garanti code**
(`transientHarm`). Confirmer simplement le set final correct.
- ✓ / ✗ : `____`
### T9 — Hystérésis (pas de bascule 3↔4)
- **contexte** : PAC en état 4 ; faire osciller le surplus dans la zone morte.
- **inject** `-300` (budget ≈ 3300) → reste **4** ; `-500` (≈3500) → reste **4** ; `-100` (≈3100) → reste **4**.
- **vérif GPIO** : K1=K2=**1** stable (aucun clignotement 3↔4). Passer `inject 300` (budget < 3000) sort en 3.
- ✓ / ✗ : `____`
---
## §5 — Watchdog L2 (compteur muet)
### T10 — Compteur muet → mode dégradé
- **inject** `-2500` (ECS/PAC servis), puis **`mute`** (>90 s sans injection).
- **log attendu** : `[Arbitre] Compteur muet depuis … mode dégradé L2` ; ECS palier 0 ; PAC **état 2**.
- **vérif GPIO** : relais ECS → **0** ; PAC K1=0,K2=0 (état 2, **jamais** blocage).
- ✓ / ✗ : `____`
### T11 — Stabilité (faux surplus piège)
- **contexte** : toujours muet ; la **dernière** valeur injectée (-2500) reste « collée » au compteur.
- **vérif GPIO** : sur plusieurs minutes muettes, ECS **reste 0** malgré ce surplus stale
(planification suspendue — pas de replanif sur cache mort).
- ✓ / ✗ : `____`
### T12 — Reprise
- **inject** `-2500` (le compteur re-parle).
- **log attendu** : `Compteur de nouveau actif — sortie du mode dégradé` ; recalcul normal.
- **vérif GPIO** : ECS resuit le surplus (palier > 0). Pas de restauration d'ancienne consigne.
- ✓ / ✗ : `____`
---
## §6 — Interaction budget partagé
### T13 — Ordre de priorité ECS↔PAC inversable
- **contexte** : surplus moyen `inject -3000`, ECS palier 2400 vs PAC P3 1500.
- **ECS prio 1 / PAC prio 2** : ECS se sert (palier), PAC voit le reliquat (600) → **état 2**.
- **Inverser les priorités** (échanger `priority` dans le bloc §1.d, rebuild/redeploy) →
PAC se sert (état 3), ECS voit le reliquat (1500 < 2400) **palier 0**.
- **vérif GPIO** : le service s'inverse selon la priorité (preuve du waterfall unifié).
- ✓ / ✗ : `____`
---
## §7 — OPTIONNEL EV / V2C (si plugin prêt)
### T14 — Ordre étagé EV → ECS (beta : EV servi avant le waterfall)
- **contexte** : borne EV branchée + surplus moyen.
- **attendu** : l'EV est servi **en premier** (proxy amont, décision B), l'ECS voit le reliquat.
- **vérif** : courant EV puis palier ECS sur le reliquat. *(V2C = session dédiée, hors Palier 1.)*
- ✓ / ✗ : `____`
---
## Checklist sécurité (AVANT mise sous tension)
- [ ] **Disjoncteur L0** du banc **accessible** et identifié (coupure physique immédiate).
- [ ] Banc câblé hors tension ; sections/calibres relais conformes aux puissances (R2000 = 2000 W).
- [ ] **Polarité / repérage K1/K2** SG-Ready vérifié (1:0=blocage, 0:0=normal, 0:1=reco, 1:1=forcé).
- [ ] Relais résistifs ECS : pas de charge inductive sur ces voies.
- [ ] `gpioinfo` relevé et **mapping offset↔relais consigné** avant tout test.
- [ ] L1 (failsafe bornes/relais) configuré si applicable ; L0 reste le filet ultime.
- [ ] Un opérateur à la main sur le disjoncteur pendant T1T6 (montées de puissance).

View File

@ -1,3 +1,12 @@
# Rend les headers du répertoire energyplugin/ accessibles aux consommateurs
# (simulation, tests) qui incluent ce .pri depuis un autre répertoire.
INCLUDEPATH += $$PWD
# [ETM] Activate ETM arbitrator — replaces SmartChargingManager::update() with EnergyArbitrator.
# Propagé à tous les consommateurs du .pri (plugin + simulation + tests).
# Uncommenter pour activer. Commité DÉSACTIVÉ jusqu'à preuve iso (3b-iv).
DEFINES += ETM_ARBITRATOR
greaterThan(QT_MAJOR_VERSION, 5) {
message("Building using Qt6 support")
CONFIG *= c++17
@ -37,6 +46,8 @@ HEADERS += \
$$PWD/types/smartchargingstate.h \
$$PWD/types/timeframe.h \
include($$PWD/etm/etm.pri)
SOURCES += \
$$PWD/energymanagerconfiguration.cpp \
$$PWD/energysettings.cpp \

View File

@ -28,6 +28,12 @@
#include "energymanagerconfiguration.h"
#include "spotmarket/spotmarketmanager.h"
// [ETM] BEGIN — EnergyArbitrator flip. Remove block to revert to upstream SmartChargingManager.
#ifdef ETM_ARBITRATOR
#include "etm/energyarbitrator.h"
#endif
// [ETM] END
#include "plugininfo.h"
EnergyPluginNymea::EnergyPluginNymea(QObject *parent) : EnergyPlugin(parent)
@ -41,8 +47,14 @@ void EnergyPluginNymea::init()
EnergyManagerConfiguration *configuration = new EnergyManagerConfiguration(this);
QNetworkAccessManager *networkManager = new QNetworkAccessManager(this);
SpotMarketManager *spotMarketManager = new SpotMarketManager(networkManager, this);
#ifdef ETM_ARBITRATOR
qCDebug(dcNymeaEnergy()) << "ETM_ARBITRATOR actif — EnergyArbitrator chargé.";
EnergyArbitrator *chargingManager = new EnergyArbitrator(energyManager(), thingManager(), spotMarketManager, configuration, this);
#else
SmartChargingManager *chargingManager = new SmartChargingManager(energyManager(), thingManager(), spotMarketManager, configuration, this);
#endif
jsonRpcServer()->registerExperienceHandler(new NymeaEnergyJsonHandler(spotMarketManager, chargingManager, this), 0, 8);
}

View File

@ -0,0 +1,217 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync
#include "ecsrelayadapter.h"
#include "plugininfo.h"
#include <QDateTime>
#include <integrations/thingmanager.h>
#include <integrations/thing.h>
#include <types/action.h>
#include <types/param.h>
EcsRelayAdapter::EcsRelayAdapter(ThingManager *thingManager,
const QString &id,
const QString &label,
const QList<int> &stages,
const QList<QList<QString>> &relayMapping,
int minOnS,
int minOffS,
int priority,
QObject *parent)
: QObject(parent)
, m_thingManager(thingManager)
, m_id(id)
, m_label(label)
, m_stages(stages)
, m_relayMapping(relayMapping)
, m_minOnS(minOnS)
, m_minOffS(minOffS)
, m_priority(priority)
{
Q_ASSERT(!m_stages.isEmpty() && m_stages.first() == 0);
Q_ASSERT(m_relayMapping.size() == m_stages.size());
}
LoadDescriptor EcsRelayAdapter::descriptor() const
{
LoadDescriptor d;
d.id = m_id;
d.label = m_label;
d.adapter = QStringLiteral("relay-stages");
d.priority = m_priority;
d.declared.stages = m_stages;
d.limits.minOnS = m_minOnS;
d.limits.minOffS = m_minOffS;
d.supportedKinds = { LoadAction::Stage };
return d;
}
LoadTelemetry EcsRelayAdapter::telemetry() const
{
LoadTelemetry t;
t.available = true;
t.lastActionAt = m_lastActionAt;
// Source de currentPowerW (consommée par le recrédit anti-clignotement du waterfall,
// RuleBasedScheduler::buildEcsStageAction — correction B) :
// - MESURÉE dès qu'au moins un relais du stage expose un state "currentPower" :
// somme des mesures. Un thermostat interne coupé (relais ON mais 0 W réel) renvoie
// alors 0 → recrédit 0, jamais de puissance fantôme.
// - DÉCLARÉE (repli) : stages[stage] UNIQUEMENT si aucun relais actif ne mesure
// (relais nus / mock sans powermetering). Approximation = palier à sa nominale.
double power = 0;
bool metered = false;
const QList<QString> &activeRelays = m_currentStage < m_relayMapping.size()
? m_relayMapping.at(m_currentStage)
: QList<QString>();
for (const QString &thingId : activeRelays) {
Thing *t2 = m_thingManager->findConfiguredThing(ThingId(thingId));
if (!t2)
continue;
if (!t2->thingClass().stateTypes().findByName("currentPower").id().isNull()) {
metered = true;
power += t2->stateValue("currentPower").toDouble();
}
}
// Repli déclaré : seulement si la mesure n'est pas disponible (pas si elle vaut 0).
if (!metered && m_currentStage > 0 && m_currentStage < m_stages.size())
power = m_stages.at(m_currentStage);
t.currentPowerW = power;
return t;
}
LoadContext EcsRelayAdapter::toLoadContext(const QDateTime &now) const
{
LoadContext ctx;
ctx.id = m_id;
ctx.adapter = QStringLiteral("relay-stages");
ctx.label = m_label;
ctx.priority = m_priority;
ctx.declared = descriptor().declared;
ctx.limits = descriptor().limits;
const LoadTelemetry tel = telemetry();
ctx.telemetry.currentPowerW = tel.currentPowerW;
ctx.telemetry.stage = m_currentStage;
ctx.telemetry.lastSwitch = m_lastSwitch;
// Fenêtre de verrou évaluée au temps de cycle : le scheduler y borne son choix
// (plancher = puissance engagée non-coupable sous minOn ; plafond = redémarrage minOff).
lockWindow(now, ctx.telemetry.minStage, ctx.telemetry.maxStage);
return ctx;
}
LoadAction EcsRelayAdapter::applyAction(const LoadAction &action, const QDateTime &now)
{
if (action.kind != LoadAction::Stage)
return action;
if (action.reason.isEmpty()) {
qCWarning(dcNymeaEnergy()) << "[EcsRelayAdapter]" << m_label
<< "— LoadAction sans reason rejetée.";
return action;
}
const int newStage = qBound(0, action.stage, m_stages.size() - 1);
if (newStage == m_currentStage)
return action; // Aucun changement → idempotent
// Verrous anti-rebond évalués au temps de cycle (même fenêtre que le scheduler) —
// bypassés si force == true (L2 watchdog).
if (!action.force && lockActive(newStage, now)) {
qCDebug(dcNymeaEnergy()) << "[EcsRelayAdapter]" << m_label
<< "— verrou anti-rebond actif, stage" << newStage << "ignoré.";
return action;
}
qCDebug(dcNymeaEnergy()) << "[EcsRelayAdapter]" << m_label
<< "→ stage" << newStage
<< "(" << (m_currentStage < m_stages.size() ? m_stages.at(m_currentStage) : 0) << "W"
<< "" << m_stages.at(newStage) << "W)"
<< "|" << action.reason;
applyRelayStage(newStage);
m_currentStage = newStage;
m_lastSwitch = now; // estampille au temps de cycle (cohérent avec la fenêtre de verrou)
m_lastActionAt = now;
LoadAction applied = action;
applied.stage = newStage;
applied.estimatedPowerW = m_stages.at(newStage);
return applied;
}
// ---- privé ---------------------------------------------------------------
void EcsRelayAdapter::lockWindow(const QDateTime &now, int &minStage, int &maxStage) const
{
const int topStage = m_stages.size() - 1;
const bool valid = m_lastSwitch.isValid();
const qint64 elapsed = valid ? m_lastSwitch.secsTo(now) : 0;
// Plancher : si ON et minOn non écoulé → interdit de descendre sous le palier courant
// (puissance engagée non-coupable, protection compresseur / anti court-cycling).
minStage = (m_currentStage > 0 && valid && elapsed < m_minOnS) ? m_currentStage : 0;
// Plafond : si à l'arrêt et minOff non écoulé → interdit de redémarrer.
maxStage = (m_currentStage == 0 && valid && elapsed < m_minOffS) ? 0 : topStage;
}
bool EcsRelayAdapter::lockActive(int newStage, const QDateTime &now) const
{
// MÊME calcul que la fenêtre exposée au scheduler → décision et exécution coïncident.
int minStage, maxStage;
lockWindow(now, minStage, maxStage);
return newStage < minStage || newStage > maxStage;
}
void EcsRelayAdapter::applyRelayStage(int stage)
{
// Set de relais CIBLE du palier (delta complet : chaque relais connu est amené à son
// état cible on/off, pas d'ajout incrémental). Gère les mappings NON-CASCADÉS où monter
// d'un palier éteint des relais (ex. 3 résistances 500/1000/2000 W : 1500=[r500,r1000]
// → 2000=[r2000] commute 3 relais).
const QSet<QString> wantOn = [&]() {
QSet<QString> s;
if (stage < m_relayMapping.size())
for (const QString &id : m_relayMapping.at(stage))
s.insert(id);
return s;
}();
QSet<QString> allRelays;
for (const auto &list : m_relayMapping)
for (const QString &id : list)
allRelays.insert(id);
auto writeRelay = [&](const QString &thingId, bool on) {
Thing *relay = m_thingManager->findConfiguredThing(ThingId(thingId));
if (!relay) {
qCWarning(dcNymeaEnergy()) << "[EcsRelayAdapter]" << m_label
<< "— relais non trouvé:" << thingId;
return;
}
StateType powerStateType = relay->thingClass().stateTypes().findByName("power");
if (!powerStateType.id().isNull()) {
Action powerAction(powerStateType.id(), relay->id(), Action::TriggeredByRule);
powerAction.setParams(ParamList() << Param(powerStateType.id(), on));
m_thingManager->executeAction(powerAction);
} else {
relay->setStateValue("power", on); // repli mock
}
};
// Intention (résistif : transitoire inoffensif, mais portée comme SG-Ready) : COUPER
// d'abord les relais hors-cible, PUIS enclencher ceux de la cible → pas de sur-puissance
// transitoire (somme des deux paliers) sur une transition non-cascadée.
for (const QString &id : allRelays)
if (!wantOn.contains(id))
writeRelay(id, false);
for (const QString &id : wantOn)
writeRelay(id, true);
}

View File

@ -0,0 +1,123 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync
#pragma once
#include <QObject>
#include <QDateTime>
#include <QList>
#include <QString>
#include "iloadadapter.h"
class Thing;
class ThingManager;
/*!
* \brief Adaptateur pour chauffe-eau ou tout relais N paliers (interface relay-stages).
*
* Pilote en production N Things powerswitch nymea : \c m_relayMapping[stage] contient
* la liste des ThingIds à mettre ON pour ce palier (les autres sont mis OFF).
*
* Exemple chauffe-eau 2400W, 2 résistances Waveshare :
* stage 0 : {} A=OFF, B=OFF
* stage 1 : {"thingId-A"} A=ON, B=OFF (1200 W)
* stage 2 : {"thingId-A", "thingId-B"} A=ON, B=ON (2400 W)
*
* \invariant applyAction() rejette silencieusement toute action dont \c reason est vide.
* \invariant applyAction() applique les verrous anti-rebond \c minOnS / \c minOffS
* SAUF si \c action.force == true (réservé L2 watchdog).
* \invariant Le stage est écrêté à [0, stages().size()-1] avant envoi matériel.
* \invariant Seul le kind Stage est traité ; les autres kinds retournent sans effet.
*/
class EcsRelayAdapter : public QObject, public ILoadAdapter
{
Q_OBJECT
public:
/*!
* \brief Constructeur.
* \param thingManager Gestionnaire nymea pour résoudre les ThingIds en Things.
* \param id Identifiant logique de la charge (ThingId de l'objet ECS dans nymea,
* ou identifiant arbitraire unique pour le mock).
* \param label Nom lisible affiché dans les logs et l'app.
* \param stages Puissances en W par palier, index 0 = off : [0, 1200, 2400].
* \param relayMapping relayMapping[i] = liste de ThingIds powerswitch ON pour le palier i.
* \param minOnS Durée minimale ON (s) anti-rebond.
* \param minOffS Durée minimale OFF (s) anti-rebond.
* \param priority Rang dans le waterfall (OPTIMIZER_PROTOCOL §5) : valeur plus BASSE
* = servi en premier (rang 1 = premier servi).
* \param parent Propriétaire Qt.
*/
explicit EcsRelayAdapter(ThingManager *thingManager,
const QString &id,
const QString &label,
const QList<int> &stages,
const QList<QList<QString>> &relayMapping,
int minOnS,
int minOffS,
int priority,
QObject *parent = nullptr);
/*!
* \brief Description statique de la charge.
* \return LoadDescriptor avec adapter="relay-stages", stages, minOnS/minOffS, priority.
*/
LoadDescriptor descriptor() const override;
/*!
* \brief Télémétrie runtime : puissance mesurée, stage courant, lastSwitch.
* \return LoadTelemetry avec currentPowerW issu de la somme des Things actifs.
*/
LoadTelemetry telemetry() const override;
/*!
* \brief Construit l'entrée loads[] §5 du SurplusContext.
* \param now Temps de cycle (\c ctx.timestamp) source unique pour minStage/maxStage.
* \return LoadContext incluant declared, limits et télémétrie ECS (stage, currentPowerW,
* lastSwitch, et la fenêtre de verrou \c minStage/maxStage évaluée à \p now).
*/
LoadContext toLoadContext(const QDateTime &now) const override;
/*!
* \brief Applique un changement de palier sur les relais.
*
* \param action LoadAction de kind Stage. Autres kinds : retour sans effet.
* \param now Temps de cycle (\c ctx.timestamp) MÊME source que toLoadContext(),
* utilisée pour l'évaluation des verrous et l'estampille \c m_lastSwitch.
* \return L'action après écrêtage (stage borné à [0, stages.size()-1]).
*
* \invariant Si \c action.reason est vide retour sans effet (log warning).
* \invariant Si verrous anti-rebond actifs ET \c action.force == false retour sans effet (log).
* \invariant Si \c action.force == true bypass verrous (L2 watchdog uniquement).
* \invariant Toute modification de stage met à jour \c m_lastSwitch (= \p now).
*/
LoadAction applyAction(const LoadAction &action, const QDateTime &now) override;
/*! \brief Stage courant (0 = off). */
int currentStage() const { return m_currentStage; }
private:
/*!
* \brief Fenêtre de paliers autorisée à l'instant \p now par les verrous minOn/minOff.
* \param now Temps de cycle.
* \param[out] minStage Plancher : palier courant si minOn non écoulé (puissance engagée
* non-coupable), sinon 0.
* \param[out] maxStage Plafond : 0 si à l'arrêt et minOff non écoulé (redémarrage interdit),
* sinon le palier le plus haut.
*/
void lockWindow(const QDateTime &now, int &minStage, int &maxStage) const;
bool lockActive(int newStage, const QDateTime &now) const;
void applyRelayStage(int stage);
ThingManager *m_thingManager;
QString m_id;
QString m_label;
QList<int> m_stages; //!< Puissances W par palier, [0]=off.
QList<QList<QString>> m_relayMapping; //!< ThingIds ON par palier.
int m_minOnS;
int m_minOffS;
int m_priority;
int m_currentStage = 0;
QDateTime m_lastSwitch; //!< Dernier changement de palier (null = jamais).
QDateTime m_lastActionAt;
};

View File

@ -0,0 +1,94 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync
#include "evadapter.h"
#include "../energyarbitrator.h"
#include "../../evcharger.h"
#include "../../types/chargingaction.h"
#include "plugininfo.h"
EvAdapter::EvAdapter(EvCharger *evCharger, EnergyArbitrator *parent)
: QObject(parent)
, m_charger(evCharger)
, m_parent(parent)
{
}
LoadDescriptor EvAdapter::descriptor() const
{
LoadDescriptor d;
d.id = m_charger->thing()->id().toString();
d.label = m_charger->name();
d.adapter = QStringLiteral("evcharger");
d.priority = 100;
d.declared.minA = m_charger->maxChargingCurrentMinValue();
d.declared.maxA = m_charger->maxChargingCurrentMaxValue();
d.declared.phases = static_cast<int>(m_charger->phaseCount());
d.limits.chargingEnabledLockS = static_cast<int>(m_charger->chargingEnabledLockDuration());
d.limits.currentChangeLockS = static_cast<int>(m_charger->chargingCurrentLockDuration());
d.supportedKinds = { LoadAction::Setpoint };
return d;
}
LoadTelemetry EvAdapter::telemetry() const
{
LoadTelemetry t;
t.currentPowerW = m_charger->currentPower();
t.available = m_charger->available();
t.lastActionAt = m_lastActionAt;
return t;
}
LoadContext EvAdapter::toLoadContext(const QDateTime &now) const
{
Q_UNUSED(now) // L'EV n'a pas de verrou de palier (pas de waterfall ECS) — now inutilisé ici.
LoadContext ctx;
ctx.id = m_charger->thing()->id().toString();
ctx.adapter = QStringLiteral("evcharger");
ctx.label = m_charger->name();
ctx.declared = descriptor().declared;
ctx.limits = descriptor().limits;
ctx.telemetry.currentPowerW = m_charger->currentPower();
ctx.telemetry.pluggedIn = m_charger->pluggedIn();
ctx.telemetry.charging = m_charger->charging();
return ctx;
}
LoadAction EvAdapter::applyAction(const LoadAction &action, const QDateTime &now)
{
if (action.kind != LoadAction::Setpoint)
return action;
if (action.reason.isEmpty()) {
qCWarning(dcNymeaEnergy()) << "[EvAdapter]" << m_charger->name()
<< "— LoadAction sans reason rejetée.";
return action;
}
const uint minA = m_charger->maxChargingCurrentMinValue();
const uint maxA = m_charger->maxChargingCurrentMaxValue();
const uint clampedA = static_cast<uint>(
qBound(static_cast<double>(minA), action.currentA, static_cast<double>(maxA)));
const uint phases = (m_charger->canSetPhaseCount() && action.phaseCount > 0)
? qBound(1u, action.phaseCount, m_charger->phaseCount())
: m_charger->phaseCount();
const auto issuer = (action.funding == LoadAction::Surplus)
? ChargingAction::ChargingActionIssuerSurplusCharging
: ChargingAction::ChargingActionIssuerTimeRequirement;
ChargingAction ca(action.chargingEnabled, clampedA, phases, issuer, false);
m_parent->doExecuteChargingAction(m_charger, ca, now); // now = temps de cycle (injectable)
m_lastActionAt = now;
LoadAction applied = action;
applied.currentA = clampedA;
applied.phaseCount = phases;
return applied;
}

View File

@ -0,0 +1,81 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync
#pragma once
#include <QObject>
#include <QDateTime>
#include "iloadadapter.h"
class EvCharger;
class EnergyArbitrator;
/*!
* \brief Adaptateur pour une borne de recharge VE (interface evcharger nymea).
*
* Traduit les LoadAction(Setpoint) en appels matériels via
* EnergyArbitrator::doExecuteChargingAction() le seul chemin d'exécution.
*
* \invariant applyAction() rejette silencieusement toute LoadAction dont \c reason est vide.
* \invariant currentA est écrêté à [maxChargingCurrentMinValue, maxChargingCurrentMaxValue].
* \invariant phaseCount est écrêté selon canSetPhaseCount() et phaseCount() du EvCharger.
* \invariant Les kinds autres que Setpoint sont retournés sans effet.
*/
class EvAdapter : public QObject, public ILoadAdapter
{
Q_OBJECT
public:
/*!
* \brief Constructeur.
* \param evCharger Borne VE à piloter (doit rester valide tant que l'adaptateur existe).
* \param parent EnergyArbitrator propriétaire utilisé pour l'exécution matérielle.
*/
explicit EvAdapter(EvCharger *evCharger, EnergyArbitrator *parent);
/*!
* \brief Retourne la description statique de la charge.
* \return LoadDescriptor construit depuis les capacités actuelles du EvCharger.
* \note Recalculé à chaque appel depuis l'état nymea du Thing.
*/
LoadDescriptor descriptor() const override;
/*!
* \brief Retourne la télémétrie runtime (puissance mesurée, disponibilité).
* \return LoadTelemetry avec currentPowerW, available et lastActionAt.
*/
LoadTelemetry telemetry() const override;
/*!
* \brief Construit l'entrée loads[] §5 du SurplusContext.
* \param now Temps de cycle (\c ctx.timestamp). Inutilisé ici : l'EV n'a pas de verrou
* de palier (hors waterfall ECS/SG-Ready). Présent pour l'uniformité de \c ILoadAdapter.
* \return LoadContext incluant declared, limits, needs et télémétrie EV.
*/
LoadContext toLoadContext(const QDateTime &now) const override;
/*!
* \brief Applique une consigne Setpoint sur la borne VE.
*
* **Inactif jusqu'à 3g** : non appelée par \c EnergyArbitrator::update() en beta.
* Le dispatch EV passe par \c adjustEvChargers() amont (hérité). Cette méthode
* sera câblée lors de la transplantation EV dans \c RuleBasedScheduler (phase 3g).
* \c descriptor() et \c telemetry() sont eux actifs dès maintenant pour le SurplusContext.
*
* \param action LoadAction de kind Setpoint. Autres kinds : retour sans effet.
* \param now Temps de cycle (\c ctx.timestamp) passé à \c doExecuteChargingAction()
* (locks anti-rebond de la borne). MÊME source que toLoadContext() (contrat ILoadAdapter).
* \return L'action après écrêtage matériel (currentA, phaseCount bornés).
*
* \invariant action.reason non vide requis log warning et retour sans effet sinon.
* \invariant currentA écrêté à [minValue, maxValue] avant envoi à executeChargingAction.
* \invariant phaseCount ajusté selon canSetPhaseCount() du EvCharger.
*/
LoadAction applyAction(const LoadAction &action, const QDateTime &now) override;
/*! \brief Borne VE sous-jacente (lecture). */
EvCharger *evCharger() const { return m_charger; }
private:
EvCharger *m_charger;
EnergyArbitrator *m_parent;
QDateTime m_lastActionAt;
};

View File

@ -0,0 +1,75 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync
#pragma once
#include <QDateTime>
#include "../types/loadaction.h"
#include "../types/loaddescriptor.h"
#include "../types/surpluscontext.h"
/*!
* \brief Vue runtime minimale exposée par un adaptateur à l'arbitre.
*/
struct LoadTelemetry {
double currentPowerW = 0; //!< Puissance mesurée (W).
bool available = true; //!< Faux si l'appareil nymea est absent ou en erreur.
QDateTime lastActionAt; //!< Dernier instant où applyAction() a produit un effet.
};
/*!
* \brief Interface pure des adaptateurs de charge.
*
* Les implémentations concrètes héritent de QObject + ILoadAdapter et déclarent leurs
* propres signaux (telemetryChanged, descriptorChanged).
*
* \invariant Les adaptateurs EXÉCUTENT, ils ne décident pas (AGENTS règle 2).
* \invariant applyAction() écrête les valeurs selon les limites matérielles réelles
* (second filet après l'écrêtage de l'arbitre).
* \invariant applyAction() avec \c reason vide doit être rejetée silencieusement.
* \invariant Les méthodes non-applyAction() retournent immédiatement (pas de I/O bloquant).
* \invariant **Temps = paramètre, jamais l'horloge.** Toute logique temporelle d'un
* adaptateur (verrous minOn/minOff, fenêtres, fraîcheur) utilise EXCLUSIVEMENT le
* \c now (= \c ctx.timestamp) reçu en paramètre de \c toLoadContext()/applyAction().
* JAMAIS \c QDateTime::currentDateTime(). C'est cette source unique, partagée avec le
* scheduler, qui rend impossible toute divergence décision/exécution et qui rend la
* logique injectable en simulation. Contrat pour tout futur adaptateur (SgReady, Battery).
*/
class ILoadAdapter {
public:
virtual ~ILoadAdapter() = default;
/*!
* \brief Description statique de la charge : capacités, limites, priorité, needs.
* \return LoadDescriptor construit depuis la configuration matérielle.
* \note Peut être rappelé à chaque cycle l'implémentation doit être légère.
*/
virtual LoadDescriptor descriptor() const = 0;
/*!
* \brief Télémétrie runtime (puissance, disponibilité, dernière action).
* \return LoadTelemetry issue de l'état courant de l'appareil nymea.
*/
virtual LoadTelemetry telemetry() const = 0;
/*!
* \brief Construit l'entrée §5 loads[] pour le SurplusContext.
* \param now Temps de cycle (\c ctx.timestamp). Source unique pour l'évaluation des
* verrous (minStage/maxStage) JAMAIS \c QDateTime::currentDateTime() côté adaptateur,
* afin que décision (scheduler) et exécution (applyAction) partagent le même temps.
* \return LoadContext incluant declared, limits, needs et télémétrie type-spécifique.
*/
virtual LoadContext toLoadContext(const QDateTime &now) const = 0;
/*!
* \brief Applique l'action et retourne ce qui a réellement é envoyé au matériel.
*
* L'arbitre a déjà écrêté selon les limites et le budget ceci est le second filet.
*
* \param action Action à appliquer. Doit avoir \c reason non vide.
* \param now Temps de cycle (\c ctx.timestamp) MÊME source que toLoadContext(),
* pour que l'évaluation des verrous coïncide avec celle vue par le scheduler.
* \return L'action après écrêtage matériel (peut différer de l'entrée).
* \note Retour silencieux sans effet si \c action.reason est vide.
*/
virtual LoadAction applyAction(const LoadAction &action, const QDateTime &now) = 0;
};

View File

@ -0,0 +1,235 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync
#include "sgreadyadapter.h"
#include "plugininfo.h"
#include <QDateTime>
#include <QSet>
#include <algorithm>
#include <climits>
#include <integrations/thingmanager.h>
#include <integrations/thing.h>
#include <types/action.h>
#include <types/param.h>
SgReadyAdapter::SgReadyAdapter(ThingManager *thingManager,
const QString &id,
const QString &label,
const QHash<int, QList<QString>> &stateRelays,
const QHash<int, double> &estimatedPowerW,
int minStateHoldS,
int priority,
QObject *parent)
: QObject(parent)
, m_thingManager(thingManager)
, m_id(id)
, m_label(label)
, m_stateRelays(stateRelays)
, m_estimatedPowerW(estimatedPowerW)
, m_minStateHoldS(minStateHoldS)
, m_priority(priority)
{
m_states = m_stateRelays.keys();
std::sort(m_states.begin(), m_states.end());
Q_ASSERT(!m_states.isEmpty());
Q_ASSERT(m_stateRelays.contains(2)); // état 2 (normal) = repli sûr obligatoire
}
LoadDescriptor SgReadyAdapter::descriptor() const
{
LoadDescriptor d;
d.id = m_id;
d.label = m_label;
d.adapter = QStringLiteral("sg-ready");
d.priority = m_priority;
d.declared.states = m_states;
d.declared.estimatedPowerW = m_estimatedPowerW;
d.limits.minStateHoldS = m_minStateHoldS;
d.supportedKinds = { LoadAction::State };
return d;
}
LoadTelemetry SgReadyAdapter::telemetry() const
{
LoadTelemetry t;
t.available = true;
t.lastActionAt = m_lastActionAt;
// Base du recrédit budget = puissance ALLOUÉE de l'état (déclaré), pas la conso mesurée
// (états 1/2 → 0 ; états 3/4 → P3/P4). Cf. invariant 8.
t.currentPowerW = m_estimatedPowerW.value(m_currentState, 0.0);
return t;
}
LoadContext SgReadyAdapter::toLoadContext(const QDateTime &now) const
{
LoadContext ctx;
ctx.id = m_id;
ctx.adapter = QStringLiteral("sg-ready");
ctx.label = m_label;
ctx.priority = m_priority;
ctx.declared = descriptor().declared;
ctx.limits = descriptor().limits;
ctx.telemetry.currentPowerW = telemetry().currentPowerW;
ctx.telemetry.state = m_currentState;
ctx.telemetry.lastSwitch = m_lastSwitch;
// Fenêtre de verrou évaluée au temps de cycle (protection court-cycling PAC).
lockWindow(now, ctx.telemetry.minState, ctx.telemetry.maxState);
return ctx;
}
LoadAction SgReadyAdapter::applyAction(const LoadAction &action, const QDateTime &now)
{
if (action.kind != LoadAction::State)
return action;
if (action.reason.isEmpty()) {
qCWarning(dcNymeaEnergy()) << "[SgReadyAdapter]" << m_label
<< "— LoadAction sans reason rejetée.";
return action;
}
// Écrêtage à un état déclaré (borne puis exigence d'appartenance).
int newState = qBound(m_states.first(), action.state, m_states.last());
if (!m_stateRelays.contains(newState)) {
qCWarning(dcNymeaEnergy()) << "[SgReadyAdapter]" << m_label
<< "— état non déclaré:" << action.state << "→ ignoré.";
return action;
}
if (newState == m_currentState)
return action; // Idempotent
// Verrou minStateHold évalué au temps de cycle (même fenêtre que le scheduler) —
// bypassé si force == true (L2 watchdog → état 2).
if (!action.force && lockActive(newState, now)) {
qCDebug(dcNymeaEnergy()) << "[SgReadyAdapter]" << m_label
<< "— verrou minStateHold actif, état" << newState << "ignoré.";
return action;
}
qCDebug(dcNymeaEnergy()) << "[SgReadyAdapter]" << m_label
<< "→ état" << newState
<< "(" << m_estimatedPowerW.value(newState, 0.0) << "W estimés)"
<< "|" << action.reason;
applyStateRelays(m_currentState, newState);
m_currentState = newState;
m_lastSwitch = now;
m_lastActionAt = now;
LoadAction applied = action;
applied.state = newState;
applied.estimatedPowerW = m_estimatedPowerW.value(newState, 0.0);
return applied;
}
// ---- privé ---------------------------------------------------------------
void SgReadyAdapter::lockWindow(const QDateTime &now, int &minState, int &maxState) const
{
const int lo = m_states.first();
const int hi = m_states.last();
const bool valid = m_lastSwitch.isValid();
const qint64 elapsed = valid ? m_lastSwitch.secsTo(now) : 0;
if (valid && elapsed < m_minStateHoldS) {
// Gel total : la PAC doit tenir son état (protection court-cycling compresseur).
minState = maxState = m_currentState;
} else {
minState = lo;
maxState = hi;
}
}
bool SgReadyAdapter::lockActive(int newState, const QDateTime &now) const
{
// MÊME calcul que la fenêtre exposée au scheduler → décision et exécution coïncident.
int minState, maxState;
lockWindow(now, minState, maxState);
return newState < minState || newState > maxState;
}
int SgReadyAdapter::transientHarm(int state)
{
// Transitoire le plus doux d'abord : neutre (2) < recommandation (3) < blocage (1) < forcé (4).
switch (state) {
case 2: return 0; // neutre
case 3: return 1; // recommandation (run doux)
case 1: return 2; // blocage (coupe le chauffage)
case 4: return 3; // forcé (démarrage franc compresseur)
default: return 2; // combinaison hors-norme : prudence
}
}
int SgReadyAdapter::stateForRelays(const QList<QString> &onRelays) const
{
const QSet<QString> want(onRelays.begin(), onRelays.end());
for (auto it = m_stateRelays.constBegin(); it != m_stateRelays.constEnd(); ++it) {
const QSet<QString> s(it.value().begin(), it.value().end());
if (s == want)
return it.key();
}
return -1;
}
QSet<QString> SgReadyAdapter::allRelays() const
{
QSet<QString> all;
for (const auto &list : m_stateRelays)
for (const QString &id : list)
all.insert(id);
return all;
}
void SgReadyAdapter::applyStateRelays(int fromState, int toState)
{
const QList<QString> targetList = m_stateRelays.value(toState);
const QSet<QString> targetOn(targetList.begin(), targetList.end());
const QList<QString> fromList = m_stateRelays.value(fromState);
const QSet<QString> currentOn(fromList.begin(), fromList.end());
// Relais dont l'état change lors de la transition.
QStringList changed;
for (const QString &relay : allRelays())
if (targetOn.contains(relay) != currentOn.contains(relay))
changed << relay;
auto writeRelay = [&](const QString &thingId, bool on) {
Thing *relay = m_thingManager->findConfiguredThing(ThingId(thingId));
if (!relay) {
qCWarning(dcNymeaEnergy()) << "[SgReadyAdapter]" << m_label << "— relais non trouvé:" << thingId;
return;
}
StateType powerStateType = relay->thingClass().stateTypes().findByName("power");
if (!powerStateType.id().isNull()) {
Action powerAction(powerStateType.id(), relay->id(), Action::TriggeredByRule);
powerAction.setParams(ParamList() << Param(powerStateType.id(), on));
m_thingManager->executeAction(powerAction);
} else {
relay->setStateValue("power", on); // repli mock
}
};
// Contrat d'atomicité : si 2 relais (ou +) changent, commuter d'abord celui dont le
// TRANSITOIRE est le plus doux (neutre/reco plutôt que blocage/forcé), puis les autres.
if (changed.size() >= 2) {
QString best;
int bestHarm = INT_MAX;
for (const QString &r : changed) {
QSet<QString> transient = currentOn;
if (targetOn.contains(r)) transient.insert(r); else transient.remove(r);
const int h = transientHarm(stateForRelays(transient.values()));
if (h < bestHarm) { bestHarm = h; best = r; }
}
writeRelay(best, targetOn.contains(best));
changed.removeAll(best);
}
// Relais restants amenés à leur valeur cible.
for (const QString &r : changed)
writeRelay(r, targetOn.contains(r));
}

View File

@ -0,0 +1,125 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync
#pragma once
#include <QObject>
#include <QDateTime>
#include <QList>
#include <QHash>
#include <QString>
#include "iloadadapter.h"
class Thing;
class ThingManager;
/*!
* \brief Adaptateur SG-Ready (PAC) interface "sg-ready", action \c kind:State.
*
* Pilote une pompe à chaleur via 2 contacts SG-Ready (encodage 2 bits 4 états NORMÉS) :
* 1 = blocage (EVU-Sperre) · 2 = normal (mains off : la PAC décide)
* 3 = recommandation (surplus) · 4 = forcé (boost)
*
* Les 4 états ne sont PAS des paliers de puissance : ils sont qualitatifs, la PAC les
* interprète selon SA logique. \c m_stateRelays[état] = ThingIds powerswitch à mettre ON
* pour cet état (encodage câblé par l'installateur ; les autres relais sont OFF).
*
* \invariant applyAction() rejette silencieusement toute action dont \c reason est vide.
* \invariant applyAction() applique le verrou \c minStateHoldS (protection court-cycling
* compresseur) SAUF si \c action.force == true (réservé L2 watchdog état 2).
* \invariant L'état est écrêté à l'ensemble \c m_states avant envoi matériel.
* \invariant Seul le kind State est traité ; les autres kinds retournent sans effet.
*
* \par Contrat d'atomicité (transport déporté Shelly/Modbus à venir)
* Une transition d'état commute parfois 2 relais (ex. 24 : 0011). Les contacts
* doivent être écrits **aussi atomiquement que possible**, et l'ORDRE de commutation
* doit éviter tout **état actif parasite** : on passe par le transitoire le plus DOUX
* (neutre = état 2, sinon recommandation = état 3) plutôt que par blocage (1) ou forcé
* (4). \c applyStateRelays() choisit cet ordre. En GPIO local le transitoire dure des µs,
* mais l'intention est portée par l'adaptateur pour rester correcte sur un bus lent.
*/
class SgReadyAdapter : public QObject, public ILoadAdapter
{
Q_OBJECT
public:
/*!
* \brief Constructeur.
* \param thingManager Gestionnaire nymea pour résoudre les ThingIds.
* \param id Identifiant logique de la charge.
* \param label Nom lisible (logs, app).
* \param stateRelays état liste de ThingIds powerswitch ON (encodage 2 bits SG-Ready).
* \param estimatedPowerW Puissance estimée (W) par état (déclaré installateur, approx.).
* \param minStateHoldS Durée minimale de maintien d'état (s) protection court-cycling.
* \param priority Rang dans le waterfall (protocole §5 : valeur plus BASSE = servi en premier).
* \param parent Propriétaire Qt.
*/
explicit SgReadyAdapter(ThingManager *thingManager,
const QString &id,
const QString &label,
const QHash<int, QList<QString>> &stateRelays,
const QHash<int, double> &estimatedPowerW,
int minStateHoldS,
int priority,
QObject *parent = nullptr);
LoadDescriptor descriptor() const override;
/*!
* \brief Télémétrie runtime. \c currentPowerW = puissance ALLOUÉE de l'état courant
* (\c declared.estimatedPowerW : 0 pour états 1/2, P3/P4 pour 3/4). C'est la base du
* recrédit budget PAS la conso mesurée de la PAC (l'état 2 autonome est déjà au
* compteur, invariant 8 : la recréditer double-compterait).
*/
LoadTelemetry telemetry() const override;
/*!
* \brief Construit l'entrée loads[] §5 (adapter="sg-ready").
* \param now Temps de cycle (\c ctx.timestamp) source unique de la fenêtre minState/maxState.
*/
LoadContext toLoadContext(const QDateTime &now) const override;
/*!
* \brief Applique un changement d'état SG-Ready (2 relais, transition atomique-douce).
* \param action LoadAction de kind State. Autres kinds : retour sans effet.
* \param now Temps de cycle MÊME source que toLoadContext() (verrou + lastSwitch).
* \return L'action après écrêtage (state borné à l'ensemble déclaré).
*/
LoadAction applyAction(const LoadAction &action, const QDateTime &now) override;
/*! \brief État SG-Ready courant (1-4). */
int currentState() const { return m_currentState; }
private:
/*!
* \brief Fenêtre d'états autorisée à \p now par le verrou minStateHold (symétrique).
* \param[out] minState / maxState Gel total (== \c m_currentState) si \c minStateHold
* non écoulé ; sinon [min, max] des états déclarés.
*/
void lockWindow(const QDateTime &now, int &minState, int &maxState) const;
bool lockActive(int newState, const QDateTime &now) const;
//! Applique l'ensemble de relais de \p toState en passant par le transitoire le plus
//! doux (cf. contrat d'atomicité). \p fromState = état courant (pour l'ordre).
void applyStateRelays(int fromState, int toState);
//! Rang de nocivité d'un état comme TRANSITOIRE (2 neutre < 3 reco < 1 blocage < 4 forcé).
static int transientHarm(int state);
//! État correspondant à un ensemble de relais ON (-1 si aucun état ne correspond).
int stateForRelays(const QList<QString> &onRelays) const;
QSet<QString> allRelays() const;
ThingManager *m_thingManager;
QString m_id;
QString m_label;
QHash<int, QList<QString>> m_stateRelays; //!< état → ThingIds ON.
QHash<int, double> m_estimatedPowerW; //!< état → W estimés (déclaré).
QList<int> m_states; //!< États déclarés triés croissants.
int m_minStateHoldS;
int m_priority;
int m_currentState = 2; //!< Démarrage en NORMAL (mains off).
QDateTime m_lastSwitch; //!< Dernier changement d'état (null = jamais).
QDateTime m_lastActionAt;
};

View File

@ -0,0 +1,305 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync
#include "energyarbitrator.h"
#include "adapters/evadapter.h"
#include "adapters/ecsrelayadapter.h"
#include "adapters/sgreadyadapter.h"
#include "scheduler/rulebasedscheduler.h"
#include "types/surpluscontext.h"
#include "types/plan.h"
#include "../rootmeter.h"
#include "../evcharger.h"
#include "plugininfo.h"
#include <energymanager.h>
#include <QTimer>
namespace {
//! Période du watchdog L2 (SAFETY.md §L2) : tick indépendant des signaux compteur.
constexpr int MeterWatchdogPeriodMs = 30 * 1000; // 30 s
//! Seuil de silence compteur au-delà duquel le mode dégradé L2 est déclenché.
constexpr int MeterSilenceThresholdS = 90; // 90 s
}
EnergyArbitrator::EnergyArbitrator(EnergyManager *em, ThingManager *tm,
SpotMarketManager *sm,
EnergyManagerConfiguration *conf,
QObject *parent)
: SmartChargingManager(em, tm, sm, conf, parent)
, m_scheduler(new RuleBasedScheduler(this, this))
{
// --- L2 : watchdog fraîcheur compteur (SAFETY.md §L2) ---
// La LOGIQUE (recordMeterUpdate / evaluateMeterFreshness) prend le temps en paramètre
// et reste testable par injection (symétrique de simulationCallUpdate). Seuls les
// DÉCLENCHEURS RÉELS (signal + QTimer, horloge murale) sont câblés ici, et exclus en
// simulation — comme les connexions amont powerBalanceEntryAdded→update() (SCM l.108-130).
#ifndef ENERGY_SIMULATION
m_lastMeterUpdate = QDateTime::currentDateTime(); // grâce au démarrage (évite un dégradé immédiat)
// Fraîcheur picotée sur powerBalanceChanged (en plus de la connexion amont L4).
connect(em, &EnergyManager::powerBalanceChanged, this, [this]() {
recordMeterUpdate(QDateTime::currentDateTime());
});
// QTimer (et non signal) : doit rester actif quand le compteur est muet.
m_meterWatchdog = new QTimer(this);
m_meterWatchdog->setInterval(MeterWatchdogPeriodMs);
connect(m_meterWatchdog, &QTimer::timeout, this, &EnergyArbitrator::onMeterWatchdogTick);
m_meterWatchdog->start();
#else
Q_UNUSED(em)
#endif
qCDebug(dcNymeaEnergy()) << "[EnergyArbitrator] Arbitre ETM initialisé.";
}
void EnergyArbitrator::runSurplusPlanning(const QDateTime &now)
{
planSurplusCharging(now);
}
void EnergyArbitrator::runSpotMarketPlanning(const QDateTime &now)
{
planSpotMarketCharging(now);
}
const QHash<EvCharger *, ChargingActions> &EnergyArbitrator::scheduledActions() const
{
return internalChargingActions();
}
void EnergyArbitrator::doExecuteChargingAction(EvCharger *charger,
const ChargingAction &action,
const QDateTime &now)
{
executeChargingAction(charger, action, now);
}
const QHash<ThingId, EvCharger *> &EnergyArbitrator::registeredEvChargers() const
{
return internalEvChargers();
}
RootMeter *EnergyArbitrator::registeredRootMeter() const
{
return internalRootMeter();
}
void EnergyArbitrator::registerEcsAdapter(EcsRelayAdapter *adapter)
{
const QString id = adapter->descriptor().id;
if (m_ecsAdapters.contains(id)) {
qCWarning(dcNymeaEnergy()) << "[EnergyArbitrator] EcsRelayAdapter déjà enregistré:" << id;
return;
}
adapter->setParent(this);
m_ecsAdapters[id] = adapter;
qCDebug(dcNymeaEnergy()) << "[EnergyArbitrator] EcsRelayAdapter enregistré:" << adapter->descriptor().label;
}
void EnergyArbitrator::registerSgReadyAdapter(SgReadyAdapter *adapter)
{
const QString id = adapter->descriptor().id;
if (m_sgReadyAdapters.contains(id)) {
qCWarning(dcNymeaEnergy()) << "[EnergyArbitrator] SgReadyAdapter déjà enregistré:" << id;
return;
}
adapter->setParent(this);
m_sgReadyAdapters[id] = adapter;
qCDebug(dcNymeaEnergy()) << "[EnergyArbitrator] SgReadyAdapter enregistré:" << adapter->descriptor().label;
}
void EnergyArbitrator::update(const QDateTime &currentDateTime)
{
qCDebug(dcNymeaEnergy()) << "Updating smart charging";
// Ordre IDENTIQUE à SmartChargingManager::update() — INTERDIT de réordonner.
// SCM : 1.updateManual 2.prepareInfo 3.verifyOverload 4.verifyRecovery
// 5.planSpot 6.planSurplus 7.adjustEv
// ETM : idem 1-4 ; insertions ETM entre 4 et 7 ;
// planSpot + planSurplus appelés via m_scheduler->getPlan() (position 5-6).
// 1-4 : préparation + sécurité (même ordre que l'amont)
updateManualSoCsWithoutMeter(currentDateTime);
prepareInformation(currentDateTime);
verifyOverloadProtection(currentDateTime);
verifyOverloadProtectionRecovery(currentDateTime);
// Mode dégradé L2 : la sécurité (L4 ci-dessus) reste active, mais on SUSPEND la
// planification et le dispatch. Replanifier sur le cache d'un compteur mort
// rallumerait les charges que le watchdog vient de couper → oscillation. Les
// consignes de repli (posées à la transition) tiennent jusqu'au retour du compteur.
if (m_degradedMode) {
qCDebug(dcNymeaEnergy()) << "[Arbitre] Mode dégradé L2 actif — planification suspendue.";
return;
}
// ETM-only : sync adapters + proxy planification → log [Arbitre]
// getPlan() appelle planSpotMarketCharging() + planSurplusCharging() (position 5-6 amont).
syncAdapters();
SurplusContext ctx = buildContext(currentDateTime);
Plan plan = m_scheduler->getPlan(ctx);
Slot slot = plan.slotCovering(currentDateTime);
for (const LoadAction &action : slot.actions) {
qCInfo(dcNymeaEnergy()) << "[Arbitre]"
<< action.loadId << "" << action.reason
<< "| activé:" << action.chargingEnabled
<< "| courant:" << action.currentA << "A"
<< "| phases:" << action.phaseCount
<< "| stratégie:" << plan.strategy;
}
// 7 : dispatch matériel (même position que l'amont — m_chargingActions rempli par getPlan())
applyActionsToAdapters(slot, currentDateTime); // ECS (kind==Stage) → m_ecsAdapters
adjustEvChargers(currentDateTime); // EV (kind==Setpoint) → proxy amont jusqu'à 3g
}
SurplusContext EnergyArbitrator::buildContext(const QDateTime &now) const
{
SurplusContext ctx;
ctx.timestamp = now;
// --- Compteur principal (AGENTS invariant 8 : mesure brute, aucune déduction) ---
RootMeter *meter = internalRootMeter();
if (meter) {
// currentPower() < 0 → export ; > 0 → import (convention amont SCM l.1141)
const double p = meter->currentPower();
ctx.meter.importW = qMax(0.0, p);
ctx.meter.exportW = qMax(0.0, -p);
ctx.meter.perPhaseA = {
meter->currentPhaseA(),
meter->currentPhaseB(),
meter->currentPhaseC()
};
}
// SurplusPv : interface inverter — déféré (remplissage prévu en 3d)
// SurplusBattery : déféré 3f
// --- loads[] : EV adapters --- (now = ctx.timestamp : source unique des verrous)
for (auto it = m_adapters.constBegin(); it != m_adapters.constEnd(); ++it)
ctx.loads.append(it.value()->toLoadContext(now));
// --- loads[] : ECS relay adapters ---
for (auto it = m_ecsAdapters.constBegin(); it != m_ecsAdapters.constEnd(); ++it)
ctx.loads.append(it.value()->toLoadContext(now));
// --- loads[] : SG-Ready adapters (PAC) ---
for (auto it = m_sgReadyAdapters.constBegin(); it != m_sgReadyAdapters.constEnd(); ++it)
ctx.loads.append(it.value()->toLoadContext(now));
return ctx;
}
void EnergyArbitrator::syncAdapters()
{
// Crée les adapters manquants
for (auto it = internalEvChargers().constBegin(); it != internalEvChargers().constEnd(); ++it) {
const QString id = it.key().toString();
if (!m_adapters.contains(id))
m_adapters[id] = new EvAdapter(it.value(), this);
}
// Supprime les adapters obsolètes
for (const QString &id : m_adapters.keys()) {
if (!internalEvChargers().contains(ThingId(id)))
m_adapters.take(id)->deleteLater();
}
}
void EnergyArbitrator::applyActionsToAdapters(const Slot &slot, const QDateTime &now)
{
for (const LoadAction &action : slot.actions) {
// L'adaptateur applique, écrête et verrouille — il ne décide pas (règle 2).
if (action.kind == LoadAction::Stage) {
EcsRelayAdapter *adapter = m_ecsAdapters.value(action.loadId);
if (adapter)
adapter->applyAction(action, now);
else
qCWarning(dcNymeaEnergy()) << "[Arbitre] action Stage sans adaptateur ECS:" << action.loadId;
} else if (action.kind == LoadAction::State) {
SgReadyAdapter *adapter = m_sgReadyAdapters.value(action.loadId);
if (adapter)
adapter->applyAction(action, now);
else
qCWarning(dcNymeaEnergy()) << "[Arbitre] action State sans adaptateur SG-Ready:" << action.loadId;
}
// EV (Setpoint) : dispatché par adjustEvChargers() amont jusqu'à 3g.
}
}
void EnergyArbitrator::onMeterWatchdogTick()
{
// Déclencheur réel (QTimer, horloge murale) → délègue à la logique injectable.
evaluateMeterFreshness(QDateTime::currentDateTime());
}
void EnergyArbitrator::recordMeterUpdate(const QDateTime &now)
{
m_lastMeterUpdate = now;
if (m_degradedMode) {
qCInfo(dcNymeaEnergy()) << "[Arbitre] Compteur de nouveau actif — sortie du mode dégradé L2.";
m_degradedMode = false;
emit chargingSchedulesChanged(); // pousse degradedMode=false (planif reprend au cycle suivant)
}
}
void EnergyArbitrator::evaluateMeterFreshness(const QDateTime &now)
{
if (!m_lastMeterUpdate.isValid())
return; // Aucune mesure reçue (démarrage) — pas de dégradé (invariant root meter absent).
const qint64 silentS = m_lastMeterUpdate.secsTo(now);
if (silentS <= MeterSilenceThresholdS)
return;
if (m_degradedMode)
return; // Déjà en repli — les consignes tiennent, pas de ré-émission (anti-oscillation).
qCWarning(dcNymeaEnergy()) << "[Arbitre] Compteur muet depuis" << silentS
<< "s (>" << MeterSilenceThresholdS << "s) — mode dégradé L2.";
applyDegradedMode(now);
}
void EnergyArbitrator::applyDegradedMode(const QDateTime &now)
{
m_degradedMode = true;
emit chargingSchedulesChanged(); // pousse degradedMode=true (notification client L2)
const QString reason =
QStringLiteral("Compteur muet depuis >90 s — consigne de repli (L2 watchdog)");
// ECS : tous paliers coupés (stage 0), force=true → bypass des verrous anti-rebond.
for (EcsRelayAdapter *adapter : m_ecsAdapters) {
LoadAction la;
la.loadId = adapter->descriptor().id;
la.kind = LoadAction::Stage;
la.stage = 0;
la.force = true;
la.reason = reason;
adapter->applyAction(la, now); // force=true → bypass verrous ; now = temps de cycle
}
// EV : repli CONSERVATEUR — n'initie aucune charge. On clampe seulement une charge
// DÉJÀ en cours au courant minimum (force=true, bypass lock). Une borne branchée mais
// non chargeante reste off (off volontaire possible : HC/spot à venir) ; débranchée →
// aucune action. La garantie "jamais 0 A si branché" relève du failsafe L1 de la borne.
for (auto it = internalEvChargers().constBegin(); it != internalEvChargers().constEnd(); ++it) {
EvCharger *ev = it.value();
if (ev->available() && ev->charging())
ev->setMaxChargingCurrent(ev->maxChargingCurrentMinValue(), now, true);
}
// SG-Ready (PAC) : repli en état 2 (NORMAL — mains off), JAMAIS état 1 (blocage).
// Sous compteur muet on cesse de piloter : la PAC chauffe selon son propre thermostat
// (la bloquer = maison qui ne chauffe plus sans raison visible). force=true → bypass minStateHold.
for (SgReadyAdapter *adapter : m_sgReadyAdapters) {
LoadAction la;
la.loadId = adapter->descriptor().id;
la.kind = LoadAction::State;
la.state = 2;
la.force = true;
la.reason = reason;
adapter->applyAction(la, now);
}
// Batterie (aucune charge réseau) : repli ajouté avec son adaptateur (3f).
}

View File

@ -0,0 +1,212 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync
#pragma once
#include "../smartchargingmanager.h"
#include "scheduler/ischeduler.h"
#include "types/surpluscontext.h"
#include "types/plan.h"
#include <QDateTime>
class QTimer;
class EvAdapter;
class EcsRelayAdapter;
class SgReadyAdapter;
class RuleBasedScheduler;
/*!
* \brief Arbitre central ETM remplace SmartChargingManager::update() (ETM_ARBITRATOR).
*
* Hérite de SmartChargingManager pour conserver la compatibilité API complète avec
* NymeaEnergyJsonHandler sans modifier le code amont.
* Seul update() est surchargé : préparation sécurité planificateur adapters.
*
* \invariant UN seul arbitre : EnergyArbitrator décide, les EvAdapter exécutent (règle 1).
* \invariant verifyOverloadProtection() est toujours appelée avant la planification (règle 4).
* \invariant Toute LoadAction transmise aux adapters a un \c reason non vide (règle 7).
* \invariant L'absence du root meter n'empêche pas le démarrage cycle ignoré silencieusement.
*/
class EnergyArbitrator : public SmartChargingManager
{
Q_OBJECT
public:
explicit EnergyArbitrator(EnergyManager *energyManager, ThingManager *thingManager,
SpotMarketManager *spotMarketManager,
EnergyManagerConfiguration *configuration,
QObject *parent = nullptr);
/*!
* \brief Déclenche planSurplusCharging() (protégée) appelé par RuleBasedScheduler.
* \param now Instant courant du cycle.
*/
void runSurplusPlanning(const QDateTime &now);
/*!
* \brief Déclenche planSpotMarketCharging() (protégée) appelé par RuleBasedScheduler.
* \param now Instant courant du cycle.
*/
void runSpotMarketPlanning(const QDateTime &now);
/*!
* \brief Actions planifiées (résultat de runSurplus/SpotMarket).
* \return Référence constante vers la table EvCharger* ChargingActions.
* \note Valide seulement après runSurplusPlanning() / runSpotMarketPlanning().
*/
const QHash<EvCharger *, ChargingActions> &scheduledActions() const;
/*!
* \brief Pont d'exécution pour EvAdapter délègue à executeChargingAction() protégée.
* \param charger Borne EV cible.
* \param action ChargingAction à appliquer.
* \param now Instant de l'action (pour les locks anti-rebond).
*/
void doExecuteChargingAction(EvCharger *charger, const ChargingAction &action, const QDateTime &now);
/*!
* \brief Liste des EvCharger enregistrés (lecture seule).
* \return Table ThingId EvCharger*.
*/
const QHash<ThingId, EvCharger *> &registeredEvChargers() const;
/*!
* \brief Root meter courant.
* \return Pointeur ou nullptr si aucun compteur principal n'est enregistré.
*/
RootMeter *registeredRootMeter() const;
/*!
* \brief Enregistre un EcsRelayAdapter pour inclusion dans le contexte et le dispatch.
*
* Appelé par le test (setup) ou la configuration de production.
* L'adaptateur est adopté comme enfant Qt de l'arbitre.
* \param adapter Adaptateur à enregistrer. Son \c descriptor().id doit être unique.
*/
void registerEcsAdapter(EcsRelayAdapter *adapter);
/*!
* \brief Enregistre un SgReadyAdapter (PAC) pour inclusion dans le contexte et le dispatch.
* \param adapter Adaptateur à enregistrer ; son \c descriptor().id doit être unique.
* Adopté comme enfant Qt de l'arbitre. Appelé par le test (setup) ou la config production.
*/
void registerSgReadyAdapter(SgReadyAdapter *adapter);
/*!
* \brief Mode dégradé L2 actif (compteur muet > 90 s) override de SmartChargingManager.
* \return \c true tant que les consignes de repli L2 tiennent ; \c false en régime normal.
* \note Exposé dans la notification \c NymeaEnergy.ChargingSchedulesChanged (champ
* \c degradedMode), émise aussi aux transitions de ce flag.
*/
bool degradedMode() const override { return m_degradedMode; }
/*!
* \brief Enregistre une mesure fraîche du compteur à l'instant \p now (logique L2).
*
* Met à jour \c m_lastMeterUpdate et, si le mode dégradé était actif, en sort
* (\c degradedMode=false + notification). \p now = temps de cycle.
* \note Logique injectable (temps en paramètre) en production appelée par le
* handler \c powerBalanceChanged ; en simulation/test appelée directement. Le
* déclencheur réel (signal) est câblé sous \c \#ifndef ENERGY_SIMULATION.
*/
void recordMeterUpdate(const QDateTime &now);
/*!
* \brief Évalue la fraîcheur du compteur à \p now et bascule en mode dégradé si muet >90 s.
*
* Si \c now \c m_lastMeterUpdate > 90 s et pas déjà dégradé \c applyDegradedMode().
* Appliqué à la TRANSITION uniquement (idempotent ensuite). \p now = temps de cycle.
* \note Logique injectable en production appelée par \c onMeterWatchdogTick() (QTimer
* horloge murale, indépendant car le compteur muet fige aussi \c update()) ; en
* simulation/test appelée directement avec le temps simulé. Symétrique de
* \c simulationCallUpdate : déclencheur réel en prod, logique testable par injection.
*/
void evaluateMeterFreshness(const QDateTime &now);
protected:
/*!
* \brief Boucle principale ETM surcharge SmartChargingManager::update().
*
* Ordre garanti :
* 1. updateManualSoCsWithoutMeter()
* 2. prepareInformation()
* 3. verifyOverloadProtection() + verifyOverloadProtectionRecovery()
* (si \c m_degradedMode actif : retour immédiat planification/dispatch suspendus, L2)
* 4. m_scheduler->getPlan() log des decisionReason
* 5. applyActionsToAdapters() (ECS Stage + SG-Ready State) + adjustEvChargers() (EV) dispatch
*
* \param currentDateTime Instant courant (timer ou simulation).
*/
void update(const QDateTime &currentDateTime) override;
private:
/*!
* \brief Construit le SurplusContext §5 : meter brut + loads EV + ECS + SG-Ready.
*
* \c ctx.meter.exportW = mesure brute du compteur (AGENTS invariant 8 aucune
* déduction interne). La déduction evReservedW est faite dans le scheduler.
* \param now Temps de cycle (\c ctx.timestamp) : pose \c ctx.timestamp et sert de SOURCE
* UNIQUE aux fenêtres de verrou (\c minStage/maxStage, \c minState/maxState) calculées
* par chaque adaptateur cohérence décision (scheduler) / exécution (applyAction).
*/
SurplusContext buildContext(const QDateTime &now) const;
/*!
* \brief Synchronise m_adapters avec les EvCharger actuellement enregistrés.
* Crée les adapters manquants, supprime les adapters obsolètes.
* \note Découverte ECS via interface 'ecsrelay' ThingManager déféré 3g config.
* En beta, les EcsRelayAdapters sont enregistrés via registerEcsAdapter().
*/
void syncAdapters();
/*!
* \brief Applique les actions d'un Slot aux LoadAdapters non-EV (ECS + SG-Ready).
*
* Itère \c slot.actions et dispatche selon le kind : \c Stage \c m_ecsAdapters
* (EcsRelayAdapter) ; \c State \c m_sgReadyAdapters (SgReadyAdapter). Les actions EV
* (\c Setpoint) restent dispatchées par \c adjustEvChargers() amont jusqu'à 3g.
* L'adaptateur écrête/verrouille lui-même (anti-rebond) et ignore toute action sans
* \c reason ou de kind non supporté aucune décision ici (règle 2).
* \param slot Créneau courant retourné par le scheduler.
* \param now Temps de cycle (\c ctx.timestamp) transmis aux adaptateurs pour une
* évaluation des verrous cohérente avec celle vue par le scheduler.
*/
void applyActionsToAdapters(const Slot &slot, const QDateTime &now);
/*!
* \brief Déclencheur RÉEL du watchdog L2 (SAFETY.md §L2) slot de \c m_meterWatchdog
* (QTimer 30 s, horloge murale ; câblé sous \c \#ifndef ENERGY_SIMULATION).
*
* Délègue simplement à \c evaluateMeterFreshness(QDateTime::currentDateTime()) : la
* LOGIQUE (seuil 90 s, bascule en dégradé) est dans cette méthode injectable, le QTimer
* n'est que le battement. Indépendant des signaux compteur : reste actif précisément
* quand le compteur est muet (le signal \c powerBalanceChanged ne fire plus, et
* \c update() piloté par le compteur s'arrête aussi). Voir \c evaluateMeterFreshness().
*/
void onMeterWatchdogTick();
/*!
* \brief Applique les consignes de repli L2 (SAFETY.md §L2, Variante B).
*
* Repli CONSERVATEUR (n'initie aucune charge) : ECS palier 0 \c force=true (bypass
* anti-rebond) ; EV en charge clamp courant minimum borne ; EV branché non chargeant
* ou débranché aucune action (planification en cours respectée ; "jamais 0 A si
* branché" relève du failsafe L1). SG-Ready/Batterie : repli ajouté à l'arrivée de
* leurs adaptateurs (3e/3f). Positionne \c m_degradedMode.
*
* \note Appelé une seule fois à la TRANSITION vers le mode dégradé. Ensuite \c update()
* suspend la planification, donc les consignes tiennent sans -émission par tick.
* \param now Instant courant (locks anti-rebond des bornes EV).
*/
void applyDegradedMode(const QDateTime &now);
RuleBasedScheduler *m_scheduler = nullptr;
QHash<QString, EvAdapter *> m_adapters; //!< loadId (ThingId string) → EvAdapter*.
QHash<QString, EcsRelayAdapter *> m_ecsAdapters; //!< loadId → EcsRelayAdapter*.
QHash<QString, SgReadyAdapter *> m_sgReadyAdapters; //!< loadId → SgReadyAdapter* (PAC).
// --- L2 watchdog fraîcheur compteur (SAFETY.md §L2) ---
QTimer *m_meterWatchdog = nullptr; //!< Tick 30 s, indépendant des signaux compteur.
QDateTime m_lastMeterUpdate; //!< Horodatage du dernier powerBalanceChanged.
bool m_degradedMode = false; //!< Vrai si les consignes de repli L2 sont actives.
};

19
energyplugin/etm/etm.pri Normal file
View File

@ -0,0 +1,19 @@
HEADERS += \
$$PWD/types/loadaction.h \
$$PWD/types/loaddescriptor.h \
$$PWD/types/surpluscontext.h \
$$PWD/types/plan.h \
$$PWD/adapters/iloadadapter.h \
$$PWD/scheduler/ischeduler.h \
$$PWD/adapters/evadapter.h \
$$PWD/adapters/ecsrelayadapter.h \
$$PWD/adapters/sgreadyadapter.h \
$$PWD/scheduler/rulebasedscheduler.h \
$$PWD/energyarbitrator.h \
SOURCES += \
$$PWD/adapters/evadapter.cpp \
$$PWD/adapters/ecsrelayadapter.cpp \
$$PWD/adapters/sgreadyadapter.cpp \
$$PWD/scheduler/rulebasedscheduler.cpp \
$$PWD/energyarbitrator.cpp \

View File

@ -0,0 +1,31 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync
#pragma once
#include "../types/surpluscontext.h"
#include "../types/plan.h"
/*!
* \brief Interface pure du planificateur d'énergie.
*
* Les implémentations concrètes héritent de QObject + IScheduler.
*
* \invariant getPlan() retourne IMMÉDIATEMENT (modèle cache, AGENTS invariant 5).
* SocketScheduler retourne son dernier plan en cache et recalcule en arrière-plan.
* \invariant getPlan() retourne TOUJOURS un Plan valide (isValid() == true).
* SocketScheduler embarque un RuleBasedScheduler en fallback jamais d'abstain
* qui remonterait à l'arbitre (AGENTS règle 6).
* \invariant Toute LoadAction du Plan retourné a \c reason non vide, en français.
*/
class IScheduler {
public:
virtual ~IScheduler() = default;
/*!
* \brief Calcule le plan d'optimisation à partir du contexte courant.
* \param ctx Contexte surplus (site, compteur, PV, batterie, charges, tarif).
* \return Plan avec au moins un Slot jamais Plan::isValid() == false.
* \note Retourne immédiatement depuis le cache ; le recalcul est asynchrone.
*/
virtual Plan getPlan(const SurplusContext &ctx) = 0;
};

View File

@ -0,0 +1,288 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync
#include "rulebasedscheduler.h"
#include "../energyarbitrator.h"
#include "../../evcharger.h"
#include "../../types/chargingaction.h"
#include "../../types/charginginfo.h"
#include "plugininfo.h"
#include <QUuid>
#include <algorithm>
RuleBasedScheduler::RuleBasedScheduler(EnergyArbitrator *arbitrator, QObject *parent)
: QObject(parent)
, m_arbitrator(arbitrator)
{
}
Plan RuleBasedScheduler::getPlan(const SurplusContext &ctx)
{
// Planification (même logique que l'amont — écrit dans m_chargingActions)
m_arbitrator->runSpotMarketPlanning(ctx.timestamp);
m_arbitrator->runSurplusPlanning(ctx.timestamp);
Slot slot;
slot.from = ctx.timestamp;
slot.to = ctx.timestamp.addSecs(60);
const auto &cas = m_arbitrator->scheduledActions();
const auto &evs = m_arbitrator->registeredEvChargers();
// Même priorité que adjustEvChargers() — iso-fonctionnel 3b
for (auto it = evs.constBegin(); it != evs.constEnd(); ++it) {
EvCharger *ev = it.value();
if (!ev->available() || !ev->pluggedIn())
continue;
const ChargingActions &actions = cas.value(ev);
LoadAction la;
if (actions.value(ChargingAction::ChargingActionIssuerTimeRequirement).chargingEnabled()) {
la = buildTimeRequirementAction(
ev, actions.value(ChargingAction::ChargingActionIssuerTimeRequirement));
} else if (actions.value(ChargingAction::ChargingActionIssuerSurplusCharging).chargingEnabled()) {
const auto &ca = actions.value(ChargingAction::ChargingActionIssuerSurplusCharging);
la.loadId = ev->thing()->id().toString();
la.kind = LoadAction::Setpoint;
la.funding = LoadAction::Surplus;
la.chargingEnabled = true;
la.currentA = ca.maxChargingCurrent();
la.phaseCount = ca.desiredPhaseCount();
la.reason = QStringLiteral("Surplus PV disponible — recharge solaire");
la.estimatedPowerW = la.currentA * 230.0 * la.phaseCount;
} else if (actions.value(ChargingAction::ChargingActionIssuerSpotMarketCharging).chargingEnabled()) {
const auto &ca = actions.value(ChargingAction::ChargingActionIssuerSpotMarketCharging);
la.loadId = ev->thing()->id().toString();
la.kind = LoadAction::Setpoint;
la.funding = LoadAction::Grid;
la.chargingEnabled = true;
la.currentA = ca.maxChargingCurrent();
la.phaseCount = ca.desiredPhaseCount();
la.reason = QStringLiteral("Tarif aWATTar favorable — recharge heure creuse");
la.estimatedPowerW = la.currentA * 230.0 * la.phaseCount;
} else {
const ChargingInfo::ChargingMode mode =
m_arbitrator->chargingInfo(ev->id()).chargingMode();
if (mode == ChargingInfo::ChargingModeEcoWithMinCurrent
|| mode == ChargingInfo::ChargingModeEcoMinWithTargetTime) {
la = buildMinCurrentAction(ev);
} else {
la = buildIdleAction(ev);
}
}
slot.actions.append(la);
}
// ---- Waterfall ECS (charges non-EV à paliers) ---------------------------------
// Correction A — déduction EV unique : ctx.meter.exportW est la mesure BRUTE
// (invariant 8). Les consignes EV de CE cycle ne sont pas encore visibles au
// compteur ; on réserve donc leur puissance commandée non encore mesurée.
double evReservedW = 0;
for (const LoadAction &la : slot.actions) {
if (la.kind != LoadAction::Setpoint || !la.chargingEnabled)
continue;
EvCharger *ev = evs.value(ThingId(la.loadId));
const double measuredW = ev ? ev->currentPower() : 0.0;
evReservedW += qMax(0.0, la.estimatedPowerW - measuredW);
}
// Surplus net SIGNÉ : exportW importW = puissance compteur. Négatif en import →
// l'ECS déleste (budget < palier) au lieu de rester allumé sur le réseau. (Le maintien
// transitoire sous minOn est géré par le verrou de l'adaptateur, pas par le budget.)
double remainingSurplusW = (ctx.meter.exportW - ctx.meter.importW) - evReservedW;
// Charges pilotables non-EV (ECS paliers + SG-Ready) triées par priorité ASCENDANTE :
// rang 1 = premier servi (OPTIMIZER_PROTOCOL §5 + annexe C — la priorité est un rang).
// Le budget de surplus est UNIQUE et cascade à travers TOUTES ces charges par priorité.
QList<LoadContext> nonEvLoads;
for (const LoadContext &lc : ctx.loads) {
if (lc.adapter == QStringLiteral("relay-stages") || lc.adapter == QStringLiteral("sg-ready"))
nonEvLoads.append(lc);
}
std::sort(nonEvLoads.begin(), nonEvLoads.end(),
[](const LoadContext &a, const LoadContext &b) { return a.priority < b.priority; });
for (const LoadContext &lc : nonEvLoads) {
if (lc.adapter == QStringLiteral("sg-ready"))
slot.actions.append(buildSgReadyStateAction(lc, remainingSurplusW));
else
slot.actions.append(buildEcsStageAction(lc, remainingSurplusW));
}
// Grid funding (ECS/PAC) : dormant jusqu'à 3f (waterfall réseau) — non implémenté ici.
Plan plan;
plan.planId = QUuid::createUuid().toString(QUuid::WithoutBraces);
plan.strategy = QStringLiteral("rule-based");
plan.timeSlots.append(slot);
return plan;
}
LoadAction RuleBasedScheduler::buildTimeRequirementAction(EvCharger *ev,
const ChargingAction &ca) const
{
// Le courant final est affiné par adjustEvChargers() (allowance root-meter).
// En 3b on log la valeur brute de la planification — iso-fonctionnel.
LoadAction la;
la.loadId = ev->thing()->id().toString();
la.kind = LoadAction::Setpoint;
la.funding = LoadAction::Grid;
la.chargingEnabled = true;
la.currentA = ca.maxChargingCurrent();
la.phaseCount = ca.desiredPhaseCount();
la.reason = QStringLiteral("Deadline VE approchante — recharge prioritaire");
la.estimatedPowerW = la.currentA * 230.0 * la.phaseCount;
return la;
}
LoadAction RuleBasedScheduler::buildMinCurrentAction(EvCharger *ev) const
{
const uint minA = qMax(EcoMinChargingCurrent, ev->maxChargingCurrentMinValue());
const uint phases = ev->phaseCount();
LoadAction la;
la.loadId = ev->thing()->id().toString();
la.kind = LoadAction::Setpoint;
la.funding = LoadAction::Surplus;
la.chargingEnabled = true;
la.currentA = minA;
la.phaseCount = phases;
la.reason = QStringLiteral("Aucun surplus — courant minimum maintenu (mode EcoMin)");
la.estimatedPowerW = la.currentA * 230.0 * la.phaseCount;
return la;
}
LoadAction RuleBasedScheduler::buildIdleAction(EvCharger *ev) const
{
LoadAction la;
la.loadId = ev->thing()->id().toString();
la.kind = LoadAction::Setpoint;
la.funding = LoadAction::Surplus;
la.chargingEnabled = false;
la.currentA = 0;
la.phaseCount = 0;
la.reason = QStringLiteral("Aucun surplus disponible — recharge suspendue");
la.estimatedPowerW = 0;
return la;
}
LoadAction RuleBasedScheduler::buildEcsStageAction(const LoadContext &lc,
double &remainingSurplusW) const
{
// Correction B (anti-clignotement) : on recrédite la conso actuelle de l'ECS au budget.
// Sans ce recrédit, allumer un palier ferait chuter l'export mesuré → palier 0 → oscillation.
const double budgetChargeW = remainingSurplusW + lc.telemetry.currentPowerW;
// Palier déclaré le plus haut qui tient dans le budget. stages[0] == 0 par construction
// (Q_ASSERT EcsRelayAdapter).
const QList<int> &stages = lc.declared.stages;
const int topStage = stages.size() - 1;
int budgetStage = 0;
for (int i = 0; i < stages.size(); ++i) {
if (stages.at(i) <= budgetChargeW)
budgetStage = i;
}
// Verrou (minOn/minOff) : le palier choisi est borné par la fenêtre déclarée par
// l'adaptateur (calculée au MÊME ctx.timestamp). Une charge verrouillée ON garde son
// palier (puissance engagée non-coupable) ; à l'arrêt sous minOff elle ne redémarre pas.
const int lo = qBound(0, lc.telemetry.minStage, topStage);
const int hi = qBound(lo, lc.telemetry.maxStage, topStage);
const int bestStage = qBound(lo, budgetStage, hi);
LoadAction la;
la.loadId = lc.id;
la.kind = LoadAction::Stage;
la.funding = LoadAction::Surplus;
la.stage = bestStage;
la.estimatedPowerW = stages.at(bestStage);
if (bestStage > budgetStage)
// Maintenu au-dessus du budget : protection compresseur (minOn non écoulé).
la.reason = QStringLiteral("Verrou minOn — %1 maintenu palier %2 (%3 W, surplus %4 W)")
.arg(lc.label).arg(bestStage).arg(stages.at(bestStage)).arg(qRound(budgetChargeW));
else if (bestStage < budgetStage)
// Empêché de monter/redémarrer par minOff malgré le surplus.
la.reason = QStringLiteral("Verrou minOff — %1 maintenu palier %2 (redémarrage trop tôt)")
.arg(lc.label).arg(bestStage);
else if (bestStage > 0)
la.reason = QStringLiteral("Surplus PV %1 W — %2 palier %3 (%4 W)")
.arg(qRound(budgetChargeW)).arg(lc.label)
.arg(bestStage).arg(stages.at(bestStage));
else
la.reason = QStringLiteral("Surplus insuffisant (%1 W) — %2 éteint")
.arg(qRound(budgetChargeW)).arg(lc.label);
// Budget restant pour les charges ECS suivantes (rang supérieur / priorité plus basse).
remainingSurplusW = budgetChargeW - la.estimatedPowerW;
return la;
}
LoadAction RuleBasedScheduler::buildSgReadyStateAction(const LoadContext &lc,
double &remainingSurplusW) const
{
const int currentState = lc.telemetry.state;
const double p3 = lc.declared.estimatedPowerW.value(3, 0.0);
const double p4 = lc.declared.estimatedPowerW.value(4, 0.0);
// Recrédit (correction B) : la puissance ALLOUÉE de l'état courant (déclaré, 0 pour 1/2)
// revient au budget — comme l'ECS. Base : "quel surplus si la PAC n'était pas pilotée ?"
const double allocatedNowW = lc.declared.estimatedPowerW.value(currentState, 0.0);
const double budgetW = remainingSurplusW + allocatedNowW;
// Mapping SÉMANTIQUE (pas "le plus haut qui rentre") avec hystérésis d'état anti-oscillation :
// monter en 4 si budget ≥ P4×1,2 ; rester en 4 tant que budget ≥ P4×1,0 (zone morte) ;
// sinon recommandation (≥ P3) ; sinon normal (état 2, mains off).
// État 1 (effacement) jamais déclenché par le surplus seul — déféré (signal tarif/réseau).
constexpr double kForceMargin = 1.2; // hystérésis d'entrée en état 4
int targetState;
if (p4 > 0.0 && budgetW >= p4 * kForceMargin)
targetState = 4;
else if (currentState == 4 && p4 > 0.0 && budgetW >= p4) // zone morte : reste forcé
targetState = 4;
else if (p3 > 0.0 && budgetW >= p3)
targetState = 3;
else
targetState = 2;
// Clamp lock-aware (fenêtre minStateHold, calculée au MÊME ctx.timestamp).
const int loW = lc.telemetry.minState > 0 ? lc.telemetry.minState : 2;
const int hiW = lc.telemetry.maxState > 0 ? lc.telemetry.maxState : 2;
int bestState = qBound(qMin(loW, hiW), targetState, qMax(loW, hiW));
// Sécurité : ne jamais commander un état non déclaré (snap au plus haut déclaré ≤ cible).
if (!lc.declared.states.contains(bestState)) {
int snapped = lc.declared.states.isEmpty() ? 2 : lc.declared.states.first();
for (int s : lc.declared.states)
if (s <= bestState && s > snapped) snapped = s;
bestState = snapped;
}
LoadAction la;
la.loadId = lc.id;
la.kind = LoadAction::State;
la.funding = LoadAction::Surplus;
la.state = bestState;
la.estimatedPowerW = lc.declared.estimatedPowerW.value(bestState, 0.0);
if (bestState != targetState)
la.reason = QStringLiteral("Verrou minStateHold — %1 maintenue état %2 (court-cycling PAC)")
.arg(lc.label).arg(bestState);
else if (bestState == 4)
la.reason = QStringLiteral("Surplus abondant %1 W — %2 forcée (état 4, ~%3 W)")
.arg(qRound(budgetW)).arg(lc.label).arg(qRound(p4));
else if (bestState == 3)
la.reason = QStringLiteral("Surplus PV %1 W — %2 recommandée (état 3, ~%3 W)")
.arg(qRound(budgetW)).arg(lc.label).arg(qRound(p3));
else
la.reason = QStringLiteral("Surplus insuffisant (%1 W) — %2 en normal (état 2, non pilotée)")
.arg(qRound(budgetW)).arg(lc.label);
// Budget restant : on ne soustrait que la puissance ALLOUÉE (états 1/2 = 0).
remainingSurplusW = budgetW - la.estimatedPowerW;
return la;
}

View File

@ -0,0 +1,117 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync
#pragma once
#include <QObject>
#include "ischeduler.h"
class EvCharger;
class ChargingAction;
class EnergyArbitrator;
/*!
* \brief Planificateur règles GPL : EV (proxy amont) + waterfall surplus ECS / SG-Ready.
*
* \c getPlan() produit un plan à 1 créneau en DEUX temps :
*
* 1. **EV proxy amont (beta, jusqu'à 3g)** : délègue la planification EV à
* \c planSurplusCharging() / \c planSpotMarketCharging() héritées de SmartChargingManager,
* relit \c m_chargingActions et les reformate en LoadAction(Setpoint) annotées d'un
* \c reason français. Le dispatch EV réel reste dans \c adjustEvChargers() amont.
*
* 2. **Waterfall non-EV (3c/3e)** : un budget de surplus UNIQUE net SIGNÉ
* \c (exportW importW) evReservedW cascade par **priorité ASC** (rang, 1 = premier
* servi) à travers les charges \c relay-stages (ECS, \c buildEcsStageAction) ET
* \c sg-ready (PAC, \c buildSgReadyStateAction). Anti-clignotement par **recrédit** de la
* conso allouée ; **clamp lock-aware** \c minStage/maxStage et \c minState/maxState (verrous
* minOn/minOff/minStateHold, protection compresseur) la fenêtre est calculée au MÊME
* \c ctx.timestamp que l'exécution (cf. \c ILoadAdapter). Ces LoadAction sont RÉELLEMENT
* dispatchées par \c EnergyArbitrator::applyActionsToAdapters().
*
* À partir de **3g** : l'EV rejoindra le waterfall unifié (toutes charges classables ensemble).
*
* \invariant getPlan() retourne IMMÉDIATEMENT (AGENTS invariant 5) et toujours un Plan valide.
* \invariant Toute LoadAction a un \c reason non vide, en français.
* \invariant EV (étape 1) : priorité Deadline VE > Surplus PV > aWATTar > Min courant > Idle
* (iso-fonctionnel amont 3b). Non-EV (étape 2) : ordre = \c priority croissant.
*/
class RuleBasedScheduler : public QObject, public IScheduler
{
Q_OBJECT
public:
/*!
* \brief Constructeur.
* \param arbitrator Arbitre propriétaire fournit l'accès à la planification et à l'état.
* \param parent Propriétaire Qt.
*/
explicit RuleBasedScheduler(EnergyArbitrator *arbitrator, QObject *parent = nullptr);
/*!
* \brief Retourne le plan pour le slot courant (EV proxy + waterfall non-EV).
*
* Étape 1 (EV) : \c runSpotMarketPlanning() + \c runSurplusPlanning() puis reformatage
* de \c scheduledActions() en LoadAction(Setpoint) log [Arbitre], dispatch amont.
* Étape 2 (non-EV) : waterfall surplus sur les charges \c relay-stages / \c sg-ready
* triées par priorité LoadAction(Stage/State) réellement dispatchées.
*
* \param ctx SurplusContext courant. Utilisé : \c ctx.timestamp (temps de cycle / verrous),
* \c ctx.meter (surplus net signé), \c ctx.loads (déclarés, télémétrie, fenêtres de verrou).
* \return Plan à 1 créneau couvrant \c ctx.timestamp + 60 s.
*/
Plan getPlan(const SurplusContext &ctx) override;
private:
/*!
* \brief Construit un LoadAction pour le cas "délai requis" (TimeRequirement).
* \param ev EvCharger concerné.
* \param ca ChargingAction planifiée (courant et phases déjà calculés par planSurplusCharging).
* \return LoadAction avec funding=Grid et reason "Deadline VE".
*/
LoadAction buildTimeRequirementAction(EvCharger *ev, const ChargingAction &ca) const;
/*!
* \brief Construit un LoadAction "courant minimum" pour les modes EcoMin.
* \param ev EvCharger concerné.
* \return LoadAction avec funding=Surplus, chargingEnabled=true, currentA=min.
*/
LoadAction buildMinCurrentAction(EvCharger *ev) const;
/*!
* \brief Construit un LoadAction "idle" (recharge désactivée, aucun surplus).
* \param ev EvCharger concerné.
* \return LoadAction avec chargingEnabled=false et reason appropriée.
*/
LoadAction buildIdleAction(EvCharger *ev) const;
/*!
* \brief Construit un LoadAction "stage" ECS par cascade de surplus (waterfall §6).
*
* Retient le palier déclaré le plus haut dont la puissance tient dans le budget courant.
* Correction B (anti-clignotement) : recrédite d'abord \c lc.telemetry.currentPowerW au
* budget (la conso actuelle de l'ECS est déjà soustraite de l'export mesuré), puis
* décrémente \c remainingSurplusW de la puissance du palier retenu.
*
* \param lc Charge ECS (adapter == "relay-stages") du SurplusContext.
* \param[in,out] remainingSurplusW Budget de surplus restant (W) ; mis à jour pour la
* charge ECS suivante (priorité inférieure / rang supérieur).
* \return LoadAction kind=Stage, funding=Surplus, \c reason français non vide.
*/
LoadAction buildEcsStageAction(const LoadContext &lc, double &remainingSurplusW) const;
/*!
* \brief Construit un LoadAction "state" SG-Ready (PAC) par mapping SÉMANTIQUE du surplus.
*
* 4 états normés (qualitatifs, pas des paliers) : surplus abondant stable 4 (forcé,
* hystérésis P4×1,2 entrée / P4×1,0 sortie) ; surplus durable 3 (recommandation, P3) ;
* sinon 2 (normal, mains off). L'état 1 (effacement) n'est PAS déclenché par le surplus
* seul (déféré : signal tarif/réseau). Recrédit (correction B) sur la puissance allouée
* (déclaré, 0 pour 1/2). Clamp lock-aware via \c minState/maxState (court-cycling PAC).
*
* \param lc Charge SG-Ready (adapter == "sg-ready") du SurplusContext.
* \param[in,out] remainingSurplusW Budget de surplus restant (W), mis à jour pour la suite.
* \return LoadAction kind=State, funding=Surplus, \c reason français non vide.
*/
LoadAction buildSgReadyStateAction(const LoadContext &lc, double &remainingSurplusW) const;
EnergyArbitrator *m_arbitrator;
};

View File

@ -0,0 +1,72 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync
#pragma once
#include <QString>
/*!
* \brief Action typée émise par l'arbitre vers un ILoadAdapter.
*
* Noms de champs identiques à OPTIMIZER_PROTOCOL.md §6 (fait autorité).
* \c funding est interne à l'arbitre absent du JSON protocole socket.
*
* \invariant \c reason doit être non vide et en français (AGENTS invariant 7).
* Un adaptateur doit rejeter toute action avec \c reason vide.
* \invariant Pour kind == Setpoint / evcharger : seuls \c chargingEnabled,
* \c currentA, \c phaseCount sont significatifs.
*/
struct LoadAction {
/*! \brief Type d'action : consigne continue, palier discret, état ou contrainte. */
enum Kind { Setpoint, Stage, State, Constraint };
/*! \brief Financement interne : Surplus (PV) ou Grid (réseau). Non sérialisé. */
enum Funding { Surplus, Grid };
/*! \brief Source d'énergie pour Setpoint batterie : "solar" ou "grid". */
enum Source { Solar, GridSource };
/*! \brief Permission de charge/décharge pour Constraint batterie. */
enum Permission { Allow, Forbid };
QString loadId; //!< ThingId de la charge cible (string).
Kind kind = Setpoint;
Funding funding = Surplus;
// --- Setpoint evcharger ---
bool chargingEnabled = false;
double currentA = 0; //!< Courant consigne (A), écrêté par l'adaptateur.
uint phaseCount = 0; //!< Nombre de phases (1 ou 3, 0 = inchangé).
// --- Setpoint battery ---
double powerW = 0;
Source source = Solar;
// --- Stage relay-stages (ECS) ---
int stage = 0; //!< Index de palier (0 = off, 1 = 1er palier, ...).
// --- State sg-ready ---
int state = 0; //!< État SG-Ready (1-4).
// --- Constraint battery ---
Permission charge = Allow;
Permission discharge = Allow;
/*!
* \brief Motif de la décision, non vide, en français.
* Obligatoire (invariant 7). L'adaptateur rejette silencieusement si vide.
*/
QString reason;
/*!
* \brief Puissance estimée (W) hint pour la comptabilité budget de l'arbitre.
* Rempli par le scheduler ; peut être 0 si inconnu.
*/
double estimatedPowerW = 0;
/*!
* \brief Forçage sécurité bypasse les verrous anti-rebond (minOn/minOff).
*
* Positionné à \c true uniquement par \c applyDegradedMode() (L2 watchdog)
* et les contraintes de sécurité. Les adaptateurs doivent appliquer l'action
* immédiatement sans vérifier les verrous temporels.
* \warning Réservé à la sécurité. Ne jamais mettre à \c true dans un scheduler.
*/
bool force = false;
};

View File

@ -0,0 +1,86 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync
#pragma once
#include <QDateTime>
#include <QList>
#include <QHash>
#include <QString>
#include "loadaction.h"
/*!
* \brief Capacités déclarées par l'installateur (plaque signalétique, câblage).
* Correspond à OPTIMIZER_PROTOCOL.md §5 loads[].declared (noms identiques).
* \note Les champs inutilisés pour un type d'adaptateur restent à leur valeur par défaut.
*/
struct LoadDeclared {
// --- evcharger ---
double minA = 0; //!< Courant minimum (A).
double maxA = 0; //!< Courant maximum (A).
int phases = 0; //!< Nombre de phases disponibles (1 ou 3).
// --- relay-stages (ECS) ---
//! Puissances en W par palier : [0, 1200, 2400] — index 0 = off.
QList<int> stages;
// --- battery ---
double maxChargeW = 0; //!< Puissance max de charge (W).
double maxDischargeW = 0; //!< Puissance max de décharge (W).
double capacityWh = 0; //!< Capacité totale (Wh).
int reserveSocPercent = 0; //!< SOC de réserve (%) — non déchargeable.
// --- sg-ready (PAC) ---
//! États supportés (toujours 1-4 ; déclaré pour symétrie avec le protocole §5).
QList<int> states;
//! Puissance estimée (W) par état, DÉCLARÉE installateur (approximative, cas
//! \c declared du protocole §5) : ex. {3: 1800, 4: 2600}. Sert au budget, n'est PAS
//! une consigne exacte. États 1 (blocage) et 2 (normal) ≈ 0 du point de vue allocation
//! surplus (la conso autonome de l'état 2 est déjà au compteur — invariant 8).
QHash<int, double> estimatedPowerW;
};
/*!
* \brief Contraintes anti-rebond et lock temporel d'une charge.
* Correspond à OPTIMIZER_PROTOCOL.md §5 loads[].limits.
*/
struct LoadLimits {
int minOnS = 0; //!< Durée minimale ON (s).
int minOffS = 0; //!< Durée minimale OFF (s).
int chargingEnabledLockS = 0; //!< Lock on/off (s) — evcharger.
int currentChangeLockS = 0; //!< Lock changement courant (s) — evcharger.
int minStateHoldS = 0; //!< Durée minimale maintien état (s) — sg-ready.
};
/*!
* \brief Besoins énergétiques déclarés par l'utilisateur pour une charge.
* Correspond à OPTIMIZER_PROTOCOL.md §5 loads[].needs.
*/
struct LoadNeeds {
int targetSocPercent = 0; //!< SOC cible (%) — EV / batterie.
QDateTime deadline; //!< Échéance absolue de recharge.
QString dailyDeadline; //!< Heure limite quotidienne, format "HH:MM".
int minEnergyWhPerDay = 0; //!< Énergie minimale par jour (Wh).
};
/*!
* \brief Description statique complète d'une charge, exposée par ILoadAdapter.
*
* L'arbitre lit ce descripteur une fois par cycle pour construire le SurplusContext.
* Les valeurs doivent refléter la configuration matérielle réelle (non les setpoints).
*
* \note \c priority : rang dans la liste ordonnée du client (OPTIMIZER_PROTOCOL §5 +
* annexe C). Valeur plus BASSE = servi en premier (rang 1 = premier servi).
* Les promotions conditionnelles (deadline, Tempo ROUGE) sont gérées par le scheduler,
* pas par un poids numérique.
*/
struct LoadDescriptor {
QString id; //!< ThingId de la charge (string).
QString label; //!< Nom lisible (affiché dans les logs).
//! Type d'adaptateur : "evcharger"|"relay-stages"|"sg-ready"|"battery".
QString adapter;
int priority = 0;
LoadDeclared declared;
LoadLimits limits;
LoadNeeds needs;
QList<LoadAction::Kind> supportedKinds; //!< Kinds acceptés par applyAction().
};

View File

@ -0,0 +1,54 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync
#pragma once
#include <QDateTime>
#include <QList>
#include <QString>
#include "loadaction.h"
// Structures miroirs de OPTIMIZER_PROTOCOL.md §6 (noms de champs identiques).
/*!
* \brief Créneau d'un plan contient les LoadAction à appliquer pendant [from, to[.
*
* \invariant Les actions sont ordonnées par priorité croissante (rang 1 = premier servi,
* OPTIMIZER_PROTOCOL §5 + annexe C).
* \invariant Un Slot vide (actions vide) est valide signifie "aucune action ce créneau".
*/
struct Slot {
QDateTime from;
QDateTime to;
QList<LoadAction> actions;
};
/*!
* \brief Plan d'optimisation retourné par IScheduler::getPlan().
*
* \invariant \c isValid() == true après tout appel à getPlan() (invariant IScheduler).
* \invariant \c planId est unique par plan généré (UUID ou compteur).
*/
struct Plan {
QString planId; //!< Identifiant unique (UUID string).
QString strategy; //!< "rule-based" | "socket" | "socket-fallback".
//! Créneaux du plan. Nommé \c timeSlots (pas \c slots, mot-clé Qt).
//! Sérialisation JSON : sous le nom "slots" — OPTIMIZER_PROTOCOL.md §6
//! fait autorité sur le nom de fil, le renommage est purement interne C++.
QList<Slot> timeSlots;
/*!
* \brief Retourne le Slot couvrant \p dt.
* \param dt Instant à couvrir.
* \return Slot dont from dt < to, ou Slot vide (from/to invalides) si aucun ne correspond.
*/
Slot slotCovering(const QDateTime &dt) const {
for (const Slot &s : timeSlots) {
if (dt >= s.from && dt < s.to)
return s;
}
return {};
}
/*! \brief Vrai si le plan contient au moins un Slot. */
bool isValid() const { return !timeSlots.isEmpty(); }
};

View File

@ -0,0 +1,120 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync
#pragma once
#include <QDateTime>
#include <QList>
#include <QString>
#include "loaddescriptor.h"
// Structures miroirs de OPTIMIZER_PROTOCOL.md §5 (noms de champs identiques).
/*! \brief Paramètres fixes du site (contrat réseau, limite par phase). */
struct SurplusSite {
double contractedPowerW = 0; //!< Puissance souscrite (W).
QList<double> phaseLimitA; //!< Limite par phase (A) : [63, 63, 63].
};
/*! \brief Mesures du compteur principal (rootmeter). */
struct SurplusMeter {
double importW = 0; //!< Puissance importée depuis le réseau (W, ≥ 0).
double exportW = 0; //!< Puissance exportée vers le réseau (W, ≥ 0).
QList<double> perPhaseA; //!< Courant par phase (A).
};
/*! \brief Production PV courante. */
struct SurplusPv {
double currentW = 0; //!< Puissance PV mesurée (W, ≥ 0).
};
/*! \brief État courant du système de stockage (batterie). */
struct SurplusBattery {
bool present = false;
double socPercent = 0;
double powerW = 0; //!< Positif = charge, négatif = décharge.
double capacityWh = 0;
int reserveSocPercent = 0;
double maxChargeW = 0;
double maxDischargeW = 0;
};
/*! \brief Entrée tarifaire (créneau HP/HC ou spot market). */
struct TariffEntry {
QDateTime from;
QString label; //!< "HP", "HC", "Tempo-Rouge", ...
double priceCtkWh = 0; //!< Prix en ct€/kWh.
};
/*! \brief Contexte tarifaire : créneau courant + prochains créneaux. */
struct SurplusTariff {
QString provider;
TariffEntry current;
QList<TariffEntry> next; //!< Créneaux suivants, ordre chronologique.
};
/*! \brief Télémétrie d'une charge dans le contexte §5 loads[].telemetry. */
struct LoadContextTelemetry {
double currentPowerW = 0; //!< Puissance mesurée (W).
// --- evcharger ---
bool pluggedIn = false;
bool charging = false;
double sessionWh = 0; //!< Énergie chargée dans la session courante (Wh).
// --- relay-stages ---
int stage = 0;
//! Fenêtre de paliers autorisée MAINTENANT par les verrous (calculée au temps de cycle).
//! Le scheduler y borne son choix : minStage = plancher (verrou minOn, puissance
//! engagée non-coupable) ; maxStage = plafond (verrou minOff, redémarrage interdit).
int minStage = 0;
int maxStage = 0;
// --- sg-ready ---
int state = 0;
//! Fenêtre d'états autorisée MAINTENANT par le verrou minStateHold (protection
//! court-cycling PAC) : gel total (minState == maxState == state) si non écoulé,
//! sinon [1, 4]. Même mécanique que minStage/maxStage de l'ECS, source = temps de cycle.
int minState = 0;
int maxState = 0;
// --- battery / electricvehicle ---
double socPercent = 0;
QDateTime lastSwitch; //!< Dernier changement d'état.
};
/*! \brief Données apprises par l'optimiseur §8 (renvoyées pour persistance). */
struct LoadLearned {
double dailyEnergyWh = 0;
//! Confiance 01 ; < 0.7 = "profil en apprentissage".
double confidence = 0.0;
};
/*!
* \brief Entrée loads[] du SurplusContext envoyé au scheduler.
* Construit par l'arbitre depuis ILoadAdapter::descriptor() + toLoadContext().
*/
struct LoadContext {
QString id;
QString adapter; //!< "evcharger"|"relay-stages"|"sg-ready"|"battery".
QString label;
int priority = 0;
LoadDeclared declared;
LoadLearned learned;
LoadContextTelemetry telemetry;
LoadNeeds needs;
LoadLimits limits;
};
/*!
* \brief Contexte complet transmis au scheduler à chaque cycle (OPTIMIZER_PROTOCOL §5).
*
* \invariant \c timestamp correspond à l'instant du début du cycle.
* \invariant \c pv.currentW est la PV mesurée brute JAMAIS le net après pilotage
* (AGENTS invariant 8 : pas de boucle de feedback).
*/
struct SurplusContext {
QDateTime timestamp;
SurplusSite site;
SurplusMeter meter;
SurplusPv pv;
SurplusBattery battery;
QList<LoadContext> loads;
SurplusTariff tariff;
// forecast : opaque, transmis tel quel si présent — réservé V2
};

View File

@ -197,6 +197,9 @@ NymeaEnergyJsonHandler::NymeaEnergyJsonHandler(SpotMarketManager *spotMarketMana
params.clear();
description = "Emitted whenever the planed charging schedules have changed.";
params.insert("chargingSchedules", QVariantList() << objectRef<ChargingSchedule>());
// [ETM] degradedMode : true quand le watchdog L2 a basculé en repli (compteur muet).
// Optionnel (additif, rétro-compatible) ; émis aussi aux transitions du mode dégradé.
params.insert("o:degradedMode", enumValueName(Bool));
registerNotification("ChargingSchedulesChanged", description, params);
// Charing manager
@ -223,6 +226,7 @@ NymeaEnergyJsonHandler::NymeaEnergyJsonHandler(SpotMarketManager *spotMarketMana
schedules << pack<ChargingSchedule>(schedule);
}
params.insert("chargingSchedules", schedules);
params.insert("degradedMode", m_smartChargingManager->degradedMode()); // [ETM] L2
emit ChargingSchedulesChanged(params);
});

View File

@ -72,6 +72,10 @@ public:
ChargingSchedules chargingSchedules() const;
// [ETM] Mode dégradé L2 (watchdog fraîcheur compteur). Base = false ;
// overridé dans EnergyArbitrator. Exposé pour la notification JSON-RPC.
virtual bool degradedMode() const { return false; }
SpotMarketManager *spotMarketManager() const;
#ifdef ENERGY_SIMULATION
@ -93,21 +97,30 @@ signals:
void chargingUpdated();
#endif
// [ETM] BEGIN — SmartChargingManager protected API for EnergyArbitrator (etm/).
// All changes below are visibility-only (private → protected / virtual added).
// Zero logic change. Revert by deleting this block and restoring private slots.
protected slots:
virtual void update(const QDateTime &currentDateTime); // [ETM] virtual added
void prepareInformation(const QDateTime &currentDateTime); // [ETM] private → protected
void planSpotMarketCharging(const QDateTime &currentDateTime); // [ETM] private → protected
void planSurplusCharging(const QDateTime &currentDateTime); // [ETM] private → protected
void adjustEvChargers(const QDateTime &currentDateTime); // [ETM] private → protected
void updateManualSoCsWithoutMeter(const QDateTime &currentDateTime); // [ETM] private → protected
void verifyOverloadProtection(const QDateTime &currentDateTime); // [ETM] private → protected
void verifyOverloadProtectionRecovery(const QDateTime &currentDateTime); // [ETM] private → protected
protected:
void executeChargingAction(EvCharger *evCharger, const ChargingAction &chargingAction, const QDateTime &currentDateTime); // [ETM] private → protected
// [ETM] Read-only state accessors — inline, no copies, no logic.
const QHash<ThingId, EvCharger *> &internalEvChargers() const { return m_evChargers; } // [ETM] new
const QHash<EvCharger *, ChargingActions> &internalChargingActions() const { return m_chargingActions; } // [ETM] new
RootMeter *internalRootMeter() const { return m_rootMeter; } // [ETM] new
// [ETM] END
private slots:
void update(const QDateTime &currentDateTime);
// Don't call these methods out of place. it's only meant to keep the otherwise long update() code tidy.
// Call update() if you want to trigger the smarties.
void prepareInformation(const QDateTime &currentDateTime);
void planSpotMarketCharging(const QDateTime &currentDateTime);
void planSurplusCharging(const QDateTime &currentDateTime);
void adjustEvChargers(const QDateTime &currentDateTime);
void updateManualSoCsWithMeter(EnergyLogs::SampleRate sampleRate, const ThingPowerLogEntry &entry);
void updateManualSoCsWithoutMeter(const QDateTime &currentDateTime);
void verifyOverloadProtection(const QDateTime &currentDateTime);
void verifyOverloadProtectionRecovery(const QDateTime &currentDateTime);
void onThingAdded(Thing *thing);
void onThingRemoved(const ThingId &thingId);
void onActionExecuted(const Action &action, Thing::ThingError status);
@ -129,7 +142,7 @@ private:
QString chargerPhaseKey(EvCharger *evCharger) const;
EnergyManager *m_energyManager = nullptr;
ThingManager *m_thingManager = nullptr;
ThingManager *m_thingManager = nullptr;
SpotMarketManager *m_spotMarketManager = nullptr;
EnergyManagerConfiguration *m_configuration = nullptr;
@ -152,8 +165,6 @@ private:
RootMeter *m_rootMeter = nullptr;
QHash<ThingId, EvCharger *> m_evChargers;
void executeChargingAction(EvCharger *evCharger, const ChargingAction &chargingAction, const QDateTime &currentDateTime);
};
#endif // SMARTCHARGINGMANAGER_H

View File

@ -257,6 +257,25 @@ QNetworkReply *EnergyTestBase::setEnergyStorageStates(uint batteryLevel, int cur
return m_networkAccessManager->post(request, QByteArray());
}
QNetworkReply *EnergyTestBase::setPowerSwitchStates(bool power, double currentPower, quint16 port)
{
QUrl requestUrl;
requestUrl.setScheme("http");
requestUrl.setHost("127.0.0.1");
requestUrl.setPort(port);
requestUrl.setPath("/setstates");
QUrlQuery query;
query.addQueryItem("power", power ? "true" : "false");
query.addQueryItem("currentPower", QString::number(currentPower));
requestUrl.setQuery(query);
QNetworkRequest request(requestUrl);
request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json");
return m_networkAccessManager->post(request, QByteArray());
}
QNetworkReply *EnergyTestBase::getActionHistory(quint16 port)
{
QUrl requestUrl;
@ -438,6 +457,31 @@ QUuid EnergyTestBase::addEnergyStorage(uint capacity, double maxChargingPowerUpp
return response.toMap().value("params").toMap().value("thingId").toUuid();
}
QUuid EnergyTestBase::addPowerSwitch(double nominalPower, quint16 port)
{
QVariantList thingParams;
QVariantMap portParam;
portParam.insert("paramTypeId", "{e3398429-45fd-4add-a789-4d11bfd9560f}");
portParam.insert("value", port);
QVariantMap nominalPowerParam;
nominalPowerParam.insert("paramTypeId", "{b850a4d1-af0f-477d-ac73-56071f371884}");
nominalPowerParam.insert("value", nominalPower);
thingParams.append(portParam);
thingParams.append(nominalPowerParam);
QVariantMap params;
params.insert("thingClassId", mockPowerSwitchThingClassId.toString());
// Nom unique par port : plusieurs relais (paliers ECS) peuvent coexister.
params.insert("name", QString("Power switch %1").arg(port));
params.insert("thingParams", thingParams);
QVariant response = injectAndWait("Integrations.AddThing", params);
verifyThingError(response);
return response.toMap().value("params").toMap().value("thingId").toUuid();
}
void EnergyTestBase::removeDevices()
{
QVariant configuredDevices = injectAndWait("Integrations.GetThings");

View File

@ -48,6 +48,7 @@ static QUuid mockChargerWithPhaseSwitchingThingClassId = QUuid("9208d9f0-280c-46
static QUuid mockSimpleChargerThingClassId = QUuid("29bcf255-b654-4764-be92-399bc26fe7c3");
static QUuid mockCarThingClassId = QUuid("4513f801-836e-40a7-8784-c02650a9bdc6");
static QUuid mockEnergyStorageThingClassId = QUuid("d0d5bbf0-249c-46ed-ac6a-5f271b2b0b0f");
static QUuid mockPowerSwitchThingClassId = QUuid("841f8905-d1d7-4053-909f-01123b497747");
using namespace nymeaserver;
@ -81,6 +82,7 @@ public:
QNetworkReply *setChargerWithPhaseCountSwitchingStates(bool connected, bool power, bool pluggedIn, const QString &phases, int maxChargingCurrent, int maxChargingCurrentMaxValue, uint desiredPhaseCount, quint16 port = 26658);
QNetworkReply *setSimpleChargerStates(bool connected, bool power, bool pluggedIn, int phaseCount, int maxChargingCurrent, quint16 port = 26659);
QNetworkReply *setEnergyStorageStates(uint batteryLevel, int currentPower, quint16 port = 26660);
QNetworkReply *setPowerSwitchStates(bool power, double currentPower, quint16 port = 26661);
QNetworkReply *getActionHistory(quint16 port);
QNetworkReply *clearActionHistroy(quint16 port);
@ -91,6 +93,7 @@ public:
QUuid addChargerWithPhaseCountSwitching(const QString &phases = "All", double maxChargingCurrentUpperLimit = 32, quint16 port = 26658);
QUuid addSimpleCharger(double maxChargingCurrentUpperLimit = 32, quint16 port = 26659);
QUuid addEnergyStorage(uint capacity = 10, double maxChargingPowerUpperLimit = 5000, double maxDischargingPowerUpperLimit = 11500, quint16 port = 26660);
QUuid addPowerSwitch(double nominalPower = 2000, quint16 port = 26661);
void removeDevices();
QVariant removeDevice(const QUuid &thingId);
@ -104,6 +107,7 @@ protected:
quint16 m_mockChargerWithPhaseCountSwitchingDefaultPort = 26658;
quint16 m_mockSimpleChargerDefaultPort = 26659;
quint16 m_mockEnergyStorageDefaultPort = 26660;
quint16 m_mockPowerSwitchDefaultPort = 26661;
bool verifyActionExecuted(const QVariantList &actionHistory, const QString &actionName);
QVariant getLastValueFromExecutedAction(const QVariantList &actionHistory, const QString &actionName, const QString &paramName);

View File

@ -32,6 +32,9 @@
#include "../../../energyplugin/nymeaenergyjsonhandler.h"
#include "../../../energyplugin/energymanagerconfiguration.h"
#include "../../../energyplugin/spotmarket/spotmarketmanager.h"
#ifdef ETM_ARBITRATOR
#include "../../../energyplugin/etm/energyarbitrator.h"
#endif
#include <jsonrpc/jsonrpcserver.h>
#include <loggingcategories.h>
@ -81,7 +84,14 @@ void ExperiencePluginEnergyMock::init()
EnergyManagerConfiguration *configuration = new EnergyManagerConfiguration(this);
QNetworkAccessManager *networkManager = new QNetworkAccessManager(this);
m_spotMarketManager = new SpotMarketManager(networkManager, this);
// [ETM] BEGIN — flip identique à energypluginnymea.cpp
#ifdef ETM_ARBITRATOR
qCDebug(dcEnergyExperience()) << "ETM_ARBITRATOR actif — EnergyArbitrator chargé (simulation).";
m_smartChargingManager = new EnergyArbitrator(m_energyManager, thingManager(), m_spotMarketManager, configuration, this);
#else
m_smartChargingManager = new SmartChargingManager(m_energyManager, thingManager(), m_spotMarketManager, configuration, this);
#endif
// [ETM] END
m_nymeaEnergyJsonHandler = new NymeaEnergyJsonHandler(m_spotMarketManager, m_smartChargingManager, this);
jsonRpcServer()->registerExperienceHandler(m_nymeaEnergyJsonHandler, 0, 2);

View File

@ -33,6 +33,11 @@ using namespace nymeaserver;
#include "../../../energyplugin/smartchargingmanager.h"
#include "../../mocks/spotmarketprovider/spotmarketdataprovidermock.h"
#ifdef ETM_ARBITRATOR
#include "../../../energyplugin/etm/energyarbitrator.h"
#include "../../../energyplugin/etm/adapters/ecsrelayadapter.h"
#include "../../../energyplugin/etm/adapters/sgreadyadapter.h"
#endif
#include <QHash>
#include <QtMath>
@ -41,11 +46,412 @@ using namespace nymeaserver;
#include <QDateTime>
#include <QSignalSpy>
#include <QProcessEnvironment>
#include <QCoreApplication>
#include <nymeacore.h>
#include "simulationtestpoint.h"
void Simulation::testEcsSurplusPV()
{
#ifndef ETM_ARBITRATOR
QSKIP("testEcsSurplusPV nécessite ETM_ARBITRATOR.");
#else
// Préambule harnais (comme run()) : DB + (ré)initialisation → expérience chargée et
// arbitre FRAIS (pas d'adaptateur ECS résiduel d'un test précédent).
cleanupTestCase();
m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite";
initTestCase();
EnergyArbitrator *arbitrator = dynamic_cast<EnergyArbitrator *>(m_experiencePlugin->smartChargingManager());
QVERIFY2(arbitrator, "smartChargingManager n'est pas un EnergyArbitrator (ETM_ARBITRATOR requis)");
ThingManager *thingManager = NymeaCore::instance()->thingManager();
// --- Root meter ---
QUuid meterThingId = addMeter();
QVERIFY2(!meterThingId.isNull(), "meter thingId invalide");
m_experiencePlugin->energyManager()->setRootMeter(meterThingId);
Thing *meterThing = thingManager->findConfiguredThing(meterThingId);
QVERIFY(meterThing);
meterThing->setStateValue("connected", true);
// --- Deux relais ECS : palier 1 = relayA (1200 W), palier 2 = A+B (2400 W) ---
QUuid relayAId = addPowerSwitch(1200, 26661);
QUuid relayBId = addPowerSwitch(1200, 26662);
QVERIFY(!relayAId.isNull() && !relayBId.isNull());
Thing *relayA = thingManager->findConfiguredThing(relayAId);
Thing *relayB = thingManager->findConfiguredThing(relayBId);
QVERIFY(relayA && relayB);
// minOn = 300 s (protection compresseur) ; minOff = 0 (pas de délai de redémarrage ici).
const int minOnS = 300;
EcsRelayAdapter *ecs = new EcsRelayAdapter(
thingManager, "ecs-surplus-test", "Chauffe-eau (test surplus)",
QList<int>({0, 1200, 2400}),
QList<QList<QString>>({ {}, {relayAId.toString()}, {relayAId.toString(), relayBId.toString()} }),
minOnS, 0, 1, arbitrator);
arbitrator->registerEcsAdapter(ecs);
const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0));
// currentPower compteur : < 0 = export (surplus PV), > 0 = import (réseau).
auto setMeterW = [&](double signedW){ meterThing->setStateValue("currentPower", signedW); };
auto cycle = [&](const QDateTime &now){ arbitrator->simulationCallUpdate(now); QCoreApplication::processEvents(); };
// ============ Régime 1 — cascade montante sur surplus (export) ============
setMeterW(-1000); cycle(t0); // 1000 W < palier 1 → éteint
QCOMPARE(ecs->currentStage(), 0);
QCOMPARE(relayA->stateValue("power").toBool(), false);
setMeterW(-1500); cycle(t0); // 1500 W → palier 1 (relayA)
QCOMPARE(ecs->currentStage(), 1);
QCOMPARE(relayA->stateValue("power").toBool(), true);
QCOMPARE(relayB->stateValue("power").toBool(), false);
setMeterW(-2500); cycle(t0.addSecs(1)); // 2500 W → palier 2 (montée autorisée même sous minOn)
QCOMPARE(ecs->currentStage(), 2);
QCOMPARE(relayA->stateValue("power").toBool(), true);
QCOMPARE(relayB->stateValue("power").toBool(), true);
// ============ Régime 2 — anti-clignotement (recrédit, hors verrou) ============
// T0+400 : minOn écoulé → plancher de verrou = 0. PV 2500, ECS tire 2400 → export net 100.
// budget = 100 + 2400 (recrédit conso) = 2500 → RESTE palier 2. Sans recrédit : 100 → éteint.
// Le plancher étant 0, c'est bien le RECRÉDIT qui maintient le palier, pas le verrou.
setMeterW(-100); cycle(t0.addSecs(400));
QCOMPARE(ecs->currentStage(), 2);
// Redescente propre au palier 1 (import partiel, minOn écoulé) pour préparer le régime 3.
setMeterW(600); cycle(t0.addSecs(401)); // import 600 → budget = -600 + 2400 = 1800 → palier 1
QCOMPARE(ecs->currentStage(), 1);
QCOMPARE(relayB->stateValue("power").toBool(), false);
// ============ Régime 3 — PROTECTION COMPRESSEUR : import < minOn → RESTE ============
// lastSwitch = T0+401. T0+501 (elapsed 100 < minOn 300) : PV 0, ECS tire 1200 → import 1200.
// budget = -1200 + 1200 = 0 → le scheduler voudrait palier 0, MAIS plancher de verrou = 1.
setMeterW(1200); cycle(t0.addSecs(501));
QCOMPARE(ecs->currentStage(), 1); // RESTE allumé — protection compresseur
QCOMPARE(relayA->stateValue("power").toBool(), true);
// ============ Régime 4 — minOn écoulé → DÉLESTE ============
// T0+801 (elapsed depuis T0+401 = 400 > minOn 300) : MÊME import 1200. Plancher = 0 → palier 0.
// Seul le TEMPS SIMULÉ a changé entre régime 3 et 4 : si le seam de temps était faux
// (horloge murale), la décision ne basculerait pas. Ce test prouve le seam ET la protection.
setMeterW(1200); cycle(t0.addSecs(801));
QCOMPARE(ecs->currentStage(), 0); // DÉLESTE
QCOMPARE(relayA->stateValue("power").toBool(), false);
#endif
}
void Simulation::testMeterSilentFallback()
{
#ifndef ETM_ARBITRATOR
QSKIP("testMeterSilentFallback nécessite ETM_ARBITRATOR.");
#else
cleanupTestCase();
m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite";
initTestCase();
EnergyArbitrator *arbitrator = dynamic_cast<EnergyArbitrator *>(m_experiencePlugin->smartChargingManager());
QVERIFY2(arbitrator, "smartChargingManager n'est pas un EnergyArbitrator (ETM_ARBITRATOR requis)");
ThingManager *thingManager = NymeaCore::instance()->thingManager();
// --- Root meter + un relais ECS (palier 1 = 2400 W) ---
QUuid meterThingId = addMeter();
QVERIFY2(!meterThingId.isNull(), "meter thingId invalide");
m_experiencePlugin->energyManager()->setRootMeter(meterThingId);
Thing *meterThing = thingManager->findConfiguredThing(meterThingId);
QVERIFY(meterThing);
meterThing->setStateValue("connected", true);
QUuid relayAId = addPowerSwitch(2400, 26661);
QVERIFY(!relayAId.isNull());
Thing *relayA = thingManager->findConfiguredThing(relayAId);
QVERIFY(relayA);
// minOn = 300 s (protection compresseur) ; minOff = 0.
EcsRelayAdapter *ecs = new EcsRelayAdapter(
thingManager, "ecs-fallback-test", "Chauffe-eau (test repli)",
QList<int>({0, 2400}),
QList<QList<QString>>({ {}, {relayAId.toString()} }),
300, 0, 1, arbitrator);
arbitrator->registerEcsAdapter(ecs);
const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0));
auto setMeterW = [&](double signedW){ meterThing->setStateValue("currentPower", signedW); };
auto cycle = [&](const QDateTime &now){ arbitrator->simulationCallUpdate(now); QCoreApplication::processEvents(); };
// --- ECS allumé sur surplus, compteur frais à T0 ---
arbitrator->recordMeterUpdate(t0);
setMeterW(-2500); cycle(t0); // surplus 2500 → palier 1 (lastSwitch ECS = T0)
QCOMPARE(ecs->currentStage(), 1);
QCOMPARE(relayA->stateValue("power").toBool(), true);
QVERIFY(!arbitrator->degradedMode());
// --- Compteur muet > 90 s → mode dégradé ; le repli force=true coupe l'ECS MÊME sous minOn ---
// (ECS commuté à T0, elapsed 91 s < minOn 300 → normalement verrouillé ; force=true bypasse.)
arbitrator->evaluateMeterFreshness(t0.addSecs(91));
QCoreApplication::processEvents();
QVERIFY(arbitrator->degradedMode());
QCOMPARE(ecs->currentStage(), 0); // coupé malgré minOn (sécurité bypasse l'anti-flapping)
QCOMPARE(relayA->stateValue("power").toBool(), false);
// --- STABILITÉ : compteur toujours muet, plusieurs update() → l'ECS RESTE à 0 ---
// (planification suspendue : pas de getPlan() sur cache mort qui rallumerait l'ECS.)
// On garde même un faux surplus au compteur pour piéger une éventuelle replanification.
setMeterW(-3000);
foreach (int dt, QList<int>({92, 120, 200, 280})) {
cycle(t0.addSecs(dt));
QVERIFY2(arbitrator->degradedMode(), "degradedMode doit rester actif pendant le silence");
QCOMPARE(ecs->currentStage(), 0);
QCOMPARE(relayA->stateValue("power").toBool(), false);
}
// --- REPRISE : le compteur re-parle → degradedMode retombe → recalcul normal ---
arbitrator->recordMeterUpdate(t0.addSecs(300));
QVERIFY(!arbitrator->degradedMode());
// Preuve "recalcul, pas restauration d'ancienne consigne" : surplus FAIBLE → l'ECS
// RESTE éteint (recalculé), il ne revient PAS à son ancien palier 1.
setMeterW(-1000); cycle(t0.addSecs(301)); // 1000 W < palier 1 → éteint
QCOMPARE(ecs->currentStage(), 0);
// Puis surplus suffisant → l'ECS resuit le surplus normalement.
setMeterW(-2500); cycle(t0.addSecs(302));
QCOMPARE(ecs->currentStage(), 1);
QCOMPARE(relayA->stateValue("power").toBool(), true);
#endif
}
void Simulation::testSgReadySurplus()
{
#ifndef ETM_ARBITRATOR
QSKIP("testSgReadySurplus nécessite ETM_ARBITRATOR.");
#else
// Encodage SG-Ready 2 bits (K1,K2) : 1=[K1] blocage · 2=[] normal · 3=[K2] reco · 4=[K1,K2] forcé.
// estimatedPowerW déclaré : P3=1500, P4=3000. Hystérésis état 4 : entrée P4×1,2=3600, sortie P4×1,0=3000.
const QHash<int, double> pacPower({ {1, 0.0}, {2, 0.0}, {3, 1500.0}, {4, 3000.0} });
const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0));
// ===================== Volets 1-3 : PAC seule =====================
cleanupTestCase();
m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite";
initTestCase();
EnergyArbitrator *arbitrator = dynamic_cast<EnergyArbitrator *>(m_experiencePlugin->smartChargingManager());
QVERIFY2(arbitrator, "smartChargingManager n'est pas un EnergyArbitrator");
ThingManager *tm = NymeaCore::instance()->thingManager();
QUuid meterId = addMeter();
m_experiencePlugin->energyManager()->setRootMeter(meterId);
Thing *meter = tm->findConfiguredThing(meterId);
QVERIFY(meter);
meter->setStateValue("connected", true);
QUuid k1 = addPowerSwitch(0, 26661);
QUuid k2 = addPowerSwitch(0, 26662);
Thing *relayK1 = tm->findConfiguredThing(k1);
Thing *relayK2 = tm->findConfiguredThing(k2);
QVERIFY(relayK1 && relayK2);
SgReadyAdapter *pac = new SgReadyAdapter(
tm, "pac-test", "PAC test",
QHash<int, QList<QString>>({ {1, {k1.toString()}}, {2, {}},
{3, {k2.toString()}}, {4, {k1.toString(), k2.toString()}} }),
pacPower, 300, 1, arbitrator);
arbitrator->registerSgReadyAdapter(pac);
auto setMeterW = [&](double signedW){ meter->setStateValue("currentPower", signedW); }; // <0 export
auto cycle = [&](const QDateTime &now){ arbitrator->simulationCallUpdate(now); QCoreApplication::processEvents(); };
// --- Volet 1 : montée d'états 2 → 3 → 4 (mapping sémantique) ---
setMeterW(-1000); cycle(t0); // budget 1000 < P3 → état 2 (normal)
QCOMPARE(pac->currentState(), 2);
setMeterW(-2000); cycle(t0); // budget 2000 ≥ P3 → état 3 (reco)
QCOMPARE(pac->currentState(), 3);
QCOMPARE(relayK2->stateValue("power").toBool(), true);
QCOMPARE(relayK1->stateValue("power").toBool(), false);
setMeterW(-2500); cycle(t0.addSecs(400)); // budget 2500+1500=4000 ≥ P4×1,2 → état 4 (hold écoulé)
QCOMPARE(pac->currentState(), 4);
QCOMPARE(relayK1->stateValue("power").toBool(), true);
QCOMPARE(relayK2->stateValue("power").toBool(), true);
// --- Volet 2 : hystérésis 3↔4 (budget oscille dans la zone morte [P4×1,0 ; P4×1,2)) ---
// hold écoulé à chaque cycle (lastSwitch=T0+400) → c'est la ZONE MORTE qui tient l'état 4, pas le verrou.
setMeterW(-300); cycle(t0.addSecs(800)); // budget 300+3000=3300 ∈ [3000,3600) → reste 4
QCOMPARE(pac->currentState(), 4);
setMeterW(-100); cycle(t0.addSecs(1200)); // budget 3100 → reste 4
QCOMPARE(pac->currentState(), 4);
setMeterW(-500); cycle(t0.addSecs(1600)); // budget 3500 → reste 4
QCOMPARE(pac->currentState(), 4);
// En-dessous de P4×1,0 → sort enfin de l'état 4 (vers 3).
setMeterW(200); cycle(t0.addSecs(2000)); // import 200 → budget -200+3000=2800 < 3000 → état 3
QCOMPARE(pac->currentState(), 3);
// --- Volet 3 : protection court-cycling (changement avant minStateHold → GELÉ) ---
// lastSwitch=T0+2000. À T0+2100 (elapsed 100 < hold 300) : surplus abondant mais GELÉ en 3.
setMeterW(-3000); cycle(t0.addSecs(2100));
QCOMPARE(pac->currentState(), 3); // gelé malgré budget ≥ P4×1,2 (protection compresseur)
// À T0+2400 (elapsed 400 > hold) : MÊME surplus → bascule en 4. Seul le temps simulé a changé.
setMeterW(-3000); cycle(t0.addSecs(2400));
QCOMPARE(pac->currentState(), 4);
// ===================== Volet 4 : interaction budget PARTAGÉ ECS↔PAC =====================
// Surplus 3000 W, ECS palier 1 = 2400 W, PAC P3 = 1500. Selon l'ordre de priorité,
// l'un se sert et l'autre voit le RELIQUAT → preuve du waterfall unifié (un seul budget).
// priority fixé à la création → on ré-initialise un arbitre frais par ordre testé.
auto runSharedBudget = [&](int ecsPrio, int pacPrio, int &ecsStageOut, int &pacStateOut) {
cleanupTestCase();
m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite";
initTestCase();
EnergyArbitrator *arb = dynamic_cast<EnergyArbitrator *>(m_experiencePlugin->smartChargingManager());
QVERIFY(arb);
ThingManager *tm2 = NymeaCore::instance()->thingManager();
QUuid mId = addMeter();
m_experiencePlugin->energyManager()->setRootMeter(mId);
Thing *m2 = tm2->findConfiguredThing(mId);
QVERIFY(m2);
m2->setStateValue("connected", true);
QUuid ke = addPowerSwitch(0, 26663); // relais ECS
QUuid j1 = addPowerSwitch(0, 26661); // relais PAC K1
QUuid j2 = addPowerSwitch(0, 26662); // relais PAC K2
// ECS : 1 palier à 2400 W, verrous à 0 (on teste le partage de budget, pas l'anti-rebond).
EcsRelayAdapter *ecs = new EcsRelayAdapter(
tm2, "ecs-wf", "ECS waterfall",
QList<int>({0, 2400}),
QList<QList<QString>>({ {}, {ke.toString()} }),
0, 0, ecsPrio, arb);
arb->registerEcsAdapter(ecs);
SgReadyAdapter *pacWf = new SgReadyAdapter(
tm2, "pac-wf", "PAC waterfall",
QHash<int, QList<QString>>({ {1, {j1.toString()}}, {2, {}},
{3, {j2.toString()}}, {4, {j1.toString(), j2.toString()}} }),
pacPower, 300, pacPrio, arb);
arb->registerSgReadyAdapter(pacWf);
m2->setStateValue("currentPower", -3000); // export 3000 W
arb->simulationCallUpdate(t0);
QCoreApplication::processEvents();
ecsStageOut = ecs->currentStage();
pacStateOut = pacWf->currentState();
};
int ecsStage = -1, pacState = -1;
// ECS prioritaire (rang 1) : ECS se sert (2400) → reliquat 600 < P3 → PAC reste en NORMAL (2).
runSharedBudget(/*ecsPrio*/ 1, /*pacPrio*/ 2, ecsStage, pacState);
QCOMPARE(ecsStage, 1);
QCOMPARE(pacState, 2);
// Priorités INVERSÉES — PAC prioritaire (rang 1) : PAC se sert (état 3, 1500) → reliquat
// 1500 < 2400 → l'ECS reste éteint (palier 0). L'ordre de service s'inverse.
runSharedBudget(/*ecsPrio*/ 2, /*pacPrio*/ 1, ecsStage, pacState);
QCOMPARE(ecsStage, 0);
QCOMPARE(pacState, 3);
#endif
}
void Simulation::testEcsRelayTopologies()
{
#ifndef ETM_ARBITRATOR
QSKIP("testEcsRelayTopologies nécessite ETM_ARBITRATOR.");
#else
const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0));
// Setup commun : arbitre frais + root meter. Retourne meter + thingManager via réf.
auto freshSetup = [&](EnergyArbitrator *&arb, ThingManager *&tm, Thing *&meter) {
cleanupTestCase();
m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite";
initTestCase();
arb = dynamic_cast<EnergyArbitrator *>(m_experiencePlugin->smartChargingManager());
QVERIFY(arb);
tm = NymeaCore::instance()->thingManager();
QUuid mId = addMeter();
m_experiencePlugin->energyManager()->setRootMeter(mId);
meter = tm->findConfiguredThing(mId);
QVERIFY(meter);
meter->setStateValue("connected", true);
};
// ===================== Topologie 1 : ECS simple (1 relais, [0, 2000]) =====================
{
EnergyArbitrator *arb; ThingManager *tm; Thing *meter;
freshSetup(arb, tm, meter);
QUuid r = addPowerSwitch(2000, 26661);
Thing *relay = tm->findConfiguredThing(r);
QVERIFY(relay);
EcsRelayAdapter *ecs = new EcsRelayAdapter(
tm, "ecs-1relay", "ECS simple",
QList<int>({0, 2000}),
QList<QList<QString>>({ {}, {r.toString()} }),
0, 0, 1, arb);
arb->registerEcsAdapter(ecs);
meter->setStateValue("currentPower", -2500); // surplus 2500 → palier 1
arb->simulationCallUpdate(t0); QCoreApplication::processEvents();
QCOMPARE(ecs->currentStage(), 1);
QCOMPARE(relay->stateValue("power").toBool(), true);
meter->setStateValue("currentPower", 1000); // import 1000 → palier 0 (off)
arb->simulationCallUpdate(t0.addSecs(1)); QCoreApplication::processEvents();
QCOMPARE(ecs->currentStage(), 0);
QCOMPARE(relay->stateValue("power").toBool(), false);
}
// ============= Topologie 2 : ECS 3 relais 500/1000/2000 W (mapping NON-CASCADÉ) =============
{
EnergyArbitrator *arb; ThingManager *tm; Thing *meter;
freshSetup(arb, tm, meter);
QUuid r500 = addPowerSwitch(500, 26661);
QUuid r1000 = addPowerSwitch(1000, 26662);
QUuid r2000 = addPowerSwitch(2000, 26663);
Thing *t500 = tm->findConfiguredThing(r500);
Thing *t1000 = tm->findConfiguredThing(r1000);
Thing *t2000 = tm->findConfiguredThing(r2000);
QVERIFY(t500 && t1000 && t2000);
// 8 niveaux binaires ; encodage bit0=r500, bit1=r1000, bit2=r2000.
EcsRelayAdapter *ecs = new EcsRelayAdapter(
tm, "ecs-3relay", "ECS 3 relais",
QList<int>({0, 500, 1000, 1500, 2000, 2500, 3000, 3500}),
QList<QList<QString>>({
{}, // 0
{r500.toString()}, // 500
{r1000.toString()}, // 1000
{r500.toString(), r1000.toString()}, // 1500
{r2000.toString()}, // 2000
{r500.toString(), r2000.toString()}, // 2500
{r1000.toString(), r2000.toString()}, // 3000
{r500.toString(), r1000.toString(), r2000.toString()}// 3500
}),
0, 0, 1, arb);
arb->registerEcsAdapter(ecs);
// Surplus 1700 → palier 1500 = [r500, r1000] (r2000 éteint).
meter->setStateValue("currentPower", -1700);
arb->simulationCallUpdate(t0); QCoreApplication::processEvents();
QCOMPARE(ecs->currentStage(), 3);
QCOMPARE(t500->stateValue("power").toBool(), true);
QCOMPARE(t1000->stateValue("power").toBool(), true);
QCOMPARE(t2000->stateValue("power").toBool(), false);
// Transition NON-CASCADÉE 1500 → 2000 : à stage 3 l'ECS mesure 1500 W (r500+r1000),
// export net 700 → budget 700+1500=2200 → palier 2000 = [r2000] SEUL.
// Vérifie le set FINAL : r500 OFF, r1000 OFF, r2000 ON (commutation de 3 relais).
meter->setStateValue("currentPower", -700);
arb->simulationCallUpdate(t0.addSecs(1)); QCoreApplication::processEvents();
QCOMPARE(ecs->currentStage(), 4);
QCOMPARE(t500->stateValue("power").toBool(), false);
QCOMPARE(t1000->stateValue("power").toBool(), false);
QCOMPARE(t2000->stateValue("power").toBool(), true);
}
#endif
}
void Simulation::run_data()
{
// Simulation infos

View File

@ -56,6 +56,22 @@ private slots:
void run_data();
void run();
// Waterfall ECS (EcsRelayAdapter) : cascade surplus, anti-clignotement (recrédit),
// et protection compresseur (import < minOn → RESTE ; import > minOn → déleste).
void testEcsSurplusPV();
// Watchdog L2 : compteur muet >90 s → mode dégradé (ECS off force=true, bypass minOn),
// planification suspendue (ECS reste 0 sur N cycles), reprise au retour du compteur.
void testMeterSilentFallback();
// SG-Ready (PAC) : montée d'états sur surplus, hystérésis 3↔4, protection court-cycling,
// et interaction budget PARTAGÉ ECS↔PAC (preuve du waterfall unifié 3e).
void testSgReadySurplus();
// ECS topologies : 1 relais (simple) + 3 relais valeurs différentes (mapping NON-CASCADÉ,
// transition 1500→2000 qui commute 3 relais) — vérifie le set de relais final correct.
void testEcsRelayTopologies();
void printStates(Thing *thing);
void updateChargerMeter(Thing *thing);

View File

@ -358,6 +358,31 @@ void IntegrationPluginEnergyMocks::setupThing(ThingSetupInfo *info)
thing->setStateValue("capacity", thing->paramValue(energyStorageThingCapacityParamTypeId));
return;
} else if (thing->thingClassId() == powerSwitchThingClassId) {
EnergyMockController *controller = new EnergyMockController(thing, this);
ParamType paramType = thing->thingClass().paramTypes().findByName("port");
quint16 port = thing->paramValue(paramType.id()).toUInt();
if (!controller->listen(QHostAddress::Any, port)) {
qCWarning(dcEnergyMocks()) << "Failed to start mock controller on port" << controller->errorString();
delete controller;
info->finish(Thing::ThingErrorThingInUse);
return;
}
connect(controller, &EnergyMockController::updateStateRequestReceived, thing, [=](const QUrlQuery &query){
// Permet au test d'imposer power / currentPower directement (ex. émuler une
// mesure dérivée, ou un thermostat coupé : power=true mais currentPower=0).
if (query.hasQueryItem("power"))
thing->setStateValue("power", QVariant(query.queryItemValue("power")).toBool());
if (query.hasQueryItem("currentPower"))
thing->setStateValue("currentPower", QVariant(query.queryItemValue("currentPower")).toDouble());
});
m_controllers.insert(thing, controller);
qCDebug(dcEnergyMocks()) << "Setting up power switch" << thing->name() << "finished successfully";
info->finish(Thing::ThingErrorNoError);
return;
}
}
@ -474,6 +499,19 @@ void IntegrationPluginEnergyMocks::executeAction(ThingActionInfo *info)
}
}
if (thing->thingClassId() == powerSwitchThingClassId) {
if (actionType.name() == "power") {
bool power = action.paramValue(actionType.paramTypes().findByName("power").id()).toBool();
double nominal = thing->paramValue(thing->thingClass().paramTypes().findByName("nominalPower").id()).toDouble();
// Relais ON → consomme sa puissance nominale ; OFF → 0 W. Le test peut écraser
// currentPower via /setstates (ex. émuler une mesure dérivée).
thing->setStateValue("power", power);
thing->setStateValue("currentPower", power ? nominal : 0.0);
qCDebug(dcEnergyMocks()) << "Mock power switch" << thing->name() << "power" << power
<< "currentPower" << thing->stateValue("currentPower");
}
}
info->finish(Thing::ThingErrorNoError);
}

View File

@ -194,7 +194,7 @@
"name": "maxChargingCurrent",
"displayName": "Maximum charging current",
"displayNameAction": "Set maximum charging current",
"type": "uint",
"type": "double",
"defaultValue":6,
"minValue": 6,
"maxValue": 32,
@ -387,7 +387,7 @@
"name": "maxChargingCurrent",
"displayName": "Maximum charging current",
"displayNameAction": "Set maximum charging current",
"type": "uint",
"type": "double",
"defaultValue":6,
"minValue": 6,
"maxValue": 32,
@ -584,7 +584,7 @@
"name": "maxChargingCurrent",
"displayName": "Maximum charging current",
"displayNameAction": "Set maximum charging current",
"type": "uint",
"type": "double",
"defaultValue":6,
"minValue": 6,
"maxValue": 32,
@ -816,6 +816,49 @@
"defaultValue": false
}
]
},
{
"name": "powerSwitch",
"displayName": "Mocked Power Switch (relais ECS)",
"id": "841f8905-d1d7-4053-909f-01123b497747",
"createMethods": ["user"],
"interfaces": ["power"],
"paramTypes": [
{
"id": "e3398429-45fd-4add-a789-4d11bfd9560f",
"name": "port",
"displayName": "Port",
"type": "uint",
"defaultValue": 26661
},
{
"id": "b850a4d1-af0f-477d-ac73-56071f371884",
"name": "nominalPower",
"displayName": "Nominal power when ON",
"type": "double",
"unit": "Watt",
"defaultValue": 2000
}
],
"stateTypes": [
{
"id": "9fa6457c-6adb-4d4a-8d47-7bdb2db2c271",
"name": "power",
"displayName": "Power",
"displayNameAction": "Switch power",
"type": "bool",
"defaultValue": false,
"writable": true
},
{
"id": "0e7e6cd5-601b-4616-8bc9-191c10e9dac7",
"name": "currentPower",
"displayName": "Current power",
"type": "double",
"unit": "Watt",
"defaultValue": 0
}
]
}
]
}