Documente ce qui a effectivement tourné, pas ce qui était supposé. Build 1.15.2+etm3 du 2026-08-08, cross arm64, réussi. - L'exclusion DH_OPTIONS=-N nymea-energy-tests était citée pour debian-qt5/rules alors que c'est qt6 qui construit (debian -> debian-qt6, dh --buildsystem=qmake6). Vérifié avant le build : l'exclusion EST présente dans debian-qt6/rules:6, donc seule la citation était périmée. Aucun paquet nymea-energy-tests produit. - Les dépendances étaient annoncées « toutes en 1.15.0 » avec Qt5 (libqt5websockets5-dev, qtbase5-dev, qttools5-dev-tools). Le build réel utilise nymea 1.15.2+202606191336~trixie1 et Qt 6.8.2 — chaîne Qt6 de bout en bout, cohérente avec la box (aucun libqt5 installé sur .75, ldd du .so ne renvoie que du Qt6). - Ajouté : debian-qt6/changelog est un lien symbolique VERSIONNÉ vers ../debian-qt5/changelog, comme copyright et nymea-energy-tests.install.in. Il n'y a qu'un changelog réel. Sans cette note, lire les deux fichiers donne l'illusion d'une divergence — c'est l'erreur que j'ai commise et rapportée. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
541 lines
32 KiB
Markdown
541 lines
32 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` |
|
||
|
||
**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 `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. À 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
|
||
- **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`.
|
||
- 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`.
|
||
|
||
---
|
||
|
||
> ⚠️ 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.
|
||
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 | ~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.
|
||
|
||
## 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.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`.
|