Le bloc d'enregistrement codé en dur d'energypluginnymea.cpp est supprimé, avec registerSgReadyAdapter(), la table m_sgReadyAdapters et claimedRelays(). Il n'existe plus aucun chemin d'enregistrement en dur : m_loadAdapters est la table UNIQUE des charges arbitrées, et toutes ses entrées viennent de LoadConfigStore. Contrat : loads[] de GetLoadTelemetry est désormais INCLUS dans GetLoadConfig, par CONSTRUCTION et non par discipline. La règle de détection publiée à +etm14 — « un loadId sans équivalent dans GetLoadConfig est une charge non configurable » — est retirée d'INTERFACE.md : elle n'avait qu'un porteur, pac-terrain, qui vient de disparaître. Aucun configurable:false n'est ajouté, il n'aurait plus rien à signaler. L'inclusion ne vaut que dans ce sens : une charge enabled:false est configurée sans être publiée. SÉCURITÉ — repli L2 d'une PAC de configuration, trou ouvert par LM-300 et refermé ici. Le repli envoyait un Setpoint(0) à toute la table et un State(2) à une table séparée réservée à la PAC en dur ; une PAC construite depuis la CONFIG vivant dans la première recevait donc une action que SgReadyAdapter rejette d'entrée, et rien d'autre — elle restait en état 4 sous compteur muet quand SAFETY.md §L2 exige l'état 2. Le motif DEGRADED_L2 s'affichait correctement : seule l'écriture manquait. Le repli lit maintenant supportedKinds (LM-301-b). ECS-411/-411-b au SG-Ready : l'état de départ est relu sur les contacts, avec relevé contact par contact et l'issue — y compris quand l'issue est l'état neutre. ECS-412 au minStateHold : armement paresseux au démarrage à froid, jamais permanent. Harnais de test : JournalCapture se chaînait sur elle-même (récursion infinie à croissance quadratique dès la deuxième capture vivante, donc OOM) ; tm/owner non initialisés sur trois sites de freshSetup(). Vérifié sur .75 avant publication — docs/RELEVE_LOTBbis.md. Suites : simulation 85, charging 74, spotmarket 44, zéro échec, un processus par test. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HajXLUczEyZd22JeewRfff
739 lines
49 KiB
Markdown
739 lines
49 KiB
Markdown
# 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 | `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` |
|
||
|
||
**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.
|
||
|
||
**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 notification
|
||
`NymeaEnergy.*`**. L'état runtime de l'arbitre vivait exclusivement dans
|
||
`qCInfo(dcNymeaEnergy)`. Ce lot lui donne un transport.
|
||
- `NymeaEnergy.GetLoadTelemetry` + `LoadTelemetryChanged`, **même charge utile**. Contrat :
|
||
`INTERFACE.md`.
|
||
- `LoadAction::reason` est un **`DecisionReason {code, params}`**, plus une `QString`. La ligne
|
||
française du journal est **rendue** depuis ce couple par `renderFr()` — 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, comme
|
||
`LoadAction::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 avec `GetLoadConfig`** — le `SgReadyAdapter`
|
||
du 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 dans `INTERFACE.md` à `+etm14` a été
|
||
RETIRÉE, et aucun `configurable: false` n'a jamais été ajouté — il n'aurait plus de porteur.
|
||
- **Déploiement sur box : `etm-powersync-deploy/deploy.sh`, jamais un `dpkg -i` à la main.**
|
||
Un glob sur `/var/lib/etm-deploy/*.deb` a rétrogradé `.75` de `+etm12` à `+etm9` le
|
||
2026-08-25. Détail : `DEPLOY.md` §7.6 (dépôt deploy) et `docs/RELEVE_LOTB.md` §8-bis (a).
|
||
|
||
**Détail lot B-bis (la PAC entre en configuration)** :
|
||
- **Le bloc `registerSgReadyAdapter()` d'`energypluginnymea.cpp` est SUPPRIMÉ**, avec la méthode
|
||
d'enregistrement, la table `m_sgReadyAdapters` et `claimedRelays()`. Il n'existe plus **aucun**
|
||
chemin d'enregistrement en dur : `m_loadAdapters` est la table UNIQUE des charges arbitrées, et
|
||
toutes ses entrées viennent de `LoadConfigStore`. `GetLoadTelemetry` publie donc exactement
|
||
l'ensemble de `GetLoadConfig` — **par construction, pas par discipline**. L'inclusion ne vaut que
|
||
dans ce sens : une charge `enabled: false` est configurée sans être publiée (contrat §9).
|
||
- **La PAC du banc est devenue une `LoadConfig` `sg-ready` ordinaire** (`pac-terrain`, K1/K2,
|
||
rang 2). Ses `estimatedPowerW` — `{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 dans `docs/TEST_TERRAIN.md` §1.d, à remplacer par la machine
|
||
réellement câblée — `estimatedPowerW` sert 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 un `State(2)` à la table séparée de la PAC en dur. Une PAC de
|
||
**configuration** vit dans la première : elle recevait un `Setpoint` que `SgReadyAdapter` rejette
|
||
d'entrée — et rien d'autre. Sous compteur muet elle restait en **état 4 (forcé)**, quand
|
||
`SAFETY.md` §L2 exige l'état 2. Invisible dans la charge utile (le motif `DEGRADED_L2`
|
||
s'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 lit `supportedKinds` au 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`** : un `m_lastSwitch` nul vaut « commutation venant d'avoir
|
||
lieu », jamais « jamais commuté » — sans quoi un redémarrage de `nymead` pendant 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é en `TEST_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 (proxy amont, décision B), **charges pilotées en watts** (kind `Setpoint` —
|
||
`RelayRouter` 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. **Ajout du lot B** : `PlanBudget` (comptabilité interne du plan, non sérialisée) et
|
||
`ILoadAdapter::runtimeView()` n'y figurent pas non plus. À documenter dans le protocole publié
|
||
+ README architecture. `INTERFACE.md` est, lui, à jour du lot B.
|
||
**Prochaine session doc** (pas avant le terrain).
|
||
- **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), et
|
||
`sgreadyadapter.cpp` **deux fois** (charge en défaut, et encodage sans état 2 → `m_usable`
|
||
faux, donc PLUS AUCUNE commande depuis la construction). Écartés du lot B à dessein : le
|
||
défaut est déjà annoncé une fois en `qCCritical` à son apparition, et il est désormais publié
|
||
en continu par `GetLoadTelemetry` (`faultCode`) — mais l'asymétrie de journal reste, et le cas
|
||
`m_usable` n'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é → toutes charges classables ensemble.
|
||
- **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
|
||
depuis `7184fe4`, la PAC SG-Ready depuis le **lot B-bis** (`LoadConfigStore`, RPC
|
||
`Get`/`SetLoadConfig`, reconstruction à chaud sur `changed()`). 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 : le
|
||
`Q_ASSERT(m_stateRelays.contains(2))` a cédé la place au refus explicite `m_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 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** : `debian` → `debian-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`.
|
||
- ⚠️ **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-vm` identique à 200 ko près, donc déterministe). Protocole : **un processus par test**
|
||
(`-functions` puis boucle, agrégation à la main), chacun encadré par
|
||
`systemd-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 pour `run`, 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 33 `ThingErrorThingClassNotFound` en 16 s
|
||
sans rien exécuter — ce qui ressemble à un run rapide et propre. Vérifier dans le préambule que
|
||
`Tests: Mock plugin path:` pointe vers un `.so` existant, 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 sur `Failed 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:arm64` 1.15.0 n'exporte pas `enableNotifications` → link fail cross.
|
||
|
||
### Vérification post-build obligatoire
|
||
|
||
```bash
|
||
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 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 (`ChargingAction` → `LoadAction`, 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.
|
||
- **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 `qCDebug` quand le succès qu'il remplace est en `qCInfo`.
|
||
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:150` journalisait le refus par verrou `minStateHold` en
|
||
> `qCDebug` quand l'application, dix lignes plus bas, était en `qCInfo`. Corrigé le
|
||
> 2026-08-13 ; c'était le seul cas, les autres `qCDebug` du 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 en `qCDebug` à dessein :
|
||
> l'entrée est en `qCWarning` et la sortie en `qCInfo`, 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. `ClearLoadFault` est 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 un `bool` — 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.
|
||
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.
|
||
|
||
11. **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é par `ChargingSchedulesChanged` sans figurer dans
|
||
> `GetChargingSchedules`. Corrigé au lot B.
|
||
- **Corollaire — la charge utile d'un `Get*` et celle de son `*Changed` se 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.
|
||
12. **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.Hello` accepte `o:locale`,
|
||
rangée dans `m_clientLocales`) : deux utilisateurs d'une même box peuvent avoir deux
|
||
langues, ce qu'une phrase composée en C++ ne peut pas servir. Et
|
||
`NymeaEnergyJsonHandler` est un `JsonHandler` **sans `pluginId`** : 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é. `timestamp`
|
||
absent = aucun cycle ; `budget` absent = 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 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 | ~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 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.
|
||
6. **Génération de la doc : `./tools/gen-doc.sh`, et rien d'autre.** Une commande
|
||
unique, le `Doxyfile` du 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 : `doxygen` et `graphviz`, de la CIBLE DOC uniquement.** Le paquet se
|
||
construit sans elles ; le conteneur `build-cross-arm64` n'a rien à faire de
|
||
Graphviz. Ne pas les ajouter aux `Build-Depends`.
|
||
- `CALL_GRAPH`/`CALLER_GRAPH` restent à `NO` **délibérément** : le moteur est
|
||
signal-driven, `powerBalanceChanged → verifyOverloadProtection()` est une connexion
|
||
Qt et non un appel. Un graphe d'appel montrerait `update()` 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** : `SmartChargingManager` est
|
||
hors `INPUT`. 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.
|
||
|
||
## 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. **Seul le VE reste hors config** (fixé à `priority = 100`, `evadapter.cpp:24` —
|
||
cf. « Dette 3g ») ; le `SgReadyAdapter` du banc y est entré au lot B-bis, et le bloc
|
||
`energypluginnymea.cpp:66-76` qui l'enregistrait n'existe plus.
|
||
- **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`).
|
||
- `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_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, `applySafeState`
|
||
au 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`.
|