Deux corrections de fond.
1. « Les 500 W consommés de plus que comptés sont démontrés » allait un cran trop
loin. Dans le cycle à verrou expiré, le routeur applique RÉELLEMENT 3000 W : il
n'y a aucun écart consommé/compté dans ce cycle-là. Ce qui est démontré
directement, c'est la moitié comptable — au même budget, honorer ou ignorer le
verrou change le résidu de 500 W et fait basculer la charge de rang 3. L'autre
moitié est observée dans le PREMIER cycle. Le résultat tient par composition de
deux moitiés observées dans deux cycles différents du même binaire : solide,
mais pas une observation directe. Un +etm2 réel reste le seul moyen de voir les
deux dans le même cycle.
2. Rétractation erronée corrigée. J'avais écrit que la question du compteur ECS ne
se posait pas, LoadConfig n'ayant aucun champ de rattachement. Le danger n'a
jamais été là : si le compteur de la charge est désigné comme ROOTMETER dans
nymea, sa puissance entre dans le bilan de surplus que l'arbitre lit par
internalRootMeter(), et le budget est faussé pour toutes les charges. Aucune
configuration n'est nécessaire — une désignation dans l'interface suffit.
Inscrit sous ECS-504 comme piège de mise en service, applicable au câblage du
compteur Modbus prévu.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Résultats ajoutés SOUS la prédiction, sans y toucher.
4.0 — La première tentative a révélé un blocage circulaire dans ECS-412, pas dans
le banc. Point de méthode consigné : la prédiction annonçait « sonde éteinte sous
+etm3 » et elle l'était, mais pour la mauvaise raison. Sans test de falsification
cherchant POURQUOI elle se vérifiait, ce relevé aurait coché la prédiction et
conclu à tort. Une prédiction juste peut être confirmée par un mécanisme faux.
4.1 — Volet 1 mesuré sous +etm4. Le contraste attendu entre les deux versions a
été observé au sein d'un même binaire, sur deux cycles consécutifs au même
budget (~3200 W) : verrou actif → chauffe-eau maintenu à 3500 W, résidu −198 W,
sonde éteinte ; verrou expiré → palier 3000 W, résidu +201 W, sonde allumée. Le
cycle à verrou expiré reproduit ce que ferait +etm2, le clamp d'ECS-306 n'ayant
alors rien à contraindre.
Arithmétique vérifiée à 2 W près du prédit, recrédit de la sonde compris. Les
deux prédictions tiennent, y compris la secondaire : la PAC de rang 2 n'a pas
changé d'état — sans la charge sonde, le relevé aurait conclu « aucun effet
observable ».
Reste à mesurer : +etm2 réellement installé, et le volet 2 (commutations).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Défaut constaté AU BANC le 2026-08-09, pas en test : une charge sonde de rang 3
est restée figée à 0 W sous 4 kW de surplus disponible.
Mécanisme. lockWindow() traitait un m_lastSwitch nul comme sentinelle
« elapsed = 0 » à CHAQUE cycle. Une charge démarrant au palier 0 avec
minOffS > 0 voyait donc offHeld vrai en permanence, maxStage forcé à 0 : elle ne
pouvait jamais s'enclencher, donc jamais commuter, donc jamais valider
m_lastSwitch. Blocage circulaire. L'intention d'ECS-412 — armer plutôt que
purger — était juste ; mon écriture rendait l'armement DÉFINITIF au lieu de
transitoire.
Portée réelle : toute charge relay-router démarrant relais ouverts avec
minOffS > 0 était gelée. Le chauffe-eau du banc (minOffS = 60) n'y échappait que
parce que ses relais étaient déjà fermés et qu'ECS-411 lui faisait reprendre un
palier non nul — au premier démarrage sur installation froide, il aurait été
bloqué lui aussi.
Correction : armement PARESSEUX. applyAction(), chemin non-const qui reçoit le
temps de cycle, estampille m_lastSwitch au premier now s'il est invalide. Le
verrou expire alors après sa durée configurée. Le repli « elapsed = 0 » ne
couvre plus que les cycles précédant la première action. L'invariant « temps =
paramètre, jamais l'horloge » est préservé : aucune horloge n'est lue, et le
contrat d'ILoadAdapter n'est pas modifié — passer now au constructeur aurait
changé l'interface pour un cas particulier.
Symétrie vérifiée : relais fermés au départ → ECS-411 donne un palier non nul,
c'est minOn qui s'arme ; relais ouverts → palier 0, c'est minOff. Les deux sont
désormais transitoires de la même façon.
Test — le vrai livrable : testEcsColdStartLockExpires. Palier 0 au départ,
minOffS = 120, budget de 5000 W. La charge reste éteinte à t0 et à t0+119, puis
s'enclenche à t0+121. Couvre aussi l'expiration de la fenêtre exposée au
scheduler (lockMaxPowerW : 0 pendant le verrou, 1000 après). Aucun test ne
combinait « palier 0 au départ » et « minOffS > 0 » — c'était le trou exact.
spec_ecs.md : ECS-412 précise que l'armement est transitoire, d'une durée égale
au verrou configuré, posé paresseusement, avec le tableau de symétrie et le
renvoi au test.
Build amd64 0 erreur. Simulation : 15/15.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Horodatée 2026-08-09, commitée avant tout profil lancé. Une reconstruction
analytique publiée après la mesure ne vaut rien ; publiée avant, elle est
falsifiable et le commit en fait foi.
Prédiction principale, binaire : sur un point à budget_charge = 3200 W (surplus
−300 W, palier verrouillé à 3500), +etm2 retient le palier 3000 et annonce un
résidu de +200 W — la charge sonde de rang 3 s'allume ; +etm3 force 3500 par
lockMinPowerW et annonce −300 W — elle reste éteinte. Si la sonde s'allume sous
+etm3, le raisonnement est faux.
Prédiction secondaire, tout aussi engageante : la PAC de rang 2 ne changera PAS
d'état entre les deux versions (200 W comme −300 W sont sous P3 = 1500 W). Sans
la charge sonde, ce relevé aurait conclu « aucun effet observable » — à tort.
Raison sous-jacente établie avant mesure : les Things sont des relais GPIO, leur
ThingClass n'expose pas currentPower, donc RelayRouter::telemetry() retombe sur
le nominal commandé et currentPowerW == palier commandé en permanence. La
divergence entre les deux versions ne peut donc apparaître qu'en import net.
Volet 2 : comptage attendu R500=7, R1000=3, R2000=1 sur un aller ; la transition
1500→2000 bascule les trois relais à elle seule (3 des 11). Piège prédit — le
cliquet : sans plateaux à surplus NÉGATIF, le palier ne redescend jamais et le
relevé serait vide.
Protocole et ordre d'exécution inclus, dont la vérification obligatoire que +etm2
relit bien la configuration écrite par +etm3 avant de lancer le profil.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
La configuration de la box de test déclarait 2 relais à 600/1200 W ; elle en
déclare désormais 3 à 500/1000/2000, posés par SetLoadConfig (RPC, rebuild à
chaud). Trois relais GPIO, Relay1/2/3 ; Relay4/5 restent au SG-Ready.
Formulation retenue : « réglée pour ÉMULER l'installation de référence », pas
« alignée sur le câblage réel ». La différence n'est pas rhétorique — elle dit
que la vérification physique en attente (INST-102) porte sur l'installation
CLIENTE, pas sur les broches de .75. Mesurer le banc à la pince ne confirmerait
que le banc.
Fait établi au passage : le câblage de .75 était DÉJÀ documenté dans
docs/TEST_TERRAIN.md:24-26 sous les noms R500/R1000/R2000, broches BCM 5/6/13,
avec les mêmes ThingId. La configuration à 600/1200 était donc un reliquat,
contredit par la documentation du banc elle-même — ce n'est pas la spec qui a été
imposée au banc, c'est le banc qui a été remis d'accord avec sa propre doc.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Documente ce qui a effectivement tourné, pas ce qui était supposé. Build
1.15.2+etm3 du 2026-08-08, cross arm64, réussi.
- L'exclusion DH_OPTIONS=-N nymea-energy-tests était citée pour debian-qt5/rules
alors que c'est qt6 qui construit (debian -> debian-qt6, dh --buildsystem=qmake6).
Vérifié avant le build : l'exclusion EST présente dans debian-qt6/rules:6, donc
seule la citation était périmée. Aucun paquet nymea-energy-tests produit.
- Les dépendances étaient annoncées « toutes en 1.15.0 » avec Qt5
(libqt5websockets5-dev, qtbase5-dev, qttools5-dev-tools). Le build réel utilise
nymea 1.15.2+202606191336~trixie1 et Qt 6.8.2 — chaîne Qt6 de bout en bout,
cohérente avec la box (aucun libqt5 installé sur .75, ldd du .so ne renvoie que
du Qt6).
- Ajouté : debian-qt6/changelog est un lien symbolique VERSIONNÉ vers
../debian-qt5/changelog, comme copyright et nymea-energy-tests.install.in. Il
n'y a qu'un changelog réel. Sans cette note, lire les deux fichiers donne
l'illusion d'une divergence — c'est l'erreur que j'ai commise et rapportée.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Version alignée sur la box cible : nymea 1.15.2, plugin installé 1.15.2+etm2.
Numérotation contiguë — aucun +etm3 n'a jamais été distribué.
Le fichier réel est debian-qt5/changelog : debian -> debian-qt6, et
debian-qt6/changelog -> ../debian-qt5/changelog. Un seul changelog pour les deux
arbres de packaging, écrit via debian/changelog.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Correction. La 0.5.1 avait corrigé le câblage en 500/1000/1500 sur la foi du
simulateur du banc ; le simulateur ne reflète pas l'installation. Le câblage réel
est 500/1000/2000. Quatrième révision de cette donnée.
Les trois consignations reviennent à l'état de la 0.5.0 :
- ECS-302 NON exercé — pondération binaire, aucune redondance d'encodage. Il
perd son caractère de préalable structurel et redescend derrière ECS-305 dans
l'étape 3 ;
- ECS-303 RÉTABLI, mais reformulé sans puissances : « hystérésis élargie aux
frontières de recombinaison », déclenchée dès qu'un passage de palier impose
de basculer plus de deux relais ;
- 8 valeurs distinctes, plafond 3500 W, usure 7/3/1 par balayage.
DÉCOUPLAGE — c'est le vrai objet de cette version. Les puissances ne vivent plus
que dans un bloc unique, §4.0 « Installation de référence », qui porte les trois
valeurs, leur source et leur date, ainsi qu'un tableau « ce que cette
installation exerce et ce qu'elle n'exerce pas ». Aucune exigence ne contient
plus de puissance : celles dont le rang ou la portée en dépendent le disent par
renvoi. Vérifié — hors §4.0, il ne subsiste que le changelog, le journal et
l'illustration P = U²/R d'ECS-500.
Motif : ce paramètre a fait quatre allers-retours, chacun coûtant la réécriture
de trois exigences. Le cinquième ne coûtera qu'un paragraphe.
Source inscrite telle qu'elle est : DÉCLARATION DE L'UTILISATEUR, NON MESURÉE.
Une vérification physique — plaque signalétique ou pince ampèremétrique — est en
attente et reste un préalable à l'ouverture de l'étape 3.
Note ajoutée sous ECS-411 : sur cette installation les encodages sont uniques,
donc 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é — la reprise
doit alors restituer l'ensemble de relais, pas seulement l'index.
Le simulateur du banc sera aligné sur cette installation dans un lot SÉPARÉ, sur
le dépôt etm-powersync-hems-sim.
Aucun code touché.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Régression contre une décision documentée : AGENTS.md, section « Verrous
minOn/minOff », énonce que le scheduler clampe et décrémente le budget au palier
réel. Le canal qui le permettait — minStage/maxStage dans LoadContextTelemetry —
a été retiré en rév. 2/3. L'écrêtage a continué d'avoir lieu dans l'adaptateur,
mais son résultat ne revenait plus à l'arbitre : pendant toute la fenêtre minOn,
les charges de priorité suivante recevaient un résidu SURESTIMÉ et
l'installation soutirait au réseau.
Règle visée : la 4 — « bornes par adaptateur écrêtent TOUTE sortie de
stratégie ». Pas la 1 : il n'y a qu'un décideur, le défaut est un décalage de
comptabilité entre décision et exécution.
Le canal est restauré EN WATTS, pas en index de palier : lockMinPowerW /
lockMaxPowerW dans LoadContextTelemetry, remplis par RelayRouter::toLoadContext()
depuis lockWindow(), convertis via la table de paliers. La frontière rév. 3 tient
— aucun identifiant de relais ni index de combinaison ne remonte. Un
lockMaxPowerW négatif signifie « aucun plafond », et non « plafond nul ».
buildSetpointAction() applique ces bornes AVANT de décrémenter le budget, donc le
résidu passé aux charges suivantes tient compte de la puissance engagée — quitte
à devenir négatif, ce qui est la réponse correcte. Le decisionReason distingue ce
cas : « Verrou minOn — X maintenue à N W (puissance engagée, budget M W) ; le
résidu en tient compte ».
Corriger dans le cycle, pas au cycle suivant : remonter le palier appliqué après
coup n'aurait rattrapé l'erreur qu'au tour d'après.
Test : testEcsBudgetUnderLock — deux charges classées, la première verrouillée à
2000 W sous un budget de 500 W ; la seconde reste à 0 parce que le résidu tient
compte des 2000 W engagés.
Build amd64 0 erreur. Simulation : 14/14.
Réf. specs/spec_ecs.md §3 ECS-306 (0.5.1).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
m_currentStage valait 0 à la construction alors que des contacts peuvent être
fermés : le moteur croyait 0 W pendant que le ballon tirait sa puissance. Même
famille de défaut qu'ECS-410 — annoncer une puissance non appliquée.
deduceStageFromThings() lit l'état réel des Things au constructeur, en trois
temps :
1. correspondance EXACTE de l'ensemble de relais fermés avec un palier — cas
nominal ;
2. à défaut, correspondance par PUISSANCE. Ce cas se produit avec les encodages
dédupliqués : deux combinaisons de même puissance existent, une seule est
conservée dans la table. Toute somme de sous-ensemble figure nécessairement
dans m_levels, qui est construit de ces sommes — la reprise aboutit donc
toujours, et le premier applyAction() normalise l'encodage des relais ;
3. hors table (troncature à MaxRelays) : palier MAXIMAL, jamais 0.
Un relais introuvable est supposé FERMÉ, avec avertissement. Le principe est
constant sur les trois branches : ne jamais annoncer moins que ce qui peut être
appliqué. Sous-estimer est le défaut qu'ECS-411 corrige ; surestimer ne fait que
retarder une montée en puissance.
Le repli initial sur le palier 0 — écrit dans un premier jet — reproduisait
exactement le défaut visé et a été corrigé avant ce commit.
Test : testEcsRestartRecovery — trois cas, dont une combinaison à deux relais
(R500+R1500 = 2000 W) et le cas tout-ouvert.
Build amd64 0 erreur. Simulation : 13/13.
Réf. specs/spec_ecs.md §5 ECS-411 (0.5.1).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Cause racine, pas symptôme. rebuildLoadAdapters() détruisait TOUS les adaptateurs
à chaque SetLoadConfig, réarmant leurs verrous. Sur un ballon thermodynamique à
minOn de 300-600 s, un client qui réordonne ses priorités depuis l'app pouvait
faire court-cycler son compresseur. C'est de la protection matérielle.
L'arbitre mémorise désormais la config ayant servi à construire chaque adaptateur
(m_builtFrom) et ne reconstruit que si le MATÉRIEL a changé — type, câblage,
paliers, plafond, verrous (sameHardware()). Un changement de rang ou de besoins
passe par updateSoftConfig(), en place : ni m_currentStage ni m_lastSwitch ne
bougent, aucun relais n'est réécrit. Le log distingue créées / mises à jour /
inchangées / retirées.
updateSoftConfig est PURE VIRTUELLE, sans implémentation par défaut. Un défaut
vide silencieux ferait qu'un futur adaptateur construit depuis LoadConfig
ignorerait sans bruit les changements de rang ; là, le compilateur force la
décision. EvAdapter et SgReadyAdapter la déclarent sans effet, avec le motif.
Démarrage à froid : après un redémarrage de nymead, m_lastSwitch est
irrécupérable. lockWindow() traite désormais un horodatage nul comme
« commutation venant d'avoir lieu » (elapsed = 0), donc verrou ARMÉ pour sa durée
configurée. L'écriture naturelle (`valid && elapsed < minOnS`) fait l'inverse et
laisserait une boucle de redémarrage court-circuiter la protection compresseur
quand elle est la plus nécessaire. Armer n'est pas verrouiller
inconditionnellement : avec une durée nulle, `0 < 0` est faux et le verrou reste
inactif — un premier jet qui forçait le verrou a fait tomber trois tests
existants, qui avaient raison.
Test : testEcsRebuildPreservesLock — SetLoadConfig pendant une fenêtre de verrou
active, le relais reste fermé ; le délestage reprend une fois minOn écoulé.
Build amd64 0 erreur. Simulation : 12/12.
Réf. specs/spec_ecs.md §3 ECS-412 (0.5.1).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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>
Précondition de la consigne d'étape 1 : les deux arbitrages étaient RELEVÉS au
journal, pas TRANCHÉS. Ils le sont maintenant, datés, dans les termes reçus.
§13-1 CLOS — le schéma « ARCHITECTURE CIBLE » d'AGENTS.md a été redessiné par le
lot de consolidation (f6a4be5) : RelayRouter et EtmVariableLoadAdapter y
figurent, le kind Stage en est retiré, SocketScheduler et BatteryAdapter sont
marqués non écrits.
§13-2 CLOS, lecture (b) — ECS-412 réduit à m_lastSwitch et redirigé vers sa cause
racine (rebuild incrémental : ne reconstruire que ce qui a changé) ; ECS-411
remonté en étape 1. L'étape 1 porte donc TROIS exigences : ECS-306, ECS-411,
ECS-412. Ajouté explicitement : au démarrage à froid, m_lastSwitch étant
irrécupérable, le verrou DOIT être ARMÉ et non purgé — l'implémentation naturelle
(QDateTime nul = verrou inactif) fait l'inverse et court-circuiterait la
protection compresseur sur une boucle de redémarrage.
Câblage du banc confirmé 500/1000/2000 (pondération binaire, 8 paliers de 0 à
3500 W au pas de 500, aucune redondance). Trois conséquences consignées :
- ECS-302 n'est PAS exercé par ce matériel — un seul encodage par palier. La
restructuration de m_relayMapping perd sa justification immédiate et cesse
d'être un préalable structurel de l'étape 3.
- ECS-305 monte en importance : sur un balayage complet R500 commute 7 fois,
R1000 3 fois, R2000 une seule. C'est la seule mesure d'usure utile.
- ECS-303 CRÉÉ — point dur 1500→2000 W : les trois relais basculent d'un coup,
donc trou de puissance à chaque franchissement avec l'ordre
coupure-avant-fermeture. La parade est un seuil d'hystérésis élargi à cette
frontière, pas un réordonnancement. À MESURER au banc avant de coder.
Aucun code touché.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Complète la couverture : 31 → 6 avertissements. Les 6 restants sont exactement
les deux fichiers différés jusqu'à l'étape 3 (relayrouter.h 3,
energyarbitrator.h 3, ce dernier étant la cible d'ECS-412).
energyratioscalculator.h traité EN PREMIER, pour la raison qui le distingue :
c'était le seul endroit où l'absence de documentation cachait une règle métier.
La règle était en fait écrite — mais en commentaires « // », invisibles à
doxygen, donc invérifiables. Conversion en blocs Doxygen SANS réécriture du
fond : les trois cas de reseed de la baseline (premier appel, changement de jour
local, compteur non monotone) et la garantie « dénominateur ≤ 0 → n/a, jamais de
NaN » deviennent des invariants opposables. Précisé au passage que le paramètre
`now` doit être en heure LOCALE — passer de l'UTC déplacerait la frontière de
journée.
Reste du lot : loadaction.h (quels champs sont significatifs selon le kind),
plan.h ([from, to[ et l'interdiction de retourner un plan invalide),
loaddescriptor.h, loadconfigstore.h (tolérance de chargement : un fichier absent
n'est pas une erreur, une entrée invalide est ignorée seule).
Build amd64 0 erreur. Simulation : 7/7, dont testEnergyRatiosAlignment.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Couverture des fichiers non visés par la restructuration à venir : l'extraction
du noyau de calcul (étape 2) et celle de m_relayMapping (étape 3) ne les
touchent pas. relayrouter.h et energyarbitrator.h sont volontairement DIFFÉRÉS —
documenter ce qui va changer produirait du bruit d'historique.
Mesure sur la config du Doxyfile : 113 → 31 avertissements.
loadconfig.h 41→0 · surpluscontext.h 30→0 · sgreadyadapter.h 4→1 (le \return
restant relève du lot suivant) · etmvariableloadadapter.h 2→0 · evadapter.h 1→0
Ce sont des contrats, pas des étiquettes : enabled dit qu'une charge déclarée
mais exclue n'est JAMAIS pilotée ; priority qu'il s'agit d'un rang ascendant et
non d'un poids ; timestamp qu'il est la source unique du temps, dont dérivent
verrous et fenêtres ; setPowerLevels qu'il trie et déduplique ; fromMap qu'il
retourne une config NON validée ; internalRootMeter() qu'il peut être nul.
Ajouts « // [ETM] » hors etm/ (smartchargingmanager.h) : ils échappent au
périmètre du Doxyfile, la frontière étant un répertoire. Un inventaire explicite
est posé au marqueur [ETM] BEGIN, distinguant les trois cas — degradedMode() et
les trois accesseurs internal* sont des ajouts ETM et sont documentés ; les huit
changements de visibilité seule gardent la documentation de l'amont. La
définition de fait d'AGENTS.md renvoie à cet inventaire et l'étend explicitement
aux ajouts hors etm/.
Build amd64 0 erreur. Simulation : 7/7.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Corrections, pas ajouts. Les deux n'apparaissent que sous une configuration
générant réellement une sortie (cf. Doxyfile) — ils étaient invisibles jusqu'ici.
- loadconfigstore.h : « <LoadConfig §4> » dans le bloc de tête était interprété
comme une balise xml/html non supportée, ce qui tronquait la description du
format fichier. Reformulé sans chevrons.
- sgreadyadapter.h : lockWindow() documentait « \param[out] minState / maxState »
en une seule directive ; doxygen n'en reconnaissait aucune des deux et
considérait « now » et « maxState » comme non documentés. Scindé en trois
directives, une par paramètre.
Aucun changement de comportement.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Périmètre energyplugin/etm/, EXTRACT_ALL=NO — sans quoi doxygen documente tout
d'office et n'avertit de rien.
GENERATE_HTML et GENERATE_XML restent à YES, et c'est structurel : le nombre
d'avertissements dépend des générateurs actifs. Sans aucune sortie, doxygen
1.9.8 signale « not documented » des membres qui le sont (mesuré sur le même
arbre : 163 sans sortie, 167 en XML seul, 109 en LaTeX, 113 en HTML+XML). Seule
la configuration HTML+XML donne un comptage honnête. L'avertissement est écrit
en tête du fichier.
XML pour une éventuelle chaîne Breathe/Sphinx — coût mesuré ~180 ms, 73
fichiers, 1,5 Mo. Note : l'amont nymea documente en qdoc, pas en doxygen ;
nymea-docs n'a pas pu être consulté.
WARN_AS_ERROR=NO : le job CI ne devient bloquant qu'à la clôture de l'étape 3
de specs/spec_ecs.md (relayrouter.h et le noyau de calcul sont différés
jusque-là). Aucun job CI créé dans ce commit.
doc-generated/ (OUTPUT_DIRECTORY) ajouté au .gitignore.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Les artefacts produits par `qmake6 CONFIG+=build_tests && make` polluaient
`git status` en permanence. Ajoutés : qrc_*.cpp, target_wrapper.sh, les trois
binaires de test (chemins explicites — ces cibles n'ont pas d'extension), le
plugininfo généré du mock, build-check/ et .cache/.
Portée du motif plugininfo volontairement limitée à
tests/mocks/plugins/energymocks/ : energyplugin/plugininfo.h est SUIVI, un motif
global aurait été trompeur.
Vérifié : aucun fichier suivi n'est nouvellement ignoré
(`git ls-files | git check-ignore --stdin` → vide).
Restent non suivis, volontairement — fichiers de travail, pas des artefacts :
BRIEF_agent_energymanager.md, consignes/, jsonRPC.txt, logs-3biv/.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Purge des affirmations devenues fausses depuis la rév. 3. Chaque correction est
vérifiée contre le code ; rien n'est repris du texte existant par défaut.
Zones : ÉTAT (tableau + Détail 3c) · « ce que le moteur sait faire » · DÉFÉRÉ
(2 entrées) · PLAN 3C · ARCHITECTURE CIBLE · WORKFLOW phase 3 · 3b révisé ·
Verrous minOn/minOff (2 endroits) · MODÈLE DE SÉCURITÉ (L2) · ROADMAP ·
RÉFÉRENCES.
Principales remises au fait :
- EcsRelayAdapter et le kind Stage, supprimés en 5100674, disparaissent du
schéma « non négociable » au profit de RelayRouter + EtmVariableLoadAdapter ;
SocketScheduler et BatteryAdapter, qui n'existent pas, sont marqués [3d]/[3f].
- EvAdapter::applyAction() n'est pas « inactif » : il est implémenté
(evadapter.cpp:62-94) mais jamais appelé (energyarbitrator.cpp:288).
3g est un travail de câblage, pas d'écriture.
- minStage/maxStage ont été retirés du contexte ; minState/maxState et
minStateHold subsistent et sont conservés tels quels.
- La configuration des charges est persistée et rechargée à chaud depuis
7184fe4 — le point (b) de la ROADMAP est fait côté moteur, seule l'UI
Flutter reste ouverte.
- Le watchdog L2 est en place, plus « à coder en 3c ».
- PLAN 3C n'est plus reproduit (pseudocode nommant une classe supprimée et un
identifiant « relay-stages » jamais livré) ; les corrections A et B, toujours
en vigueur, sont conservées avec leurs références actuelles.
- PRÉCONDITION ajoutée au basculement de SgReadyAdapter sur la config :
retirer d'abord Q_ASSERT(m_stateRelays.contains(2)) au profit d'un refus
explicite — l'assertion garde l'état 2, repli sûr du mode dégradé L2, et
disparaît en release.
Aucune règle absolue 1 à 10 modifiée (vérifié identique à HEAD).
INVARIANTS BUILD / PACKAGING intact. Aucun code touché.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Deux documents normatifs, préalables au lot AGENTS.md qui les référence.
spec_ecs.md 0.4.3 — spécification ECS multi-palier, écrite après audit du code :
- 0.4.1 : statut réel des binaires de test, portée du point de gouvernance
AGENTS.md, citation de règle dans ECS-306, recouvrement ECS-411/ECS-412 ;
- 0.4.2 : étape 2 « type domaine » → « noyau de calcul » (collision avec LM-100) ;
- 0.4.3 : ECS-110 étendu — la validation ne doit pas reposer sur Q_ASSERT,
absent du binaire release (QT_NO_DEBUG).
spec_loadmodel.md 0.1.1 — modèle de charges (domaine × mécanisme), intention de
conception. Ne déclenche aucun travail.
Aucun code touché.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Expose autoconsommation/autonomie comme source canonique de l'energymanager,
pour remplacer le seam interim app-side (EnergyRatiosInterim.compute).
Voie (c) : méthode + notification sur le handler NymeaEnergy de NOTRE plugin
(déjà forké/buildé), calculées depuis EnergyManager::totalX() — interface
abstraite stable. PAS de champs sur Energy.PowerBalance → aucun fork du coeur
nymea (nymea-experience-plugin-energy) à rebaser perpétuellement.
- EnergyRatiosCalculator (GPL pur mesure) : Δ de cumuls, baseline minuit local,
reseed (1er appel / nouveau jour / non-monotone Δ<0), den<=0 -> n/a, clamp
[0,100]. Porté 1:1 du seam interim app.
- NymeaEnergy.GetEnergyRatios -> {o:selfConsumptionRate, o:autonomyRate} Double,
champ OMIS si n/a (jamais null).
- NymeaEnergy.EnergyRatiosChanged : meme forme, branchee sur powerBalanceChanged(),
emise uniquement quand une valeur (ou sa disponibilite) change.
- testEnergyRatiosAlignment : vecteurs joues 1:1 contre l'interim (seed, normal,
clamp-bas, den<=0->n/a, non-monotone, nouveau jour local).
- docs/INTERFACE_energyratios.md : contrat pour l'agent app (RPC a consommer).
Build prod 0/0. Suite simulation 25/0 (testLoadConfigRpc traverse le handler
modifie -> pas de regression).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Build contre nymea 1.15.2. Paquet rév. 3 pour test live RelayRouter sur hems (testing).
(changelog réel = debian-qt5/changelog ; debian → debian-qt6 → ../debian-qt5/changelog.)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
L'arbitre construit et pilote les routeurs ; le repli L2 couvre les DEUX types ENSEMBLE
(atomique : pas d'intermédiaire avec charge active sans repli).
- Map unique m_loadAdapters (QHash<QString, ILoadAdapter*>) : RelayRouter + EtmVariableLoadAdapter
cohabitent par polymorphisme. registerRelayRouter() ajouté.
- rebuildLoadAdapters() : la distinction de TYPE vit ICI — RelayRouter si relays[], sinon
EtmVariableLoadAdapter. Au-dessus tout est ILoadAdapter (Setpoint W).
- Dispatch Setpoint AGNOSTIQUE au type : routage par loadId, le polymorphisme absorbe (pas de
if(RelayRouter)). Le scheduler distingue Setpoint (etmvariableload + relay-router) vs State
(sg-ready) — nature de l'action, pas classe concrète.
- Repli L2 : applyDegradedMode boucle sur TOUS les ILoadAdapter → Setpoint(0) force=true. Pour
le RelayRouter, coupe TOUS les relais (bypass minOn). Ferme le trou T2 pour le routeur.
- testMeterSilentFallback étendu au cas RELAIS : compteur muet → 2 relais OFF force=true,
restent OFF sur N cycles. Certifie la sécurité relais.
Build prod 0/0 ; suite (config/L2/migrés) verte. Étape qui rouvre le déploiement.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Restauration de l'ex-EcsRelayAdapter (b7bfd58) repositionné en ROUTEUR distinct, sous
l'optimiseur watt-pur. ILoadAdapter consommant Setpoint(W) au lieu de Stage :
- Paliers DÉRIVÉS : toutes les combinaisons (sommes de sous-ensembles) des relais, dédupliquées,
triées, 0 inclus — source de vérité unique = les relais (descriptor().powerLevels/maxPowerW).
- applyAction(Setpoint W) : mappe powerW → combinaison la plus haute ≤ powerW, applique les
verrous minOn/minOff EN INTERNE (clamp, plus exposés au scheduler), commute via
ThingManager::executeAction (interface "power"), off-before-on. force=true → bypass (repli L2).
- currentPowerW : mesuré si relais expose "currentPower", sinon nominal commandé (GPIO bool nu).
- LoadContext : watts uniquement (powerLevels/currentPowerW) — aucun relais ne franchit la frontière.
Classe INERTE à ce commit (non câblée à l'arbitre) : aucune charge ne l'utilise, donc aucune
charge sans repli L2. Câblage + repli L2 = étape 4 (atomiquement). Build prod 0/0.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
LoadConfig gagne la forme "relay-router" (ECS multipalier rév. 3) : liste de relais
power (relays[] = [{thingId, powerW}]) + minOnS/minOffS. Coexiste avec la forme
"etmvariableload" (continu/dynamic ou multipalier natif). powerLevels NON stockés pour
le cas relais (DÉRIVÉS par le routeur en étape 3-4).
- LoadConfigRelay (struct) + relays/minOnS/minOffS dans LoadConfig (Q_GADGET + toMap/fromMap).
- isValid() CONDITIONNELLE au type (adapter/mode), pas "tous champs requis" :
relay-router → relays[] valides ; etmvariableload → powerLevels (fixed) | maxPowerW (dynamic).
- Schéma SET : TOUS les champs spécifiques en "o:" (powerLevels, maxPowerW, relays, minOnS,
minOffS, needs) — sinon nymea rejette une des deux formes avant le handler (bug objectRef
strict de T4, doublé en rév. 3). GET reste typé objectRef<LoadConfig>.
- testLoadConfigRpc étendu : SET des DEUX formes (etmvariableload + relay-router) tous deux
acceptés, round-trip relays[]/minOnS, rejets conditionnels (powerLevels sans 0 ; relays[] vide).
Build prod 0/0 ; suite config/L2/migrés verte.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Vérification nymea : un integration-plugin ne peut pas piloter les things d'un autre
plugin (thingManager privé ; seuls cœur/règles/scripts/experience-plugins le font).
Donc la combinatoire watts→relais ne peut vivre que côté experience-plugin.
rév. 3 acte le déplacement de frontière :
- Optimiseur watt-pur (ne connaît jamais un relais) | ROUTEUR (watts→relais,
experience-plugin, a le ThingManager) | relais = things "power" nus.
- ECS multipalier : LoadConfig devient une LISTE de relais power (relays[] +
minOnS/minOffS) ; powerLevels DÉRIVÉS des combinaisons (source = les relais).
- Cas continu (EV/triac, EtmVariableLoadAdapter) inchangé.
- §10 IMPACT APP explicite : l'écran « Configurer » passe à une liste de relais +
picker findConfiguredThings("power") (UI-only) — l'agent app doit lire rév. 3
avant de toucher l'UI.
À déposer À L'IDENTIQUE dans etm-powersync-app (miroir manuel, hors de ce repo).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Pont moteur↔app : déclaration des charges etmvariableload persistée et éditable,
construction des adaptateurs à chaud depuis la config (remplace le registre en dur).
- LoadConfig / LoadConfigNeeds (Q_GADGET typés, introspectables) — forme mot pour mot
du LoadDescriptor §4 (jonction inter-repos avec l'app).
- LoadConfigStore : persistance atomique /var/lib/nymea/energy-load-configuration.json,
validation en bloc, signal changed().
- Handler NymeaEnergy.GetLoadConfig / SetLoadConfig (+ notif LoadConfigChanged). GET typé
objectRef<LoadConfig> ; SET schéma inline o: (powerLevels/needs conditionnels §4).
- EnergyArbitrator : setLoadConfigStore + rebuildEtmVariableLoadAdapters (enabled==true
seulement, §9) ; repli L2 = setPowerSetpoint(0) force=true sur tout etmvariableload
(ferme le trou sécurité ouvert en T2).
- Tests simulation : migration ECS→etmvariableload (arrondi fixed/dynamic, recrédit,
délestage, round-trip powerSetpoint), budget partagé etmvariableload↔PAC, watchdog L2
réactivé, persistance + construction depuis config + injection RPC end-to-end.
PAC SG-Ready reste hors config (§8, gelée). Les 5 tâches du brief sont couvertes.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
RuleBasedScheduler : buildSetpointAction (contrat rév. 2 §5) — fixed = arrondi au plus
haut powerLevels ≤ budget ; dynamic = clamp(budget, 0, maxPowerW) ; recrédit currentPowerW
de DÉBUT de cycle (invariant 8) ; résidu cascadé. Waterfall unifié : etmvariableload
(Setpoint W) + sg-ready (State) triés par priorité, un seul budget.
EnergyArbitrator : registerEtmVariableLoadAdapter, inclusion dans buildContext, dispatch
Setpoint par loadId (l'EV reste au proxy amont).
Infra de test : ThingClass mock etmVariableLoad (currentPowerW read / powerSetpoint write)
+ handler executeAction + helper addEtmVariableLoad — valide l'implémentabilité de
l'interface avant le thing réel. Les scénarios simulation migrés arrivent en T4.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
debian/rules : DH_OPTIONS=-N nymea-energy-tests — les binaires de test
n'existent pas dans le build deb (ils nécessitent CONFIG+=build_tests).
L'exclusion est globale sur tous les outils dh, pas de paquet vide produit.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
libnymea-tests:arm64 1.15.0 n'exporte pas enableNotifications — le link
des tests dans dpkg-buildpackage échoue. Politique : les tests ne font pas
partie du paquet de prod ; ils sont réservés au CI/dev (CONFIG+=build_tests).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Enregistre les adaptateurs ECS (3 relais R500/R1000/R2000) et SG-Ready (K1/K2)
avec les ThingIds réels du banc hems dans EnergyPluginNymea::init(), compilé
uniquement sous ETM_ARBITRATOR. Bloquant pour le cross-build arm64 Palier 1.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>