etm-powersync-app/INTERFACE_etmvariableload.md
Patrick Schurig a4487324dc docs: resync du miroir INTERFACE_etmvariableload — rév. 2 → rév. 3 + LM-200/201/202
Ce miroir était resté en rév. 2 alors que le canonique du dépôt moteur est en rév. 3 depuis
le 2026-08-09. La divergence portait sur le point le plus structurant : la rév. 2 plaçait la
combinatoire watts→relais « dans le thing », ce que la rév. 3 renverse — un integration-plugin
nymea ne peut PAS piloter les things d'un autre plugin, donc la combinatoire vit côté
experience-plugin, et l'écran « Configurer » change (§10, annoncé « impact app majeur »).
Le bandeau désignait en outre un chemin canonique faux (racine au lieu de docs/).

Contenu neuf pour l'app, au-delà du rattrapage de révision :

- §4-bis — LM-201, UN CANAL DE COMMANDE, UNE CHARGE. Décide de ce que l'écran « Configurer »
  doit CRÉER : une PAC chauffage+ECS sans ballon séparé = UNE charge, pas deux. Avec ballon
  séparé = deux. Une PAC qui fait aussi du froid = toujours une seule.
- §4-bis — LM-202, thermostats et zones NE SONT PAS des charges : ils ouvrent une vanne, ils ne
  portent pas la consommation. Leur écran, leur namespace, jamais LoadConfig.
- §4-ter — LM-200, mécanismes recevables selon l'organe, et le piège d'UI « ma PAC est pilotée
  par deux relais donc relay-router » : les contacts SG-Ready SONT des things power, mais ils
  signalent au lieu de porter la charge.
- domain est explicitement SANS force normative — ne construire aucune règle dessus. Le moteur
  n'interdit rien : c'est l'app qui guide au moment du choix, seul endroit fiable puisqu'elle
  sait quel appareil l'installateur déclare.
- Lot B-bis : loads[] de GetLoadTelemetry est désormais INCLUS dans GetLoadConfig ; la règle de
  détection « loadId sans équivalent » est retirée et aucun configurable:false n'existe.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HajXLUczEyZd22JeewRfff
2026-08-25 21:44:19 +02:00

20 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 Où 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,
  "domain":    "ecs",                  // o: — catégorie d'INTENTION, cf. ci-dessous
  "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).
  • domain (optionnel) — énumération FERMÉE : ecs · ev · heating · hvac · battery.
    • Pure métadonnée d'intention. L'arbitre ne la lit pas ; aucun comportement moteur n'en dépend. Elle enregistre le choix de l'installateur, qui sélectionne une catégorie puis lui rattache ses Things.
    • Des codes, jamais des libellés affichables — même raison que pour les motifs de décision (locale nymea par connexion, handler hors catalogue de traduction ; cf. INTERFACE.md). Le nom montré à l'utilisateur est fabriqué par l'app.
    • Absente ou vide → charge « non classée ». Le plugin ne la range pas d'office : il n'a aucun moyen de deviner juste, et un classement inventé serait indistinguable d'un classement choisi. Forme exacte, vérifiée au banc : GetLoadConfig sérialise via le méta-objet, donc toutes les propriétés déclarées sortent, y compris vides — une charge non classée revient avec "domain": "", exactement comme "sgReady": {}, "powerLevels": [] ou "needs": {"dailyDeadline": ""} sur une charge qui ne les porte pas. C'est le corollaire LM-302, et c'est ce qui rend l'aller-retour Get → Set possible. La forme persistée (fichier JSON), elle, omet la clé. Le client doit donc traiter "" et l'absence comme « non classée » — et ne jamais afficher la chaîne vide comme un domaine.
    • SetLoadConfig rejette en bloc une valeur hors énumération. Une valeur libre acceptée aujourd'hui deviendrait une valeur à supporter le jour où la couche domaine en fera son discriminant.
    • Elle ne préempte pas la couche domaine de specs/spec_loadmodel.md : elle en deviendra le discriminant quand cette couche arrivera. L'énumération de ce spec (§2 : Ecs/Hvac/Ev/SmartHome) est une intention plus ancienne ; c'est celle ci-dessus qui est implémentée.
    • ⚠️ domain n'a AUCUNE force normative aujourd'hui, et n'en aura pas à court terme. Il groupe l'ordre de service, rien d'autre. Ne construire aucune règle dessus — ni côté app, ni côté moteur. Ce que heating et hvac désignent exactement reste ouvert et ne se saura qu'en dessinant la PAC communicante.

4-bis. Ce qui fait une charge — un canal de commande, une charge

Règle LM-201 (specs/spec_loadmodel.md §2). Elle décide de ce que l'écran « Configurer » doit créer, et c'est la question que l'app pose en premier.

Une charge existe quand il y a un canal de commande distinct pour un service. Le domaine ne décrit pas la machine, il décrit ce qu'on arbitre — une PAC air/eau fait chauffage, rafraîchissement et ECS, et cela ne fait pas trois charges.

