Patrick Schurig d9c16d34ea docs+deb: 1.15.2+etm9 — frontière RPC, clés internes optionnelles
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 06:32:32 +02:00

799 lines
46 KiB
Markdown

# SPEC — ECS multi-palier
Version : 0.5.5
Dépôt : `etm-powersync-energy-plugin-etm` (experience-plugin ems)
Branche : `feature/beta-rulebased`
Contrats faisant autorité : `docs/OPTIMIZER_PROTOCOL.md`, `docs/SAFETY.md`, `AGENTS.md`
Ce document est **normatif** et **subordonné à `AGENTS.md`**. En cas de conflit,
`AGENTS.md` gagne et cette spec est corrigée.
Chaque exigence porte un identifiant stable. Un test porte l'identifiant de
l'exigence qu'il couvre. **DOIT** = obligatoire, **DEVRAIT** = recommandé avec
justification si écarté, **PEUT** = optionnel.
Un agent qui découvre une contradiction entre la spec et le code s'arrête et la
remonte. Il ne tranche pas seul.
> **0.4.0** — révision après audit du code réel. Les versions ≤ 0.3.0 nommaient
> `EcsRelayAdapter` (classe supprimée en `5100674`) et attribuaient le choix du
> palier au scheduler. Corrigé ci-dessous. Ne pas se fier à une copie antérieure.
>
> **0.4.1** — passe de vérification de la 0.4.0 contre le code. Quatre
> corrections : statut réel des binaires de test (§9), portée exacte du point de
> gouvernance `AGENTS.md` (§0), citation de règle dans ECS-306 (§3), et
> recouvrement ECS-411 ↔ ECS-412 porté en point ouvert (§13-2).
>
> **0.4.2** — étape 2 renommée « extraction du **noyau de calcul** » : le mot
> « domaine » entrait en collision avec `spec_loadmodel.md` LM-100, où il
> désigne la couche métier `Ecs`/`Hvac`/`Ev`/`SmartHome`. Seul changement.
>
> **0.4.3** — ECS-110 étendu : la validation ne doit pas reposer sur `Q_ASSERT`,
> absent du binaire release. Constat issu de la consolidation d'`AGENTS.md`.
>
> **0.5.0** — §13-1 et §13-2 **tranchés et consignés** ; l'étape 1 est débloquée.
> ECS-412 réduit à `m_lastSwitch` (lecture (b)) et redirigé vers sa cause racine.
>
> **0.5.1 — CORRECTION du câblage.** La 0.5.0 consignait 500/1000/**2000** ;
> c'était faux. Le câblage réel est **500/1000/1500**. Trois consignations
> s'inversent : ECS-302 **est** exercé et redevient préalable structurel de
> l'étape 3 ; **ECS-303 est supprimé** (le « point dur » était une conséquence
> d'ECS-302 non traité, pas une exigence d'hystérésis) ; 7 valeurs et plafond
> 3000 W, non 8 et 3500. Ne pas se fier à une copie 0.5.0.
>
> **0.5.2 — CORRECTION du câblage et DÉCOUPLAGE.** Le câblage réel est
> **500/1000/2000** ; la 0.5.1, qui l'avait corrigé en 500/1000/1500 sur la foi
> du simulateur, était fausse — le simulateur ne reflète pas l'installation.
> Quatrième révision de cette donnée. Trois consignations reviennent à l'état de
> la 0.5.0 : ECS-302 **non exercé** par l'installation de référence et redescendu
> dans l'ordre de l'étape 3 ; **ECS-303 rétabli** ; 8 valeurs, plafond 3500 W.
>
> Pour que cela ne se reproduise pas, les puissances ne vivent plus **que** dans
> le bloc §4.0 « Installation de référence ». Aucune exigence n'en contient. Une
> exigence dont le rang ou la portée en dépend le dit par renvoi.
---
## §0 — Domicile et découpage (tranché)
**ECS-001 — RÉSOLU, négatif, reconfirmé contre le `libnymea` du build.**
`/usr/include/nymea/integrations/integrationplugin.h` : la section `protected`
n'expose que `myThings()`, `hardwareManager()`, `pluginStorage()`,
`apiKeyStorage()`, `setMetaData()`. Pas de `thingManager()`. Un
`IntegrationPlugin` ne peut pas commander un Thing d'un autre plugin.
**ECS-002** — Il n'y a **ni integration-plugin, ni dépôt, ni ThingClass**. L'ECS
ne possède aucun matériel.
**ECS-003 — Découpage réel (rév. 3).** Les classes en vigueur :
| Composant | Fichier | Rôle |
|---|---|---|
| `RuleBasedScheduler` | `etm/scheduler/rulebasedscheduler.cpp` | Alloue le budget de surplus, cascade par `priority` ascendante, arrondit sur les paliers dérivés |
| `RelayRouter` | `etm/adapters/relayrouter.{h,cpp}` | Convertit des watts en palier, tient les verrous `minOn`/`minOff`, écrit les Things `powerswitch` |
| `LoadConfig`, `LoadConfigRelay` | `etm/types/loadconfig.{h,cpp}` | Description d'une charge |
| `LoadConfigStore` | `etm/config/loadconfigstore.{h,cpp}` | Persistance + RPC `Get/SetLoadConfig` |
| `EtmVariableLoadAdapter` | `etm/adapters/etmvariableloadadapter.{h,cpp}` | Charge continue (triac) |
**ECS-004 / ECS-005 — Frontière tenue.** Aucun identifiant de relais ne franchit
la frontière vers le scheduler : seuls des watts dérivés circulent. Cette
propriété est **acquise et non négociable** ; toute évolution la préserve.
> **Point de gouvernance à trancher hors spec — c'est le SCHÉMA d'`AGENTS.md`
> qui est périmé, pas la règle 2.**
>
> Portée exacte, vérifiée : la règle absolue 2 dit « Les LoadAdapters exécutent,
> ils ne décident pas […] **Aucune logique de répartition dedans.** » Le
> `RelayRouter` ne fait **aucune répartition** — il traduit une enveloppe en
> watts vers une combinaison, il ne s'attribue rien (ECS-007 tient). **Telle
> qu'elle est écrite, la règle 2 n'est pas violée.** Aucun agent ne doit conclure
> d'ici qu'il faut réécrire l'architecture.
>
> Ce qui contredit réellement le code, c'est le schéma « ARCHITECTURE CIBLE (non
> négociable) », `AGENTS.md:228-250`, qui affiche encore :
>
> ```
> EvAdapter EcsRelayAdapter SgReadyAdapter BatteryAdapter
> (setpoint, (stage 0/1/2) (state 1-4)
> ```
>
> Deux éléments morts d'un coup : la classe `EcsRelayAdapter` **et** le kind
> `Stage`, tous deux supprimés en `5100674`.
>
> **Décision attendue : redessiner ce schéma dans `AGENTS.md`** (`RelayRouter`,
> kind `Setpoint`, frontière watts↔relais), et non seulement « entériner une
> frontière ». Tant que ce n'est pas fait, le document normatif décrit des
> classes qui n'existent plus.
**ECS-007** — Aucune couche ECS NE DOIT décider quelle part du surplus lui
revient. Acquis (`rulebasedscheduler.cpp:97-121`).
**ECS-008** — L'ECS publie `available` et `energyRemaining` ; Héos les consomme.
L'ems exécute, Héos optimise.
---
## §1 — État des exigences (après audit)
Le code **n'est pas en production client** : ni sur `main` (`f4d5b20`), ni sur un
tag. Il vit sur `feature/beta-rulebased` et `landing-silo`, rattaché au banc
hems. Une modification de comportement ne casse donc aucune installation.
**Décision : ÉTENDRE, pas refondre.** La frontière watts↔relais est posée et
tenue, l'énumération des sous-ensembles est correcte et testée, la config est
externalisée. Le mélange avec nymea est superficiel : trois fonctions pures
(`m_levels`, `stageForPower`, `lockWindow`) noyées dans une classe qui fait
aussi l'I/O — une extraction d'environ 40 lignes, pas une reconstruction.
| Exigence | Objet | Statut |
|---|---|---|
| ECS-001 | Pas d'accès cross-thing | ✅ reconfirmé sur le libnymea du build |
| ECS-100 | Désignation par `ThingId` | ✅ `loadconfig.h:46` |
| ECS-101 | Cas à un seul étage | ✅ `relayrouter.cpp:49-64`, testé |
| ECS-102 | Configuration externalisée | ✅ `LoadConfigStore` + RPC — était marqué ouvert à tort |
| ECS-300 | Palier ≤ budget | ⚠️ vrai hors verrou, faux sous verrou → **ECS-306** |
| ECS-301 | Puissances quelconques, non cascadé | ✅ énumération des 2^N sous-ensembles, testé sur une topologie non cascadée |
| ECS-302 | Encodages équivalents | ❌ le second encodage est jeté (structure) — **non exercé par l'installation de référence**, cf. §4.0 |
| ECS-304 | Testable hors nymea | ⚠️ 3 fonctions pures extractibles, le reste couplé |
| ECS-400 | Temporisations non codées en dur | ✅ `minOnS`/`minOffS` de la config |
| ECS-402 | Coupure avant fermeture | ✅ `relayrouter.cpp:240-244`, testé |
| ECS-403 | Clamp lock-aware côté scheduler | ❌ `minStage`/`maxStage` **retirés** du contexte en rév. 2/3 |
| ECS-404 | Anti-oscillation | ✅ recrédit `currentPowerW`, `rulebasedscheduler.cpp:186` |
| ECS-110 | Validation de configuration | ⚠️ partielle |
| ECS-111 | Suppression d'un Thing référencé | ❌ non défini |
| ECS-305 | Compteur de commutations | ❌ absent |
| ECS-410 | Échec d'écriture | ❌ absent, `available` codé en dur |
| ECS-411 | Reprise du palier | ❌ absent |
| ECS-412 | Survie au `rebuild` | ❌ absent (nouveau) |
| §5 | Mesure par charge | ⚠️ demi-pas |
| §6 | Thermique | ❌ absent, et sans canal d'entrée |
**ECS-203 — CLOS.** Aucune ThingClass exposée, donc aucune interface nymea à
implémenter. L'unité interne reste le **watt**.
---
## §2 — Ordre de traitement
L'ordre est normatif : chaque étape conditionne la suivante.
**Étape 1 — corrections de comportement (ECS-306, ECS-412).** Ce sont des
défauts qui produisent un mauvais comportement sur du code qui tourne au banc,
pas des fonctionnalités manquantes. Ils passent devant tout le reste.
> **Tranché le 2026-08-08 (§13-2, lecture (b)) — l'étape 1 est OUVERTE.** Elle
> porte **trois** exigences : ECS-306, **ECS-411** (remonté ici) et ECS-412
> **réduit à `m_lastSwitch`**. `m_currentStage` se redéduit des Things une fois
> ECS-411 en place : il ne demande aucun code dédié.
**Étape 2 — extraction du noyau de calcul (type pur, sans `ThingManager`).**
Sortir `m_levels`, `stageForPower` et `lockWindow`. Débloque ECS-304 en vrai
unitaire et conditionne l'étape 3.
> Ne pas lire « domaine » ici. `specs/spec_loadmodel.md` LM-100 emploie ce mot
> dans son sens courant — la couche métier `Ecs`/`Hvac`/`Ev`/`SmartHome`, qui ne
> parle à aucun matériel. L'étape 2 ne sort **aucune** classe de ce genre : elle
> extrait le noyau de calcul **interne au mécanisme relais**. Rien à voir.
**Étape 3 — ECS-302 / ECS-305.** Changement de structure de `m_relayMapping` :
un palier doit pouvoir porter PLUSIEURS combinaisons.
> **ECS-305 passe devant ECS-302.** L'installation de référence (§4.0) n'exerce
> pas ECS-302 — aucun palier n'y a deux encodages — tandis que sa pondération
> binaire déséquilibre fortement l'usure, ce que seul ECS-305 rend mesurable.
> ECS-302 reste dans cette étape, mais il n'en est plus le préalable structurel.
>
> **Ne pas ouvrir l'étape 3 avant la confirmation physique du câblage** (§4.0,
> §13-3) : c'est elle qui décide de l'ordre interne de cette étape.
**Étape 4 — ECS-410** (échec d'écriture). ECS-411 a été **remonté en étape 1**
(§13-2). ECS-410 reste ici : il touche le même chemin `applyRelayStage` /
`available`, mais il demande d'attendre le résultat de `executeAction`, ce qui
est un changement de nature — pas une lecture d'état au démarrage.
**Étape 5 — ECS-110 (complément) et ECS-111.**
**Étape 6 — remontée d'`available` dans `LoadContext`**, avec la mise à jour de
`OPTIMIZER_PROTOCOL.md` qu'exige §9 dans le même lot. Préalable structurel au
thermique.
**Étape 7 — §6 thermique.**
---
## §3 — Corrections de comportement
**ECS-306 — Cohérence du budget avec le palier réellement appliqué.** Le budget
décrémenté par le scheduler DOIT correspondre au palier effectivement appliqué,
y compris lorsqu'un verrou empêche la descente.
Constat : `lockWindow` (`relayrouter.cpp:193-203`) remonte `minStage` à
`m_currentStage` tant que `minOn` n'est pas écoulé, et `qBound` (l. 155) force
alors un palier **au-dessus** du budget ; or le scheduler a déjà retranché le
palier plus bas qu'il avait choisi (`rulebasedscheduler.cpp:223`). Pendant toute
la fenêtre `minOn`, les charges de priorité suivante reçoivent un résidu
**surestimé** — donc l'installation soutire au réseau. Ce n'est pas une décision
d'allocation (ECS-007 tient), c'est un effet de bord sur l'allocation.
Règle visée : **`AGENTS.md` règle absolue 4** — « bornes par adaptateur
**écrêtent TOUTE sortie** de stratégie ». L'écrêtage existe bien (`qBound`,
`relayrouter.cpp:155`), mais son résultat n'est jamais renvoyé à l'arbitre :
c'est exactement le trou. *Ne pas invoquer ici la règle 1* — son mécanisme est
« deux décideurs sur le même surplus », et il n'y en a qu'un. Un agent qui suit
cette piste ira chercher un second décideur qui n'existe pas ; le défaut est un
décalage de comptabilité entre décision et exécution.
Le canal qui rendait le scheduler lock-aware **a existé** : `minStage`/`maxStage`
dans `LoadContextTelemetry`, retirés en rév. 2/3 (`surpluscontext.h:62-64`). La
correction DEVRAIT restaurer un signal de verrou vers le scheduler plutôt que se
contenter de remonter le palier appliqué après coup : la première corrige dans le
cycle, la seconde seulement au cycle suivant.
**ECS-412 — Survie des horodatages de verrou au `rebuild`.**
`rebuildLoadAdapters()` (`energyarbitrator.cpp:137-144`) détruit et reconstruit
les adaptateurs à chaque `SetLoadConfig`, remettant `m_currentStage` **et**
`m_lastSwitch` à zéro.
**Périmètre réduit à `m_lastSwitch`** (§13-2, lecture (b)) : `m_currentStage` se
redéduit de l'état réel des Things une fois ECS-411 en place, donc il ne demande
aucun code dédié ici. `m_lastSwitch` est l'irréductible — l'horodatage d'un
verrou n'existe nulle part dans le matériel — et c'est la part qui protège le
compresseur.
**Corriger la cause racine, pas le symptôme.** Ne PAS transporter `m_lastSwitch`
à travers la reconstruction : `rebuildLoadAdapters()` DOIT ne reconstruire **que
ce qui a changé**. Une charge dont le câblage et les verrous sont identiques ne
doit pas être détruite parce qu'une AUTRE charge a changé de priorité. Cela règle
`m_lastSwitch`, évite des réécritures de relais inutiles, et vaut pour toutes les
charges — y compris `EtmVariableLoadAdapter`.
**Démarrage à froid.** Après un redémarrage de `nymead`, `m_lastSwitch` est
irrécupérable. Le défaut sûr est alors d'**ARMER** le verrou — traiter la
dernière commutation comme venant d'avoir lieu — et non de le purger. Une boucle
de redémarrage court-circuiterait sinon la protection compresseur exactement
quand elle est la plus nécessaire. **L'implémentation naturelle fait l'inverse
(`QDateTime` nul = verrou inactif) : ce point DOIT être écrit explicitement dans
le code et couvert par un test.**
**Cet armement DOIT être TRANSITOIRE**, d'une durée égale au verrou configuré, et
jamais permanent. Il DOIT être posé **paresseusement**, au premier `now` reçu par
le chemin non-const (`applyAction()`), et non calculé dans la fenêtre de verrou :
un horodatage nul servant de sentinelle « écoulé = 0 » à chaque cycle produit un
**blocage circulaire** — une charge démarrant au palier 0 avec `minOffS > 0` ne
peut jamais s'enclencher, donc jamais commuter, donc jamais valider son
horodatage. Passer `now` au constructeur n'est PAS la réponse : cela changerait le
contrat d'`ILoadAdapter` pour un cas particulier.
**Symétrie exigée.** Les deux cas de démarrage à froid sont armés de la même
façon et expirent de la même façon :
| État initial des relais | Palier déduit (ECS-411) | Verrou armé |
|---|---|---|
| fermés | non nul | `minOn` |
| ouverts | 0 | `minOff` |
Le second cas protège d'un `nymead` qui redémarre juste après une ouverture : le
maintien est justifié, c'est sa **permanence** qui ne l'est pas.
Cette exigence porte son test : **`testEcsColdStartLockExpires`** — palier 0 au
départ, `minOffS > 0`, budget largement suffisant ; la charge reste éteinte
pendant `minOffS`, puis s'enclenche. Le défaut a été constaté **au banc** le
2026-08-09 (charge sonde figée à 0 W sous 4 kW de surplus disponible), alors
qu'aucun test ne combinait « palier 0 au départ » et « `minOffS > 0` ».
Il ne s'agit pas de confort : les verrous sont de la **protection matérielle**.
Avec un `minOn` de 300 à 600 s sur un ballon thermodynamique, un client qui
réordonne ses priorités depuis l'app réarme les verrous et peut faire
court-cycler son compresseur. Un test DOIT couvrir « `SetLoadConfig` pendant une
fenêtre de verrou active ».
> **Recouvrement avec ECS-411 — ordonnancement à arbitrer (§13-2).** Les deux
> champs à préserver n'ont pas le même statut. `m_currentStage` est
> **redéductible** : si ECS-411 est en place, un `RelayRouter` reconstruit
> retrouve son palier depuis les Things sans code dédié. `m_lastSwitch` ne l'est
> **pas** — l'horodatage d'un verrou n'existe nulle part dans le matériel, et
> c'est justement la part qui protège le compresseur. ECS-412 a donc un
> irréductible (`m_lastSwitch`) et une part que l'étape 4 rendrait gratuite
> (`m_currentStage`). Voir §13-2 pour l'arbitrage.
---
## §4 — Combinatoire (`RelayRouter`)
## §4.0 — Installation de référence
**Ce bloc est le SEUL endroit de la spec qui porte des puissances.** Aucune
exigence n'en contient. Une exigence dont le rang, la portée ou l'exercice
dépendent du câblage le dit par renvoi ici. Une correction de câblage ne doit
toucher que ce bloc.
| Relais | Puissance |
|---|---|
| R1 | 500 W |
| R2 | 1000 W |
| R3 | 2000 W |
Additif, pondération binaire. **8 valeurs distinctes**, de 0 à **3500 W**, au pas
de 500 W. **Aucune redondance** : un seul encodage par palier.
| W | Encodage |
|---|---|
| 0 | — |
| 500 | R1 |
| 1000 | R2 |
| 1500 | R1+R2 |
| 2000 | R3 |
| 2500 | R1+R3 |
| 3000 | R2+R3 |
| 3500 | R1+R2+R3 |
**Source : déclaration de l'utilisateur, 2026-08-08. PAS une mesure.** Cette
valeur a changé quatre fois. Une **vérification physique** — plaque signalétique
ou pince ampèremétrique — est **en attente**, et elle est un **préalable à
l'ouverture de l'étape 3**.
**Le simulateur du banc ne reflète pas cette installation.**
`etm-powersync-hems-sim`, `sim/sim_ecs_router.py:38`, est câblé
`{1: 500.0, 2: 1000.0, 3: 1500.0}`. Son alignement fait l'objet d'un lot séparé
sur ce dépôt. Corriger le banc pour qu'il corresponde au matériel réel n'est pas
« ajuster le banc pour faire passer un test » — c'en est l'inverse.
### Ce que cette installation exerce, et ce qu'elle n'exerce pas
| Point | Conséquence |
|---|---|
| Aucune redondance | **ECS-302 n'est pas exercé.** L'exigence reste valable — les puissances sont de la configuration, un autre chantier peut être câblé 1-2-3 — mais elle perd son caractère de préalable structurel et redescend dans l'ordre de l'étape 3. |
| Encodages uniques | La reprise d'état (ECS-411) ne peut pas diverger : toute somme lue correspond à un unique ensemble de relais. La correspondance exacte aboutit toujours. |
| Transition 1500 → 2000 W | `{R1+R2}``{R3}` : **les trois relais basculent d'un coup**, deux ouvertures et une fermeture. Seule transition du balayage dans ce cas. Voir **ECS-303**. |
| Usure par balayage complet (0 → 3500 W) | **R1 commute 7 fois, R2 3 fois, R3 une seule** — 11 commutations au total. Voir **ECS-305**. |
---
**ECS-302 — Encodages équivalents.** Un palier DOIT pouvoir porter **plusieurs**
combinaisons de relais, et la combinaison retenue DOIT être celle dont le coût de
transition depuis l'état courant est le plus faible : d'abord le nombre de relais
à basculer, puis le relais dont le compteur de commutations est le moins entamé.
Le second encodage n'est pas mal arbitré, il est **jeté au constructeur**.
`relayrouter.cpp:58` fait `if (!byPower.contains(sum)) byPower.insert(sum, set)`
— la première combinaison rencontrée gagne, c'est-à-dire le masque le plus bas.
`m_relayMapping` étant un `QList<QList<QString>>`, la structure **ne peut pas**
porter deux combinaisons. ECS-302 est donc un changement de structure, pas
l'ajout d'un critère.
> **Non exercé par l'installation de référence** (§4.0) : sa pondération binaire
> ne produit aucune redondance. L'exigence reste valable — les puissances sont
> des données de configuration — mais elle **n'est plus un préalable structurel**
> et redescend dans l'ordre de l'étape 3. Elle redeviendrait prioritaire sur une
> installation à encodages multiples.
**ECS-303 — Hystérésis élargie aux frontières de recombinaison.** Lorsque le
passage d'un palier au suivant impose de basculer plus de deux relais
simultanément, le seuil d'hystérésis de cette frontière DOIT être élargi par
rapport aux autres.
Justification : une recombinaison complète coûte le maximum de commutations et,
sous l'ordre coupure-avant-fermeture d'ECS-402, fait traverser un creux de
puissance à la charge. Une oscillation du surplus autour d'une telle frontière
est donc bien plus coûteuse qu'ailleurs, en usure comme en confort.
L'installation de référence (§4.0) présente exactement une frontière de ce type.
La largeur du seuil DOIT être établie par le relevé de commutations au banc, pas
choisie a priori.
**ECS-305 — Compteurs de commutations.** Chaque relais DOIT porter un compteur de
commutations, exposé en télémétrie.
Le relais de plus faible puissance est le bit de poids faible de la combinatoire
et s'use nettement plus vite que les autres ; le déséquilibre exact dépend du
câblage (§4.0). Le comptage par relais est **la seule façon de savoir quand le
premier contacteur arrivera en fin de vie** — la puissance commutée ne le dit pas.
\note Ces comptes dépendent aussi de l'encodage choisi : traiter ECS-302 les
modifie. Raison de plus pour instrumenter avant d'optimiser.
**ECS-307 — Grain des temporisations.** `minOn`/`minOff` sont aujourd'hui **par
charge** (un unique `m_lastSwitch`, `relayrouter.h:107`), ce qui satisfait
ECS-400. Le grain par relais qu'appelle ECS-305 n'existe pas. Décider en étape 3
s'il est nécessaire, ou si le compteur de commutations suffit à l'observation.
**ECS-308** — Le plafond `MaxRelays = 16` (`relayrouter.cpp:17`) tronque
silencieusement au-delà, avec un warning. Acceptable ; DOIT rester documenté.
---
## §5 — Robustesse d'exécution
**ECS-410 — Échec d'écriture.** `writeRelay` (`relayrouter.cpp:222-236`) jette le
`ThingActionInfo*` retourné par `executeAction` : aucune attente de `finished`,
aucun retour arrière, aucun arrêt total. `available` est codé en dur à `true`
(`relayrouter.cpp:91`). Un relais introuvable produit un warning, et
`m_currentStage` est mis à jour comme si tout avait réussi — **on annonce une
puissance non appliquée**, ce que ECS-410 interdit explicitement.
Comportement exigé : (1) attendre le résultat de `executeAction` ; (2) sur échec,
tenter le retour à l'état précédent ; (3) à défaut, commander l'arrêt total ;
(4) à défaut, `available = false` et cesser toute commande. Le canal existe déjà,
il est simplement ignoré.
> Sur l'installation de référence (§4.0), les encodages étant uniques, la
> correspondance exacte de l'ensemble de relais aboutit toujours et le modèle
> d'état ne peut pas diverger du physique. Sur un câblage à encodages multiples,
> une correspondance par puissance seule renverrait un palier dont l'encodage
> canonique diffère de l'ensemble réellement fermé, et la transition suivante
> calculerait son coupure-avant-fermeture contre un état faux. La reprise DOIT
> donc restituer l'ensemble de relais, pas seulement l'index de palier.
**ECS-411 — Reprise du palier.** `m_currentStage` vaut 0 à la construction
(`relayrouter.h:106`) et aucun état de relais n'est relu. Le palier courant DOIT
être déduit de l'état réel des Things.
**ECS-414 — Généralisation de l'échelle d'échec. FAIT (2026-08-09).** ECS-410 était
implémenté par le seul `RelayRouter`. `SgReadyAdapter` (`sgreadyadapter.cpp:212`) et
`EtmVariableLoadAdapter` (`etmvariableloadadapter.cpp:134`) jettent toujours le
`ThingActionInfo*` retourné par `executeAction` : ils gardent le défaut d'origine
— annoncer un état non appliqué.
L'échelle DOIT leur être étendue. `clearFault()` et `applySafeState()` sont déjà
déclarées sur ces adaptateurs, sans effet pour la première : le point d'accroche
existe, seul le suivi asynchrone manque.
**Le plancher est propre à chaque adaptateur** (même principe qu'ECS-413) :
consigne 0 W pour `EtmVariableLoadAdapter`, mais **état 2** pour `SgReadyAdapter`.
Ouvrir les deux contacts d'une PAC n'est pas une mise en sécurité, c'est une
commande — et selon l'encodage câblé, potentiellement le **blocage**.
**Le repli honore le même contrat d'atomicité que le chemin aller.** Un état
SG-Ready est porté par deux bits : si une écriture échoue, on se retrouve dans un
motif valide mais non voulu, et le retour depuis ce motif peut exiger de traverser
le blocage. `applyStateRelays()` prend donc un **ensemble de relais réellement
fermés**, et non un index d'état, afin que `transientHarm` ordonne le repli comme
il ordonne l'aller. Sans cela, la récupération serait plus dangereuse que la panne
qu'elle corrige.
**Les écritures de repli contournent `minStateHoldS`** — 900 s sur une PAC :
attendre un quart d'heure pour sortir d'un état non voulu n'est pas défendable.
\note Un contact **injoignable est supposé FERMÉ** pour la télémétrie (cohérence
ECS-411) **mais toujours COMMANDÉ** : ce qu'on ne peut pas vérifier doit être
écrit, sinon l'hypothèse conservatrice masquerait l'échec et la transition
paraîtrait réussie sans qu'aucune écriture n'ait été tentée.
> **Reste au lot de mise en configuration du `SgReadyAdapter`** : les paliers
> `{3: 1500, 4: 3000}` demeurent codés en dur (`energypluginnymea.cpp:66-76`).
> Ce lot porte sa propre précondition — retirer `Q_ASSERT(m_stateRelays.contains(2))`
> (`sgreadyadapter.cpp:36`) au profit d'un refus explicite, cf. ECS-110.
> **Le principe dégagé par ces deux exigences ne vit pas ici.** « Supposer
> conservateur pour annoncer, tenter systématiquement pour agir » vaut pour tout
> mécanisme, pas pour l'ECS : il est inscrit en **LM-104**
> (`specs/spec_loadmodel.md` §1). ECS-411 et ECS-414 en sont deux applications,
> pas la source.
**ECS-413 — Désactivation en état sûr.** Désactiver une charge (`enabled: false`)
ou la retirer de la configuration DOIT laisser son matériel dans l'**état sûr
défini par son adaptateur**, et non dans son dernier état commandé. L'état sûr est
appliqué **avant** la destruction de l'adaptateur ; si l'écriture échoue,
l'échelle d'ECS-410 s'applique.
Constaté au banc le 2026-08-09 : un `SetLoadConfig` posant `enabled: false` sur une
charge alors au palier 3500 W a détruit l'adaptateur en laissant **les trois relais
fermés**. Plus personne ne les commandait ; ils y seraient restés indéfiniment,
juste avant une intervention de câblage.
**L'état sûr est propre à chaque adaptateur** — une exigence formulée « tout
couper » serait FAUSSE :
| Adaptateur | État sûr |
|---|---|
| `RelayRouter` | tous relais **ouverts** |
| `EtmVariableLoadAdapter` | consigne **0 W** |
| `SgReadyAdapter` | **état 2** (normal, mains off) — **jamais** l'état 1 (blocage), cf. `docs/SAFETY.md` |
Couper une PAC en la bloquant serait une régression de sécurité, pas une mise en
sécurité.
**Mécanisme.** L'application passe par le **chemin d'action normal avec
`force = true`**, comme le mode dégradé L2 (`energyarbitrator.cpp:339-347`) :
c'est le mécanisme qui existe déjà pour contourner les verrous dans une situation
où l'arrêt prime. Rien de nouveau à inventer.
> **Cette exigence NE S'APPLIQUE PAS à l'arrêt du plugin ni à un redémarrage de
> `nymead`.** Là, l'état DOIT être conservé : c'est précisément ce qu'ECS-411 relit
> au démarrage, et couper l'eau chaude à chaque redémarrage de service serait une
> régression. La distinction est **intentionnelle** — la désactivation est un acte
> délibéré de l'opérateur, un redémarrage n'en est pas un.
**Test : `testEcsDisableLeavesSafeState`.** Charge à un palier non nul,
`SetLoadConfig` avec `enabled: false` ; vérifier que les relais sont **ouverts** et
que l'adaptateur n'est détruit qu'**ensuite**. Second cas obligatoire : un simple
`rebuild` **sans** désactivation NE DOIT PAS déclencher la mise en sécurité — sans
quoi ECS-412 serait annulé, chaque changement de rang coupant la charge.
---
## §6 — Configuration
**ECS-110 — Validation.** `LoadConfig::isValid()` (`loadconfig.cpp:80-127`)
refuse déjà `powerW ≤ 0`, `relays[]` vide, `thingId` vide, `minOnS`/`minOffS`
négatifs. DOIT en outre refuser : **deux étages sur le même Thing**, et un Thing
**absent ou n'exposant pas l'interface attendue**.
Note de conception : `isValid()` ne connaît pas le `ThingManager`, donc la
seconde vérification ne peut pas y vivre. Elle appartient à la construction de
l'adaptateur, pas au type de configuration.
**Validation effective en build release.** Aucune validation de configuration NE
DOIT reposer sur `Q_ASSERT`. Le paquet est construit par `dh --buildsystem=qmake6`
sans `CONFIG += debug` : qmake compile en release, `QT_NO_DEBUG` est défini, et
`Q_ASSERT` disparaît du binaire livré. Une vérification qui n'existe que chez le
développeur n'est pas une vérification — elle donne au lecteur du code
l'impression d'un filet qui n'est pas là chez le client.
Quatre `Q_ASSERT` d'invariant de configuration existent aujourd'hui dans `etm/` :
| Emplacement | Invariant gardé | Peut-il échouer aujourd'hui ? |
|---|---|---|
| `relayrouter.cpp:66` | `m_levels` non vide, `[0] == 0` | **Non** — le masque vide garantit la clé 0 par construction |
| `etmvariableloadadapter.cpp:34` | `powerLevels[0] == 0` | **Non** — déjà refusé par `isValid()` (`loadconfig.cpp:114-115`) |
| ~~`sgreadyadapter.cpp:35`~~ | `states` non vide | **SUPPRIMÉ (2026-08-09)** — remplacé par le refus `isValid()` |
| ~~`sgreadyadapter.cpp:36`~~ | état 2 présent = **repli sûr obligatoire** | **SUPPRIMÉ (2026-08-09)** — voir ci-dessous |
Aucun ne masque donc de défaut à ce jour : ECS-110 est bien tenu par
`LoadConfig::isValid()`, pas par ces assertions. **Le risque était prospectif et daté ; l'échéance est
tombée le 2026-08-09**, quand `SgReadyAdapter` est passé à la configuration. Les
deux assertions ont donc été retirées **avant** ce basculement, dans cet ordre,
et remplacées par deux gardes réels :
- `LoadConfig::isValid()` **refuse** toute configuration `sg-ready` dépourvue de
l'état 2, avec un message explicite. Une PAC inapte au repli sûr du mode
dégradé L2 n'est pas construite ;
- `SgReadyAdapter` conserve un drapeau `m_usable` : si on le construit malgré
tout hors du chemin de configuration, il **journalise en critique et refuse
toute commande** au lieu de disparaître sous `QT_NO_DEBUG`.
Exigence : toute vérification portant sur des données venant de la configuration
DOIT vivre dans `isValid()` ou dans la construction de l'adaptateur, avec un
chemin d'erreur explicite (refus + message). Les `Q_ASSERT` restants ne sont
admis que sur des invariants **structurels**, impossibles à violer depuis la
configuration — les deux premières lignes du tableau.
**ECS-110-b — Un Thing n'appartient qu'à UNE charge active.** Ajouté le 2026-08-09,
après vérification demandée : `LoadConfig::isValid()` s'arrête au bord d'une charge et
ne voyait donc **rien** du cas général. Le trou était réel et complet — deux charges de
configuration pouvaient revendiquer le même Thing (un relais déclaré à la fois dans la
charge ECS et dans un état SG-Ready), avec exactement les conséquences que la garde de
l'arbitre évite pour la PAC codée en dur : deux commandes contradictoires sur un organe,
et sa puissance comptée deux fois dans le budget (règle absolue 1). Rien ne l'empêchait,
et rien ne l'aurait signalé. Deux doublons **intra**-charge manquaient aussi : deux
étages de `relay-router` sur le même Thing (le routeur les fusionnait en silence, en
annonçant le double de la puissance réelle) et un relais répété dans un même état
SG-Ready.
La vérification d'ensemble ne peut pas vivre dans `isValid()` : elle est portée par
`LoadConfigStore::validateSet()`, appelée par `setConfigs()` (rejet total, rien persisté)
et au chargement (l'entrée en conflit est écartée, le reste du fichier survit — écarter
une charge ne commande rien, la charger commanderait faux).
Deux points de portée, tranchés :
- **Seules les charges `enabled` sont confrontées.** Une charge désactivée ne construit
aucun adaptateur ; l'interdire empêcherait de préparer une configuration de
remplacement. Activer repasse par `setConfigs()`, donc par la vérification ;
- **un relais présent dans plusieurs états SG-Ready reste légitime** — l'état 4 est
précisément l'union des états 1 et 3. Seule la répétition dans un **même** état est
refusée.
Les identifiants sont normalisés via `QUuid` : la configuration mélange `{uuid}` et
`uuid`, une comparaison textuelle aurait laissé passer le conflit. Les identifiants de
charge en double sont refusés par la même passe — la seconde entrée était jusqu'ici
perdue en silence à la construction.
**ECS-111 — Suppression d'un Thing référencé.** Comportement non défini
aujourd'hui : `findConfiguredThing` renvoie `nullptr`, `writeRelay` logue et
continue, `telemetry()` saute le relais donc `metered = false` et l'on retombe
sur le nominal commandé — **on publie la puissance d'un relais disparu**. À
définir et tester.
---
## §7 — Mesure par charge
`telemetry()` (`relayrouter.cpp:104-114`) lit déjà `currentPower` sur les Things
relais : c'est un demi-pas vers ECS-500.
**ECS-500** — Calibrage par mesure (enclencher chaque étage seul, mesurer,
écrire la puissance réelle). Justification : P = U²/R, un étage annoncé à 1000 W
sous 230 V délivre environ 1090 W sous 240 V.
**ECS-501** — Détecter un étage commandé dont la puissance mesurée est quasi
nulle, en distinguant le thermostat mécanique ouvert (tous les étages chutent
ensemble) d'un étage en défaut (un seul chute).
**ECS-502** — Un étage en défaut DOIT être retiré des paliers disponibles.
**ECS-503** — Sans mesure, le comportement actuel est conservé tel quel.
**ECS-504** — La mesure par charge NE DOIT PAS être réinjectée dans le calcul du
surplus (`AGENTS.md` règle 8, pas de boucle de feedback). Elle sert au diagnostic
et au recrédit anti-clignotement déjà en place.
> **Piège de mise en service, indépendant de toute configuration.** Le compteur
> d'une charge NE DOIT JAMAIS être désigné comme **rootmeter** dans nymea. Aucun
> champ de `LoadConfig` n'est en cause : il suffit d'une désignation dans
> l'interface. L'arbitre lit le rootmeter par `internalRootMeter()`
> (`energyarbitrator.cpp:229`) et en tire `meter.importW`/`exportW` — la
> consommation de la charge entrerait donc **dans le bilan de surplus lui-même**,
> et le budget serait faussé pour toutes les charges. Le compteur d'une charge est
> un instrument de diagnostic ; le rootmeter mesure le point de livraison.
---
## §8 — Thermique
Aucune température ni `energyRemaining` dans le dépôt. **Et il manque le canal
d'entrée** : `available` vit dans `LoadTelemetry` (`iloadadapter.h:15`) mais
l'arbitre ne le recopie pas dans `LoadContext`. Même avec une sonde posant
`available = false`, le scheduler ne le verrait pas. C'est un préalable
structurel, indépendant de tout le thermique (étape 6).
**ECS-600** — La sonde est optionnelle ; sans elle, le comportement actuel est
conservé intégralement.
**ECS-601** — Avec sonde, `available` passe à faux quand la consigne est
atteinte, afin que le waterfall libère le budget. C'est le **seul** point
d'entrée du thermique dans l'arbitrage : la charge se retire, elle ne négocie pas.
**ECS-602**`energyRemaining` publié en kWh (`m · c · ΔT`).
**ECS-603** — Cycle anti-légionelle périodique, indépendant du surplus. Il
consomme du réseau : il DOIT passer par une `LoadAction` explicite avec son
`decisionReason`, jamais par un contournement du budget.
**ECS-604** — La sonde n'est jamais la seule limite haute. Le thermostat
mécanique reste **L0**. Perte de sonde → mode sans sonde, pas arrêt d'urgence.
**ECS-605** — Hystérésis paramétrable par charge, jamais codée en dur.
---
## §9 — Traçabilité (exigences ouvertes)
| Exigence | Type | Test |
|---|---|---|
| ECS-306 | simulation | `testEcsBudgetUnderLock` |
| ECS-412 | simulation | `testEcsRebuildPreservesLock` + **`testEcsColdStartLockExpires`** (armement à froid transitoire) |
| ECS-303 | banc | relevé de commutations à la frontière de recombinaison (§4.0) — **mesurer avant de coder** |
| ECS-305 | unitaire | `testEcsSwitchCount` — compteur par relais |
| ECS-302 | unitaire | `testEcsSwitchCost` — non exercé par l'installation de référence ; à tester sur un câblage à encodages multiples |
| ECS-304 | unitaire | `testEcsLevelsPure` |
| ECS-410 | simulation | `testEcsPartialFailure` — cible, retour arrière, arrêt total, défaut collant |
| ECS-411 | simulation | `testEcsRestartRecovery` |
| ECS-413 | simulation | `testEcsDisableLeavesSafeState` — + cas négatif : un rebuild sans désactivation ne coupe pas |
| ECS-414 | simulation | `testSgReadyPartialFailure` — plancher = état 2, atomicité du repli, contact injoignable |
| LM-302 (frontière RPC) | simulation | `testLoadConfigRpc` §3bis — va-et-vient Get → Set réinjecté tel quel (vérifié : le test échoue sans le correctif) |
| ECS-110-b | simulation | `testThingOwnershipIsExclusive` — conflit entre charges (3 mécanismes), formes d'uuid, id dupliqué, doublons intra-charge, tolérance d'une charge désactivée |
| ECS-110 (SG-Ready) / LM-300 | simulation | `testSgReadyFromConfig` — refus sans état 2, refus de charges utiles mélangées, round-trip, pilotage, désactivation → état 2 |
| ECS-110, ECS-111 | unitaire | `testEcsConfigValidation` — DOIT s'exécuter aussi en build release (`QT_NO_DEBUG`), sinon il ne prouve rien du binaire livré |
| ECS-501, ECS-502 | simulation | `testEcsStageFault` |
| ECS-601, ECS-602 | simulation | `testEcsTemperatureTarget` |
| ECS-603 | simulation | `testEcsLegionella` |
| ECS-600, ECS-604 | simulation | `testEcsNoSensorFallback` |
Acquis à ne pas refaire : `testEcsRelayTopologies`, `testLoadConfigRelayRouter`,
`testLoadConfigBuildsAdapters`, `testLoadConfigRpc`, `testEcsSurplusPV`,
`testMeterSilentFallback`.
> Des binaires de test datant du 8 juin — antérieurs au `RelayRouter` — traînent
> dans l'arbre de travail. **Rebuild systématique**, jamais de conclusion tirée
> d'un binaire trouvé sur place.
>
> Ils ne sont **pas versionnés** : `git ls-files tests/auto/simulation/nymea-energy-simulation`
> renvoie vide. Ce sont des reliquats de build que `.gitignore` ne couvre pas,
> d'où leur apparition permanente en `??`. Même cas : `qrc_*.cpp`, `moc_*`,
> `target_wrapper.sh`, et les `plugininfo.h` / `extern-plugininfo.h` générés du
> mock. Le remède est donc de **les ignorer ou les supprimer**, pas de « ne pas
> s'y fier ». Hygiène, hors périmètre ECS.
**Définition de fait** : celle d'`AGENTS.md`.
**Critère d'acceptation banc** : profil de surplus réel sur 24 h avec relevé du
nombre de commutations **par relais** (ECS-305 le rend enfin mesurable).
---
## §10 — Hors périmètre
Allocation du surplus, prévision et plan journalier (Héos), contacteur heures
creuses, transport matériel (Waveshare D8, session dédiée), PAC hors SG-Ready.
Signalé au passage, hors ECS : les `ThingId` K1/K2 et les paliers
`{3:1500, 4:3000}` de la PAC du banc sont codés en dur dans
`energypluginnymea.cpp:68-74`. À traiter avec la couche config, pas ici.
---
## §11 — Dette contractuelle à ne pas aggraver
`OPTIMIZER_PROTOCOL.md` ne reflète déjà pas `LoadAction.force`, les fenêtres de
verrou ni `degradedMode`. ECS-306 et l'étape 6 modifient le contexte. **Toute
exigence touchant au contexte ou aux actions DOIT embarquer sa mise à jour du
protocole dans le même lot.**
---
## §12 — Journal des décisions
| Date | Décision |
|---|---|
| 2026-08-06 | ECS multi-palier attaqué en premier ; ECS-simple = son cas à 1 palier |
| 2026-08-07 | Étages déclarés par référence de Thing + puissance par sortie |
| 2026-08-07 | ECS relais et ECS via PAC séparés ; les PAC reportées |
| 2026-08-08 | ECS-001 négatif → pas d'integration-plugin |
| 2026-08-08 | ECS-203 clos : unité interne = watt |
| 2026-08-08 | Audit : `RelayRouter` (et non `EcsRelayAdapter`), code hors production → **étendre** |
| 2026-08-08 | ECS-306 et ECS-412 créés et placés en étape 1 |
| 2026-08-08 | `ioConnections()` écarté formellement |
| 2026-08-08 | Vérification 0.4.1 : règle 2 non violée — c'est le schéma d'`AGENTS.md` qui est périmé |
| 2026-08-08 | ECS-306 rattaché à la règle 4 (écrêtage non remonté), pas à la règle 1 |
| 2026-08-08 | Recouvrement ECS-411 ↔ ECS-412 relevé → §13-2, bloque l'ouverture de l'étape 1 |
| 2026-08-08 | Étape 2 : « type domaine » → « noyau de calcul » (collision avec `spec_loadmodel.md` LM-100) |
| 2026-08-08 | ECS-110 : validation interdite de reposer sur `Q_ASSERT` (absent en release) |
| 2026-08-08 | **§13-1 CLOS** — schéma « ARCHITECTURE CIBLE » d'`AGENTS.md` redessiné (`f6a4be5`) |
| 2026-08-08 | **§13-2 CLOS, lecture (b)** — ECS-412 réduit à `m_lastSwitch` + cause racine (rebuild incrémental) ; ECS-411 remonté en étape 1 |
| 2026-08-08 | **Étape 1 OUVERTE** — trois exigences : ECS-306, ECS-411, ECS-412 |
| 2026-08-08 | ECS-412 : au démarrage à froid, le verrou est **ARMÉ** (défaut sûr), jamais purgé |
| 2026-08-09 | ECS-412 précisé : cet armement est **TRANSITOIRE** et posé paresseusement au premier `now`. Défaut de blocage circulaire constaté **au banc**, corrigé, couvert par `testEcsColdStartLockExpires` |
| 2026-08-10 | **Va-et-vient RPC cassé par LM-300**`GetLoadConfig` sérialise via le méta-objet, donc un `sgReady` VIDE sur toute charge non SG-Ready ; le schéma SET exigeait `states` dès la clé présente, et nymea rejetait avant le handler. Constaté au banc à la première tentative réelle de `SetLoadConfig` : aucun test ne rejouait le geste de l'app (lire, modifier, réécrire). Corrigé en `o:states` — c'est le mur documenté en LM-302, la validation réelle restant dans `isValid()` |
| 2026-08-09 | **ECS-110-b créé et FAIT** — un Thing n'appartient qu'à une charge active. Trou trouvé en répondant à la question « le cas général est-il couvert ? » : il ne l'était pas du tout. `LoadConfigStore::validateSet()` ; doublons intra-charge également refusés |
| 2026-08-09 | **SG-Ready configurable (LM-300)** — union discriminée par mécanisme ; les deux `Q_ASSERT` de `sgreadyadapter.cpp` retirés au profit d'un refus `isValid()` + drapeau `m_usable` (ECS-110 tenu hors `Q_ASSERT`) ; principe général porté en LM-104, hors ECS |
| 2026-08-09 | **ECS-414 FAIT** — échelle généralisée à SgReady et EtmVariableLoad ; plancher = état 2 pour la PAC ; repli soumis au même `transientHarm` que l'aller ; contact injoignable supposé fermé mais toujours commandé |
| 2026-08-09 | **ECS-414 créé** — généralisation d'ECS-410 à SgReady et EtmVariableLoad, rattachée au lot SG-Ready en configuration |
| 2026-08-09 | **ECS-413 créé**`enabled: false` laissait les relais fermés (constat de banc, juste avant une intervention de câblage). État sûr propre à chaque adaptateur ; ne s'applique PAS à un redémarrage de `nymead`, qu'ECS-411 couvre |
| 2026-08-08 | ~~Câblage 500/1000/2000~~**donnée fausse**, corrigée le jour même (cf. ligne suivante) |
| 2026-08-08 | ~~Câblage 500/1000/1500 (source simulateur)~~**donnée fausse** : le simulateur ne reflète pas l'installation |
| 2026-08-08 | ~~ECS-302 exercé~~ — découlait de la donnée fausse |
| 2026-08-08 | ~~ECS-303 supprimé~~ — découlait de la donnée fausse |
| 2026-08-08 | **Câblage 500/1000/2000** — 8 valeurs, plafond 3500 W, aucune redondance. Source : déclaration utilisateur, **non mesurée**. Quatrième révision de cette donnée ; vérification physique en attente, préalable à l'étape 3 |
| 2026-08-08 | **Découplage** : les puissances ne vivent plus que dans §4.0. Aucune exigence n'en contient |
| 2026-08-08 | **ECS-302 non exercé** par l'installation de référence — n'est plus préalable structurel ; ECS-305 passe devant dans l'étape 3 |
| 2026-08-08 | **ECS-303 rétabli** et reformulé sans puissances : hystérésis élargie aux frontières imposant une recombinaison de plus de deux relais |
| 2026-08-08 | Alignement du simulateur du banc sur 500/1000/2000 — lot séparé sur `etm-powersync-hems-sim` |
| 2026-08-08 | **Configuration de `.75` réglée pour ÉMULER l'installation de référence** — 3 relais GPIO déclarés 500/1000/2000, via `SetLoadConfig`. `.75` est un banc qui émule §4.0, il ne l'incarne pas : la vérification physique en attente porte sur l'**installation cliente**, pas sur les broches de la box de test |
| 2026-08-08 | Câblage du banc **déjà documenté** dans `docs/TEST_TERRAIN.md:24-26` (R500/R1000/R2000, broches BCM 5/6/13) — la configuration à 600/1200 était le reliquat, contredit par la documentation du banc lui-même |
---
## §13 — Points ouverts
1. **ECS-307** — grain des temporisations par relais : nécessaire, ou le compteur
de commutations suffit-il ? Le câblage binaire rend la question plus concrète
le câblage de référence déséquilibre fortement l'usure (§4.0), ce qui rend
la question plus concrète sans la trancher.
2. **ECS-605** — asymétrie de l'hystérésis montée/descente, à régler à l'usage.
La frontière de recombinaison relève désormais d'**ECS-303**.
3. **Câblage à confirmer physiquement** — la valeur de §4.0 provient d'une
déclaration, pas d'une mesure, et a changé quatre fois. Préalable à
l'ouverture de l'étape 3.
4. **Conflit d'écriture**`connectIO()` est le mécanisme natif si l'on veut un
jour exposer un relais ECS au pilotage manuel de l'utilisateur. Ce serait
alors un conflit d'écriture à arbitrer, pas une aide.
**CLOS le 2026-08-08 :**
- **§13-1 gouvernance** — le schéma « ARCHITECTURE CIBLE » d'`AGENTS.md` a été
redessiné (lot de consolidation douze zones, commit `f6a4be5`) : `RelayRouter`
et `EtmVariableLoadAdapter` y figurent, le kind `Stage` en est retiré,
`SocketScheduler` et `BatteryAdapter` sont marqués non écrits.
- **§13-2 ordonnancement** — **lecture (b) retenue**. ECS-412 est réduit à
`m_lastSwitch` et redirigé vers sa cause racine (rebuild incrémental) ;
ECS-411 remonte en étape 1. L'étape 1 porte donc trois exigences.
`testEcsRebuildPreservesLock` couvre obligatoirement « `SetLoadConfig` pendant
une fenêtre de verrou active ».
**CLOS par l'audit** : le waterfall ne choisit pas entre encodages équivalents
(il n'en voit qu'un) ; `ioConnections()`/`connectIO()` est un lien 1↔1
état-à-état avec pour seule transformation un booléen `inverted` — il ne peut
exprimer ni le N→1, ni l'arithmétique, ni l'ordre de commutation, ni les
temporisations.