Patrick Schurig 9624b6ec87 docs(specs): spec_ecs 0.4.3 + spec_loadmodel 0.1.1
Deux documents normatifs, préalables au lot AGENTS.md qui les référence.

spec_ecs.md 0.4.3 — spécification ECS multi-palier, écrite après audit du code :
  - 0.4.1 : statut réel des binaires de test, portée du point de gouvernance
    AGENTS.md, citation de règle dans ECS-306, recouvrement ECS-411/ECS-412 ;
  - 0.4.2 : étape 2 « type domaine » → « noyau de calcul » (collision avec LM-100) ;
  - 0.4.3 : ECS-110 étendu — la validation ne doit pas reposer sur Q_ASSERT,
    absent du binaire release (QT_NO_DEBUG).

spec_loadmodel.md 0.1.1 — modèle de charges (domaine × mécanisme), intention de
conception. Ne déclenche aucun travail.

Aucun code touché.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 10:50:50 +02:00

483 lines
25 KiB
Markdown

# SPEC — ECS multi-palier
Version : 0.4.3
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 — 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é 1500→2000 |
| ECS-302 | Encodages équivalents | ❌ le second encodage est **jeté**, pas arbitré → structurel |
| 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.
> Périmètre d'ECS-412 en étape 1 : **suspendu à l'arbitrage §13-2**, qui peut le
> réduire à `m_lastSwitch` et remonter ECS-411 ici. Ne pas ouvrir l'étape 1 sans
> l'avoir tranché.
**É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`.
Doit précéder tout ce qui s'appuiera dessus.
**Étape 4 — ECS-411 puis ECS-410**, même chemin `applyRelayStage` / `available`,
à faire ensemble. ECS-411 peut remonter en étape 1 selon l'arbitrage §13-2.
**É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 au `rebuild`.** `rebuildLoadAdapters()`
(`energyarbitrator.cpp:137-144`) détruit et reconstruit les adaptateurs à chaque
`SetLoadConfig`, remettant `m_currentStage` **et** `m_lastSwitch` à zéro. Le
palier courant et les horodatages de verrou DOIVENT survivre à un `rebuild`
lorsque la charge concernée n'a pas changé de câblage.
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`)
**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é.
Requalification issue de l'audit : 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. Sur un câblage
500/1000/1500, le palier 1500 W retient `{R500, R1000}` et `{R1500}` disparaît.
`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.
**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'usera le premier ; c'est la grandeur
qui décide de la durée de vie des contacteurs.
**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é.
**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.
---
## §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 | **Non** — construction codée en dur |
| `sgreadyadapter.cpp:36` | état 2 présent = **repli sûr obligatoire** | **Non** aujourd'hui — construction codée en dur (`energypluginnymea.cpp:66-76`) |
Aucun ne masque donc de défaut à ce jour : ECS-110 est bien tenu par
`LoadConfig::isValid()`, pas par ces assertions. **Le risque est prospectif et il
est daté** : le jour où `SgReadyAdapter` bascule sur la configuration — travail
déjà inscrit au `DÉFÉRÉ` d'`AGENTS.md``sgreadyadapter.cpp:36` devient le seul
garde-fou sur la présence de l'état 2, l'état de repli sûr du mode dégradé L2,
et ce garde-fou sera absent du binaire livré.
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-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.
---
## §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` |
| ECS-302, ECS-305 | unitaire | `testEcsSwitchCost` |
| ECS-304 | unitaire | `testEcsLevelsPure` |
| ECS-410 | simulation | `testEcsPartialFailure` |
| ECS-411 | simulation | `testEcsRestartRecovery` |
| 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) |
---
## §13 — Points ouverts
1. **Gouvernance** — redessiner le schéma « ARCHITECTURE CIBLE » d'`AGENTS.md`
(`EcsRelayAdapter` et le kind `Stage` y figurent encore ; cf. §0). Le document
normatif décrit des classes supprimées tant que ce n'est pas fait.
2. **Ordonnancement ECS-411 ↔ ECS-412** — bloque l'ouverture de l'étape 1. Deux
lectures :
- **(a) garder l'ordre du §2** : ECS-412 traite `m_currentStage` et
`m_lastSwitch` dès l'étape 1 ; ECS-411 en réécrira la moitié en étape 4.
Fidèle au principe « les défauts de comportement passent devant », au prix
d'un aller-retour.
- **(b) réduire ECS-412 à `m_lastSwitch`** et remonter ECS-411 en étape 1 avec
lui : ils partagent le chemin de reconstruction, et `m_currentStage` devient
gratuit. Évite l'aller-retour, mais fait entrer en étape 1 une exigence que
le §2 justifiait autrement.
Dans les deux cas, `testEcsRebuildPreservesLock` DOIT couvrir « `SetLoadConfig`
pendant une fenêtre de verrou active » : c'est l'irréductible.
3. **ECS-307** — grain des temporisations par relais : nécessaire, ou le compteur
de commutations suffit-il ?
4. **ECS-605** — asymétrie de l'hystérésis, à régler à l'usage.
5. Câblage réel du banc : 500/1000/1500 ou 500/1000/2000. Ne bloque pas le code,
mais le premier est le seul qui exerce ECS-302.
6. **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 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.