docs(agents): consolidation des zones périmées (12 zones, aucun code)

Purge des affirmations devenues fausses depuis la rév. 3. Chaque correction est
vérifiée contre le code ; rien n'est repris du texte existant par défaut.

Zones : ÉTAT (tableau + Détail 3c) · « ce que le moteur sait faire » · DÉFÉRÉ
(2 entrées) · PLAN 3C · ARCHITECTURE CIBLE · WORKFLOW phase 3 · 3b révisé ·
Verrous minOn/minOff (2 endroits) · MODÈLE DE SÉCURITÉ (L2) · ROADMAP ·
RÉFÉRENCES.

Principales remises au fait :
  - EcsRelayAdapter et le kind Stage, supprimés en 5100674, disparaissent du
    schéma « non négociable » au profit de RelayRouter + EtmVariableLoadAdapter ;
    SocketScheduler et BatteryAdapter, qui n'existent pas, sont marqués [3d]/[3f].
  - EvAdapter::applyAction() n'est pas « inactif » : il est implémenté
    (evadapter.cpp:62-94) mais jamais appelé (energyarbitrator.cpp:288).
    3g est un travail de câblage, pas d'écriture.
  - minStage/maxStage ont été retirés du contexte ; minState/maxState et
    minStateHold subsistent et sont conservés tels quels.
  - La configuration des charges est persistée et rechargée à chaud depuis
    7184fe4 — le point (b) de la ROADMAP est fait côté moteur, seule l'UI
    Flutter reste ouverte.
  - Le watchdog L2 est en place, plus « à coder en 3c ».
  - PLAN 3C n'est plus reproduit (pseudocode nommant une classe supprimée et un
    identifiant « relay-stages » jamais livré) ; les corrections A et B, toujours
    en vigueur, sont conservées avec leurs références actuelles.
  - PRÉCONDITION ajoutée au basculement de SgReadyAdapter sur la config :
    retirer d'abord Q_ASSERT(m_stateRelays.contains(2)) au profit d'un refus
    explicite — l'assertion garde l'état 2, repli sûr du mode dégradé L2, et
    disparaît en release.

Aucune règle absolue 1 à 10 modifiée (vérifié identique à HEAD).
INVARIANTS BUILD / PACKAGING intact. Aucun code touché.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Patrick Schurig 2026-08-08 10:51:10 +02:00
parent 9624b6ec87
commit f6a4be5560

295
AGENTS.md
View File

