# SPEC — ECS multi-palier Version : 0.5.0 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. > Câblage du banc confirmé **500/1000/2000** : ECS-302 n'est plus exercé par ce > matériel, ECS-305 monte, ECS-303 créé pour le point dur 1500→2000 W. --- ## §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. > **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`. Doit précéder tout ce qui s'appuiera dessus. **É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.** 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`) > **Câblage du banc CONFIRMÉ le 2026-08-08 : 500 / 1000 / 2000 W.** Pondération > binaire — huit valeurs distinctes de 0 à 3500 W au pas de 500 W, **aucune > redondance**, un seul encodage par palier. > > | Palier | Relais ON | W | > |---|---|---| > | 0 | — | 0 | > | 1 | 500 | 500 | > | 2 | 1000 | 1000 | > | 3 | 500+1000 | 1500 | > | 4 | 2000 | 2000 | > | 5 | 500+2000 | 2500 | > | 6 | 1000+2000 | 3000 | > | 7 | 500+1000+2000 | 3500 | > > Ceci clôt le point ouvert « 500/1000/1500 ou 500/1000/2000 » et déplace trois > exigences — voir ECS-302, ECS-305 et ECS-303 ci-dessous. **ECS-302 — Encodages équivalents. NON EXERCÉ PAR CE MATÉRIEL.** Avec une pondération binaire il n'y a **rien à arbitrer** : un seul encodage par palier. L'exigence reste valable pour un câblage 1-2-3 ailleurs (deux encodages pour 1500 W), mais **le changement de structure de `m_relayMapping` perd sa justification immédiate** : il redescend dans l'ordre de l'étape 3 et **cesse d'être un préalable structurel**. Ne plus le traiter comme tel. **ECS-303 — Point dur 1500 → 2000 W.** Cette transition bascule **les trois relais d'un coup** (R500 et R1000 s'ouvrent, R2000 se ferme). Avec l'ordre coupure-avant-fermeture d'ECS-402, elle produit un **trou de puissance à chaque franchissement**, en plein milieu de la plage de surplus utile. La parade n'est PAS dans la combinatoire — aucun réordonnancement ne supprime le trou, puisque aucun sous-ensemble intermédiaire ne vaut ni 1500 ni 2000 W. Elle est dans un **seuil d'hystérésis élargi à cette frontière précise**. Entrée commune avec ECS-605 (hystérésis paramétrable par charge). **À MESURER au banc avant de coder quoi que ce soit** : c'est le relevé de commutations qui dira quelle largeur donner au seuil. **ECS-302 (suite) — 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>`, 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. IMPORTANCE RELEVÉE** par le câblage binaire confirmé. Chaque relais DOIT porter un compteur de commutations, exposé en télémétrie. Sur un balayage complet des huit paliers, **R500 commute sept fois, R1000 trois fois, R2000 une seule** : le bit de poids faible s'use sept fois plus vite que le bit de poids fort. Le comptage par relais devient **la seule façon de savoir quand le contacteur 500 W arrivera en fin de vie** — la puissance commutée, elle, ne le dit pas. **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-303 | banc, puis simulation | relevé de commutations à la frontière 1500/2000 — **mesurer avant de coder** | | ECS-305 | unitaire | `testEcsSwitchCount` — compteur par relais | | ECS-302 | unitaire | `testEcsSwitchCost` — **non exerçable sur le câblage 500/1000/2000** (encodage unique) | | 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) | | 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-08 | Câblage banc confirmé **500/1000/2000** — ECS-302 non exercé, ECS-305 relevé, **ECS-303 créé** (point dur 1500→2000) | --- ## §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 (R500 commute 7× plus que R2000), sans la trancher. 2. **ECS-303 / ECS-605** — largeur de l'hystérésis à la frontière 1500/2000 W, et asymétrie montée/descente. **À mesurer au banc avant de coder** : c'est le relevé de commutations qui donne le chiffre. 3. **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 ». - **Câblage du banc** — confirmé **500/1000/2000 W** (cf. §4). ECS-302 n'est pas exercé par ce matériel ; ECS-305 monte en importance ; ECS-303 créé. **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.