etm-powersync-energy-plugin.../docs/INTERFACE_etmvariableload.md
Patrick Schurig b7bfd58139 feat(etm): adaptateur etmvariableload (interface charge pilotée, Setpoint W)
Côté energymanager : EtmVariableLoadAdapter consomme l'interface etmvariableload
(states currentPowerW read / powerSetpoint write) pour les charges à puissance
pilotable (EV / ECS résistif / routeur PV). La combinatoire matérielle, l'anti-rebond
et les phases vivent dans le thing (contrat §1/§3) — l'adaptateur n'écrit qu'un
setpoint W et relit currentPowerW.

- LoadDescriptor : champs additifs powerLevels / maxPowerW (déclaration §3/§5).
- Contrat partagé versionné : docs/INTERFACE_etmvariableload.md (rév. 2).
- La déclaration de l'interface nymee + le driver thing relèvent d'une session device.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 09:06:16 +02:00

8.1 KiB
Raw Blame History

INTERFACE etmvariableload — contrat charge pilotée · rév. 2

Source de vérité unique. Déposé à l'identique dans les deux repos (etm-powersync-app et etm-powersync-energy-plugin-etm). Toute divergence se tranche ici, pas dans le code. Un agent qui a besoin d'un champ absent met à jour ce doc d'abord, dans les deux repos.

