Constat d'origine, rejoué et daté au banc : 90 s d'écoute, arbitre actif, ~2 kW exportés
→ ZÉRO notification NymeaEnergy.*. L'état runtime de l'arbitre (allocation, budget, verrous,
paliers, disponibilité, motifs) ne franchissait aucune frontière RPC : il vivait exclusivement
dans qCInfo(dcNymeaEnergy). Expliquer les décisions au client n'avait donc aucun transport.
Après déploiement, même geste : 13 notifications en 480 s.
Aucune phrase composée par la box. La locale nymea est PAR CONNEXION (JSONRPC.Hello o:locale),
et NymeaEnergyJsonHandler est un JsonHandler sans pluginId — donc hors du catalogue de
traduction : rien ne rattraperait une phrase française émise là. La box transporte un CODE et
des PARAMÈTRES ; la phrase est fabriquée par le client.
LoadAction::reason devient DecisionReason{code, params}. La ligne française du journal est
RENDUE depuis ce couple par renderFr(), source unique — jamais maintenue en parallèle. Les 19
codes sont EXTRAITS des sites existants, pas inventés ; les motifs d'ECS-309 et ECS-309-b sont
repris mot pour mot. Prouvé par comparaison de traces (1726 lignes de journal identiques en
simulation), par une table d'or qui échoue au premier code sans phrase attendue, et au banc sur
7 motifs distincts. Le libellé de la charge n'entre pas dans la charge utile : il est substitué
au RENDU, par l'appelant qui le connaît.
Les verrous sortent en SECONDES RESTANTES, pas en watts. Ceci n'enfreint pas ECS-306 : cette
exigence interdit qu'un index de PALIER franchisse la frontière d'ALLOCATION ; une valeur
d'AFFICHAGE sortante est un autre chemin. Le mécanisme sort en union discriminée
(relay|variable|sgReady), toutes clés internes optionnelles au schéma (LM-302).
ILoadAdapter::runtimeView() est PURE VIRTUELLE à dessein — mais la pure virtuelle force à
RÉPONDRE, pas à répondre quelque chose. testRuntimeViewIsSubstantial() est le second verrou :
les quatre adaptateurs, amenés dans un état non trivial, doivent rendre les valeurs runtime
réelles, les verrous en secondes ET leur expiration, et les codes de défaut. Deux mutants
« vue vide » introduits puis retirés : le test échoue sur chacun.
budget publié : surplusW, evReservedW, recreditedW, allocatedW, remainingW, avec l'identité
surplus + recrédit − alloué == restant. recreditedW n'est pas décoratif : sans lui l'identité
serait fausse DÈS QU'UNE CHARGE CONSOMME, et le client chercherait un écart inexplicable. Le
banc l'a montré aussitôt — surplusW −250 W avec recreditedW 3000 W — rendant lisible l'artefact
de banc documenté dans AGENTS.md (relais sans mesure + rootmeter aveugle).
Champs omis, jamais nuls : timestamp absent = aucun cycle depuis le démarrage ; budget absent =
mode dégradé, planification suspendue. Publier un zéro se lirait « exécuté, rien à allouer ».
Cadence : changement qualitatif ou variation d'allocation > 50 W, plus un battement de 60 s.
C'est le battement qui rend un arbitre FIGÉ détectable — les notifications continuent tandis que
le timestamp cesse d'avancer. Observé 3 fois au banc. Volume mesuré : ≈ 1,7 Mo/jour, un client.
GetChargingSchedules rend enfin degradedMode : il était POUSSÉ en notification seule, donc
illisible par un client connecté après la bascule (famille ECS-411). Règles absolues 11 et 12
ajoutées à AGENTS.md.
LoadConfig : champ optionnel domain (ecs·ev·heating·hvac·battery), PURE métadonnée d'intention
que l'arbitre ne lit pas. Absente ou vide = non classée. Forme exacte découverte au banc et
corrigée dans la doc ET le test : GetLoadConfig sérialise via le méta-objet, donc la propriété
sort TOUJOURS, à "" — comme sgReady:{} ou powerLevels:[]. C'est le corollaire LM-302.
Règle 7-c, deux silences fermés : les retours idempotents du RelayRouter et du SgReadyAdapter
étaient MUETS — « consigne reçue, palier déjà bon » ne se distinguait pas de « l'action n'est
jamais parvenue à l'adaptateur », et c'est le cas ordinaire qu'on vient vérifier.
⚠️ Porté au contrat : loads[] n'est PAS en correspondance 1:1 avec GetLoadConfig. « pac-terrain »
est la PAC SG-Ready CODÉE EN DUR d'energypluginnymea.cpp (K1/K2 = Relay4/Relay5, {3:1500,
4:3000}) — arbitrée et publiée, mais inconfigurable : ni rang, ni domain, ni enabled:false.
INTERFACE.md porte la règle de détection pour l'app. Son sort reste À TRANCHER (RELEVE_LOTB §8-bis b).
Déploiement : la recette de TEST_TERRAIN.md §b portait un glob « dpkg -i /tmp/…_*_arm64.deb »,
de la même famille que celui qui a rétrogradé .75 de +etm12 à +etm9. Remplacée par
etm-powersync-deploy/deploy.sh (archivage avant dépôt, installation par nom, refus de
rétrogradation, contrôle de version après coup).
Vérifié sur .75 (1.15.2+etm13 puis +etm14) : critères 1, 2, 4, 5, 6, 7, 8 du brief. Critère 3
(épisode dégradé) vérifié en simulation seulement — le déclencher demande de faire taire un
simulateur SunSpec partagé, hors périmètre. Relevé complet, critère par critère, avec ce qui n'a
PAS été vérifié sur la machine et pourquoi : docs/RELEVE_LOTB.md.
Suites : simulation 38/38, charging 46/46, spotmarket 32/32, y compris en build DEBUG où les
Q_ASSERT_X du validateur JSON-RPC sont actifs (le schéma déclaré correspond donc à la charge
utile émise, retours ET notifications). Cross-arm64 : 0 avertissement. Doxygen : 10, inchangé.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016MfvSskkYrrFriYcj4683D
282 lines
16 KiB
Markdown
282 lines
16 KiB
Markdown
# 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,
|
||
"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`)
|
||
```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).
|
||
- **`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.
|
||
|
||
---
|
||
|
||
## 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` :
|
||
```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 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.**
|