LA QUESTION QUE NI L'UN NI L'AUTRE N'AVAIT POSÉE : que fait la dérogation ENTRE le figement de sessionEnergy et l'échéance de durée ? Elle achète au maximum sur une mesure morte, et sur un lien qui perd 40 % de ses trames « au bout » peut vouloir dire des heures. POSITION RETENUE : une dérogation dont la cible cesse d'être mesurable CESSE. Elle n'attend pas sa durée. C'est R6 transposée — là-bas « servie oui, tenue jamais » ; ici « active oui, vérifiable non, donc on s'arrête ». Une dérogation est une commande qui ACHÈTE, et la maintenir sans savoir où elle en est, c'est acheter à l'aveugle. L'instrument existe déjà : measurement.source et le régime de LM-1009, qui le disent au cycle même. QUATRE FINS, et le motif dit laquelle a mordu : targetReached, unplugged, measurementLost, durationElapsed. Les quatre appellent des gestes différents — et une dérogation qui se terminerait sans dire laquelle laisserait l'utilisateur relancer un boost que le lien empêchera encore. L'ASYMÉTRIE, reprise du plugin V2C. La confirmation de débranchement existe (k_absencesPourClore polls) et porte une propriété qu'il faut garder : SEULS LES POLLS RÉUSSIS font avancer le compteur d'absences. Une coupure réseau le gèle au lieu de le faire progresser — un lien mort ne peut donc pas fabriquer un faux débranchement. Et les deux erreurs ne coûtent pas la même chose : terminer à tort rend la main, continuer à tort achète au maximum sur une mesure morte. D'où la règle, gravée dans AGENTS.md : un mécanisme qui achète s'arrête dans le doute, et ses seuils de confirmation sont asymétriques par conception — prompts à conclure la fin, lents à conclure la poursuite. C'est l'inverse du réglage qu'on choisirait pour un mode. La durée devient le FILET DU FILET : la perte de mesure attrape le cas où l'instrument se tait et se signale ; la durée attrape le cas où même le régime ne se signale pas. Deux filets parce que le premier repose lui-même sur un instrument.
107 KiB
AGENTS.md — etm-powersync-energy-plugin-etm
Moteur HEMS. Fork GPL de nymea-energy-plugin-nymea, étendu de l'optimisation EV
vers un gestionnaire d'énergie complet (EV, ECS, PAC SG-Ready, batterie).
- Licence : GPL-3.0 · Miroir public : OUI
- Branche de travail :
feature/beta-rulebased - Document d'interface faisant autorité :
docs/OPTIMIZER_PROTOCOL.md(le contrat stratégie/arbitrage — interne ET socket).INTERFACE.mdfait autorité sur l'API JSON-RPC.
ÉTAT
| Phase | Statut | Commit(s) |
|---|---|---|
| 0 — analyse fork / structure | ✅ FAITE | f4d5b20 |
| 1 — renommage .pro + métadonnées debian | ✅ FAITE | f4d5b20 |
| 2 — design arbitre validé | ✅ FAITE | 074fa71 |
| 3a — structs protocole + interfaces | ✅ FAITE | 4ae1939 |
| 3b — EnergyArbitrator + scheduler + adapter | ✅ FAITE — iso-fonctionnalité prouvée | 5f49e4c, d8ebd65, [3b-iv] |
| 3c — adaptateur ECS à paliers + waterfall ECS | ✅ FAITE — suite 18/18 + charging 46/46 | 6298d5d→54ba229 |
| 3e — SgReadyAdapter | ✅ FAITE — suite 19/19 | 83d5ad9→d8079e8 |
| rév. 3 — frontière optimiseur↔routeur | ✅ FAITE — RelayRouter, LoadConfig/LoadConfigStore, kind Stage retiré |
5100674, e16aca4, 5585a5c, 7184fe4, 88626cf |
lot B — frontière de TÉLÉMÉTRIE + o:domain |
✅ FAITE — GetLoadTelemetry/LoadTelemetryChanged, motifs en {code, params}, degradedMode lisible |
1.15.2+etm13 → +etm14 |
| lot B-bis — la PAC entre en CONFIGURATION | ✅ FAITE — plus aucun adaptateur codé en dur ; loads[] ⊆ GetLoadConfig par construction |
1.15.2+etm15 |
| 3g-1 — les bornes entrent dans le waterfall | ✅ FAITE — décision partagée, motifs communs ; mais la décision n'atteignait pas le matériel | 1.15.2+etm20 → +etm22 |
| 3g-2 — une charge, un commandeur | ✅ FAITE et vérifiée sur machine — dispatch EV réel, evFloorW() = exigence réelle, rang de borne configurable |
1.15.2+etm23 |
Détail 3b :
EnergyArbitrator : public SmartChargingManager— justification dans## DÉCISIONS DE DESIGNEvAdapter+RuleBasedSchedulerimplémentés- Build : 0 erreur / 0 warning
ETM_ARBITRATORactif dansenergyplugin.pri- Iso-fonctionnalité prouvée :
- Simulation : 226 lignes décisions identiques (Theoretically / Surplus / Current load), diff = 0
- Tests charging : 57 lignes décisions identiques, diff = 0 ; 46/46 PASS ref ET ETM
- [Arbitre] présents avec raisons françaises pour les 4 cas (idle, surplus PV, aWATTar, deadline)
Détail 3c (clôturée, commits 6298d5d waterfall → 54ba229 testMeterSilentFallback) :
LoadAction.force = false— bypass des verrous en repli sécurité.- Adaptateur ECS à N paliers powerswitch (
applyRelayStage(), verrousminOnS/minOffS, bypass siforce == true), enregistré explicitement auprès de l'arbitre. Classe remplacée depuis : voir la ligne rév. 3 du tableau. buildContext():SurplusMeterbrut (exportW = max(0, -meter->currentPower())),loads[]EV + charges pilotées ;SurplusPvdéféré.- Waterfall (tri priorité ASC = rang) + dispatch + watchdog L2 (mode dégradé
conservateur, planification suspendue) +
degradedMode/notification + tests. - Correctif clé
[3c-3-fix]: surplus net signé (délestage en import) ; clamp lock-aware ; seam de temps unifié (now = ctx.timestamp,lockWindow()source unique) ; watchdog injectable (recordMeterUpdate/evaluateMeterFreshness, déclencheurs sous#ifndef ENERGY_SIMULATION). - Tests :
testEcsSurplusPV(4 régimes) +testMeterSilentFallback(stabilité + reprise). Suite simulation 18/18, charging 46/46, plugin prod 0/0. - arm64 cross : NON vérifié dans le sandbox dev (pas de toolchain /
Qt6-aarch64 / docker) → relève de l'infra de build CI (
etm-powersync-deploy). À confirmer là-bas.
Le clamp lock-aware de 3c passait par
telemetry.minStage/maxStage. Ces champs ont été retirés duSurplusContexten rév. 3 ; le verrou est aujourd'hui interne à l'adaptateur (relayrouter.cpp:193-203). Écarts connus entre cette cible et le code :specs/spec_ecs.md§1.
3e CLÔTURÉE (commits 83d5ad9 types → d8079e8 testSgReadySurplus) :
SgReadyAdapter: 4 états normés (kind:State), encodage 2 bits,lockWindowsymétrique (minStateHold, protection court-cycling PAC), atomicité de transition (transientHarm: passe par le neutre/reco, jamais par blocage/forcé — contrat transport déporté).- Scheduler : mapping sémantique (≥P4×1,2→forcé, hystérésis 1,2/1,0 ; ≥P3→reco ; sinon normal ; état 1 jamais via surplus) + waterfall UNIFIÉ ECS+SG-Ready (un seul budget, trié par priorité). Mode dégradé L2 → état 2 (mains off, jamais blocage ; SAFETY.md corrigé).
- Tests :
testSgReadySurplus(montée · hystérésis · court-cycling · budget partagé ECS↔PAC avec inversion de priorité) +testEcsRelayTopologies(ECS 1 relais + 3 relais non-cascadé 1500→2000, off-before-on) — commitdfdd988. - DoD 3e : amd64 0/0 ✓ · simulation 20/20 ✓ ·
decisionReasonfrançais (forcé/reco/normal/ verrou) ✓ · arm64 → CI (idem 3c). - Audit Doxygen fait (5 findings → 0 :
\param now, docs périmées 3c/3e) — commit51760a7. docs/TEST_TERRAIN.mdcréé : procédure Palier 1 (14 tests) pour le banc nymea-dev arm64.
Détail lot B (frontière de télémétrie) :
- Le constat : 90 s d'écoute sur
.75, arbitre actif, ~2 kW exportés → zéro notificationNymeaEnergy.*. L'état runtime de l'arbitre vivait exclusivement dansqCInfo(dcNymeaEnergy). Ce lot lui donne un transport. NymeaEnergy.GetLoadTelemetry+LoadTelemetryChanged, même charge utile. Contrat :INTERFACE.md.LoadAction::reasonest unDecisionReason {code, params}, plus uneQString. La ligne française du journal est rendue depuis ce couple parrenderFr()— source unique, jamais maintenue en parallèle (etm/types/decisionreason.h).ILoadAdapter::runtimeView(now): nouvelle méthode pure virtuelle — défaut, verrou en secondes restantes, charge utile de mécanisme. Pure à dessein : un mécanisme ajouté qui hériterait d'un défaut vide sortirait de la télémétrie sans bruit.PlanBudget(interne au plan, jamais sérialisé vers l'optimiseur socket, commeLoadAction::funding) :surplusW,evReservedW,recreditedW,allocatedW,remainingW.LoadConfig::domain— métadonnée d'intention, énumération fermée, non lue par l'arbitre.- ⚠️
loads[]n'était PAS en correspondance 1:1 avecGetLoadConfig— leSgReadyAdapterdu banc, codé en dur, était arbitré et publié tout en restant inconfigurable. Refermé par le lot B-bis (ci-dessous) : la règle de détection écrite dansINTERFACE.mdà+etm14a été RETIRÉE, et aucunconfigurable: falsen'a jamais été ajouté — il n'aurait plus de porteur. - Déploiement sur box :
etm-powersync-deploy/deploy.sh, jamais undpkg -ià la main. Un glob sur/var/lib/etm-deploy/*.deba rétrogradé.75de+etm12à+etm9le 2026-08-25. Détail :DEPLOY.md§7.6 (dépôt deploy) etdocs/RELEVE_LOTB.md§8-bis (a).
Détail lot B-bis (la PAC entre en configuration) :
- Le bloc
registerSgReadyAdapter()d'energypluginnymea.cppest SUPPRIMÉ, avec la méthode d'enregistrement, la tablem_sgReadyAdaptersetclaimedRelays(). Il n'existe plus aucun chemin d'enregistrement en dur :m_loadAdaptersest la table UNIQUE des charges arbitrées, et toutes ses entrées viennent deLoadConfigStore.GetLoadTelemetrypublie donc exactement l'ensemble deGetLoadConfig— par construction, pas par discipline. L'inclusion ne vaut que dans ce sens : une chargeenabled: falseest configurée sans être publiée (contrat §9). - La PAC du banc est devenue une
LoadConfigsg-readyordinaire (pac-terrain, K1/K2, rang 2). SesestimatedPowerW—{3: 1500, 4: 3000}— sont reconduits du bloc supprimé, donc PROVISOIRES et NON MESURÉS : ils décrivaient une PAC inventée, et aucune PAC n'est sous tension sur K1/K2. Marqués comme tels dansdocs/TEST_TERRAIN.md§1.d, à remplacer par la machine réellement câblée —estimatedPowerWsert de base au recrédit budget, une estimation fausse décale l'allocation de tout le waterfall, pas seulement celle de la PAC. - TROU DE SÉCURITÉ refermé au passage : le repli L2 envoyait un
Setpoint(0)à toute la table des charges pilotées et unState(2)à la table séparée de la PAC en dur. Une PAC de configuration vit dans la première : elle recevait unSetpointqueSgReadyAdapterrejette d'entrée — et rien d'autre. Sous compteur muet elle restait en état 4 (forcé), quandSAFETY.md§L2 exige l'état 2. Invisible dans la charge utile (le motifDEGRADED_L2s'affichait bien, seule l'ÉCRITURE manquait). Règle qui en sort : dès qu'un mécanisme rejoint la table commune, tout code qui fabrique une action litsupportedKindsau lieu de présumer du kind. - ECS-411 appliqué au SG-Ready : l'état de départ est relu sur les contacts, plus supposé
à 2. Le vrai piège est le corollaire LM-104 —
applyAction(état 2)sur un adaptateur qui se croit en 2 est idempotent, donc muet, donc les contacts restent en 4 : l'hypothèse de prudence devient un masquage de panne. Le relevé publie ce qui a été lu, contact par contact, avec l'issue — y compris quand l'issue est l'état neutre (ECS-411-b) — et passe en avertissement si un contact est injoignable. - ECS-412 appliqué au
minStateHold: unm_lastSwitchnul vaut « commutation venant d'avoir lieu », jamais « jamais commuté » — sans quoi un redémarrage denymeadpendant que le compresseur tourne autoriserait une coupure immédiate. L'armement est paresseux : la première décision reçue pose l'estampille et le verrou expire. Jamais permanent — c'est le blocage circulaire corrigé côté routeur le 2026-08-09. Conséquence à connaître : toute reconstruction d'adaptateur réarme le verrou pour sa durée (900 s sur une PAC réelle). - La suppression du bloc en dur ne passe PAS par
applySafeState()— et ne le peut pas : c'est un événement de compilation, pas d'exécution ; ECS-413 ne s'applique qu'à un adaptateur qui sort d'une configuration vivante. Le filet est procédural et documenté enTEST_TERRAIN.md§1.d : poser la config sg-ready AVANT d'installer+etm15, sinon K1/K2 se retrouvent sans propriétaire, dans leur dernier état commandé — possiblement fermés.
Ce que le moteur sait faire aujourd'hui
- Arbitrage central unique : un budget de surplus net signé, cascade par priorité (rang).
- Charges : EV dans le waterfall (3g-1/3g-2 : même budget, même rang, mêmes motifs ;
seuls l'échéance et le tarif dynamique restent au proxy, financés au réseau — et une borne
sans voiture assignée y entre aussi depuis LM-1208-b : elle perd l'échéance et la cible
d'énergie, pas l'arbitrage),
charges pilotées en watts (kind
Setpoint—RelayRouterpour une combinaison de relais,EtmVariableLoadAdapterpour une consigne continue), SG-Ready PAC (4 états, kindState) — toutes les charges non-EV classables ensemble sur le budget partagé. - Sécurité : protection compresseur (verrous
minOn/minOffpar charge tenus dans l'adaptateur, seam de temps unifiénow = ctx.timestamp; fenêtre d'étatsminState/maxStateexposée au scheduler pour SG-Ready) ; watchdog L2 (compteur muet >90 s → mode dégradé conservateur, planif suspendue, reprise par recalcul) ;verifyOverloadProtection()amont intacte ;degradedModenotifié. - Local-first : zéro cloud (invariant 10).
DÉFÉRÉ (ordre indicatif)
- Passe README + contrats :
OPTIMIZER_PROTOCOL.mdne reflète PAS encore plusieurs ajouts —LoadAction.force(bypass sécurité L2),telemetry.minState/maxState(fenêtre de verrou SG-Ready),degradedMode(notification), et la formerelay-routerdeLoadConfig.minStage/maxStagene sont plus à documenter : les champs ont été retirés du contexte en rév. 3. Ajout du lot B :PlanBudget(comptabilité interne du plan, non sérialisée) etILoadAdapter::runtimeView()n'y figurent pas non plus. À documenter dans le protocole publié- README architecture.
INTERFACE.mdest, lui, à jour du lot B. Prochaine session doc (pas avant le terrain).
- README architecture.
- Silences de REFUS restants (règle 7-c/7-d), signalés et NON corrigés au lot B. Trois
refus rendent la main sans une ligne, alors que l'application correspondante est en
qCInfo:relayrouter.cpp(charge en défaut → action refusée),etmvariableloadadapter.cpp(idem), etsgreadyadapter.cppdeux fois (charge en défaut, et encodage sans état 2 →m_usablefaux, donc PLUS AUCUNE commande depuis la construction). Écartés du lot B à dessein : le défaut est déjà annoncé une fois enqCCriticalà son apparition, et il est désormais publié en continu parGetLoadTelemetry(faultCode) — mais l'asymétrie de journal reste, et le casm_usablen'a, lui, aucune annonce à sa naissance. À traiter avec ECS-414. - Waveshare D8 : plugin DEVICE (8 powerswitch) sous l'adaptateur — session dédiée (chantier transport Modbus RTU/RS485).
- V2C (borne EV) : intégration — session dédiée.
- 3d SocketScheduler (handshake/heartbeat/repli optimiseur).
- 3f BatteryAdapter (constraints + charge réseau plafonnée) + waterfall grid-funding.
Sa forme est décidée :
specs/spec_loadmodel.md§14 — le stockage devient une charge rangée du waterfall (« l'eau chaude avant la batterie » se règle au glisser-déposer), le seuilbatteryLevelConsiderationcesse d'être un rang pour redevenir une réserve, et le double commandeur se mesure (controlHealthy) au lieu de se deviner. 3g transplantation EV dans le waterfall unifié— FAITE (3g-1 + 3g-2). Reste la moitié MATÉRIELLE de LM-1204-b, qui exige deux bornes réelles au banc.- Couche config priorités (UI Flutter drag-and-drop) — cf.
## ROADMAP. Note : toutes les charges non-EV sont configurables et persistées — les charges pilotées depuis7184fe4, la PAC SG-Ready depuis le lot B-bis (LoadConfigStore, RPCGet/SetLoadConfig, reconstruction à chaud surchanged()). Plus rien n'est codé en dur, et c'est un invariant de contrat, pas une propreté : cf.docs/TEST_TERRAIN.md§1.d. Ce qui reste déféré ici n'est donc que l'UI de classement. La précondition ECS-110 est levée : leQ_ASSERT(m_stateRelays.contains(2))a cédé la place au refus explicitem_usable, présent dans le binaire release — c'était bien avant le basculement, pas après. - arm64 cross-compile validé en pré-déploiement (infra CI
etm-powersync-deploy). Doxyfile+ job CIdoxygen -W(automatiser l'audit doc).
FAIT (2026-08-28) :
assignedCarIdest retiré du filtre d'entrée du waterfall (evParticipatesInWaterfall) —specs/spec_loadmodel.mdLM-1208-b. Restent deux exclusions, et elles ne disent pas la même chose : le mode manuel est une INTENTION, l'absence de véhicule branché un CONSTAT. LM-1208-c est venu avec : le proxy ne prépare pas une borne sans voiture, elle entre donc dans le waterfall sansChargingProcessInfo, et l'accesseur rendait un défaut plausible (1 phase, pas de bascule) au lieu de dire « absent » — une borne triphasée annoncée monophasée, budget divisé par trois, en silence. D'oùinternalHasProcessInfo()et le repli sur lephaseCountde la borne. Éprouvé partestChargerWithoutCarIsArbitrated, vérifié échouant sans le correctif (3 680 W au lieu de 6 000). Suites : simulation 29/29, charging 16/16, loadmodel 18/18, spotmarket 7/7 — amd64 0 erreur / 0 avertissement.
DÉPLOYÉ SUR .75 — 1.15.2+etm32, 2026-08-30. counts est en ligne et l'identité de
réconciliation se vérifie sur des données réelles : Σ counts[budget.allocatedW] = 1 700 =
budget.allocatedW, Σ counts[draw.committedW] = 0 = draw.committedW.
EV_GRID_STARTn'est PAS reproductible au banc, et il ne faut pas le fabriquer.pluggedInest en lecture seule — aucune action RPC ne l'écrit — et sansassignedCarIdla borne reste horsloads[]. Le partage entre les deux registres est une propriété du code, pas du matériel : la suite de simulation en porte la trace complète (testEvGridStartIsAnnouncedAndBounded), reproduite verbatim dansdocs/BRIEF_depuis_plugin.md. Le banc n'ajouterait que la confirmation que le binaire arm64 fait pareil.
DÉPLOYÉ SUR .75 — 1.15.2+etm27, 2026-08-29. Cross-build arm64 (build-etm27), posé par
deploy.sh, version relue, nymead actif. Vérifié sur machine : aller-retour Get→Set
verbatim neutre ; SetLoadConfig à rangs doublés refusé avec sa raison, 4 charges
intactes et rangs inchangés ; rankOrigin: "auto" fabriqué par un client refusé ;
rankOrigin publié à "" sur les quatre entrées.
Deux vérifications ne sont PAS observables sur
.75, et il ne faut pas fabriquer la condition (même arbitrage que lemeterThingId) :
La migration d'une config à rangs doublés. La config du banc porte les rangs 1-2-3-4 : elle a été réparée à la main le 2026-08-28 au soir. Le cas est éprouvé par
testRankUniquenessRefusesWriteButNeverDiscards, qui écrit un vrai JSON à rangs doublés, recharge et vérifie la survie des deux entrées — le banc n'ajouterait que « le binaire arm64 fait pareil ». Il se rencontrera sur une box cliente non migrée, et c'est là qu'il comptera.
— OBSERVÉ le 2026-08-29 : une seconde wallbox simulée appairée aprèsrankOrigin: "auto"+etm27a été provisionnée au rang 5, aucun autre rang modifié, avecrankOrigin=autoet la ligne de journal complète. L'écho verbatim d'une entrée portant"auto"— y compris avec un renommage ailleurs — est accepté : un renommage n'est jamais bloqué par une borne auto-provisionnée. Le seul refus est l'écho périmé (payload mis en cache avant un déplacement qui a fait tomber la marque), et le remède est de relire.⚠️ L'AUTO-PROVISIONNEMENT NE TOURNE QU'À LA CRÉATION DE L'ADAPTATEUR — mesuré le 2026-08-29, et ma note de la veille disait le contraire. Retirer l'entrée d'une borne dont le Thing vit ne la fait PAS recréer :
syncAdapters()n'appelleprovisionEvChargerConfig()que dans la branche!m_adapters.contains(id), et l'adaptateur survit à la suppression de l'entrée. Constaté : rien recréé en 140 s.Conséquence : la borne reste arbitrée mais absente de
GetLoadConfig, à un rang de queue non réglable (evPriority()→ 1000). C'est le trou de 3g-2 — « arbitrée mais absente de la configuration » — rouvert par le côté client. Il ne se referme qu'au redémarrage denymead.CORRIGÉ en
1.15.2+etm28(LM-1209-e) : le rattrapage est cyclique, sa condition est « cette borne n'a pas d'entrée » et non « l'adaptateur vient de naître ». Une entrée supprimée revient au cycle suivant, en queue et enrankOrigin: "auto"— le rang posé par l'installateur est perdu avec l'entrée, ce qui fait de la suppression un geste déclassant, à ne pas confondre avecenabled: false. Le REFUS de provisionnement, lui, ne s'annonce qu'une fois par borne : cyclique, il noierait sinon le journal.
DÉPLOYÉ SUR .75 — 1.15.2+etm26, 2026-08-28. Cross-build arm64 dans build-cross-arm64
(bundle git → /root/build-etm26), posé par deploy.sh, version relue sur la box, nymead
actif. measuredW a bien disparu de la charge utile ; measurement {source, powerW} est publié.
Ce que la box publie est la CONFIGURATION VOULUE, pas un angle mort — et il ne faut pas la défaire pour « mieux voir » (consigne Patrick, 2026-08-28) :
chauffe-eauetpac-terrainportent chacun unmeterThingIdvers un compteur RÉEL et distinct (ECS-Meter1 500 W,PAC-Meter800 W). Ils publient doncsource: "meter", et le chemindevicen'est jamais atteint. Lapac-terrainne publie donc PASnone: le compteur explicite l'emporte, y compris sur unsg-ready. LM-1105-b décrit ce que la PAC publierait sans compteur dédié.- Les deux bornes sont
pluggedIn: false(V2C Trydan enChargingModeEcoWithMinCurrent, wallbox simulée enChargingModeEco, aucune voiture assignée — donc plus exclues par ce motif depuis+etm25). Elles sont absentes deloads[], pas ennone: la V2C ne peut donc pas montrer sonsource: "meter"tant que rien n'y est branché.⚠️ NE PAS retirer ces
meterThingIdpour observerdevice/none. C'est techniquement possible — champ souple, aucun rebuild ni réarmement de verrou — et c'est quand même à ne pas faire : ces compteurs ont été posés le 2026-08-27,meterqui l'emporte EST le comportement voulu, et les deux chemins masqués sont déjà éprouvés en simulation, vérifiés échouant (testMeasurementSourceIsPublished).Corollaire de « à guetter, jamais à fabriquer » : le banc sert les cas qu'on ne sait pas fabriquer, pas à rejouer ce qu'on a démontré. Défaire une configuration juste pour la voir autrement échange une preuve contre une observation — et laisse la box dans un état que personne n'a voulu.
Note de méthode, tirée du même passage : trois attentes de vérification étaient fausses parce qu'elles raisonnaient par mécanisme là où la configuration décide. C'est la même erreur que LM-1106 corrige côté client (« décider sur
source, jamais suradapter») — elle se refait aussi bien en lisant un banc qu'en écrivant une app.
À VÉRIFIER AU BANC, distinct et non tranché : le compteur racine lit ~1,5 kW de moins que la somme des charges mesurées (3 250 W contre 3 980 W pour la seule V2C, plus 800 W de PAC). Même famille que l'aveuglement documenté aux résistances ECS. Ce n'est pas un problème d'arbitrage — câblage ou vue SunSpec — mais un budget juste suppose un compteur qui voit tout.
FAIT (2026-08-28, 1.15.2+etm26) — la mesure par charge se lit sur l'appareil
piloté, specs/spec_loadmodel.md LM-1105/LM-1106, sur un constat d'usage de l'agent app.
meterThingId était la SEULE source : il fallait désigner une borne comme son propre compteur
pour qu'elle publie ce qu'elle mesure déjà. Il reste prioritaire — un réglage sans effet est
pire que pas de réglage — mais cesse d'être requis.
Ce que la vérification a trouvé, et qui décide de la forme : telemetry().currentPowerW porte
déjà trois régimes sous un seul nom — mesuré (evcharger), mesuré OU palier nominal
(relay-router, dont le booléen metered est calculé puis jeté), déclaré (sg-ready,
délibérément, invariant 8). D'où measurement {source, o:powerW} en remplacement de
measuredW, avec source: "none" comme état positif. Contrat déplacé : INTERFACE.md.
ILoadAdapter::deviceMeasurement() est pure virtuelle, comme runtimeView() : un mécanisme
ajouté qui hériterait d'un « non mesurable » par défaut sortirait de la mesure sans bruit, et son
écran afficherait « pas mesurable » avec l'autorité d'un constat.
Deux affinements imposés par l'implantation, tous deux de modèle : LM-1105-a — une mesure
partielle n'est pas une mesure (relais mixtes → none) ; LM-1105-b — la règle présuppose que
le Thing piloté est TRAVERSÉ par la puissance, ce qu'un contact SG-Ready n'est pas : sg-ready
est none par construction, et c'est le seul mécanisme où meterThingId reste la seule voie.
Éprouvé par testMeasurementSourceIsPublished (trois sources + les deux règles de priorité),
la règle « capacité jugée sur la classe » vérifiée échouant quand on la juge sur les relais
actifs.
Ce lot ne touche PAS ce que l'arbitre lit : LM-1101 tient, et le recrédit anti-clignotement continue de lire
LoadContext::telemetry.currentPowerW, qui vient de l'adaptateur et n'a jamais transité parmeterThingId. Ne pas y voir une réouverture de la correction B.
FAIT (2026-08-28,
1.15.2+etm27) — le rang par défaut des bornes auto-provisionnées. Tranché : insertion en queue (1 + max(rangs)), jamais en tête —specs/spec_loadmodel.mdLM-1209. L'auto-provisionnement remplit un trou, il ne décide pas : le rang est un choix de l'installateur, et une détection ne réordonne pas ce qu'un humain a décidé. Ne contredit pas le défaut « le VE d'abord », qui décrit une mise en service INITIALE et non un ajout ultérieur.Trois pièces avec : LM-1209-a l'unicité de rang garantie plutôt que supposée (et la mise en garde qui va avec — l'égalité ne coûte pas le déterminisme, le tri est total sur
(rang, identifiant)depuis 3g-2 ; elle coûte la GOUVERNABILITÉ) ; LM-1209-b insérer ne renumérote personne ; LM-1209-co:rankOrigin— la création se dit.Et une règle générale en est sortie, LM-1209-a-bis : une vérification qui ÉCARTE et une vérification qui REFUSE ne peuvent pas être la même fonction. Mettre l'unicité de rang dans
validateSet()— queload()utilise pour écarter — aurait fait perdre des charges au démarrage sur une configuration héritée, sansapplySafeState.validateRanks()est donc séparée et n'est appelée que parsetConfigs(). Migration : au chargement, signalé bruyamment, conservé, réparé à la première écriture.
PROCHAINE ACTION : rien de tranché en attente.
§10 — DÉBLOQUÉ (2026-08-29). L'agent app a rendu sa maquette —
etm-powersync-app/docs/mockups/deux_passes_eco_confort_mockup.html, elle fait foi — et 8.5
est tranché. Décisions et exigences : specs/spec_loadmodel.md LM-1010. Précédence des
motifs et réponse à R4 : docs/DESIGN_10.md.
⚠️ PRÉCONDITION DU §10, et elle n'est pas dans la maquette (Patrick, 2026-08-30) : le §10 ne part pas sans l'extension du DÉLESTAGE au waterfall. C'est LM-1006 couplage 1 — si tous les planchers éco se servent au réseau le même matin sans soleil, ils s'additionnent sur le disjoncteur de branchement. Or la protection de surcharge d'aujourd'hui ne déleste que les bornes. Éco et délestage sont le même lot ; les séparer livrerait un plancher capable de faire disjoncter une installation.
FAIT — R2 : funding est descendu au NIVEAU (1.15.2+etm30, posé sur .75). levels[] est
en ligne, funding a disparu du niveau de la charge, et l'identité de réconciliation est écrite
dans INTERFACE.md — vérifiée sur la vraie box : Σ targetW(surplus) = 5 000 =
budget.allocatedW.
PROCHAINE EXIGENCE : R3 — chaque watt dans exactement un compteur, mapping publié. BLOQUÉE, et le blocage est un résultat : une des trois destinations n'en est pas une.
budget.evReservedWn'est PAS un registre d'allocation. C'est un terme de correction du budget, et il mélange deux natures : la correction A — commandé non encore mesuré, calculé avant la cascade (rulebasedscheduler.cpp:90-106) — et la part réseau d'EV_GRID_START, qui est une vraie tranche d'allocation (:355).Donc une borne servie au réseau qui tire déjà ce qu'on lui a commandé y contribue 0 alors que son
targetWvaut 7 000 W :Σ counts == targetWserait vrai au premier cycle et faux ensuite. Ça passerait les tests et dériverait en production — la pire forme d'échec.Ce que R3 a mis au jour : les watts achetés n'ont aucun registre aujourd'hui.
draw.committedWdevient donc un préalable à R3, pas une suite. Il est petit — somme destargetWfinancés au réseau — et ne change aucune arithmétique : la ligne 355 est de la publication seule. Ce qui change est le sens d'evReservedW, qui redevient une correction. Proposé à l'app (brief §10), en attente de sa réponse : c'est son identité de réconciliation.
Ancienne prochaine action, faite :
R2 — funding descend au NIVEAU. C'est la seule des huit
exigences qui soit un changement de forme d'un champ existant et non un ajout : chaque écran
qui s'appuiera d'ici là sur un funding par charge en augmentera le coût de déplacement, les
sept autres attendent sans se renchérir. Au §10, la même charge sera financée au réseau pour
son éco et au surplus pour son confort dans le même cycle — un funding par charge devient
indécidable, et c'est lui qui porte l'identité de réconciliation de l'app.
À corriger dans la maquette avant de coder : elle pose que
allocatedWpublie « ce qui a été commandé ». C'est faux —buildTelemetry()publie le plan, etapplyActionsToAdapters()reçoit le slotconst.allocatedWest du décidé, commetargetWle sera. L'écart que l'écran veut nommer est entreallocatedWet la charge utile de mécanisme, déjà publiée. Détail et mesure :docs/DESIGN_10.md.
§10 — l'ancien point ouvert, pour mémoire. Le design est écrit :
docs/DESIGN_10.md. Sur les cinq points à trancher, quatre le sont — réserve batterie
(8.1), repli dégradé (8.3), plafond §14a portant les deux formes sans conversion (8.2), cible de
confort en comparateur (8.4).
Reste 8.5 — l'ordre de service apparent : un plancher éco de rang 4 passe devant un confort
de rang 1, ce qui est voulu et aura l'air d'un bug. Question posée à l'agent app le
2026-08-29, docs/BRIEF_depuis_plugin.md §9 — sur quoi la liste est ordonnée, une entrée ou
deux par charge, et ce qu'on voit d'une charge dont l'éco est servi et le confort non.
⚠️ Le §10 ne se code pas avant cette réponse. La troisième question n'a aucun équivalent dans la télémétrie d'aujourd'hui — un motif, une allocation, un seul de chaque. Commencer par le moteur reviendrait à inventer une charge utile puis à demander à l'écran de s'en arranger.
Deux notes de conception le contraignent, à relire avant de coder :
LM-1006-1 (le délestage est un plafond de soutirage à trois sources) et LM-1009
(une échéance ne vaut que ce que son avancement se mesure — sessionEnergy est publiée et lue
par personne, tandis que le pourcentage comparé à la cible est estimé par le moteur lui-même).
LM-1009 porte trois décisions arrêtées — avancement en énergie livrée, obligation par session
d'abord, estimation étiquetée à la publication — et deux conditions de livraison : dégrader
visiblement sur les bornes sans sessionEnergy, et savoir dire lequel des deux régimes
s'applique. Ce ne sont pas des recommandations.
Ensuite : passe contrats (OPTIMIZER_PROTOCOL + README).
Le trou de
pluggedIntrouvé au même passage est CORRIGÉ — une borne sans véhicule branché est hors arbitrage, elle n'absorbe plus le budget de la cascade.
Le chantier de documentation a exhumé son premier défaut réel (2026-08-29).
ECS-111— « on publie la puissance d'un relais disparu » — dormait dans une table de statut supprimée depuis, et est ressortie dans les quatorze règles « spécifiées seules en territoire livré » quetools/gen-rules.pya listées. La lecture a montré que le chiffre fantôme n'était pas seulement affiché : il alimentait le recrédit, donc le budget, pendant qu'availablerestait vrai. Ni un test ni le banc ne l'avaient vu. Corrigé le jour même (ECS-111, option (a) — sortie de l'arbitrage, maintien dans la comptabilité).
Restent à guetter au banc, jamais à fabriquer : EV_GRID_START (fenêtre
[plancher × 0,5 ; plancher[), BATTERY_RESERVE avec un surplus réel sous le seuil, et
LM-1204-b avec deux bornes réelles — la Terra AC ne répond toujours pas à l'ARP.
Sauvegarder l'historique quand la forge est injoignable
Un bundle COPIÉ ailleurs n'est pas une sauvegarde. Un bundle CLONÉ à blanc depuis ailleurs en est une. La différence ne se voit que le jour où elle compte, et ce jour-là il est trop tard pour la découvrir.
# 1. produire — --all, pas une branche : les tags et les autres branches comptent aussi
git bundle create ~/etm-plugin-$(date +%Y%m%d-%H%M).bundle --all
# 2. déposer sur une AUTRE machine (le banc fait l'affaire)
ssh etm@192.168.1.75 'mkdir -p ~/git-secours'
scp ~/etm-plugin-*.bundle etm@192.168.1.75:~/git-secours/
# 3. PROUVER qu'il est restaurable — depuis la machine de destination, pas depuis celle-ci
ssh etm@192.168.1.75 'cd ~/git-secours && B=$(ls -t *.bundle | head -1) &&
git bundle verify "$B" && git clone -q --bare "$B" /tmp/v &&
git --git-dir=/tmp/v log --oneline -1 &&
git --git-dir=/tmp/v rev-list --count HEAD && rm -rf /tmp/v'
L'étape 3 est celle qu'on saute. Elle coûte dix secondes et c'est la seule qui distingue « restaurable depuis une autre machine » de « présent sur une autre machine ».
Quand. Dès que la forge est injoignable et que des commits s'accumulent en local. Le 2026-08-30, 64 commits ne vivaient que sur
etm-powersync-dev, fibre coupée — et le bundle aussi, tant qu'il y restait.Ce que ça ne remplace pas : un
git push. Un bundle est une photo, pas un remote — il ne se met pas à jour, et personne d'autre ne travaille dessus. À rafraîchir à chaque session tant que la forge ne répond pas.
Lire le journal de la box — et ce qu'une absence prouve
journalctl -u nymead, SANS sudo. L'utilisateur etm est dans le groupe adm, qui
donne accès au journal. sudo journalctl échoue — pas de TTY pour saisir le mot de passe —
et l'échec part sur stderr : stdout est vide, le grep en aval ne trouve rien, et la
commande rend 0. Un relevé conduit ainsi ne dit rien et en a l'air.
Constaté le 2026-08-29, sur mes propres relevés des deux jours précédents : deux recherches « la ligne X apparaît-elle ? » n'avaient rien lu du tout. Et l'explication que j'avais donnée à l'absence — « cette ligne est en
qCDebug, le niveau n'est pas actif » — était fausse : les lignesD | NymeaEnergy:sortent bel et bien dans le journal de.75. Une cause plausible avancée pour un silence qu'on n'a pas mesuré, c'est le corollaire LM-104 appliqué à l'instrument : l'hypothèse de prudence devient un masquage de panne.
RÈGLE — une absence ne s'affirme que dans un flux dont on a prouvé qu'il coule. Citer une
ligne qu'on a LUE dans la même fenêtre, puis dire ce qui n'y est pas. docs/RELEVE_LOTD.md
§ (09:14:55) est le modèle : il cite la ligne 0 créée(s), 0 mise(s) à jour, 2 INCHANGÉE(S)
qu'il a lue, puis conclut « aucune ligne construit depuis config, aucun ECS-413, aucune
commutation ». L'absence y porte, parce que la présence l'accompagne.
Audit du 2026-08-29 : les relevés du dépôt ont été repris sur ce critère. Aucun ne repose sur un journal vide —
RELEVE_LOTBbis.md§267 parle d'un dry-runreprepro, pas du journal. Le seul défaut trouvé était dans la procédure :docs/TEST_TERRAIN.mdprescrivaitsudo journalctldans son helperlogs(), celui qu'un opérateur lance pendant un essai — corrigé. Sa ligne 51, elle, était déjà juste : le document se contredisait.
Remotes git — cartographie VÉRIFIÉE le 2026-08-30 (la précédente était fausse sur deux points, et l'un d'eux aurait fait échouer tout push) :
| Remote | URL | État |
|---|---|---|
origin |
https://git.etm-powersync.fr/ETM-Schurig/etm-powersync-energy-plugin-etm.git |
dépôt de travail, mais l'HTTPS n'authentifie pas |
origin-ssh |
gitea-lan:ETM-Schurig/etm-powersync-energy-plugin-etm.git |
LE MÊME dépôt, par SSH — c'est la route qui marche |
etm-public |
gitea-lan:ETM-Schurig/powersync-energy-plugin-etm.git |
⚠️ le dépôt N'EXISTE PAS — Cannot find repository |
etm-pro |
gitea-lan:...-etmpro.git |
reliquat historique — ne pas utiliser |
Pousser : git push origin-ssh feature/beta-rulebased.
gitea-lan=192.168.1.113, et c'est la seule route SSH qui fonctionne : le port 22 degit.etm-powersync.frest filtré. Le même Gitea sert les deux noms — d'où le fait qu'unoriginen HTTPS et unorigin-sshen SSH désignent le même dépôt, pas deux.Ce qui était faux avant : «
gitea-lan= miroir public GPL, ne jamais pousser directement ». Le cheminpowersync-energy-plugin-etm.git(sans le préfixeetm-) ne correspond à aucun dépôt — un push y aurait échoué, pas publié. La prudence était donc fondée sur une cartographie inexacte, et elle bloquait la seule route disponible.Ce qui reste vrai : la publication du miroir public GPL est un geste manuel de Patrick (
sync-public.sh). Vérifier l'ascendance avant tout push (git merge-base --is-ancestor <distant> HEAD) : c'est ce qui distingue une avance rapide d'un écrasement.
INVARIANTS BUILD / PACKAGING (ne pas re-questionner)
Nom et version du paquet Debian
| Champ | Valeur | Raison |
|---|---|---|
| Source / Package | powersync-energy-plugin-nymea |
Fork ETM Voie B (FORK-WORKFLOW.md) — distingué de l'amont nymea-energy-plugin-nymea |
| Version | <nymea>+etmN (N incrémenté à chaque release) |
Ex. 1.15.2+etm1 si la box cible tourne nymea 1.15.2 |
| Distribution | trixie |
OS cible hems (arm64 Debian 13 Trixie) |
| Provides/Conflicts/Replaces | nymea-energy-plugin-nymea |
Voie B obligatoire : remplace l'amont proprement, sans double chargement |
TARGET .so |
libnymea_energypluginnymea.so |
Inchangé (nom de chargement nymea fixé par l'amont) |
Build cross arm64
- Hôte : conteneur LXD
build-cross-arm64suretm-powersync-dev(amd64) - Commande :
dpkg-buildpackage -aarm64 -b -uc -usdans/root/etm-powersync-energy-plugin-etm - Dépendances arm64 requises :
libnymea-dev:arm64,libnymea-energy-dev:arm64,libnymea-tests-dev:arm64,nymea-experience-plugin-energy:arm64,qt6-base-dev:arm64. Versions constatées au build du 2026-08-08 (1.15.2+etm3) : nymea1.15.2+202606191336~trixie1, Qt6.8.2+dfsg-9+deb13u2— et non1.15.0/Qt5 comme l'indiquait cette section. La chaîne est Qt6 de bout en bout, cf. ci-dessous. lrelease: fourni parqt6-tools-dev-tools— nécessaire si absent du conteneur- Arbre vierge : lancer
lreleaseAVANT le premierdpkg-buildpackage. Constaté le 2026-08-09 en construisant1.15.2+etm7depuis ungit archive.energyplugin.prorésout$$files(translations/*.qm)au moment deqmake; sur un arbre sans.qm, la liste est vide, les traductions ne sont pas installées, etdh_installéchoue enmissing filesaprès quelreleaseles a pourtant fabriquées. Le clone canonique/root/etm-powersync-energy-plugin-etmmasque le défaut : ses.qmtraînent d'un build antérieur. Remède :lrelease energyplugin/translations/*.ts, puis supprimer.qmake.stashet lesMakefileavant de relancer. Ce n'est pas un défaut du code. - Ne pas builder sur le Pi : Zero 2 W, 512 Mo → OOM garanti
Tests hors paquet deb
- Les tests (
tests/) ne sont pas compilés dans le.debde prod. etm-powersync-energy-plugin-etm.pro: tests sousbuild_tests { SUBDIRS += tests }uniquement.debian-qt6/rules:DH_OPTIONS=-N nymea-energy-tests— exclut le paquet tests de tous les outils dh. C'est bien qt6 qui construit :debian→debian-qt6, dont lesrulesportentdh $@ --buildsystem=qmake6.debian-qt5/rules(--buildsystem=qmake) n'est plus la référence ; il conserve la même exclusion, plus unoverride_dh_missingabsent de qt6. Vérifié au build du 2026-08-08 : aucun paquetnymea-energy-testsproduit.- Un seul changelog pour les deux arbres :
debian-qt6/changelogest un lien symbolique versionné vers../debian-qt5/changelog, commecopyrightetnymea-energy-tests.install.in. Écrire dansdebian/changelogécrit donc l'unique fichier réel. Ne pas en déduire une divergence entre les deux arbres — il n'y en a pas. - Pour compiler les tests localement :
qmake CONFIG+=build_tests && make. - ⚠️ NE JAMAIS lancer la suite de simulation en un seul processus. Chaque test instancie un
serveur nymea complet et rien n'est rendu entre deux : en un processus le binaire atteint 7-8
Gio de RSS et l'OOM killer emporte la session graphique (constaté deux fois le 2026-08-25 ;
total-vmidentique à 200 ko près, donc déterministe). Protocole : un processus par test (-functionspuis boucle, agrégation à la main), chacun encadré parsystemd-run --user --scope -p MemoryMax=2G -p MemorySwapMax=0— un test qui dérape meurt seul. Vaut pour toute validation, y compris les vérifications intermédiaires. Ainsi lancée, la suite tient en 61-66 Mo par test (413 Mo pourrun, data-driven). - Contrôler que le run teste vraiment quelque chose. Les chemins de plugins mock dépendent du
cwd: lancé au mauvais endroit, le binaire enchaîne 33ThingErrorThingClassNotFounden 16 s sans rien exécuter — ce qui ressemble à un run rapide et propre. Vérifier dans le préambule queTests: Mock plugin path:pointe vers un.soexistant, et que le nombre de tests passés est cohérent. - Port 4847 : instabilité connue du harnais nymea. Le plugin mock système rouvre un port FIXE
à chaque cycle
cleanupTestCase()/initTestCase(); les tests qui réinstancient un serveur en cours de route échouent par intermittence surFailed to open HTTP port. Port in use?. Rejouer une fois et étiqueter, jamais masquer. Même famille que la cause de fond ci-dessous. - Cause racine :
libnymea-tests:arm641.15.0 n'exporte pasenableNotifications→ link fail cross.
Vérification post-build obligatoire
dpkg-deb -I powersync-energy-plugin-nymea_*.deb | grep -E "Architecture|Version|Depends|Provides|Conflicts|Replaces"
# Attendu : Architecture: arm64 ; Version: <nymea>+etm* aligné sur la box cible
# Depends: libnymea-energy (>= <nymea>...) — la version doit matcher la box
# Provides/Conflicts/Replaces: nymea-energy-plugin-nymea (Voie B obligatoire)
Dater le paquet qui porte la GRANDEUR, pas celui qui porte la fonction
La fonction et la grandeur qu'elle consomme peuvent vivre dans deux paquets différents. C'est la seconde qu'il faut dater.
Le 2026-09-04, l'étalement éco ne pouvait pas se démontrer sur .75 : ECS-Meter. totalEnergyConsumed valait 10 kWh, immobile. Le moteur était à jour — +etm53, posé la
veille. Ce qui manquait était dans nymea-plugin-generic-energy, jamais reconstruit depuis
le 19 juin, qui possède la ThingClass smartMeterConsumer et n'intégrait rien.
Ce qui rend le piège coûteux, c'est que la consigne de vérification aurait rassuré :
dpkg -l | grep powersync-energy # → « 1.15.2+etm53 » : tout va bien
Elle interroge le paquet de la fonction. Le silence venait d'un paquet qu'on ne t'avait pas livré — et sans la ligne ci-dessous, il te serait imputé.
C'est un cran plus fin que le piège du 2026-09-03. Là, il fallait vérifier la version avant
de conclure. Ici, il faut vérifier la version du bon paquet — et savoir lequel suppose
de connaître qui possède la classe. Donc : ne jamais dater de mémoire, toujours demander à
dpkg qui possède la classe, PUIS dater ce paquet-là.
# Qui possède la ThingClass ? (à lancer sur la box)
CLASSE=smartMeterConsumer
for so in /usr/lib/*/nymea/plugins/*.so; do
{ strings -a "$so"; strings -a -el "$so"; } | grep -qx "$CLASSE" && dpkg -S "$so"
done
# → powersync-plugin-generic-energy: /usr/lib/aarch64-linux-gnu/nymea/plugins/libnymea_...so
dpkg-query -W -f='${Package} ${Version}\n' powersync-plugin-generic-energy
Les DEUX encodages sont interrogés : une chaîne QStringLiteral est stockée en UTF-16 et reste
invisible à un strings nu. C'est ce qui avait failli faire conclure à l'absence de champs
publiés, alors qu'ils étaient bien là.
Corollaire sur deploy.sh. Son verrou anti-rétrogradation compare la version du même nom
de paquet. Un paquet ETM qui RENOMME celui qu'il remplace (nymea-plugin-* →
powersync-plugin-*) se présente donc en « première installation » : aucune version n'est
comparée, et le verrou ne protège pas l'échange. Le vérifier à la main dans ce cas — c'est
exactement la même faille une couche plus bas, un garde-fou qui interroge le mauvais objet.
PLAN 3C — CLÔTURÉ
Le plan de mise en œuvre a été exécuté (6298d5d → 54ba229) puis remanié en rév. 3.
Il n'est plus reproduit ici : son pseudocode nommait une classe supprimée
(EcsRelayAdapter) et un identifiant d'adaptateur qui n'a jamais existé dans le code
livré ("relay-stages" ; la valeur réelle est "relay-router", relayrouter.cpp:74).
Autorité courante sur l'ECS : specs/spec_ecs.md.
Deux décisions de ce plan restent en vigueur et ne doivent pas être redécidées.
Correction A — déduction EV unique, dans le scheduler
ctx.meter.exportW est la mesure brute (règle absolue 8). Les consignes EV du cycle
courant ne sont pas encore visibles au compteur : leur puissance commandée non encore
mesurée est réservée dans getPlan(), après le proxy EV — jamais dans buildContext().
Implémentation : rulebasedscheduler.cpp:86-97.
Exemple : PV 9 kW, EV stable 7360 W, export mesuré 1140 W → evReservedW = 0,
budget charge = 1140 W.
Correction B — anti-clignotement par recrédit de la consommation courante
budgetCharge = remainingSurplusW + lc.telemetry.currentPowerW
Sans ce recrédit : la charge monte d'un palier → l'export chute → elle redescend → oscillation. Même mécanique que l'EV en amont.
Implémentation : rulebasedscheduler.cpp:186.
NE PAS « corriger » cette correction sur la foi d'un emballement observé au banc. Sur le banc
.75, deux conditions se composent : les relais GPIO n'exposent pascurrentPower(donc le recrédit crédite le nominal commandé) et le rootmeter est une vue SunSpec aveugle aux résistances câblées sur ces broches (donc la conso ECS ne fait jamais chuter l'export). Le budget double-compte alors et le palier grimpe jusqu'au plafond — constaté le 2026-08-09, quatorze plateaux sans une commutation.C'est un artefact de banc. Sur une installation réelle l'ECS est derrière le compteur réseau : sa consommation fait réellement chuter l'export, et le recrédit compense exactement ce que la charge vient de retirer. Le supprimer casserait l'anti-oscillation sans rien régler. Cf.
docs/RELEVE_ECS306.md§4.2.
⚠️ Tout plan antérieur mentionnant « créer etm/ avec PowerSyncClient et StaticHcHpProvider comme première étape » ou « injecter l'optimiseur dans SmartChargingManager » est INVALIDE et ABANDONNÉ. Ne pas le reprendre, quelle qu'en soit la source (fichier, mémoire de session, contexte).
ARCHITECTURE CIBLE (non négociable)
┌──────────────────────────────┐
│ ARBITRAGE CENTRAL │ ← généralisation du
│ budget de surplus UNIQUE │ SmartChargingManager amont
│ waterfall par priorités │
└──────┬───────────────────────┘
│ IScheduler (= contrat OPTIMIZER_PROTOCOL)
┌───────────┴────────────┐
RuleBasedScheduler SocketScheduler [3d]
(in-process, V1, GPL) (client unix://|tcp://
plan à 1 créneau repli rules si absent/mort)
│
│ ↓ LoadAction — enveloppe en W (Setpoint),
│ ou State (SG-Ready)
│ ↑ LoadContext — télémétrie + bornes de verrou
│
┌──────────┬─────────┴─────┬────────────────┬──────────────────┐
EvAdapter RelayRouter EtmVariableLoad SgReadyAdapter BatteryAdapter
W → combi- Adapter W → état 1-4 contrainte +
[câblage naison de W → consigne setpoint W
différé relais, continue réseau
3g] verrous [3f]
│ │ │ │ │
└────────────┴──────────────┴─────────────────┴────────────────────┘
│
Things nymea
(evcharger · powerswitch · états
powerSetpoint/currentPowerW)
Légende — sans marque : en place. [3d] [3f] : prévu, non écrit (cf.
## DÉFÉRÉ). [3g] : classe écrite, câblage différé (voir « EV » ci-dessous).
Contrat descendant. Le scheduler distribue une enveloppe en watts. Le kind Stage
a été supprimé (5100674) : aucun index de palier ni identifiant de relais ne descend —
seulement des watts, fussent-ils arrondis sur les paliers déclarés
(buildSetpointAction(), rulebasedscheduler.cpp:196-205). Seul SG-Ready conserve un kind
State : 4 états normés constructeur, non exprimables en watts.
Contrat montant. Chaque adaptateur remonte sa télémétrie et les bornes que ses verrous imposent au cycle courant, afin que l'arbitre décrémente le budget du palier réellement applicable.
Écarts connus entre cette cible et le code : specs/spec_ecs.md §1.
Traduction, pas répartition. Un adaptateur convertit une enveloppe en consigne
matérielle — RelayRouter retient la combinaison de relais dont la puissance approche
l'enveloppe par le bas (relayrouter.cpp:181-191), EtmVariableLoadAdapter module en
continu (etmvariableloadadapter.cpp:91), SgReadyAdapter mappe sur un état normé. Aucun
ne s'attribue de budget : la règle 2 est respectée.
Instanciation. RelayRouter et EtmVariableLoadAdapter sont construits par
EnergyArbitrator::rebuildLoadAdapters() depuis LoadConfigStore (persisté, RPC
Get/SetLoadConfig, reconstruction à chaud) — energyarbitrator.cpp:137-175.
SgReadyAdapter est encore codé en dur (energypluginnymea.cpp:66-76 : ThingId
K1/K2, paliers {3: 1500, 4: 3000}), à basculer sur la config avec la couche priorités.
Réserve sur EtmVariableLoadAdapter : il est exercé de bout en bout par
testLoadConfigBuildsAdapters, mais aucun matériel réel n'implémente encore l'interface
etmvariableload — seul le mock la porte
(tests/mocks/plugins/energymocks/integrationpluginenergymocks.json:865-888).
EV — transplanté (3g-1 + 3g-2). EvAdapter::applyAction() est appelé : le dispatch route
vers m_adapters les bornes que le waterfall a commandées, nommées par le scheduler dans
Plan::waterfallEvIds. Les bornes servies par la cascade ne repassent plus par
adjustEvChargers() — deux crochets virtuels (evSurplusPlannedByWaterfall,
evCommandedByWaterfall) les en sortent, et c'est ce qui garantit « une charge, un commandeur ».
Ce qui RESTE au proxy : l'échéance de départ et le tarif dynamique (financement réseau, DESIGN_3g §3.1), la protection de surcharge, l'estimation de SoC, le filtrage d'entrée. Une borne servie par ces chemins garde son dispatch amont, allowance de compteur comprise.
Brèche assumée : une borne peut DÉMARRER en soutirant, sous acquisitionTolerance — c'est le
comportement du proxy, rendu visible (motif EV_GRID_START, funding: grid, comptabilité
partagée). Bornée : surplus réel exigé, au plus une borne par cycle, sous le plafond L4.
Détail et limites : docs/DESIGN_3g.md §3.1-bis.
Règles absolues :
-
UN seul arbitre. Le budget de surplus est une ressource unique, arbitrée à UN endroit. INTERDIT : managers frères par type de charge (EcsManager, BatteryManager à côté du SmartChargingManager) — deux décideurs sur le même surplus = sur-engagement et oscillations.
-
Les LoadAdapters exécutent, ils ne décident pas. Un adaptateur : parle à son matériel, déclare ses capacités/contraintes (
declared,limits, types d'action), expose sa télémétrie, applique lesLoadActionreçues. Aucune logique de répartition dedans. -
Le SmartChargingManager amont est EV-spécifique : il se GÉNÉRALISE en arbitrage multi-charges (
ChargingAction→LoadAction, bornes EV → adaptateurs). On ne branche PAS l'optimiseur dans le manager EV tel quel. -
La boucle de sécurité est intouchable :
verifyOverloadProtection()(temps réel) + bornes par adaptateur écrêtent TOUTE sortie de stratégie, interne ou socket. -
Plan par créneaux (OPTIMIZER_PROTOCOL §6) : seul le créneau courant est exécuté. Le rule-based répond un plan à 1 créneau. Modèle async = cache : le plan du cycle précédent s'applique, le recalcul se fait en fond. Jamais d'attente dans
update(). -
Repli toujours fonctionnel : optimiseur absent/mort/abstain → rule-based. Capabilities (
tier,optimizerExpected,optimizerAlive,activeStrategy) reflètent l'état en continu. -
decisionReasonnon vide, en français, sur chaque action. Action sans reason = rejetée.- 7-b — le motif nomme le mécanisme qui a produit l'issue (ECS-309), et il ne le nomme que si ce mécanisme a réellement agi (ECS-309-b). Un motif faux est pire qu'un motif absent : il envoie diagnostiquer le mauvais problème, avec l'autorité d'une explication.
- 7-c — un silence ne doit jamais être ambigu. Journaliser l'issue, pas seulement
l'issue remarquable. Un traitement qui ne s'annonce que dans son cas notable rend
« exécuté, rien à signaler » indistinguable de « pas exécuté » — et c'est justement
dans le cas ordinaire qu'on vient vérifier qu'il a bien eu lieu.
Journaliser ce qui a été LU, pas seulement ce qui en est déduit : une conclusion
juste tirée d'une lecture incertaine se relit comme une conclusion sûre.
ECS-411 déduisait le palier de l'état des relais mais ne l'annonçait qu'au palier non nul (
if (i > 0)). Au palier 0 — le cas de l'armement minOff, celui qu'on vient précisément observer — le journal était muet, et « relais lus, tous ouverts » ne se distinguait pas de « reprise non exécutée ». Constaté au banc le 2026-08-13. Le relevé détaillé exigé par 7-c a immédiatement révélé un second défaut, de fond celui-là : un relais annoncé à(0W)parce que sa nominale était redéduite des encodages solo, que la déduplication peut évincer — d'où un palier 0 annoncé pendant que 1500 W circulaient. Rendre la lecture visible est ce qui rend le défaut trouvable ; c'est le sens de la règle, pas un effet secondaire. - 7-d — un refus DOIT être au moins aussi visible que l'application correspondante.
Jamais un refus en
qCDebugquand le succès qu'il remplace est enqCInfo. L'asymétrie est le défaut, pas le niveau : un resserrement de la journalisation fait alors disparaître le refus avant le succès, et il ne reste au journal que les décisions qui ont abouti. Or un refus est plus informatif qu'une application réussie — il dit qu'une décision a été prise et n'a PAS été exécutée.sgreadyadapter.cpp:150journalisait le refus par verrouminStateHoldenqCDebugquand l'application, dix lignes plus bas, était enqCInfo. Corrigé le 2026-08-13 ; c'était le seul cas, les autresqCDebugdu moteur étant symétriques (traces d'enregistrement et de construction, où les deux branches sont au même niveau). Le marqueur « mode dégradé L2 actif » reste enqCDebugà dessein : l'entrée est enqCWarninget la sortie enqCInfo, donc l'épisode reste délimité sans une ligne par cycle. - Une issue rendue à un opérateur distant se journalise là où elle est décidée, pas
dans chaque implémentation.
ClearLoadFaultest le seul levier de reprise à distance : son issue est écrite dans l'arbitre, qui couvre tous les adaptateurs d'un coup et ne peut pas être oublié par un adaptateur futur. Quand le type de retour permet de forcer la réponse —clearFault()rend unbool— le préférer à la discipline. - Les tests de ces quatre points portent sur le TEXTE publié, jamais sur la non-vacuité d'une chaîne : un test « motif non vide » n'aurait rien vu, dans aucun de ces cas.
-
Pas de boucle de feedback : surplus = PV mesurée + compteur, jamais le net après pilotage.
-
Aucun composant propriétaire ici (Héos = repo privé
etm-powersync-optimizer). Ce repo doit compiler et tourner seul, GPL pur. -
ZÉRO cloud — aucun appel réseau sortant vers un service distant (ni n8n, ni mail, ni push tiers). Le système fonctionne sans internet (autoconsommation, local-first). Toute alerte est locale : notification nymea in-app + signalisation physique (buzzer/relais via règle nymea). Le moteur expose l'état, il ne contacte personne. Exception : le plugin est CLIENT d'un optimiseur sur socket local/LAN (OPTIMIZER_PROTOCOL,
unix://outcp://du réseau de l'installation) — jamais un service cloud externe. -
Tout élément runtime a un
Get*ET un*Changed. Jamais une notification seule. Une notification sans lecture laisse le client connecté APRÈS le changement dans un silence ambigu : il ne peut pas distinguer « rien n'a changé » de « j'ai raté la transition ». C'est la famille ECS-411, appliquée à la frontière RPC.degradedModeétait poussé parChargingSchedulesChangedsans figurer dansGetChargingSchedules. Corrigé au lot B.- Corollaire — la charge utile d'un
Get*et celle de son*Changedse déclarent UNE fois. Deux déclarations divergent, et c'est l'appel de reprise à froid qui casse : celui qu'on n'exerce pas tous les jours. Cf.NymeaEnergyJsonHandler::loadTelemetrySchema(). - Corollaire — le
Get*rend ce qui a été PUBLIÉ, il ne recalcule pas. Un recalcul donne des grandeurs fraîches sous un horodatage périmé : un état qui n'a jamais existé, et que le client ne peut rapprocher d'aucune notification.
- Corollaire — la charge utile d'un
-
Aucune phrase en langue naturelle dans une charge utile RPC. La box transporte un code et des paramètres ; la phrase est fabriquée par le client, dans la langue de son utilisateur. La locale nymea est par connexion (
JSONRPC.Helloaccepteo:locale, rangée dansm_clientLocales) : deux utilisateurs d'une même box peuvent avoir deux langues, ce qu'une phrase composée en C++ ne peut pas servir. EtNymeaEnergyJsonHandlerest unJsonHandlersanspluginId: structurellement hors du catalogue de traduction nymea — rien ne rattraperait une phrase française émise là.- Le libellé de la charge est de la langue naturelle : il n'entre pas dans les paramètres, il est substitué au rendu, par l'appelant qui le connaît.
- Le journal reste en français, mais rendu depuis le code par une fonction unique
(
renderFr). Une ligne maintenue en parallèle du code divergerait, et c'est le diagnostic terrain qui deviendrait faux. - Corollaire de forme : un champ absent vaut mieux qu'un champ nul ou forgé.
timestampabsent = aucun cycle ;budgetabsent = planification suspendue. Un zéro publié se lirait « exécuté, rien à faire ».
CE QUI TIENT ET CE QUI S'ÉRODE
La différence n'a jamais été la qualité de l'argument — c'est de savoir s'il existe une machine qui le rejoue.
Une semaine d'août 2026 l'a montré six fois, et toujours dans le même sens : les décisions bien raisonnées et non exécutables ont dérivé sans bruit, celles qu'un programme rejouait ont tenu.
| Ce qui a dérivé | Ce qui a tenu |
|---|---|
la table d'état de spec_ecs.md §1 — écrite à la main, déclarait ECS-410/411/412 « absents » alors qu'ils étaient testés |
l'index des règles, généré, qui casse sur un orphelin |
| la table §9 « Traçabilité » — nommait huit tests qui n'ont jamais existé | ci-quality.sh, qui régénère et refuse |
| « l'entrée supprimée sera recréée » — écrit au brief, faux, jamais exercé | testDeletedEvChargerConfigComesBack, vérifié échouant |
ECS-111 — défaut connu, décrit dans la spec, jamais corrigé pendant des mois |
le test qui fige la charge, vérifié échouant |
l'absence de draw.authorisedW — défendue en prose à trois endroits |
testLoadTelemetryRpc, qui casse si on ajoute le champ |
rankOrigin « auto » et la précédence des motifs, laissés à déduire de chaque côté |
la règle écrite une fois, côté moteur, et le refus RPC qui l'applique |
La règle pratique qui en sort : quand une décision est prise, demander quelle machine la rejouera — un test, un garde-fou de générateur, un refus à la frontière RPC. S'il n'y en a aucune, la décision est une intention, et elle se périmera à la vitesse du code qu'elle décrit.
Corollaire, éprouvé lui aussi : un garde-fou qu'on n'a jamais vu échouer ne protège rien. Chaque test d'invariant de cette semaine a été vérifié échouant avant d'être déclaré prêt — c'est la seule façon de savoir qu'il regarde ce qu'on croit.
Le versant CONTRAT du même principe : un contrat qu'aucun appelant n'exerce n'est pas un contrat tenu, c'est un contrat non testé. ECS-415 en est le cas d'école —
applyAction()renvoyait, sur refus, l'action demandée : un appelant y relisait l'état qu'il venait de demander et concluait au succès. Le défaut a vécu sans conséquence tant que personne ne lisait ce retour (le dispatch l'ignore), et il est devenu grave à la minute où un appelant s'y est fié — publiant 3 kW rendus par une PAC restée en état 4.Les deux se répondent : le garde-fou dit « une règle que rien ne rejoue se périme », le contrat dit « une garantie que personne ne consomme n'a jamais été vérifiée ». Avant d'exploiter une valeur de retour qu'aucun code n'exploitait, l'épingler d'abord par un test — le premier appelant est aussi le premier vérificateur.
Une CINQUIÈME classe, et c'est la seule qu'aucun contrôle ne pouvait attraper (2026-09-01)
Une prémisse fausse sous une arithmétique exacte.
Les quatre autres se laissent prendre par un instrument : un champ qui ment (
ECS-111), une valeur de retour indiscernable de son contraire (ECS-415), un instrument muet (le déclencheur à l'horloge murale), une assertion vacante (la contre-épreuve inerte). Toutes ont une trace — quelque chose, quelque part, dit le faux ou ne dit rien.Celle-ci n'en a aucune. Le recrédit anti-clignotement rend au budget la consommation d'une charge, sur l'hypothèse qu'on pourra la lui reprendre. Si la charge n'obéit pas, la maison importe 3,5 kW que personne n'a décidés — et pourtant l'identité publiée,
surplus + recrédit − alloué == restant, tient exactement, dans les deux cas.Aucun contrôle de cohérence ne pouvait l'attraper, et aucun n'aurait pu. L'arithmétique est juste ; c'est l'hypothèse sur le monde qui ne l'est pas.
La parade, et elle se retient en une phrase : une identité qui tient ne prouve pas que le modèle est juste, seulement qu'il est cohérent. Ce qu'une identité ne peut jamais attraper, c'est une hypothèse sur le monde.
D'où la question à poser à chaque identité qu'on publie : sur quoi repose-t-elle qui ne soit pas dans ses propres termes ? Ici, sur l'obéissance de la charge — grandeur qui n'apparaît nulle part dans la formule, et qu'il a fallu aller mesurer ailleurs (
command.divergent) pour la rendre vérifiable. Une hypothèse ne se teste pas par recoupement interne : elle se teste en la confrontant au monde, ou elle ne se teste pas.
Corollaire de conception : l'asymétrie des latences joue dans le bon sens, et c'est une PROPRIÉTÉ, pas une chance. Le refus dangereux est celui de la DESCENTE — commander la baisse et ne pas être suivi — et c'est celui dont la latence est la plus courte (V2C : 7 s contre 30 s à la montée). Le refus qui coûte de l'argent se constate donc plus vite que celui qui n'en coûte pas. Ce n'est vrai que parce que le seuil vient du mécanisme : une constante unique perdrait exactement cette propriété, en traitant les deux sens pareil.
La question appliquée aux identités déjà publiées
« Sur quoi cette identité repose-t-elle qui ne soit pas dans ses propres termes ? » — passée aux trois autres, elle donne trois réponses inégales, et c'est le but :
Identité Ce sur quoi elle repose, hors de ses termes Vérifié ? Σ counts == targetWqu'aucun site d'émission n'oublie de renseigner countsoui — testCountsSumToTargetrelit la charge utile et ne fait confiance à aucun site ; c'est ainsi qu'un huitième site a été trouvéauthorisedW − committedW == remainingWque le compteur voie toute la maison — une charge en amont du compteur laisse l'identité vraie et sans objet non — rien ne le vérifie, et rien ne le peut depuis le moteur Σ levels[].targetW == estimatedPowerWque le décidé soit l'appliqué : elle est interne au PLAN et ne dit rien du matériel, que l'adaptateur écrête après coup depuis peu — c'est exactement ce que command.divergentmesureLa troisième est la leçon. Une identité peut être parfaitement vraie et ne parler que d'elle-même. Celle-là décrit ce que le moteur a décidé, pas ce que la maison fait — et il a fallu un champ HORS de la formule pour relier les deux. La deuxième reste ouverte, et il faut le savoir plutôt que de croire qu'elle prouve quelque chose sur l'installation.
La règle générale, à poser AVANT de se servir d'une identité comme garde : une identité dont les deux termes viennent de la MÊME source ne vérifie rien. Elle est vraie par construction — et elle rassure exactement là où rien ne protège. La question n'est donc pas « tient-elle ? » mais « de quoi parle-t-elle vraiment ? ».
Et c'est le même défaut que la contre-épreuve qui ne mord pas, vu de l'autre côté. Perturber une grandeur qui ne pouvait pas changer le résultat, et vérifier une identité qui se referme sur elle-même, sont deux façons de faire un geste de vérification qui ne touche rien. Dans les deux cas le voyant est vert, dans les deux cas il n'a rien mesuré. Le réflexe commun : demander ce que ce contrôle aurait attrapé s'il avait échoué — s'il n'y a pas de réponse, il ne contrôle rien.
Cas d'espèce, et il mord fort : l'arithmétique de dates (2026-09-05)
Entre deux instants du MÊME fuseau, la soustraction rend la durée d'HORLOGE MURALE, jamais la durée réelle. Conséquence directe : un contrôle qui cherche les journées de 23 ou 25 heures avec cette arithmétique rendra 24 h partout, et conclura qu'elles n'existent pas.
Trois de mes propres contrôles s'y sont trompés dans le même lot — un balayage annuel qui n'a rien trouvé, puis deux calculs rendant 24 h sur les deux dates de transition — et cela sur la question exacte que le champ
periodStartedAtexiste pour régler. Le contrôle qui prouve passe par les époques, jamais par une soustraction de dates.La parade générale, et c'est elle qui compte : l'attendu d'un test ne se calcule pas avec les primitives du code testé. Les époques de référence du test de frontière viennent d'un oracle calculé hors de Qt. Les re-dériver avec
QDateTimeaurait refermé l'identité sur elle-même — exactement la faute que la règle ci-dessus traque, retrouvée un cran plus bas : non plus dans l'assertion, mais dans la fabrication de sa valeur attendue.Deux instruments m'ont menti dans le même lot pour la même raison de forme — répondre « normal » parce qu'ils ne mesurent pas ce qu'on croit :
readelf -n | grep "Build-ID:"rend vide, parce quereadelfécritBuild ID:avec une espace. Conclusion tirée sur le moment : « les symboles ne correspondent pas. » Ils correspondaient.- la soustraction de dates ci-dessus.
Dans les deux cas la sortie était plausible — un vide, un 24. Un instrument qui rend une valeur crédible ne signale jamais qu'il a mesuré autre chose.
Une conclusion JUSTE posée sur un raisonnement FAUX survit jusqu'à ce qu'on bâtisse dessus
Deux occurrences, de deux côtés de la frontière :
allocatedWcôté app, la marge par phase côté moteur. Dans les deux cas la conclusion était bonne, la raison non — et personne ne relit le pourquoi tant que le résultat est bon.Cas moteur : le design affirmait qu'« une phase qui exporte a une marge PLUS GRANDE que le plafond, donc elle ne borne personne ». La conclusion est vraie ; la raison est fausse — la marge est écrêtée au plafond, l'export ne permettant pas de tirer plus que le disjoncteur. Le texte a vécu ainsi jusqu'à ce qu'un test bâtisse sur le raisonnement et attende
(25 + 10) × 230: il a échoué, et c'est ce qui a révélé l'erreur.C'est le mécanisme qui rend cette classe durable : la conclusion PROTÈGE le raisonnement de l'examen. Un résultat faux se voit ; un résultat juste ferme la question. La faute ne se révèle qu'au moment où quelqu'un s'appuie sur le pourquoi plutôt que sur le quoi — et ce moment peut ne jamais venir, ou venir des mois plus tard, dans un lot qui n'a plus rien à voir.
La parade est celle des récidives, transposée : quand une décision est écrite avec sa raison, la raison mérite son propre contrôle. Un test qui vérifie la conclusion ne vérifie pas le raisonnement — et le raisonnement est ce sur quoi le lot SUIVANT s'appuiera.
Une DÉROGATION qui ne sait pas dire quand elle finit ne devrait pas exister (2026-09-07)
Un mode se choisit une fois et vaut jusqu'au prochain choix ; une dérogation répond à un besoin ponctuel et s'éteint avec lui. Confondre les deux produit un mécanisme qui ne rend jamais la main : un boost encore actif après la fin est un mode qu'on a oublié de quitter.
La règle a une conséquence de conception, pas seulement d'ergonomie : si la fin n'est pas observable, la dérogation ne doit pas être offerte sous cette forme. Sur un chauffe-eau sans sonde, « jusqu'à ce que ce soit chaud » n'est pas mesurable — la fin doit alors être une durée, explicitement, et se dire à la saisie : l'écran de résultat parle trop tard. Même raisonnement que
progressMeasurable.Et la durée est un FILET, jamais une option. Toute dérogation en porte une, même quand elle a une meilleure condition de fin — parce que cette condition repose sur un instrument, et qu'un instrument peut se taire. Une cible qui ne remonte plus, une sonde qui tombe, un compteur qui se fige : la dérogation ne se termine jamais, en commandant le maximum. C'est « l'instrument dont le silence ressemble à un résultat », appliqué cette fois à une commande qui achète.
Et un mécanisme qui ACHÈTE s'arrête dans le doute. Deux erreurs sont possibles quand un signal devient douteux : terminer à tort, ou continuer à tort. Elles ne coûtent pas la même chose — terminer à tort rend la main (l'utilisateur relance), continuer à tort achète au maximum sur une mesure morte. Les seuils de confirmation doivent donc être asymétriques par conception : prompts à conclure la fin, lents à conclure la poursuite. C'est l'inverse du réglage qu'on choisirait pour un mode, et c'est parce qu'une dérogation achète.
Corollaire sur le retour : l'état d'avant se MÉMORISE, il ne se déduit pas. Retomber sur un défaut à la fin d'une dérogation détruit un réglage en silence — la même famille que le repli d'échéance qui réécrivait
EcoWithMinCurrentenEco.Design complet :
docs/DESIGN_DEROGATION.md.
Le MIROIR : toutes les prémisses vraies, la conclusion fausse (2026-09-05)
Le bloc ci-dessus traite la conclusion juste posée sur un raisonnement faux. Voici l'inverse, et il se lit encore moins bien : chaque prémisse était vérifiable et vraie, et la conclusion était fausse quand même.
Cas d'école. «
logEvent()prend un verrou et fait une I/O SQLite dans le thread qui écrit, donc chaque trace vole du temps à la boucle d'arbitrage. » Le verrou existe. L'I/O existe. Elle est bien dans le thread appelant. Et la conclusion est fausse :qCInfone passe pas parlogEvent()— il va au gestionnaire de messages — et sur la cible le moteur de journal est Influx, qui empile et poste en asynchrone. Le chemin coûteux existe et n'est jamais emprunté par ce qui était accusé de l'emprunter.La règle : un chemin coûteux dans le code ne coûte que s'il est PARCOURU. La lecture établit le mécanisme ; elle n'établit jamais la conséquence. Passer de l'un à l'autre demande la fréquence, et la fréquence ne se lit pas — elle se mesure. Chiffré ici : 2,1 ms par cycle de 60 s, soit 0,0035 %.
Et la mesure aussi peut porter à côté. Une première estimation en Python donnait 11 µs par ligne ; la vraie est 70. L'écart n'est pas du bruit : le proxy mesurait l'écriture, alors que le coût est dans le formatage — deux segments du même chemin, et le proxy en tenait le mauvais. Une mesure ne vaut que si elle couvre le chemin ENTIER, dans les conditions réelles. Ici il a fallu un banc en Qt, lié à la vraie bibliothèque, sur le vrai matériel.
Le bon résidu, quand une piste tombe : le mécanisme reste documenté, avec son chiffre. Le jour où quelqu'un branchera
logEvent()sur un signal fréquent, la mesure d'aujourd'hui dira à quoi comparer. Une hypothèse écartée par la mesure vaut mieux qu'une hypothèse jamais posée — elle laisse une borne derrière elle.
Quand un contrôle attrape quatre fois la même erreur, c'est le GESTE qui est piégeux
Le piège d'insertion Doxygen — un bloc glissé entre un commentaire et sa déclaration, si bien que la voisine hérite de
\paramqu'elle n'a pas — a été commis quatre fois en une semaine, à attention soutenue, et rattrapé quatre fois parci-quality.Un outil qui attrape quatre fois la même erreur chez quelqu'un d'attentif ne signale pas un défaut d'attention : il signale que le geste lui-même est piégeux. Le contrôle rattrape après coup ; ce qu'il faut alors, c'est un garde-fou à l'écriture.
D'où
tools/inserer-doc.py: il localise la déclaration, remonte au-dessus du bloc de documentation qui lui est déjà attaché, et insère là. Le bloc existant reste collé à sa propre déclaration, et l'erreur devient inexprimable au lieu d'être rattrapée.La règle générale : compter les récidives d'un même contrôle. Une fois est une inattention ; quatre fois est une conception à revoir — et la question n'est plus « comment mieux faire attention ? » mais « comment rendre l'erreur impossible à commettre ? ».
La diversité du mock n'est pas un confort de test (2026-09-03, TROISIÈME occurrence)
Trois fois, un défaut réel s'est révélé introuvable parce que le banc est plus homogène que la réalité : un compteur cohérent par construction entre courants et puissances (correctif
rootmeter), des classes portant TOUTES une puissance (source: noneinatteignable), et une triphasée toujours équilibrée (LM-1210-ajamais exercée).La diversité du mock décide de ce qu'aucun test ne pourra jamais trouver. Ce n'est donc pas une commodité qu'on ajoute quand on a le temps : c'est une borne supérieure sur ce que la suite peut prouver, et elle se fixe en écrivant les classes, pas en écrivant les tests.
Corollaire vérifié : ajouter une classe hétérogène (
dryRelay) n'a pas seulement débloqué un test — elle a trouvé un défaut, le mock déréférençant le contrôleur de chaque Thing en supposant que toute classe en possède un. L'homogénéité protégeait sa propre prémisse fausse.
Deux masques empilés dans la même chaîne (2026-09-04, QUATRIÈME occurrence)
Même famille, autre étage : l'environnement de vérification était plus indulgent que la réalité, et il l'a été deux fois de suite dans la même chaîne de build.
- Le paquetage qui NOMME le paquet vivait dans l'arbre de travail, pas dans git. Le build passait parce qu'il partait de l'arbre.
- Renommer les
.install.indedebian-qt5a fait pendouiller les dix liens dedebian-qt6qui pointaient dessus. Le build passait quand même : les.installd'un build PRÉCÉDENT traînaient encore, et make les trouvait à jour sans jamais suivre le lien.Un artefact périmé couvrait un lien cassé, et le build vert couvrait les deux. Aucune des deux erreurs n'était visible depuis le répertoire où l'on travaille — et c'est la propriété qui les rend chères : un répertoire de travail accumule de l'état qui répond à la place du dépôt. La question « est-ce que ça compile ? » n'a pas de valeur si l'on ne dit pas depuis quoi.
La seule vérification qui les révèle est la reconstruction dans un répertoire VIERGE, à partir de
git archive— jamais depuis l'arbre de travail, même « propre » au sens degit status. Un arbre sans modification en attente peut porter des fichiers générés que personne ne versionne, et ce sont eux qui mentent.La bonne façon de fermer : le md5. Le
.soreconstruit depuis git seul s'est révélé identique bit à bit à celui posé sur la box le matin même. Ce n'est pas la même affirmation que « ça compile » : c'est la preuve que le build reproductible produit exactement ce qui tourne. Tant qu'on ne l'a pas comparé, on a deux artefacts dont on espère qu'ils coïncident.
CINQUIÈME occurrence, et elle inverse le mécanisme (2026-09-05)
Les quatre précédentes décrivent une homogénéité qui masque un cas. Celle-ci en fabrique un faux, et c'est plus dangereux : un cas masqué se signale par un test qui ne mord pas ; un cas fabriqué se signale par des données qui rassurent.
Le journal de divergence, posé la veille, a produit 14 épisodes en 24 h — de quoi croire le phénomène fréquent et dimensionner tout un lot dessus. Or
ECS-Meterpublie 1500 W en permanence etPAC-Meter800 W : ce sont des simulateurs à valeur fixe. L'écart mesuré n'est pas une désobéissance, c'est la distance entre une consigne qui varie et un compteur qui n'en a rien à faire. Les quatorze épisodes décrivent le banc, pas le phénomène.Ce qui rend cette forme traître : l'instrument fonctionne, la donnée est réelle, le compte est juste. Rien n'est cassé. Seule la QUESTION à laquelle ces nombres répondent n'est pas celle qu'on croyait poser — et un nombre n'annonce jamais de quelle question il est la réponse.
Parade : avant de compter, exiger que l'instrument ait pu observer autre chose que ce qu'il observe. Ici : un compteur d'écart-type nul sur la fenêtre n'est pas un compteur, c'est une constante déguisée, et tout écart mesuré contre lui est un artefact. Le critère de tri correspondant est écrit avant les données, daté et poussé — cf.
docs/DESIGN_COMMANDE_PERDUE.md§6. Un critère rédigé après coup se plie aux résultats sans jamais mentir, et sans qu'on puisse le prouver.
SIXIÈME occurrence — ce n'est plus le mock, c'est L'ENVIRONNEMENT (2026-09-07)
Les cinq précédentes portent sur le banc simulé. Celle-ci porte sur le poste de vérification lui-même, et elle masque une classe entière de défauts.
Deux horloges identiques. Box et téléphone étaient tous deux en
Europe/Parispendant toute la campagne. À décalage nul, un instant rendu en UTC, en heure box ou en heure téléphone donne le même texte — donc aucun défaut de conversion ne pouvait se voir. Une bascule de fuseau d'un quart d'heure en a révélé deux d'un coup, dont l'un avait traversé toute la campagne.Rien dans le code ne pouvait le signaler, et c'est ce qui distingue cette forme : il n'y a pas d'assertion à écrire, pas de classe de mock à ajouter. La prémisse fausse n'est pas dans le banc, elle est dans la coïncidence de deux réglages extérieurs.
Le geste qui en découle : basculer le fuseau de la box de temps en temps est un RÉVÉLATEUR, pas seulement une manipulation de capture. Quinze minutes en
Europe/London, et tout ce qui confond « instant » et « heure murale » se dénonce. À faire quand rien ne mesure, et à noter aux deux bouts (cf. les coutures deDESIGN_COMMANDE_PERDUE.md§7).La distinction qu'il faut tenir, et elle vaut pour tout ce dépôt publie :
nature où elle se rend un RÉGLAGE — dailyDeadline, une plage tarifairechaîne d'horloge murale qu'on interprète dans le fuseau qui fait foi pour ce réglage un INSTANT — measuredSince,command.since,timestampabsolu, ancré à la capture chez le lecteur, dans SON fuseau Et le piège de mise en œuvre, mesuré :
.toUTC()au RENDU ne protège de rien. UnQDateTimeen spécificationQt::LocalTimene garde que des composantes murales et résout son époque à la conversion, avec le fuseau courant. Seul l'ancrage à la capture tient —now.toUTC()au moment où l'on stocke.
Piège de build, 2026-09-01 : changer la DISPOSITION d'une structure dans un en-tête inclus transitivement (ici un
QDateTimeajouté àEcoProgress, atteint parLoadAction) peut laisser des objets compilés contre l'ancienne. Le symptôme est un plantage dans un destructeur — qui ressemble en tout point à un défaut mémoire du code qu'on vient d'écrire. Avant de chercher le bug :make clean. Si le plantage disparaît, il n'a jamais existé.
Corollaire fin, appris le 2026-09-01 : une contre-épreuve qui NE MORD PAS ne prouve pas que le code est bon — elle prouve qu'on a perturbé au mauvais endroit.
Cas d'école : l'assertion « sous
unmeasurable,remainingWh == targetWh». La contre-épreuve naturelle — faire soustrairedeliveredWh— n'a rien changé, parce que sous ce régimedeliveredWhvaut 0. La perturbation était inerte. Il aurait été facile d'en conclure « le code est robuste » ; la vérité était « l'assertion n'est qu'un COROLLAIRE d'une autre garde ».Rejouée à l'endroit où le risque vit réellement — publier l'estimation interne comme un fait — elle a mordu instantanément, en faisant sortir
ECO_FLOOR_METsousunmeasurable.La règle : quand une contre-épreuve ne mord pas, la question n'est pas « le code est-il bon ? » mais « qu'ai-je réellement perturbé, et cette grandeur pouvait-elle changer le résultat ? ». Si elle ne le pouvait pas, l'essai n'a rien testé — et l'assertion qu'il devait valider est peut-être garantie ailleurs, par un invariant qu'on n'a pas identifié. C'est un cran plus fin que « vérifier le test échouant » : on peut voir un test échouer pour la mauvaise raison, et on peut le voir passer pour une raison qui n'est pas la sienne.
Et le cas INVERSE : une contre-épreuve inerte qui n'est PAS un défaut (2026-09-06)
Tout ce qui précède traite l'inertie comme un signal d'alarme. Il existe un cas où elle est saine, et il faut savoir le distinguer — sinon on ouvre une chasse au défaut sur du code correct, ce qui coûte deux fois : le temps, et la confiance dans le signal.
Le cas : le mécanisme tire sa valeur du CONTEXTE, pas de la valeur qu'on perturbe. Perturber le champ ne peut alors rien changer, non pas parce que le chemin est mort, mais parce que le champ n'est pas ce qui décide. Relevé sur le minimum réseau : le plancher vient de la borne (
6 A × phases × tension), pas du champ déclaré — donc modifier le champ laisse le plancher intact, et c'est correct.Le test qui sépare les deux, et c'est le même que partout : qu'est-ce qui déciderait, si ce n'est pas ça ?
- Inertie MALSAINE : on ne sait pas nommer ce qui décide à la place → le chemin est probablement mort, ou l'assertion est garantie par un invariant qu'on n'a pas identifié.
- Inertie SAINE : on nomme précisément l'autre source, et on peut la perturber ELLE pour voir la contre-épreuve mordre.
La conséquence pratique : une inertie saine n'est acceptable que si la contre-épreuve est déplacée sur la vraie source, jamais abandonnée. Écrire « ne mord pas, c'est normal » sans exhiber le contrôle qui mord, c'est refermer l'identité sur elle-même une fois de plus.
Un troisième mode d'érosion, et il est le SYMÉTRIQUE des deux autres (2026-08-31). Le garde-fou dit « une règle que rien ne rejoue se périme ». Voici l'inverse : une règle rendue IMMORTELLE par sa propre garde.
Cas d'école,
draw.authorisedW. Le champ a été refusé parce qu'il aurait affiché « un plafond de soutirage qui n'existe pas » — motif juste. Il a été défendu en prose à trois endroits, puis épinglé par un test qui casse si on l'ajoute. Puis le lot délestage a créé le plafond. Le motif était tombé ; la formulation, elle, a survécu — protégée par un test qui, lui, ne savait pas que le monde avait changé.La parade est déjà pratiquée ailleurs dans ce dépôt : le commentaire d'un test d'invariant doit dire à quelle condition il devient faux. Ceux qui portent « si ça casse, ce n'est pas le test qu'il faut corriger » ont bien vieilli, parce qu'ils nomment l'intention. Celui-là disait ce qu'il interdisait, jamais à quel moment il aurait dû mourir.
Règle pratique : quand un test fige une ABSENCE, son commentaire écrit la condition de levée. « Ce champ reste absent tant que rien ne le mesure » se relit et se lève ; « ce champ ne doit pas exister » se relit et s'obéit.
Un QUATRIÈME mode d'érosion, et il est l'inverse du troisième (2026-09-04). La règle immortelle survit parce que sa garde la protège. Celle-ci meurt parce que le chemin qui y menait s'est raréfié — et c'est un CORRECTIF qui l'a raréfié.
Cas d'école, le taux constant sous
unmeasurable(LM-1013-b-v). Décidé le 2026-09-01, avec son chiffrage et sa raison. PuissessionWha été alimenté depuis le compteur de charge, et le régimeunmeasurableest passé de « la V2C, tous les jours » à trois cas matériels rares. La décision n'a jamais été écrite : ni dans le code, ni dans la spec, ni au contrat. Trois jours plus tard, elle a été retrouvée par une question, pas par un contrôle.Ce qui rend cette classe traître, c'est que le correctif est un PROGRÈS. On ne se méfie pas de ce qui améliore les choses. Réduire un régime dégradé à ses cas irréductibles est exactement ce qu'il faut faire — et c'est précisément ce qui a fait disparaître la décision de la vue : plus personne ne passait devant, donc plus rien ne la rappelait.
Parade : quand un lot RÉDUIT la fréquence d'un chemin, lister ce qui vivait sur ce chemin et n'a pas encore été fait. Une décision non écrite ne se périme pas au rythme de sa pertinence, elle se périme au rythme de sa VISIBILITÉ — et les deux viennent de se découpler. Une décision qu'on prend sans l'écrire le jour même mise sur le fait qu'on repassera devant.
Un chiffre faux dans une spec finit par servir de prémisse à quelqu'un (2026-09-04)
Le chiffrage qui justifiait le taux constant — « ×2,5 sur une obligation de 4 kWh » — a été refait plutôt que repris au moment de l'écrire. Sur la fenêtre quotidienne, qui est celle du modèle, c'est ×4,75 ; le ×2,5 valait pour une fenêtre bien plus courte. La conclusion ne bougeait pas, le chiffre si.
C'est la variante numérique du raisonnement faux sous une conclusion juste (bloc ci-dessus), et elle se transmet plus facilement : un chiffre se cite, se recopie, et entre dans le calcul de quelqu'un d'autre sans que personne ne remonte à ses conditions. Ici il aurait voyagé avec « 4 kWh » et sans « fenêtre de 2,5 h ».
Règle : un chiffre écrit dans une spec porte les conditions qui le produisent, ou il est refait avant d'être écrit. Et quand il change, le dire — la spec garde les deux valeurs et pourquoi elles diffèrent, sinon le lecteur qui se souvient de l'ancienne croit à une erreur.
« Lent par construction » et « figé » ont la même forme — c'est une règle d'investigation
Relevé le 2026-09-04 sur
selfConsumptionRate, soupçonné d'être un compteur figé de plus : constant à 100 sur 40 secondes d'observation, pendant qu'autonomyRatevariait à côté.Il n'était pas figé. C'est un Δ de cumuls sur la journée locale : il bouge lentement par construction, et 100 % était la valeur JUSTE — zéro export cumulé sur la journée à cet instant. Sur un cycle de surplus complet il a parcouru 53,4 → 98,4, en 54 valeurs distinctes.
La leçon n'est pas « observer plus longtemps ». Elle aurait marché ici, et elle échoue ailleurs : une grandeur à cadence horaire résisterait à une heure d'observation. Le discriminant n'est pas la durée, c'est la NATURE de la grandeur — instantanée, cumulée sur une période, événementielle. Elle se lit dans le contrat, pas dans le signal.
Règle : avant de conclure qu'une grandeur est figée, dire ce qu'elle est censée faire et à quelle cadence. Sans cette phrase, toute observation est trop courte ou trop longue sans qu'on puisse le savoir — et c'est ainsi qu'on ouvre une chasse au défaut sur un champ qui va bien, après en avoir laissé passer un qui, lui, l'était vraiment.
Fait de méthode, relevé le 2026-08-30 : la règle d'ECS-415 était déjà écrite, dans le commentaire de la branche voisine du même fichier, à trois lignes de la branche fautive (« ce retour était MUET, si bien que "état déjà bon" ne se distinguait pas de "action jamais reçue" »). Elle ne s'était pas propagée pour autant.
Une règle écrite à un endroit ne se propage pas toute seule. C'est ce qui justifie, quand un défaut de ce genre apparaît, de balayer les autres implantations du même contrat plutôt que de corriger localement — le balayage a trouvé le même travers sur la branche « motif vide » du
RelayRouteret de l'EtmVariableLoadAdapter. Corollaire pour la relecture : la proximité d'un bon commentaire n'est pas une preuve que le code voisin le respecte.
RÉPONSES FIGÉES (ne plus poser ces questions)
- Plages HC/HP et tarifs : configuration JSON, jamais hardcodé. Prévoir Tempo (6 types de jours), pas seulement HC/HP.
- Async : modèle cache (cf. règle 5).
- Bugs upstream : commits séparés du code ETM, message préfixé
[upstream-fix]. Candidats PR nymea (fix phases EV, Keba) = patchs isolés, propres, upstreamables. protocolVersion: constante"1.0", pas un paramètre de config.- Renommage : FAIT (Phase 1, commit
f4d5b20). TARGET et noms de paquets debian INCHANGÉS (.so drop-in remplaçant l.amont — garantit un seul plugin énergie chargé).
WORKFLOW OBLIGATOIRE
Chaque phase produit un livrable VALIDÉ PAR PATRICK avant la suivante. Jamais de code avant validation du design de la phase.
- Phase 0 — Analyse (en cours) : répondre par écrit, code lu à l'appui : (a) quelles charges SmartChargingManager pilote-t-il (types manipulés) ; (b) ChargingAction peut-il exprimer « ECS palier 1 » / « batterie décharge interdite » — citer ses champs ; (c) avec des managers séparés, où vivrait le budget unique. Zéro code, zéro plan d'implémentation.
- Phase 1 — Renommage :
git mvdu.pro, TARGET, debian/. Un commit, revue. - Phase 2 — Design de l'arbitrage généralisé : interface
LoadAdapter(méthodes, ce qu'un adaptateur déclare), flux du budget, mappingLoadAction→adaptateurs, où vitIScheduler. Texte + signatures, pas d'implémentation. Validation Patrick. - Phase 3 — Implémentation par étapes (chacune : compile amd64 + cross arm64,
et un scénario
docker-simulation.shqui la prouve = DoD) : 3a. structs du protocole (contexte, plan, actions) ; 3b. arbitre + RuleBasedScheduler + EvAdapter (iso-fonctionnel avec l'amont sur EV) ; 3c. adaptateur ECS à paliers (livré, puis remplacé parRelayRouteren rév. 3) ; 3d. SocketScheduler (handshake/heartbeat/repli, testé contre un optimiseur factice ~50 lignes) ; 3e. SgReadyAdapter ; 3f. BatteryAdapter (constraints + charge réseau plafonnée). - Bugs upstream : au fil de l'eau, commits
[upstream-fix]séparés.
DÉCISIONS DE DESIGN (écarts et justifications)
3b révisé — délégation EV à l'amont (beta assumée)
Décision Patrick : hybride étagé pour la beta.
En beta : les décisions EV restent dans les méthodes amont
planSurplusCharging / planSpotMarketCharging (SmartChargingManager), inchangées.
RuleBasedScheduler::getPlan() les appelle en proxy et reformate leurs sorties
(ChargingActions) en LoadAction pour le log [Arbitre].
EvAdapter::applyAction() est implémenté (evadapter.cpp:62-94) mais jamais
appelé : applyActionsToAdapters() ne route les Setpoint que vers les charges pilotées,
et les EvAdapter vivent dans une table séparée jamais parcourue
(energyarbitrator.cpp:288). descriptor() et telemetry() sont utilisés dès maintenant
pour le SurplusContext. 3g est un travail de câblage, pas d'écriture — ne pas
planifier la réécriture d'un adaptateur qui existe.
Pipeline ETM réel (waterfall budget Surplus/Grid, applyAction) arrive en 3c
pour les charges non-EV (ECS, SG-Ready), alimenté par le surplus restant après
déduction de l'addedPower des consignes EV du cycle courant (pas encore visible
au compteur).
Limitations beta assumées :
- EV toujours prioritaire ; waterfall appliqué uniquement aux charges non-EV.
- Le classement drag-and-drop (priorités) ne portera que sur les charges non-EV.
Étape 3g (post-beta) : transplantation réelle de la logique EV dans
RuleBasedScheduler → priorités libres entre toutes les charges (EV, ECS, SG-Ready,
batterie).
Dette 3g — convention de priorité EV : RÉGLÉE en 3g-2. priority = 100 en dur a cédé la
place à EnergyArbitrator::evPriority(), qui lit le rang dans LoadConfig (LM-1207 : le rang du
VE vit là où vivent tous les rangs, jamais dans un second système). Toute borne détectée reçoit
d'office une entrée evcharger — rang, libellé, domain: ev, et aucune charge utile : ses
limites viennent du Thing. Le tri est total (à rang égal, l'identifiant départage), donc
reproductible d'un cycle à l'autre. Contrat : INTERFACE.md.
3b-iii — EnergyArbitrator hérite de SmartChargingManager
Design validé en session : "nouvelle classe dans etm/, n'étend pas SmartChargingManager".
Écart implémenté : EnergyArbitrator : public SmartChargingManager.
Justification :
-
Contrainte NymeaEnergyJsonHandler : ce handler amont prend un
SmartChargingManager*dans son constructeur.
Sans héritage, toute solution propre (interface commune, pointeur générique) nécessiterait de modifiernymeaenergyjsonhandler.h/.cpp— violation de la règle "Modifier le code amont uniquement pour corriger des bugs". -
verifyOverloadProtection() intacte : héritée bit-pour-bit, connectée aux mêmes signaux via le constructeur du parent. Zéro risque de régression sur la sécurité.
-
simulationCallUpdate() polymorphe : appelle
update()virtuel → redirige automatiquement versEnergyArbitrator::update(). Les tests amont passent sans modification. -
Minimal upstream diff : seuls les attributs
protected/virtualchangent danssmartchargingmanager.h(marqués// [ETM]). Zéro logique upstream modifiée.
Risque accepté : EnergyArbitrator a accès à l'état privé de SCM via les
accesseurs internal*. La discipline AGENTS (LoadAdapters exécutent, ne décident pas ;
un seul arbitre) compense. Si SCM était refactorisé en amont pour exposer une interface
publique propre, l'héritage pourrait être remplacé par composition.
Verrous minOn/minOff — protection compresseur (décision Patrick)
Le délestage du waterfall est strict au niveau budget (surplus net signé : en import, budget négatif → palier 0). Mais une charge à compresseur (PAC, ballon thermodynamique) ou un VE ont un temps de fonctionnement minimum incompressible : ce n'est pas du confort, c'est de la protection matérielle (le court-cycling détruit le compresseur).
Séparation des responsabilités :
- Le scheduler décide l'enveloppe idéale selon le budget (peut vouloir 0 W).
- L'adaptateur borne ce choix par sa fenêtre de verrou, évaluée au temps de cycle :
une charge verrouillée ON garde son palier ; l'import transitoire est borné par
minOn, pas illimité.
- Charges en watts : la fenêtre est interne à l'adaptateur
(
RelayRouter::lockWindow(),relayrouter.cpp:193-203) et n'est pas exposée au scheduler. - SG-Ready : la fenêtre est exposée, via
telemetry.minState/maxState(surpluscontext.h:66-71), et le scheduler clampe dessus (rulebasedscheduler.cpp:254-264).
- Charges en watts : la fenêtre est interne à l'adaptateur
(
minOnS/minOffSsont des paramètres par charge, lus depuis la configuration persistée (LoadConfig,energyarbitrator.cpp:162) — jamais codés en dur.
Écarts connus entre cette cible et le code : specs/spec_ecs.md §1.
Défauts indicatifs par type (à affiner à la mise en service) :
| Type de charge | minOn | minOff | Raison |
|---|---|---|---|
| Ballon résistif (ECS simple) | ~60 s | ~60 s | anti-rebond relais seul |
| Ballon thermodynamique / PAC | ~300–600 s | ~300 s | protection compresseur (anti court-cycling) |
| SG-Ready PAC (3e) | minStateHoldS ~900 s |
— | maintien d'état imposé constructeur |
Seam de temps : la fenêtre de verrou (décision) ET le verrou de applyAction
(exécution) partagent le même now = ctx.timestamp via lockWindow() — source unique,
divergence impossible par construction, injectable en simulation. Voir
iloadadapter.h:30-35 (contrat « temps = paramètre, jamais l'horloge »).
MODÈLE DE SÉCURITÉ (décision Patrick — immuable)
Cinq couches indépendantes. Chacune est conçue pour qu'une défaillance des couches
supérieures n'affecte pas les couches inférieures. Voir docs/SAFETY.md pour le détail.
| Couche | Qui | Quoi |
|---|---|---|
| L0 | Disjoncteur / Linky matériel | Coupure physique — hors logiciel |
| L1 | Failsafe natif des bornes | Config installateur, checklist ETM |
| L2 | Watchdog fraîcheur compteur (en place) | QTimer piloté : si lastMeterUpdate > 90 s → mode dégradé (EV min/off, ECS off, pas de charge réseau batterie), decisionReason explicite, notification nymea. Scénario simulation dédié : "compteur muet → repli". |
| L3 | Watchdog systemd sur nymead | Repo etm-powersync-deploy, hors scope ici |
| L4 | Logique signal-driven existante | Boucle update() déclenchée par événements |
Règles de code :
- Le watchdog L2 est piloté par
QTimer(pas par signalmeterChanged) pour rester actif même si le signal ne fire plus. - Mode dégradé = consignes de repli (EV au minimum si pluggedIn, ECS off, etc.)
decisionReasonnon vide + notificationEnergyManagerChangedavecdegradedMode.
verifyOverloadProtection()(L4) est déclenchée par deux mécanismes : (a) signalpowerBalanceChanged(temps réel — SCM.cpp ligne 127, mécanisme principal) ; (b) appel cyclique en position 3 d'update()(SCM.cpp ligne 313, filet périodique). La position dansupdate()est INTOUCHABLE — même dansEnergyArbitrator::update().
DÉFINITION DE FAIT (par étape de phase 3)
- Compile amd64 et cross arm64.
- Scénario de simulation ajouté/étendu qui démontre le comportement (le harnais
docker-simulation.sh+tests/autohérités sont le banc de test). decisionReasonvisibles dans les logs de simulation.- Aucune régression des tests amont existants.
- Toute classe/méthode publique de
etm/porte un commentaire Doxygen :\brief,\param,\return, et surtout le contrat de comportement (invariants, écrêtage, hypothèses que l'appelant peut faire). Les headers 3a servent de modèle — les convertir au format Doxygen lors du passage 3b. Y compris les ajouts// [ETM]horsetm/: ils échappent au périmètre duDoxyfile(frontière = un répertoire) et se documentent à la main. Leur inventaire est tenu dansenergyplugin/smartchargingmanager.h, au marqueur[ETM] BEGIN— toute nouvelle marque[ETM]dans un en-tête amont s'y ajoute. - Génération de la doc :
./tools/gen-doc.sh, et rien d'autre. Une commande unique, leDoxyfiledu dépôt, épinglé. Un compte d'avertissements ne vaut que rattaché à une configuration exacte — 163 sans générateur de sortie contre 113 avec HTML+XML sur le même arbre — donc un chiffre produit « à sa façon » ne se compare à rien. Le script signale aussi une version de doxygen différente de la référence.- Dépendances :
doxygenetgraphviz, de la CIBLE DOC uniquement. Le paquet se construit sans elles ; le conteneurbuild-cross-arm64n'a rien à faire de Graphviz. Ne pas les ajouter auxBuild-Depends. CALL_GRAPH/CALLER_GRAPHrestent àNOdélibérément : le moteur est signal-driven,powerBalanceChanged → verifyOverloadProtection()est une connexion Qt et non un appel. Un graphe d'appel montreraitupdate()en point d'entrée orphelin et raterait le mécanisme principal de la couche L4.- Le graphe d'héritage est tronqué par construction :
SmartChargingManagerest horsINPUT. L'arête est dessinée, mais la boîte est un cul-de-sac — sans membres, sans ancêtres, non cliquable. Ne pas élargir le périmètre pour « réparer » : cela ferait entrer tout l'amont dans la mesure.
- Dépendances :
ROADMAP — configuration des priorités par l'utilisateur (post-beta)
- ACQUIS (3e) : le waterfall trie déjà ECS + SG-Ready ensemble par
priority(rang ASC, 1 = servi en premier), budget unifié qui cascade à travers toutes ces charges. - ACQUIS (3g-1/3g-2) : le VE est dans le tri. Toutes les charges — VE, ECS, SG-Ready — partagent un budget et un rang unique, et le VE peut être classé derrière l'ECS.
- MANQUE pour des priorités réglables par le client :
- (a)
3g— fait. Le rang d'une borne est un champ deLoadConfig, modifiable à chaud parSetLoadConfig, comme celui de toute autre charge. - (b) Couche « config priorités » — le pont moteur↔UI, un morceau à part entière
(ni 3e ni 3g), aujourd'hui à moitié livré :
- FAIT côté moteur (
7184fe4) :priorityest exposé et persisté par charge, viaGetLoadConfig/SetLoadConfig(LoadConfigStore), et appliqué à chaud —changed()→rebuildLoadAdapters(). - RESTE côté app : l'UI Flutter de réordonnancement (drag-and-drop), qui consomme ces deux appels. Aucun travail moteur n'y est attaché.
- FAIT côté moteur (
- (a)
- État actuel : pour les charges pilotées (
relay-router,etmvariableload),priorityest un champ de la configuration persistée (loadconfig.h:73), transmis à l'adaptateur lors de sa construction (energyarbitrator.cpp:162,167), et modifiable à chaud.register*Adapterne fixe plus rien : il enregistre un adaptateur déjà construit. LeSgReadyAdapterdu banc y est entré au lot B-bis, et le VE en 3g-2 : plus rien n'est hors configuration. - Argument démo nymea : le client réordonne ses charges, le surplus suit.
RÉFÉRENCES
docs/OPTIMIZER_PROTOCOL.md— le contrat. §5 (SurplusContext), §6 (plan/actions), §7 (repli), annexe C (priorités).README.md— architecture (deux boucles, frontière),etm_powersync_energy.svg.INTERFACE.md— API JSON-RPC existante (NymeaEnergy, cible futureEms).docs/RELEVE_LOTB.md— relevé de vérification du lot B (frontière de télémétrie +o:domain) sur.75, critère par critère, avec ce qui n'a PAS été vérifié sur la machine et pourquoi.docs/RELEVE_3g1.md— relevé MACHINE de 3g-1 sur.75: ce que le banc a dit, y compris les trois défauts qu'il a révélés et que 3g-2 corrige.docs/RECAP_3g2.md— 3g-2 : ce que le lot corrige, et pourquoi.docs/DESIGN_10.md— design du §10 (éco/confort + délestage), à valider. Le plafond de soutirage devient un objet, les deux passes calculent des cibles et une seule commande sort.docs/PREPA_BANC_20260828.md— préparation du banc pour la V2C Trydan et la Skoda Enyaq, avec les deux blocages silencieux trouvés la veille (phaseCountabsent,sessionEnergydélibérément non exposée).docs/BRIEF_depuis_plugin.md— brief ÉMIS vers l'agent app après 3g-2 : ce qui bouge pour l'écran (l'inclusion rétablie, la mise en garde surallocatedWqui tombe,EV_GRID_START, le rang de borne au glisser-déposer).docs/RELEVE_3g2.md— relevé MACHINE de 3g-2 sur.75(+etm23) : le doublet disparu, le plancher passé de 2 070 à 4 140 W, l'entréeevchargerauto-provisionnée — et les deux défauts que le passage a trouvés, dont le trou depluggedIn.docs/RELEVE_LOTBbis.md— relevé de vérification du lot B-bis (la PAC entre en configuration) sur.75: l'ordre de migration exercé, la concordance des deux frontières,applySafeStateau retrait, l'expiration du verrou à froid — et §7, les puissances SG-Ready qui ne sont pas des données d'installation.specs/spec_ecs.md— autorité sur l'ECS multi-palier : état des exigences après audit, ordre de traitement, arbitrages ouverts. Subordonné à ce fichier.specs/spec_loadmodel.md— modèle de charges (domaine × mécanisme). Intention de conception : ne déclenche aucun travail.- Carte globale du workspace :
../AGENTS.md.