Patrick Schurig 0cbc9480db docs(build): arbre vierge — lrelease avant le premier dpkg-buildpackage
Constaté en construisant 1.15.2+etm7 depuis un git archive : qmake fige la liste des
.qm avant que lrelease ne les produise, et dh_install échoue en missing files. Le clone
canonique masque le défaut, ses .qm survivant d'un build antérieur.

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

34 KiB
Raw Blame History

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.md fait 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 6298d5d54ba229
3e — SgReadyAdapter FAITE — suite 19/19 83d5ad9d8079e8
rév. 3 — frontière optimiseur↔routeur FAITE — RelayRouter, LoadConfig/LoadConfigStore, kind Stage retiré 5100674, e16aca4, 5585a5c, 7184fe4, 88626cf

Détail 3b :

  • EnergyArbitrator : public SmartChargingManager — justification dans ## DÉCISIONS DE DESIGN
  • EvAdapter + RuleBasedScheduler implémentés
  • Build : 0 erreur / 0 warning
  • ETM_ARBITRATOR actif dans energyplugin.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(), verrous minOnS/minOffS, bypass si force == true), enregistré explicitement auprès de l'arbitre. Classe remplacée depuis : voir la ligne rév. 3 du tableau.
  • buildContext() : SurplusMeter brut (exportW = max(0, -meter->currentPower())), loads[] EV + charges pilotées ; SurplusPv dé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 du SurplusContext en 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, lockWindow symé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) — commit dfdd988.
  • DoD 3e : amd64 0/0 ✓ · simulation 20/20 ✓ · decisionReason français (forcé/reco/normal/ verrou) ✓ · arm64 → CI (idem 3c).
  • Audit Doxygen fait (5 findings → 0 : \param now, docs périmées 3c/3e) — commit 51760a7.
  • docs/TEST_TERRAIN.md créé : procédure Palier 1 (14 tests) pour le banc nymea-dev arm64.

Ce que le moteur sait faire aujourd'hui

  • Arbitrage central unique : un budget de surplus net signé, cascade par priorité (rang).
  • Charges : EV (proxy amont, décision B), charges pilotées en watts (kind SetpointRelayRouter pour une combinaison de relais, EtmVariableLoadAdapter pour une consigne continue), SG-Ready PAC (4 états, kind State) — toutes les charges non-EV classables ensemble sur le budget partagé.
  • Sécurité : protection compresseur (verrous minOn/minOff par charge tenus dans l'adaptateur, seam de temps unifié now = ctx.timestamp ; fenêtre d'états minState/maxState exposée au scheduler pour SG-Ready) ; watchdog L2 (compteur muet >90 s → mode dégradé conservateur, planif suspendue, reprise par recalcul) ; verifyOverloadProtection() amont intacte ; degradedMode notifié.
  • Local-first : zéro cloud (invariant 10).

