Trouvé au banc le 2026-08-27, en exerçant 3g-2 (docs/RELEVE_3g2.md §3.1). Une wallbox en Eco, voiture assignée, mais `pluggedIn: false`, s'est vu allouer 4 853 W qu'elle ne pouvait pas tirer — `currentPower: 0`, `charging: false`. La PAC derrière elle n'a survécu que sur son propre recrédit. Trois cycles consécutifs : 2 350 / 4 853 / 5 464 W retirés du budget et donnés à personne. evParticipatesInWaterfall() reprenait le filtre d'entrée du proxy — voiture assignée, mode non manuel — mais pas `pluggedIn`. Le proxy n'en avait pas besoin : son allocation n'était pas une cascade, donc gaspiller ne privait personne. En cascade, si. L'asymétrie était déjà visible dans le code : la boucle EV du scheduler, qui sert les financements réseau, teste `!ev->pluggedIn()` depuis toujours ; seul le chemin waterfall ne le faisait pas. C'est le défaut du relevé 3g-1 §3.4 par une autre porte : là le plancher mentait, ici c'est le filtre d'entrée. Défaut de 3g-1, révélé par 3g-2 parce que la décision atteint enfin le matériel — et corrigé à part du lot, comme les quatre précédents. Hors arbitrage, et non « servie à 0 avec un motif » : même famille que les deux autres exclusions (DESIGN_3g §4). Une borne débranchée ne consomme rien, sa consommation nulle est déjà au compteur, et lui donner un rang la ferait apparaître dans la cascade pour n'y rien faire. Le journal l'annonce comme les deux autres cas. testUnpluggedChargerTakesNoBudget, VÉRIFIÉ ÉCHOUANT SANS LE CORRECTIF : la borne débranchée disparaît du contexte et la charge suivante reçoit les 3 000 W entiers ; la seconde moitié rebranche le véhicule pour prouver que c'est bien `pluggedIn` qui décide, et non une exclusion fortuite. Suite complète : simulation 28/28, charging 16/16, loadmodel 16/16, spotmarket 7/7. Build amd64 0/0. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017TVywtayEeoRJ2yWUW4iZj
52 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),
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.
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).
PROCHAINE ACTION : le rang par défaut des bornes auto-provisionnées, qui retombe à
égalité avec le rang 1 déjà occupé au lieu de mettre la borne « en tête »
(docs/RELEVE_3g2.md §3.2). Trois issues possibles, aucune tranchée — autoriser le rang 0,
renuméroter les autres, ou assumer l'égalité et laisser l'app poser les vrais rangs à la mise
en service. Sans conséquence sur la reproductibilité (le tri est total), donc à décider, pas à
réparer dans l'urgence.
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.
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.
Remotes git :
origin(https://git.etm-powersync.fr/...) = remote de travail — push normaletm-public(gitea-lan:...powersync-energy-plugin-etm) = miroir public GPL → push MANUEL par Patrick uniquement (sync-public.sh)etm-pro= reliquat historique — ne pas utiliser, cartographie à clarifier
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)
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 (ECS-404) 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 ».
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/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.