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

170 lines
8.1 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 | Où | 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&lt;uint&gt; | 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) :
```jsonc
{
"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. `LoadAction` — **deux** 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`) :
```jsonc
"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 »).