etm-powersync-energy-plugin.../docs/INTERFACE_etmvariableload.md
Patrick Schurig 1f976f0189 docs(contract): INTERFACE charge pilotée rév. 3 — frontière optimiseur↔routeur
Vérification nymea : un integration-plugin ne peut pas piloter les things d'un autre
plugin (thingManager privé ; seuls cœur/règles/scripts/experience-plugins le font).
Donc la combinatoire watts→relais ne peut vivre que côté experience-plugin.

rév. 3 acte le déplacement de frontière :
- Optimiseur watt-pur (ne connaît jamais un relais) | ROUTEUR (watts→relais,
  experience-plugin, a le ThingManager) | relais = things "power" nus.
- ECS multipalier : LoadConfig devient une LISTE de relais power (relays[] +
  minOnS/minOffS) ; powerLevels DÉRIVÉS des combinaisons (source = les relais).
- Cas continu (EV/triac, EtmVariableLoadAdapter) inchangé.
- §10 IMPACT APP explicite : l'écran « Configurer » passe à une liste de relais +
  picker findConfiguredThings("power") (UI-only) — l'agent app doit lire rév. 3
  avant de toucher l'UI.

À déposer À L'IDENTIQUE dans etm-powersync-app (miroir manuel, hors de ce repo).

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

12 KiB
Raw Blame History

INTERFACE charge pilotée — contrat · rév. 3

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. 3 — déplacement de frontière (lire avant de coder)

La rév. 2 plaçait la combinatoire matérielle « dans le thing ». Vérification faite : un integration-plugin nymea ne peut PAS piloter les things d'un autre plugin (thingManager y est privé ; seuls le cœur, le moteur de règles, les scripts et les experience-plugins commandent des things tiers). Donc la combinatoire watts→relais ne peut vivre que côté experience-plugin — là où l'arbitre a déjà le ThingManager.