@ -17,8 +17,9 @@ vers un gestionnaire d'énergie complet (EV, ECS, PAC SG-Ready, batterie).
| 2 — design arbitre validé | ✅ FAITE | `074fa71` |
| 3a — structs protocole + interfaces | ✅ FAITE | `4ae1939` |
| 3b — EnergyArbitrator + scheduler + adapter | ✅ FAITE — iso-fonctionnalité prouvée | `5f49e4c`, `d8ebd65`, `[3b-iv]` |
| 3c — EcsRelayAdapter + waterfall ECS | ✅ FAITE — suite 18/18 + charging 46/46 | `6298d5d``54ba229` |
| 3c — adaptateur ECS à paliers + waterfall ECS | ✅ FAITE — suite 18/18 + charging 46/46 | `6298d5d``54ba229` |
| 3e — SgReadyAdapter | ✅ FAITE — suite 19/19 | `83d5ad9``d8079e8` |
| rév. 3 — frontière optimiseur↔routeur | ✅ FAITE — `RelayRouter`, `LoadConfig`/`LoadConfigStore`, kind `Stage` retiré | `5100674`, `e16aca4`, `5585a5c`, `7184fe4`, `88626cf` |
**Détail 3b** :
- `EnergyArbitrator : public SmartChargingManager` — justification dans `## DÉCISIONS DE DESIGN`
@ -30,25 +31,30 @@ vers un gestionnaire d'énergie complet (EV, ECS, PAC SG-Ready, batterie).
- Tests charging : 57 lignes décisions identiques, diff = 0 ; 46/46 PASS ref ET ETM
- [Arbitre] présents avec raisons françaises pour les 4 cas (idle, surplus PV, aWATTar, deadline)
**Détail 3c (morceaux déjà compilés, 0 erreur / 0 warning)** :
- **Morceau 0**`LoadAction.force=false` (bypass verrous sécurité) ✅
- **Morceau 1**`EcsRelayAdapter` (.h + .cpp) : pilote N Things powerswitch,
`applyRelayStage()`, verrous `minOnS/minOffS`, bypass si `force==true`
- Enregistrement explicite via `EnergyArbitrator::registerEcsAdapter()` (tests + config)
- **Morceau 2**`buildContext()` : `SurplusMeter` brut (`exportW = max(0, -meter->currentPower())`),
`loads[]` EV + ECS, `SurpusPv` déféré 3d ✅
**Détail 3c (clôturée, commits `6298d5d` waterfall → `54ba229` testMeterSilentFallback)** :
- `LoadAction.force = false` — bypass des verrous en repli sécurité.
- Adaptateur ECS à N paliers powerswitch (`applyRelayStage()`, verrous
`minOnS`/`minOffS`, bypass si `force == true`), enregistré explicitement auprès
de l'arbitre. **Classe remplacée depuis** : voir la ligne rév. 3 du tableau.
- `buildContext()` : `SurplusMeter` brut (`exportW = max(0, -meter->currentPower())`),
`loads[]` EV + charges pilotées ; `SurplusPv` déféré.
- Waterfall (tri priorité ASC = rang) + dispatch + watchdog L2 (mode dégradé
conservateur, planification suspendue) + `degradedMode`/notification + tests.
- **Correctif clé** `[3c-3-fix]` : surplus **net signé** (délestage en import) ;
clamp lock-aware ; **seam de temps unifié** (`now = ctx.timestamp`,
`lockWindow()` source unique) ; watchdog injectable
(`recordMeterUpdate`/`evaluateMeterFreshness`, déclencheurs sous
`#ifndef ENERGY_SIMULATION`).
- Tests : `testEcsSurplusPV` (4 régimes) + `testMeterSilentFallback` (stabilité +
reprise). Suite simulation **18/18**, charging **46/46**, plugin prod 0/0.
- **arm64 cross : NON vérifié dans le sandbox dev** (pas de toolchain /
Qt6-aarch64 / docker) → relève de l'infra de build CI (`etm-powersync-deploy`).
À confirmer là-bas.
**3c CLÔTURÉE** (commits `6298d5d` waterfall → `54ba229` testMeterSilentFallback) :
- Morceaux 3-7 faits. Waterfall ECS (tri priorité ASC = rang) + dispatch + watchdog L2
(mode dégradé conservateur, planif suspendue) + degradedMode/notification + tests.
- **Correctif clé** `[3c-3-fix]` : surplus **net signé** (délestage en import) + **clamp
lock-aware** `minStage/maxStage` (protection compresseur) ; **seam de temps unifié**
(`now=ctx.timestamp`, `lockWindow()` source unique) ; watchdog injectable
(`recordMeterUpdate`/`evaluateMeterFreshness`, déclencheurs sous `#ifndef ENERGY_SIMULATION`).
- Tests : `testEcsSurplusPV` (4 régimes) + `testMeterSilentFallback` (stabilité + reprise).
Suite simulation **18/18**, charging **46/46**, plugin prod 0/0.
- **arm64 cross : NON vérifié dans le sandbox dev** (pas de toolchain/Qt6-aarch64/docker)
→ relève de l'infra de build CI (`etm-powersync-deploy`). À confirmer là-bas.
> Le clamp lock-aware de 3c passait par `telemetry.minStage`/`maxStage`. Ces
> champs ont été retirés du `SurplusContext` en rév. 3 ; le verrou est aujourd'hui
> interne à l'adaptateur (`relayrouter.cpp:193-203`). Écarts connus entre cette
> cible et le code : `specs/spec_ecs.md` §1.
**3e CLÔTURÉE** (commits `83d5ad9` types → `d8079e8` testSgReadySurplus) :
- `SgReadyAdapter` : 4 états normés (kind:State), encodage 2 bits, `lockWindow` symétrique
@ -67,27 +73,42 @@ vers un gestionnaire d'énergie complet (EV, ECS, PAC SG-Ready, batterie).
### Ce que le moteur sait faire aujourd'hui
- **Arbitrage central unique** : un budget de surplus net signé, cascade par **priorité** (rang).
- **Charges** : EV (proxy amont, décision B), **ECS** (paliers, `Stage`), **SG-Ready PAC**
(4 états, `State`) — ECS+PAC classables ensemble sur le budget partagé.
- **Sécurité** : protection compresseur (verrous lock-aware `minStage/maxState`, seam de temps
unifié) ; watchdog L2 (compteur muet >90 s → mode dégradé conservateur, planif suspendue,
- **Charges** : EV (proxy amont, décision B), **charges pilotées en watts** (kind `Setpoint`
`RelayRouter` pour une combinaison de relais, `EtmVariableLoadAdapter` pour une consigne
continue), **SG-Ready PAC** (4 états, kind `State`) — toutes les charges non-EV classables
ensemble sur le budget partagé.
- **Sécurité** : protection compresseur (verrous `minOn`/`minOff` par charge tenus dans
l'adaptateur, seam de temps unifié `now = ctx.timestamp` ; fenêtre d'états
`minState`/`maxState` exposée au scheduler pour SG-Ready) ; watchdog L2
(compteur muet >90 s → mode dégradé conservateur, planif suspendue,
reprise par recalcul) ; `verifyOverloadProtection()` amont intacte ; `degradedMode` notifié.
- **Local-first** : zéro cloud (invariant 10).
### DÉFÉRÉ (ordre indicatif)
- **Passe README + contrats** : `OPTIMIZER_PROTOCOL.md` ne reflète PAS encore plusieurs ajouts
3c/3e — `LoadAction.force` (bypass sécurité L2), `telemetry.minStage/maxStage` et
`minState/maxState` (fenêtres de verrou), `degradedMode` (notification). À documenter dans
le protocole publié + README architecture. **Prochaine session doc** (pas avant le terrain).
`LoadAction.force` (bypass sécurité L2), `telemetry.minState/maxState` (fenêtre de verrou
SG-Ready), `degradedMode` (notification), et la forme `relay-router` de `LoadConfig`.
`minStage`/`maxStage` ne sont plus à documenter : les champs ont été retirés du contexte en
rév. 3. À documenter dans le protocole publié + README architecture.
**Prochaine session doc** (pas avant le terrain).
- **Waveshare D8** : plugin DEVICE (8 powerswitch) sous l'adaptateur — **session dédiée**
(chantier transport Modbus RTU/RS485).
- **V2C** (borne EV) : intégration — **session dédiée**.
- **3d** SocketScheduler (handshake/heartbeat/repli optimiseur).
- **3f** BatteryAdapter (constraints + charge réseau plafonnée) + waterfall grid-funding.
- **3g** transplantation EV dans le waterfall unifié → toutes charges classables ensemble.
- **Couche config priorités** (API JSON-RPC + UI Flutter drag-and-drop) — cf. `## ROADMAP`.
Note : la déclaration des adaptateurs est aujourd'hui **codée** (`energypluginnymea.cpp`),
pas configurable à chaud — cf. `docs/TEST_TERRAIN.md` §1.d.
- **Couche config priorités** (UI Flutter drag-and-drop) — cf. `## ROADMAP`.
Note : la déclaration des **charges pilotées** est configurable et persistée depuis
`7184fe4` (`LoadConfigStore`, RPC `Get`/`SetLoadConfig`, reconstruction à chaud sur
`changed()`). Reste codé en dur : le **`SgReadyAdapter` du banc**
(`energypluginnymea.cpp:66-76`) — cf. `docs/TEST_TERRAIN.md` §1.d.
**PRÉCONDITION à ce basculement** : retirer d'abord
`Q_ASSERT(m_stateRelays.contains(2))` (`sgreadyadapter.cpp:36`) au profit d'un refus
explicite. Cette assertion garde l'état 2 — le repli sûr du mode dégradé L2 — et
disparaît du binaire release (`QT_NO_DEBUG`). Tant que la construction est codée en
dur, elle ne peut pas échouer ; dès que l'état vient de la config, elle devient le seul
garde-fou, et il n'existera pas chez le client. **Avant le basculement, pas après**
cf. `specs/spec_ecs.md` ECS-110.
- **arm64** cross-compile validé en pré-déploiement (infra CI `etm-powersync-deploy`).
- **`Doxyfile` + job CI `doxygen -W`** (automatiser l'audit doc).
@ -142,79 +163,36 @@ dpkg-deb -I powersync-energy-plugin-nymea_*.deb | grep -E "Architecture|Version|
---
## PLAN 3C (validé, morceaux 0-2 déjà compilés)
## PLAN 3C — CLÔTURÉ
Plan approuvé par Patrick. Corrections A (double déduction EV) et B (anti-clignotement ECS)
**intégrées** dans le design ci-dessous.
Le plan de mise en œuvre a été exécuté (`6298d5d``54ba229`) puis remanié en rév. 3.
Il n'est plus reproduit ici : son pseudocode nommait une classe supprimée
(`EcsRelayAdapter`) et un identifiant d'adaptateur qui n'a jamais existé dans le code
livré (`"relay-stages"` ; la valeur réelle est `"relay-router"`, `relayrouter.cpp:74`).
Autorité courante sur l'ECS : `specs/spec_ecs.md`.
### Morceaux déjà compilés (0 erreur/warning)
Deux décisions de ce plan restent **en vigueur** et ne doivent pas être redécidées.
| # | Fichier(s) | Ce qui a été fait |
|---|-----------|-------------------|
| 0 | `types/loadaction.h` | `bool force = false` — bypass verrous sécurité |
| 1 | `adapters/ecsrelayadapter.h/.cpp` | Adaptateur N-paliers powerswitch, anti-rebond, `applyRelayStage()`, `etm.pri` |
| 2 | `energyarbitrator.h/.cpp` | `buildContext()` : `SurplusMeter` brut, `loads[]` EV+ECS, `registerEcsAdapter()`, `m_ecsAdapters` |
### Correction A — déduction EV unique, dans le scheduler
### Morceaux à venir
`ctx.meter.exportW` est la mesure **brute** (règle absolue 8). Les consignes EV du cycle
courant ne sont pas encore visibles au compteur : leur puissance commandée non encore
mesurée est réservée dans `getPlan()`, après le proxy EV — jamais dans `buildContext()`.
**3 — Waterfall ECS dans `RuleBasedScheduler::getPlan()`**
Implémentation : `rulebasedscheduler.cpp:86-97`.
Exemple : PV 9 kW, EV stable 7360 W, export mesuré 1140 W → `evReservedW = 0`,
budget charge = 1140 W.
Après la boucle EV proxy, ajouter :
### Correction B — anti-clignotement par recrédit de la consommation courante
```
// Déduction unique (correction A) — ctx.meter.exportW = mesure brute
evReservedW = Σ EV en charge dans slot.actions : max(0, commandedA×phases×230 ev->currentPower())
remainingSurplusW = max(0, ctx.meter.exportW evReservedW)
// Tri loads ECS par priorité ASC (priority 1 = servi en premier ; protocole §5 + annexe C)
pour chaque LoadContext lc où lc.adapter == "relay-stages" :
budgetCharge = remainingSurplusW + lc.telemetry.currentPowerW // correction B anti-clignotement
bestStage = palier le plus haut dont stages[i] ≤ budgetCharge
reason = "Surplus PV — ECS palier N (W)" ou "Surplus insuffisant — ECS éteint"
remainingSurplusW = remainingSurplusW + lc.telemetry.currentPowerW stages[bestStage]
// Grid funding : dormant jusqu'à 3f (commente, n'implémente pas)
```
**4 — `syncAdapters()` extension + `applyActionsToAdapters(Slot)` dans `update()`**
- `syncAdapters()` : commentaire "découverte ECS via interface 'ecsrelay' déférée 3g"
- `applyActionsToAdapters(Slot)` : itère slot.actions, dispatche via `m_ecsAdapters` pour kind==Stage
**5 — Watchdog L2** (cf. SAFETY.md §L2)
- `QTimer m_meterWatchdog` 30 s — picoté sur `powerBalanceChanged` SIGNAL pour `m_lastMeterUpdate`
- `onMeterWatchdogTick()` : si `now m_lastMeterUpdate > 90 s``applyDegradedMode()`
- `applyDegradedMode()` : ECS stage 0 force=true + EV courant minimum, reason="Compteur muet..."
**6 — `degradedMode()` + notification + INTERFACE.md**
- `virtual bool degradedMode() const` dans `SmartChargingManager` (retourne false, `// [ETM]`)
- Override dans `EnergyArbitrator`
- Champ `"degradedMode": bool` dans `ChargingSchedulesChanged` (additif, rétro-compatible)
- Mise à jour `docs/INTERFACE.md` + limite dans `docs/SAFETY.md` : "valeur strictement constante non détectée"
**7 — Mock powerswitch + tests**
- Mock JSON : ThingClass `mockPowerSwitch` (état `power` bool, état `currentPower` double)
- `energytestbase.h` : `mockPowerSwitchThingClassId`
- `testEcsSurplusPV` : cas normal + cas "ECS déjà au palier 1, surplus stable → Y RESTE" (anti-clignotement)
- `testMeterSilentFallback` : compteur muet 90 s → mode dégradé → ECS off ; **vérifier que
l'ECS RESTE à 0 sur plusieurs cycles `update()` pendant le silence** (planification
suspendue, pas de rallumage sur cache mort) ; dégel → reprise normale + `degradedMode=false`.
Repli EV : seul un VE *déjà en charge* est clampé au minimum (pas d'activation forcée).
Seam de test : piloter `onMeterWatchdogTick()` / `m_lastMeterUpdate` sans 90 s d'horloge réelle.
### Arithmétique du budget (corrections A + B)
**Correction A — déduction EV unique, dans le scheduler** :
```
exportW dans ctx.meter = mesure brute (invariant 8, protocole §5)
evReservedW = déduction dans getPlan() APRÈS proxy EV, avec m_chargingActions fraîches
Pas de déduction dans buildContext().
```
Exemple : PV 9 kW, EV stable 7360 W, export mesuré 1140 W → evReservedW=0, budget ECS=1140 W ✓
**Correction B — anti-clignotement par recrédit de la conso actuelle** :
```
budgetCharge = remainingSurplusW + lc.telemetry.currentPowerW
```
Identique à SCM ~l.1245 pour l'EV. Sans ce recrédit : ECS palier 1 → export chute → palier 0 → oscillation.
Sans ce recrédit : la charge monte d'un palier → l'export chute → elle redescend →
oscillation. Même mécanique que l'EV en amont.
Implémentation : `rulebasedscheduler.cpp:186`.
---
@ -234,20 +212,68 @@ Identique à SCM ~l.1245 pour l'EV. Sans ce recrédit : ECS palier 1 → export
│ waterfall par priorités │
└──────┬───────────────────────┘
│ IScheduler (= contrat OPTIMIZER_PROTOCOL)
┌───────────┴───────────┐
RuleBasedScheduler SocketScheduler
(in-process, V1, GPL) (client unix://|tcp://, V1 aussi —
plan à 1 créneau personne en face en beta : repli rules)
┌───────────┴───────────
RuleBasedScheduler SocketScheduler [3d]
(in-process, V1, GPL) (client unix://|tcp://
plan à 1 créneau repli rules si absent/mort)
│ distribue le budget en LoadAction typées
┌──────────┬────┴─────┬──────────────┐
EvAdapter EcsRelayAdapter SgReadyAdapter BatteryAdapter
(setpoint, (stage 0/1/2) (state 1-4) (constraint +
iface setpoint W réseau)
evcharger
nymea)
│ ↓ LoadAction — enveloppe en W (Setpoint),
│ ou State (SG-Ready)
│ ↑ LoadContext — télémétrie + bornes de verrou
┌──────────┬─────────┴─────┬────────────────┬──────────────────┐
EvAdapter RelayRouter EtmVariableLoad SgReadyAdapter BatteryAdapter
W → combi- Adapter W → état 1-4 contrainte +
[câblage naison de W → consigne setpoint W
différé relais, continue réseau
3g] verrous [3f]
│ │ │ │ │
└────────────┴──────────────┴─────────────────┴────────────────────┘
Things nymea
(evcharger · powerswitch · états
powerSetpoint/currentPowerW)
```
**Légende** — sans marque : en place. `[3d]` `[3f]` : prévu, **non écrit** (cf.
`## DÉFÉRÉ`). `[3g]` : classe écrite, câblage différé (voir « EV » ci-dessous).
**Contrat descendant.** Le scheduler distribue une **enveloppe en watts**. Le kind `Stage`
a été supprimé (`5100674`) : **aucun index de palier ni identifiant de relais ne descend —
seulement des watts, fussent-ils arrondis sur les paliers déclarés**
(`buildSetpointAction()`, `rulebasedscheduler.cpp:196-205`). Seul SG-Ready conserve un kind
`State` : 4 états normés constructeur, non exprimables en watts.
**Contrat montant.** Chaque adaptateur remonte sa télémétrie **et les bornes que ses
verrous imposent au cycle courant**, afin que l'arbitre décrémente le budget du palier
réellement applicable.
Écarts connus entre cette cible et le code : `specs/spec_ecs.md` §1.
**Traduction, pas répartition.** Un adaptateur convertit une enveloppe en consigne
matérielle — `RelayRouter` retient la combinaison de relais dont la puissance approche
l'enveloppe par le bas (`relayrouter.cpp:181-191`), `EtmVariableLoadAdapter` module en
continu (`etmvariableloadadapter.cpp:91`), `SgReadyAdapter` mappe sur un état normé. Aucun
ne s'attribue de budget : la règle 2 est respectée.
**Instanciation.** `RelayRouter` et `EtmVariableLoadAdapter` sont construits par
`EnergyArbitrator::rebuildLoadAdapters()` depuis `LoadConfigStore` (persisté, RPC
`Get`/`SetLoadConfig`, reconstruction à chaud) — `energyarbitrator.cpp:137-175`.
`SgReadyAdapter` est encore **codé en dur** (`energypluginnymea.cpp:66-76` : `ThingId`
K1/K2, paliers `{3: 1500, 4: 3000}`), à basculer sur la config avec la couche priorités.
Réserve sur `EtmVariableLoadAdapter` : il est exercé de bout en bout par
`testLoadConfigBuildsAdapters`, mais **aucun matériel réel n'implémente encore l'interface
`etmvariableload`** — seul le mock la porte
(`tests/mocks/plugins/energymocks/integrationpluginenergymocks.json:865-888`).
**EV, limite beta.** `EvAdapter::applyAction()` est **implémenté** (`evadapter.cpp:62-94`)
mais **jamais appelé** : le dispatch ne route les `Setpoint` que vers les charges pilotées,
et les `EvAdapter` vivent dans une table séparée jamais parcourue
(`energyarbitrator.cpp:288`). Les décisions EV restent prises par les méthodes amont, en
proxy, **avant** le waterfall ; l'EV n'entre donc pas dans le tri par priorité.
`descriptor()` et `telemetry()` sont utilisés dès maintenant pour le `SurplusContext`.
**3g est un travail de câblage, pas d'écriture.**
Règles absolues :
1. **UN seul arbitre.** Le budget de surplus est une ressource unique, arbitrée à UN
endroit. **INTERDIT : managers frères par type de charge** (EcsManager,
@ -311,7 +337,8 @@ avant validation du design de la phase.
et un scénario `docker-simulation.sh` qui la prouve = DoD) :
3a. structs du protocole (contexte, plan, actions) ;
3b. arbitre + RuleBasedScheduler + EvAdapter (iso-fonctionnel avec l'amont sur EV) ;
3c. EcsRelayAdapter (paliers) ; 3d. SocketScheduler (handshake/heartbeat/repli,
3c. adaptateur ECS à paliers (livré, puis remplacé par `RelayRouter` en rév. 3) ;
3d. SocketScheduler (handshake/heartbeat/repli,
testé contre un optimiseur factice ~50 lignes) ; 3e. SgReadyAdapter ;
3f. BatteryAdapter (constraints + charge réseau plafonnée).
- **Bugs upstream** : au fil de l'eau, commits `[upstream-fix]` séparés.
@ -326,8 +353,12 @@ avant validation du design de la phase.
`planSurplusCharging` / `planSpotMarketCharging` (`SmartChargingManager`), inchangées.
`RuleBasedScheduler::getPlan()` les appelle en **proxy** et reformate leurs sorties
(`ChargingActions`) en `LoadAction` pour le log `[Arbitre]`.
`EvAdapter::applyAction()` est **inactif** jusqu'à 3g — mais `descriptor()` et
`telemetry()` sont utilisés dès maintenant pour le `SurplusContext`.
`EvAdapter::applyAction()` est **implémenté** (`evadapter.cpp:62-94`) mais **jamais
appelé** : `applyActionsToAdapters()` ne route les `Setpoint` que vers les charges pilotées,
et les `EvAdapter` vivent dans une table séparée jamais parcourue
(`energyarbitrator.cpp:288`). `descriptor()` et `telemetry()` sont utilisés dès maintenant
pour le `SurplusContext`. **3g est un travail de câblage, pas d'écriture** — ne pas
planifier la réécriture d'un adaptateur qui existe.
**Pipeline ETM réel** (waterfall budget Surplus/Grid, `applyAction`) arrive en **3c**
pour les charges non-EV (ECS, SG-Ready), alimenté par le surplus *restant* après
@ -391,13 +422,20 @@ ou un VE ont un **temps de fonctionnement minimum incompressible** : ce n'est pa
confort, c'est de la **protection matérielle** (le court-cycling détruit le compresseur).
**Séparation des responsabilités** :
- Le **scheduler** décide le palier idéal selon le budget (peut vouloir « palier 0 »).
- L'**adaptateur** borne ce choix via `minStage`/`maxStage` (fenêtre `lockWindow()` évaluée
au temps de cycle) : une charge verrouillée ON garde son palier ; l'import transitoire
est **borné par minOn**, pas illimité. Le scheduler clampe et décrémente le budget au
palier réel (puissance engagée non-coupable) → budget correct pour les charges suivantes.
- `minOnS`/`minOffS` sont des **paramètres par charge** (constructeur `EcsRelayAdapter`,
config installateur) — **jamais codés en dur**.
- Le **scheduler** décide l'enveloppe idéale selon le budget (peut vouloir 0 W).
- L'**adaptateur** borne ce choix par sa fenêtre de verrou, évaluée au temps de cycle :
une charge verrouillée ON garde son palier ; l'import transitoire est **borné par
minOn**, pas illimité.
- Charges en watts : la fenêtre est **interne** à l'adaptateur
(`RelayRouter::lockWindow()`, `relayrouter.cpp:193-203`) et n'est pas exposée au
scheduler.
- SG-Ready : la fenêtre **est** exposée, via `telemetry.minState`/`maxState`
(`surpluscontext.h:66-71`), et le scheduler clampe dessus
(`rulebasedscheduler.cpp:254-264`).
- `minOnS`/`minOffS` sont des **paramètres par charge**, lus depuis la configuration
persistée (`LoadConfig`, `energyarbitrator.cpp:162`) — **jamais codés en dur**.
Écarts connus entre cette cible et le code : `specs/spec_ecs.md` §1.
**Défauts indicatifs par type** (à affiner à la mise en service) :
@ -407,10 +445,10 @@ confort, c'est de la **protection matérielle** (le court-cycling détruit le co
| Ballon thermodynamique / PAC | ~300600 s | ~300 s | **protection compresseur** (anti court-cycling) |
| SG-Ready PAC (3e) | `minStateHoldS` ~900 s | — | maintien d'état imposé constructeur |
**Seam de temps** : `minStage`/`maxStage` (décision) ET le verrou de `applyAction`
**Seam de temps** : la fenêtre de verrou (décision) ET le verrou de `applyAction`
(exécution) partagent le **même `now = ctx.timestamp`** via `lockWindow()` — source unique,
divergence impossible par construction, injectable en simulation. Voir `iloadadapter.h`
(contrat « temps = paramètre, jamais l'horloge »).
divergence impossible par construction, injectable en simulation. Voir
`iloadadapter.h:30-35` (contrat « temps = paramètre, jamais l'horloge »).
---
@ -423,7 +461,7 @@ supérieures n'affecte pas les couches inférieures. Voir `docs/SAFETY.md` pour
|--------|-----|------|
| **L0** | Disjoncteur / Linky matériel | Coupure physique — hors logiciel |
| **L1** | Failsafe natif des bornes | Config installateur, checklist ETM |
| **L2** | Watchdog fraîcheur compteur (à coder en 3c) | `QTimer` piloté : si `lastMeterUpdate > 90 s` → mode dégradé (EV min/off, ECS off, pas de charge réseau batterie), `decisionReason` explicite, notification nymea. Scénario simulation dédié : "compteur muet → repli". |
| **L2** | Watchdog fraîcheur compteur (en place) | `QTimer` piloté : si `lastMeterUpdate > 90 s` → mode dégradé (EV min/off, ECS off, pas de charge réseau batterie), `decisionReason` explicite, notification nymea. Scénario simulation dédié : "compteur muet → repli". |
| **L3** | Watchdog systemd sur nymead | Repo `etm-powersync-deploy`, hors scope ici |
| **L4** | Logique signal-driven existante | Boucle `update()` déclenchée par événements |
@ -460,12 +498,19 @@ supérieures n'affecte pas les couches inférieures. Voir `docs/SAFETY.md` pour
- **MANQUE pour des priorités réglables par le client** :
- (a) **3g** : transplanter le VE dans le waterfall unifié → toutes les charges
(VE, ECS, SG-Ready, batterie) classables ensemble.
- (b) **Couche « config priorités »** : exposer et persister le `priority` de chaque
charge via l'API JSON-RPC, modifiable depuis l'app Flutter (drag-and-drop des
priorités de l'UI). C'est le **pont moteur↔UI**, un morceau à part entière
(ni 3e ni 3g).
- **État actuel** : `priority` est fixé à la création de l'adaptateur (`register*Adapter`),
pas d'interface de réglage à chaud.
- (b) **Couche « config priorités »** — le pont moteur↔UI, un morceau à part entière
(ni 3e ni 3g), aujourd'hui **à moitié livré** :
- **FAIT côté moteur** (`7184fe4`) : `priority` est exposé et persisté par charge,
via `GetLoadConfig` / `SetLoadConfig` (`LoadConfigStore`), et appliqué à chaud —
`changed()``rebuildLoadAdapters()`.
- **RESTE côté app** : l'UI Flutter de réordonnancement (drag-and-drop), qui consomme
ces deux appels. Aucun travail moteur n'y est attaché.
- **État actuel** : pour les **charges pilotées** (`relay-router`, `etmvariableload`),
`priority` est un champ de la configuration persistée (`loadconfig.h:73`), transmis à
l'adaptateur lors de sa construction (`energyarbitrator.cpp:162,167`), et modifiable
**à chaud**. `register*Adapter` ne fixe plus rien : il enregistre un adaptateur déjà
construit. Restent hors config : le VE (fixé à `priority = 100`, `evadapter.cpp:24`
cf. « Dette 3g ») et le `SgReadyAdapter` du banc (`energypluginnymea.cpp:66-76`).
- **Argument démo nymea** : le client réordonne ses charges, le surplus suit.
## RÉFÉRENCES
@ -474,4 +519,8 @@ supérieures n'affecte pas les couches inférieures. Voir `docs/SAFETY.md` pour
§7 (repli), annexe C (priorités).
- `README.md` — architecture (deux boucles, frontière), `etm_powersync_energy.svg`.
- `INTERFACE.md` — API JSON-RPC existante (`NymeaEnergy`, cible future `Ems`).
- `specs/spec_ecs.md`**autorité sur l'ECS multi-palier** : état des exigences après
audit, ordre de traitement, arbitrages ouverts. Subordonné à ce fichier.
- `specs/spec_loadmodel.md` — modèle de charges (domaine × mécanisme).
**Intention de conception : ne déclenche aucun travail.**
- Carte globale du workspace : `../AGENTS.md`.