DÉFÉRÉ (ordre indicatif)

  • Passe README + contrats : OPTIMIZER_PROTOCOL.md ne 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 forme relay-router de LoadConfig. minStage/maxStage ne sont plus à documenter : les champs ont été retirés du contexte en rév. 3. À documenter dans le protocole publié + README architecture. Prochaine session doc (pas avant le terrain).
  • 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é → toutes charges classables ensemble.
  • Couche config priorités (UI Flutter drag-and-drop) — cf. ## ROADMAP. Note : la déclaration des charges pilotées est configurable et persistée depuis 7184fe4 (LoadConfigStore, RPC Get/SetLoadConfig, reconstruction à chaud sur changed()). Reste codé en dur : le SgReadyAdapter du banc (energypluginnymea.cpp:66-76) — cf. docs/TEST_TERRAIN.md §1.d. PRÉCONDITION à ce basculement : retirer d'abord Q_ASSERT(m_stateRelays.contains(2)) (sgreadyadapter.cpp:36) au profit d'un refus explicite. Cette assertion garde l'état 2 — le repli sûr du mode dégradé L2 — et disparaît du binaire release (QT_NO_DEBUG). Tant que la construction est codée en dur, elle ne peut pas échouer ; dès que l'état vient de la config, elle devient le seul garde-fou, et il n'existera pas chez le client. Avant le basculement, pas après — cf. specs/spec_ecs.md ECS-110.
  • arm64 cross-compile validé en pré-déploiement (infra CI etm-powersync-deploy).
  • Doxyfile + job CI doxygen -W (automatiser l'audit doc).

PROCHAINE ACTION : test terrain vendredi (docs/TEST_TERRAIN.md, Palier 1), puis passe contrats (OPTIMIZER_PROTOCOL + README).

Remotes git :

  • origin (https://git.etm-powersync.fr/...) = remote de travail — push normal
  • etm-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-arm64 sur etm-powersync-dev (amd64)
  • Commande : dpkg-buildpackage -aarm64 -b -uc -us dans /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) : nymea 1.15.2+202606191336~trixie1, Qt 6.8.2+dfsg-9+deb13u2 — et non 1.15.0/Qt5 comme l'indiquait cette section. La chaîne est Qt6 de bout en bout, cf. ci-dessous.
  • lrelease : fourni par qt6-tools-dev-tools — nécessaire si absent du conteneur
  • Arbre vierge : lancer lrelease AVANT le premier dpkg-buildpackage. Constaté le 2026-08-09 en construisant 1.15.2+etm7 depuis un git archive. energyplugin.pro résout $$files(translations/*.qm) au moment de qmake ; sur un arbre sans .qm, la liste est vide, les traductions ne sont pas installées, et dh_install échoue en missing files après que lrelease les a pourtant fabriquées. Le clone canonique /root/etm-powersync-energy-plugin-etm masque le défaut : ses .qm traînent d'un build antérieur. Remède : lrelease energyplugin/translations/*.ts, puis supprimer .qmake.stash et les Makefile avant 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 .deb de prod.
  • etm-powersync-energy-plugin-etm.pro : tests sous build_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 : debiandebian-qt6, dont les rules portent dh $@ --buildsystem=qmake6. debian-qt5/rules (--buildsystem=qmake) n'est plus la référence ; il conserve la même exclusion, plus un override_dh_missing absent de qt6. Vérifié au build du 2026-08-08 : aucun paquet nymea-energy-tests produit.
  • Un seul changelog pour les deux arbres : debian-qt6/changelog est un lien symbolique versionné vers ../debian-qt5/changelog, comme copyright et nymea-energy-tests.install.in. Écrire dans debian/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.
  • Cause racine : libnymea-tests:arm64 1.15.0 n'exporte pas enableNotifications → 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é (6298d5d54ba229) 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 pas currentPower (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, limite beta. EvAdapter::applyAction() est implémenté (evadapter.cpp:62-94) mais jamais appelé : le dispatch 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). Les décisions EV restent prises par les méthodes amont, en proxy, avant le waterfall ; l'EV n'entre donc pas dans le tri par priorité. descriptor() et telemetry() sont utilisés dès maintenant pour le SurplusContext. 3g est un travail de câblage, pas d'écriture.

Règles absolues :

  1. 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.
  2. 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 les LoadAction reçues. Aucune logique de répartition dedans.
  3. Le SmartChargingManager amont est EV-spécifique : il se GÉNÉRALISE en arbitrage multi-charges (ChargingActionLoadAction, bornes EV → adaptateurs). On ne branche PAS l'optimiseur dans le manager EV tel quel.
  4. La boucle de sécurité est intouchable : verifyOverloadProtection() (temps réel) + bornes par adaptateur écrêtent TOUTE sortie de stratégie, interne ou socket.
  5. 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().
  6. Repli toujours fonctionnel : optimiseur absent/mort/abstain → rule-based. Capabilities (tier, optimizerExpected, optimizerAlive, activeStrategy) reflètent l'état en continu.
  7. decisionReason non vide, en français, sur chaque action. Action sans reason = rejetée.
  8. Pas de boucle de feedback : surplus = PV mesurée + compteur, jamais le net après pilotage.
  9. Aucun composant propriétaire ici (Héos = repo privé etm-powersync-optimizer). Ce repo doit compiler et tourner seul, GPL pur.
  10. 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:// ou tcp:// du réseau de l'installation) — jamais un service cloud externe.

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 mv du .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, mapping LoadAction→adaptateurs, où vit IScheduler. 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.sh qui 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é par RelayRouter en 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 : EvAdapter::descriptor() met priority = 100 (evadapter.cpp:24), reliquat de l'ancienne convention « poids, valeur haute = premier ». Inoffensif en beta : l'EV est servi par le proxy avant le waterfall ECS et n'entre pas dans le tri ECS (ascendant, rang 1 = premier servi, protocole §5). À reconcilier quand l'EV rejoindra le waterfall unifié : la priorité devient un rang (1, 2, 3…), pas un poids — sinon priority=100 placerait l'EV en dernier d'un tri ascendant.


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 :

  1. 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 modifier nymeaenergyjsonhandler.h/.cpp — violation de la règle "Modifier le code amont uniquement pour corriger des bugs".

  2. 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é.

  3. simulationCallUpdate() polymorphe : appelle update() virtuel → redirige automatiquement vers EnergyArbitrator::update(). Les tests amont passent sans modification.

  4. Minimal upstream diff : seuls les attributs protected/virtual changent dans smartchargingmanager.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).
  • minOnS/minOffS sont 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 ~300600 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 signal meterChanged) 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.)
    • decisionReason non vide + notification EnergyManagerChanged avec degradedMode.
  • verifyOverloadProtection() (L4) est déclenchée par deux mécanismes : (a) signal powerBalanceChanged (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 dans update() est INTOUCHABLE — même dans EnergyArbitrator::update().

DÉFINITION DE FAIT (par étape de phase 3)

  1. Compile amd64 et cross arm64.
  2. Scénario de simulation ajouté/étendu qui démontre le comportement (le harnais docker-simulation.sh + tests/auto hérités sont le banc de test).
  3. decisionReason visibles dans les logs de simulation.
  4. Aucune régression des tests amont existants.
  5. 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] hors etm/ : ils échappent au périmètre du Doxyfile (frontière = un répertoire) et se documentent à la main. Leur inventaire est tenu dans energyplugin/smartchargingmanager.h, au marqueur [ETM] BEGIN — toute nouvelle marque [ETM] dans un en-tête amont s'y ajoute.

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.
  • LIMITE beta : le VE reste hors du tri (décidé par l'amont avant le waterfall, décision B) → on ne peut pas classer le VE derrière l'ECS.
  • MANQUE pour des priorités réglables par le client :
    • (a) 3g : transplanter le VE dans le waterfall unifié → toutes les charges (VE, ECS, SG-Ready, batterie) classables ensemble.
    • (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) : priority est exposé et persisté par charge, via GetLoadConfig / 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é.
  • État actuel : pour les charges pilotées (relay-router, etmvariableload), priority est 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*Adapter ne fixe plus rien : il enregistre un adaptateur déjà construit. Restent hors config : le VE (fixé à priority = 100, evadapter.cpp:24 — cf. « Dette 3g ») et le SgReadyAdapter du banc (energypluginnymea.cpp:66-76).
  • 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 future Ems).
  • specs/spec_ecs.mdautorité 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.