rév. 3 acte cette réalité : un relais est un thing power nu (comme n'importe quel relais Modbus/MQTT) ; la combinaison watts→relais vit dans une couche ROUTEUR distincte, sous l'optimiseur, côté experience-plugin. L'optimiseur reste watt-pur et ne connaît jamais un relais. Impact app majeur en §10 — l'écran « Configurer » change.


1. Principe & frontière (rév. 3)

Trois couches, la frontière entre OPTIMISEUR et ROUTEUR.

Couche Raisonne en Connaît
Optimiseur RuleBasedScheduler / SocketScheduler (GPL ou socket) watts priorités, powerLevels (W), surplus, besoins. Jamais un relais.
— FRONTIÈRE — l'optimiseur émet LoadAction{Setpoint, powerW} ; rien d'autre ne descend
Routeur couche adaptateur, experience-plugin (a le ThingManager) watts → relais la liste de relais, la combinatoire, minOn/minOff, l'agrégation currentPowerW
Relais things power (plugins GPIO / Modbus / MQTT) on/off leur propre matériel

Invariant directeur (rév. 3). L'optimiseur raisonne uniquement en watts : aucun relais, aucun index de combinaison ne franchit la frontière vers le haut. Le routeur traduit le setpoint W en commutation de relais et n'expose vers le haut que des watts (powerLevels, currentPowerW). L'optimiseur décide de la puissance ; le routeur décide des relais.


2. Portée

Charge Couche d'exécution Nature
ECS résistif multipalier RelayRouter (rév. 3) N relais power → combinaison watts
ECS simple on/off RelayRouter (cas dégénéré : 1 relais, niveaux [0, nominal]) 1 relais power
EV charger / Routeur PV / triac (modulation continue) EtmVariableLoadAdapter (inchangé depuis rév. 2) 1 thing à puissance pilotable, setpoint direct
PAC SG-Ready SgReadyAdapter (modèle à états, §8) exclue — pas une charge en watts

Les deux exécuteurs (RelayRouter, EtmVariableLoadAdapter) parlent à l'optimiseur la même langue : LoadAction{Setpoint, powerW}. Chacun exécute selon sa nature — combinaison de relais pour l'un, écriture d'un setpoint continu pour l'autre. Un triac n'est pas un routeur de relais (pas de combinatoire) : on ne le force pas dans ce moule.


3. Les exécuteurs sous l'optimiseur

3.a Cas relais (ECS multipalier) — RelayRouter

  • Pas de thing etmvariableload unique. La charge logique est une liste de relais power.
  • Chaque relais est un thing implémentant l'interface nymea power (state power bool writable ; optionnellement currentPower W en lecture). Fourni par le plugin device (GPIO/Modbus/MQTT) — rien de spécifique ECS dans ce plugin.
  • Le RelayRouter (experience-plugin) :
    1. reçoit LoadAction{Setpoint, powerW} de l'optimiseur ;
    2. mappe powerW → la combinaison de relais la plus haute ≤ powerW ;
    3. applique l'anti-rebond minOn/minOff (ici, pas dans le thing) et l'ordre off-before-on (pas de sur-puissance transitoire sur une transition non-cascadée) ;
    4. commande chaque relais via ThingManager::executeAction (state power) ;
    5. agrège currentPowerW = somme des currentPower des relais ON, ou à défaut de mesure (relais GPIO bool sans wattmètre) la somme des nominaux commandés. Dans ce dernier cas currentPowerW reflète le commandé, pas le mesuré — acceptable, à garder en tête pour le diagnostic terrain.
  • powerLevels/maxPowerW sont DÉRIVÉS des combinaisons atteignables de la liste de relais — source de vérité unique = les relais. Le routeur les calcule et les expose à l'optimiseur ; l'assistant app fait le même calcul pour l'affichage.

3.b Cas continu (EV / routeur PV) — interface device etmvariableload

Inchangé rév. 2 : 1 thing exposant un state powerSetpoint (write) + currentPowerW (read), piloté par EtmVariableLoadAdapter. (maxPowerW/powerLevels en read optionnels.)

State Accès Type Sémantique
powerSetpoint write uint W consigne de l'optimiseur
currentPowerW read uint W puissance réellement appliquée
maxPowerW read uint W plafond physique

4. LoadConfig — config app → energymanager (rév. 3)

Émis par l'écran « Configurer charge pilotée » ; persisté côté plugin (/var/lib/nymea/energy-load-configuration.json), jamais en SharedPreferences. App = éditeur : NymeaEnergy.GetLoadConfig / SetLoadConfig.

Cas relais (adapter: "relay-router", mode fixed)

{
  "id":        "ecs-chauffe-eau",      // charge LOGIQUE (plus un thingId unique)
  "label":     "Chauffe-eau",
  "adapter":   "relay-router",
  "mode":      "fixed",
  "priority":  2,                      // rang ; 1 = servi en premier
  "enabled":   true,
  "needs":     { "dailyDeadline": "06:00", "minEnergyWhPerDay": 4000 },
  "relays": [                          // ← la combinatoire vit ICI (routeur), pas chez l'optimiseur
    { "thingId": "{uuid-relais-500W}",  "powerW": 500  },
    { "thingId": "{uuid-relais-1000W}", "powerW": 1000 },
    { "thingId": "{uuid-relais-2000W}", "powerW": 2000 }
  ],
  "minOnS":  60,
  "minOffS": 60
  // powerLevels / maxPowerW : ABSENTS de la config — DÉRIVÉS de relays[] par le routeur.
}

Cas continu (adapter: "etmvariableload", mode dynamic)

{
  "id": "{uuid-thing-triac}", "label": "Routeur PV salon", "adapter": "etmvariableload",
  "mode": "dynamic", "maxPowerW": 3000, "priority": 1, "enabled": true
}

Règles :

  • relays[] ne franchit jamais la frontière : il vit dans le routeur. L'optimiseur ne reçoit que powerLevels/maxPowerW (watts dérivés) via le contexte.
  • relays[].thingId = ThingId du thing power. Choix par PICKER côté app (voir §10) — le picker est UI-only et ne fait qu'aider la saisie : il résout nom du relais → ThingId et écrit des ThingIds dans relays[]. Aucun champ supplémentaire au contrat.
  • minOnS/minOffS : verrous anti-rebond (protection relais / compresseur), par charge.
  • enabled:false → rôle déclaré mais exclu de l'arbitrage (aucune action, aucun adaptateur construit).

5. Règle d'arrondi (optimiseur) — inchangée

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

  • Fixed : level = max( l ∈ powerLevels | l ≤ budget ) puis Setpoint(level). L'optimiseur connaît la granularité dérivée → pas de sur-allocation, zéro cycle de retard. (Le routeur traduit ensuite level → relais.)
  • Dynamic : Setpoint( clamp(budget, 0, maxPowerW) ).
  • Résidu : lire currentPowerW de début de cycle (télémétrie de l'exécuteur — PAS de relecture post-setpoint : invariant 8). Le résidu budget currentPowerW repart vers la charge suivante de la priorité du même cycle.

Déclaré vs réel : powerLevels/maxPowerW (dérivés des relais) = référence de planification. currentPowerW = juge runtime. Si le réel plafonne durablement sous le déclaré → erreur de câblage/déclaration, l'installateur corrige. L'optimiseur n'écrase jamais la déclaration.


6. LoadActiondeux kinds (inchangé)

Kind Pour Porte
Setpoint EV / ECS / routeur PV (via RelayRouter ou EtmVariableLoadAdapter) powerW
état SG-Ready PAC (interface à états, §8) state (14)
Constraint batterie (déféré 3f) conservé

L'index de combinaison de relais n'existe pas au niveau LoadAction : il est privé au routeur. Le kind Stage reste supprimé (rév. 2). reason reste porté par chaque LoadAction.


7. Expo du reason — tableau additif loadActions[] (inchangé)

Dans GetChargingSchedules / ChargingSchedulesChanged :

"loadActions": [
  { "loadId": "ecs-chauffe-eau", "setpointW": 1500, "currentPowerW": 1480, "reason": "Surplus PV 1.6 kW — ECS rang 2" },
  { "loadId": "uuid-pac",        "state": 3,                                "reason": "Surplus PV — PAC en état 3" }
]

setpointW/currentPowerW optionnels (absents pour une charge à états) ; loadId + reason toujours présents. C'est le canal de la decision card de l'app.


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

Hors charge en watts. SgReadyAdapter (états 14, combos K1/K2, hystérésis, minStateHold) n'est pas réécrit. L'arbitre lui envoie un état, pas un setpoint W. Repli L2 : état 2 (normal, mains off), jamais état 1 (blocage).


9. Sécurité — repli L2 (rév. 3)

  • Watchdog L2 (evaluateMeterFreshness, 90 s, applyDegradedMode) inchangé. En dégradé :
    • charges en watts (RelayRouter et EtmVariableLoadAdapter) → Setpoint(0) force=true. Pour le RelayRouter, ça coupe tous les relais (force bypasse minOn/minOff).
    • PAC → état 2.
  • Ordre update() (sécurité avant planif) intact. enabled:false → exclu de l'arbitrage.

10. IMPACT APP — écran « Configurer charge pilotée » (⚠️ lire avant de coder l'UI)

Ce qui change pour etm-powersync-app en rév. 3. L'agent app doit lire ceci avant de toucher à l'écran Configurer, sinon il code contre une frontière périmée (rév. 2).

  • AVANT (rév. 2) : configurer une ECS = choisir 1 thing etmvariableload + saisir des powerLevels.
  • APRÈS (rév. 3) : configurer une ECS = ajouter une liste de relais (things power) avec, pour chacun, sa puissance (W), + les verrous minOnS/minOffS. Plus de « 1 thing etmvariableload » pour le cas relais.
  • Picker relais (UI-only) : l'app présente les things implémentant l'interface power (via l'API d'intégration nymea, équivalent findConfiguredThings("power")) pour que l'installateur choisisse ses relais par NOM (taper un UUID au doigt = erreur garantie). Le picker résout nom → ThingId et écrit des ThingIds dans relays[]. Il n'ajoute aucun champ au contrat (comme l'assistant résistances).
  • Assistant résistances (UI-only, conservé) : à partir des relais et de leurs puissances, calcule et affiche les powerLevels atteignables (combinaisons) — pour que l'installateur voie ce qu'il obtient. Ces powerLevels ne sont pas envoyés (dérivés côté plugin).
  • SetLoadConfig reçoit donc relays[] (+ minOnS/minOffS) pour une charge relais ; le cas continu (triac) garde un thingId + maxPowerW. Voir §4.

11. Dépendances liées (côté plugin)

  • Handler nymeaenergyjsonhandler : Get/SetLoadConfig + persistance (fait ; schéma étendu rév. 3).
  • loadActions[] dans GetChargingSchedules / ChargingSchedulesChanged (fait).
  • Arbitre : construit RelayRouter (si relays[]) ou EtmVariableLoadAdapter (si continu) depuis LoadConfig ; repli L2 unifié sur les exécuteurs en watts.

Changelog

  • rév. 1 : etmvariableload interface unique, watts, un seul kind d'action.
  • rév. 2 : PAC SG-Ready exclue (modèle à états séparé) ; LoadAction garde deux kinds (Setpoint + état SG-Ready) ; suppression du kind Stage.
  • rév. 3 : frontière déplacée entre optimiseur (watt-pur) et routeur (watts→relais, experience-plugin) ; un relais = thing power nu ; LoadConfig du cas ECS devient une liste de relais (relays[] + minOnS/minOffS) ; powerLevels dérivés ; cas continu (EtmVariableLoadAdapter) inchangé. Impact app §10.