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

239 lines
12 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 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`)
```jsonc
{
"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`)
```jsonc
{
"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. `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` (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` :
```jsonc
"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.**