La 0.5.0 consignait 500/1000/2000 sur la foi de la consigne d'étape 1. Le
simulateur du banc contredit cette donnée ; arbitrage : le simulateur reflète
l'installation, la donnée était fausse.
Câblage : 500/1000/1500 W, additif, 7 valeurs distinctes de 0 à 3000 W, avec UNE
redondance — 1500 W admet {R1500} et {R500+R1000}.
Trois corrections :
1. ECS-302 est EXERCÉ. Il redevient préalable structurel de l'étape 3 et reprend
son rang devant tout ce qui s'appuiera sur m_relayMapping.
2. ECS-303 SUPPRIMÉ. Le « point dur » 1500→2000 n'est pas une exigence
d'hystérésis, c'est une conséquence directe d'ECS-302 non traité :
relayrouter.cpp:58 retient la première combinaison rencontrée (masque le plus
bas), donc {R500+R1000} pour 1500 W, et jette {R1500}. La transition vers
2000 W = {R500+R1500} demande alors d'ouvrir R1000 et de fermer R1500, avec le
creux qu'impose l'ordre coupure-avant-fermeture. Avec {R1500} pour 1500 W, la
même transition ne coûterait QU'UNE commutation : fermer R500. Aucune entrée
ECS-303 ni ECS-605 de ce côté.
3. 7 valeurs et plafond 3000 W, non 8 et 3500. ECS-305 reste valable, comptes
corrigés : par balayage complet R500 commute 5 fois, R1000 3 fois, R1500 une
seule (vérifié sur l'encodage effectivement retenu par le code actuel).
RÉSERVE INSCRITE : la source est le simulateur (sim/sim_ecs_router.py:38, sommé
dans _power_w(), confirmé en vol sur MQTT), PAS une mesure physique. Ce chiffre a
déjà changé trois fois et il commande désormais l'ordre de l'étape 3 : il doit
être confirmé sur plaque signalétique ou au pince ampèremétrique AVANT
l'ouverture de cette étape. Consigné en §13-3 et en garde-fou sous l'étape 3.
Aucun code touché.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
31 KiB
SPEC — ECS multi-palier
Version : 0.5.1
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 en5100674) 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.mdLM-100, où il désigne la couche métierEcs/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 — 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.mdqui 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
RelayRouterne 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
EcsRelayAdapteret le kindStage, tous deux supprimés en5100674.Décision attendue : redessiner ce schéma dans
AGENTS.md(RelayRouter, kindSetpoint, 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_currentStagese 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.mdLM-100 emploie ce mot dans son sens courant — la couche métierEcs/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. Préalable structurel —
doit précéder tout ce qui s'appuiera dessus. Exercé par le câblage réel
(1500 W a deux encodages, §4).
Ne pas ouvrir l'étape 3 avant confirmation physique du câblage (§13-3) : c'est lui qui justifie l'ordre de cette étape, et il provient aujourd'hui du simulateur, pas d'une mesure.
É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_currentStageest redéductible : si ECS-411 est en place, unRelayRouterreconstruit retrouve son palier depuis les Things sans code dédié.m_lastSwitchne 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 CONFIRMÉ le 2026-08-08 : 500 / 1000 / 1500 W. Additif (les relais se somment), 7 valeurs distinctes de 0 à 3000 W, avec une redondance : 1500 W a DEUX encodages.
W Encodage(s) 0 — 500 R500 1000 R1000 1500 R1500 ou R500+R1000 ← deux encodages 2000 R500+R1500 2500 R1000+R1500 3000 R500+R1000+R1500 Source : le simulateur du banc (
etm-powersync-hems-sim,sim/sim_ecs_router.py:38—PALIERS = {1: 500.0, 2: 1000.0, 3: 1500.0}, sommés dans_power_w()), confirmé en vol sur MQTT (relais 2+3 fermés → 2500 W publiés, ce qu'une cascade ne produirait pas). Ce n'est PAS une mesure physique. Cette valeur a déjà changé trois fois et elle commande désormais l'ordre de l'étape 3 : elle DOIT être confirmée sur plaque signalétique ou au pince ampèremétrique avant l'ouverture de l'étape 3.
ECS-302 — Encodages équivalents. EXERCÉ PAR CE MATÉRIEL. 1500 W admet
{R1500} et {R500+R1000} : il y a bien un arbitrage à rendre. L'exigence
redevient un préalable structurel de l'étape 3 et reprend son rang devant
tout ce qui s'appuiera sur m_relayMapping.
Ce que fait le code aujourd'hui, et ce que ça coûte : le constructeur retient la
première combinaison rencontrée, c'est-à-dire le masque le plus bas
(relayrouter.cpp:58, if (!byPower.contains(sum))). Pour 1500 W il retient
donc {R500+R1000} et jette {R1500}. Conséquence directe sur la transition
1500 → 2000 W ({R500+R1500}) : il faut ouvrir R1000, garder R500, fermer
R1500 — et comme l'encodage retenu impose de passer par l'ordre
coupure-avant-fermeture d'ECS-402, la charge traverse un creux. Avec l'encodage
{R1500} pour 1500 W, la même transition ne demanderait qu'une seule
commutation : fermer R500.
Il n'y a donc pas d'exigence d'hystérésis à ouvrir de ce côté. Le « point dur 1500→2000 » qu'envisageait la 0.5.0 est une conséquence d'ECS-302 non traité, pas un phénomène propre au câblage. Aucune entrée pour ECS-303 (créé puis supprimé en 0.5.1) ni pour ECS-605. 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.
Sur un balayage complet des sept paliers (0 → 3000 W), avec l'encodage retenu aujourd'hui : R500 commute 5 fois, R1000 3 fois, R1500 une seule. Le relais de plus faible puissance s'use cinq fois plus vite que le plus gros. Le comptage par relais est 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.
\note Ces comptes dépendent 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é.
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-305 | unitaire | testEcsSwitchCount — compteur par relais |
| ECS-302 | unitaire | testEcsSwitchCost — exerçable : 1500 W = {R1500} ou {R500+R1000} |
| 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-simulationrenvoie vide. Ce sont des reliquats de build que.gitignorene couvre pas, d'où leur apparition permanente en??. Même cas :qrc_*.cpp,moc_*,target_wrapper.sh, et lesplugininfo.h/extern-plugininfo.hgé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 | |
| 2026-08-08 | Câblage 500/1000/1500 — 7 valeurs, plafond 3000 W, 1500 W à deux encodages. Source : le simulateur (sim/sim_ecs_router.py:38), PAS une mesure — à confirmer sur plaque ou au pince avant l'ouverture de l'étape 3. Troisième volte-face sur ce chiffre, et il commande désormais l'ordre de l'étape 3 |
| 2026-08-08 | ECS-302 exercé — redevient préalable structurel de l'étape 3, devant tout ce qui s'appuie sur m_relayMapping |
| 2026-08-08 | ECS-303 SUPPRIMÉ — le « point dur » 1500→2000 est une conséquence d'ECS-302 non traité, pas une exigence d'hystérésis. Aucune entrée ECS-605 de ce côté |
§13 — Points ouverts
- 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 5× plus que R1500), sans la trancher.
- ECS-605 — asymétrie de l'hystérésis montée/descente, à régler à l'usage. Sans rapport avec la frontière 1500/2000 : celle-ci relève d'ECS-302.
- Câblage à confirmer physiquement — 500/1000/1500 provient du simulateur, pas d'une mesure. À vérifier sur plaque ou au pince ampèremétrique avant l'ouverture de l'étape 3, dont il commande l'ordre.
- 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.mda été redessiné (lot de consolidation douze zones, commitf6a4be5) :RelayRouteretEtmVariableLoadAdaptery figurent, le kindStageen est retiré,SocketScheduleretBatteryAdaptersont marqués non écrits. - §13-2 ordonnancement — lecture (b) retenue. ECS-412 est réduit à
m_lastSwitchet redirigé vers sa cause racine (rebuild incrémental) ; ECS-411 remonte en étape 1. L'étape 1 porte donc trois exigences.testEcsRebuildPreservesLockcouvre obligatoirement «SetLoadConfigpendant une fenêtre de verrou active ». - Câblage du banc — 500/1000/1500 W, source simulateur (cf. §4, et la réserve : à confirmer physiquement avant l'étape 3). ECS-302 est exercé et redevient préalable structurel de l'étape 3.
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.