rév. 2 — corrige la rév. 1 sur un point structurant : etmvariableload ne couvre que les charges à puissance pilotable (EV, ECS résistif, routeur PV). La PAC SG-Ready en est exclue : elle reste un modèle à états, interface séparée et conservée (voir §8). LoadAction garde donc deux kinds (Setpoint ET l'état SG-Ready), pas un seul.


1. Principe & frontière

Trois niveaux, trois responsables. Aucune notion de matériel ne remonte au-dessus du thing.

Niveau Quoi
Câblage thing / plugin device (wizard Things) GPIO, registres Modbus, quelle résistance sur quelle phase
Déclaration config charge pilotée (app) mode, paliers atteignables en W, priorité, besoins
Runtime interface etmvariableload setpoint W envoyé ↔ puissance réelle remontée

Invariant directeur. L'energymanager raisonne uniquement en watts. Aucune résistance, aucun relais, aucun index de palier matériel ne franchit l'app ni n'atteint l'arbitre. Le thing décide du matériel ; l'energymanager décide de la puissance.


2. Portée de etmvariableload

EST une etmvariableload (charge à puissance pilotable) :

  • EV charger (setpoint courant/puissance),
  • ECS résistif — classique (on/off) ou multipalier,
  • Routeur PV / triac (modulation continue).

N'EST PAS une etmvariableload :

  • PAC SG-Ready → modèle à états (2 bits, hystérésis, minStateHold). Interface distincte, conservée telle quelle. Voir §8. Un signal SG-Ready est une suggestion d'état à la PAC, pas une consigne de puissance en watts — le forcer en setpoint W serait faux.

3. Interface device etmvariableload

Implémentée par le thing (ECS / EV / routeur). Côté moteur, seul l'EtmVariableLoadAdapter consommateur est livré ; la déclaration de l'interface nymea + le driver (résistances/triac/ anti-rebond/phases) sont une session device dédiée, hors repo moteur.

State Accès Type Unité Sémantique
maxPowerW read uint W Plafond physique de la charge.
powerLevels read list<uint> W Paliers atteignables, triés croissants, 0 inclus. Présent en fixed. Vide/absent ⇒ modulation continue.
powerSetpoint write uint W Consigne demandée par l'energymanager.
currentPowerW read uint W Puissance réellement appliquée. Ferme la boucle de quantification.

Le thing, en recevant un powerSetpoint : choisit la combinaison matérielle concrète, applique ses verrous anti-rebond (minOn/minOff) et son équilibrage de phases, puis publie currentPowerW. L'energymanager n'a aucune visibilité là-dessus — c'est voulu.


4. LoadDescriptor — config app → energymanager

Émis par l'écran « Configurer charge pilotée » (sous-ensemble de OPTIMIZER_PROTOCOL.md §5, noms identiques) :

{
  "id":       "uuid-thing-ecs",
  "label":    "Chauffe-eau",
  "adapter":  "etmvariableload",
  "mode":     "fixed",                          // "fixed" | "dynamic"
  "powerLevels": [0, 600, 1200, 1800, 2400],    // REQUIS si fixed — absent si dynamic
  "maxPowerW": 3000,                            // REQUIS si dynamic ; plafond aussi en fixed
  "priority": 2,                                // rang ; 1 = servi en premier
  "enabled":  true,
  "needs": { "dailyDeadline": "06:00", "minEnergyWhPerDay": 4000 }
}
  • powerLevels saisi par l'installateur — directement ou via l'assistant résistances (UI-only : additionne les unités, pré-remplit, puis disparaît ; aucune résistance n'est stockée ni envoyée).
  • Config persistée côté plugin (/var/lib/nymea/energy-load-configuration.json), jamais en SharedPreferences. App = éditeur : NymeaEnergy.GetLoadConfig / SetLoadConfig.

5. Règle d'arrondi (energymanager)

À chaque cycle, pour une charge dont c'est le tour dans la priorité :

  • Fixed : level = max( l ∈ powerLevels | l ≤ budget ) puis setPowerSetpoint(level). L'arbitre connaît la granularité déclarée → pas de sur-allocation, zéro cycle de retard.
  • Dynamic : setPowerSetpoint( clamp(budget, 0, maxPowerW) ).
  • Résidu : lire le currentPowerW de début de cycle (télémétrie de l'adapter, comme la Correction B — PAS une relecture post-setpoint : invariant 8, aucune boucle de feedback). Le résidu budget currentPowerW repart vers la charge suivante de la priorité du même cycle.

Déclaré vs réel : la déclaration (powerLevels/maxPowerW) est la référence de planification. currentPowerW est le juge de paix runtime. Si le réel plafonne durablement sous le déclaré → erreur de déclaration, l'installateur corrige. L'energymanager n'écrase jamais la déclaration.


6. LoadActiondeux kinds

Kind Pour Porte
Setpoint EV / ECS / routeur (etmvariableload) powerW
état SG-Ready PAC (interface à états, §8) state (14)
Constraint batterie (déféré 3f) conservé
  • Supprimer Stage (ECS → Setpoint). Retirer stage/minStage/maxStage de LoadContextTelemetry (le verrou ECS redescend dans le thing).
  • Conserver le kind d'état SG-Ready et state/minState/maxState pour la PAC — son minStateHold (protection court-cycling compresseur) reste dans son chemin, inchangé.
  • reason reste porté par chaque LoadAction (déjà le cas).

7. Expo du reason — tableau additif loadActions[]

Sérialiser dans GetChargingSchedules / ChargingSchedulesChanged un champ additif (rétro-compatible, ne touche pas la structure EV ChargingAction) :

"loadActions": [
  { "loadId": "uuid-ecs", "setpointW": 1200, "currentPowerW": 1180, "reason": "Surplus PV 1.3 kW — ECS rang 2" },
  { "loadId": "uuid-pac", "state": 3,                              "reason": "Surplus PV — PAC en état 3" }
]
  • Couvre toutes les charges, y compris la PAC. setpointW/currentPowerW optionnels (absents pour une charge à états) ; loadId + reason toujours présents.
  • Nécessite que l'arbitre retienne le dernier plan (aujourd'hui loggé puis jeté, energyarbitrator.cpp:140-153) — petite addition d'état.
  • C'est le canal que consomme la decision card de l'app : « pour chaque charge, le pourquoi ».

8. PAC SG-Ready — modèle à états (conservé)

Hors etmvariableload. Le SgReadyAdapter existant (états 14, combos K1/K2, hystérésis, minStateHold) n'est pas réécrit. L'arbitre lui envoie un état (kind §6), pas un setpoint W. Repli L2 : état 2 (normal, mains off), jamais état 1 (blocage) — comportement actuel conservé.

Une PAC pilotée par Modbus qui exposerait une vraie puissance pilotable pourra, plus tard, implémenter etmvariableload. Tant qu'on pilote par signal SG-Ready, c'est un modèle à états.


9. Sécurité (déjà en place)

  • Watchdog L2 (evaluateMeterFreshness, 90 s, applyDegradedMode) existant. En dégradé, repli charge pilotée = setPowerSetpoint(0) force=true (remplace l'ECS stage-0 actuel) ; PAC → état 2. Conserver l'ordre update() (sécurité avant planif) intact.
  • enabled:false → rôle déclaré mais exclu de l'arbitrage (aucune action envoyée).

10. Dépendances liées (à coder en même temps, côté plugin)

  • Handler nymeaenergyjsonhandler : Get/SetLoadConfig + persistance.
  • loadActions[] dans GetChargingSchedules / ChargingSchedulesChanged.
  • Câbler l'arbitre pour construire ses adapters depuis LoadConfig au lieu du registre en dur (energypluginnymea.cpp:58-80 — le « 3g »).