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>
170 lines
8.1 KiB
Markdown
170 lines
8.1 KiB
Markdown
# 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<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) :
|
||
|
||
```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` (1–4) |
|
||
| `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 1–4, 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 »).
|