Patrick Schurig 4a55d440df docs(spec): LM-104 — supposer conservateur pour annoncer, tenter pour agir
Le principe dégagé par les tests d'ECS-411 puis d'ECS-414 ne vit pas sous ECS-414 :
il vaut pour tout mécanisme, présent et futur. Il est donc inscrit en LM-104
(spec_loadmodel.md §1), ECS-411 et ECS-414 n'en étant que deux applications.

Les deux moitiés sont indissociables, et c'est l'omission de la seconde qui piège :
supposer « contact fermé » puis en déduire « donc rien à écrire » transforme une
hypothèse de prudence en masquage de panne. La prudence porte sur ce qu'on DIT de
l'installation, jamais sur ce qu'on lui ENVOIE.

Consigne aussi : le tableau ECS-110 (les deux Q_ASSERT de sgreadyadapter sont
supprimés, l'échéance prospective est tombée), la charge utile sg-ready réellement
implémentée en LM-300, et le statut de spec_loadmodel.md — §3 n'est plus une intention.

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

43 KiB

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-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-602energyRemaining 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
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-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/2000donné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'écritureconnectIO() 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 ordonnancementlecture (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.