Installation Ce que l'app doit créer Pourquoi
PAC chauffage + ECS, sans ballon séparé UNE charge (heating) un seul canal SG-Ready commande la machine entière
PAC + ballon séparé DEUX charges (heating, ecs) le ballon a son propre canal
PAC qui fait aussi du froid UNE charge même machine, même canal, même rang — ce qui change est la saison, pas la configuration
  • Ne pas créer de charge ECS sur une PAC sans ballon séparé. La répartition entre chauffage et ECS appartient à la régulation de la PAC ; il n'y a rien à arbitrer indépendamment.
  • Deux charges ne peuvent pas revendiquer le même Thing. SetLoadConfig rejette en bloc (LM-302-b / ECS-110-b). Ce n'est pas une limite technique : deux charges sur un canal unique recevraient deux consignes pour un seul organe, et le budget compterait la même puissance deux fois.

Les thermostats et les zones ne sont PAS des charges (LM-202). Un thermostat ouvre une vanne ; il ne porte pas la consommation, qui reste à la machine. Les déclarer produirait des entrées à 0 W dans le waterfall pendant que la PAC est comptée ailleurs. Zones et thermostats sont de la configuration de confort — ils disent où va la chaleur, pas combien on en achète. Ils vivent dans leur écran et leur namespace, jamais dans LoadConfig.

4-ter. Mécanismes recevables selon l'organe (LM-200)

Une PAC se pilote de deux façons, et de deux seulement : sg-ready (deux contacts secs de signalisation, 4 états normés) ou communicante via un plugin nymea — ce dernier mécanisme n'existe pas encore.

Ni relay-router, ni etmvariableload : le premier couperait l'alimentation du compresseur et lui retirerait son autorité sur son propre cycle (dégivrage, retour d'huile, temporisation de redémarrage) ; le second suppose un cadran en watts qu'un compresseur n'est pas.

⚠️ Piège d'UI : « ma PAC est pilotée par deux relais, donc relay-router ». Non. Les contacts SG-Ready sont des things power — au banc .75, K1/K2 en sont deux. Mais ils signalent, ils ne portent pas la charge. Voir des relais dans une configuration ne dit rien du mécanisme qui convient. Le picker de relais de §10 sert les deux mécanismes.

Le moteur n'interdit pas ces combinaisons — domain est facultatif et sans force normative, un refus serait contournable en effaçant le champ. C'est donc l'app qui guide au moment du choix, et c'est le seul endroit où le guidage est fiable : elle sait quel appareil l'installateur est en train de déclarer, le moteur ne le sait pas.


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. LoadAction — deux kinds (inchangé)

Kind Pour Porte
Setpoint EV / ECS / routeur PV (via RelayRouter ou EtmVariableLoadAdapter) powerW
état SG-Ready PAC (interface à états, §8) state (1–4)
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 — ⚠️ REMPLACÉ par NymeaEnergy.GetLoadTelemetry

Ce paragraphe décrit une intention qui n'a jamais été implémentée, et qui ne le sera pas. loadActions[] n'existe pas dans GetChargingSchedules ni dans ChargingSchedulesChanged. Le canal réel de la decision card est NymeaEnergy.GetLoadTelemetry + sa notification LoadTelemetryChanged (contrat : INTERFACE.md).

Une différence de fond, et non de forme : le reason ci-dessous est une phrase française composée par la box. C'est intenable — la locale nymea est par CONNEXION, et ce handler est hors du catalogue de traduction. La télémétrie transporte donc {code, params}, et la phrase est fabriquée par le client. La ligne française du journal, elle, est rendue depuis le même code par une fonction unique (renderFr), pour qu'elle ne puisse pas en diverger.

Forme historique, conservée pour mémoire :

7-bis. Ancienne forme (mémoire)

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 1–4, 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.
  • lot B (télémétrie) : domain ajouté à LoadConfig (§4, optionnel, énumération fermée) ; §7 remplacé — le reason sort désormais en {code, params} par NymeaEnergy.GetLoadTelemetry / LoadTelemetryChanged, plus jamais en phrase composée.
  • 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.
  • lot B-bis (la PAC entre en configuration) : plus aucun adaptateur codé en dur — loads[] de GetLoadTelemetry est inclus dans GetLoadConfig, par construction ; la règle de détection « un loadId sans équivalent dans GetLoadConfig » est retirée d'INTERFACE.md et aucun configurable: false n'a été ajouté. pac-terrain est devenue une LoadConfig sg-ready ordinaire.
  • §4-bis / §4-ter ajoutés : LM-201 — un canal de commande, une charge — qui décide de ce que l'écran « Configurer » doit créer (une PAC sans ballon séparé = une charge, pas deux) ; LM-202 — thermostats et zones ne sont pas des charges ; LM-200 — les mécanismes recevables selon l'organe, et le piège « pilotée par deux relais donc relay-router ». domain est explicitement sans force normative : c'est l'app qui guide au moment du choix, le moteur ne le peut pas.