Correction de mon explication du volet 2. Le chauffe-eau n'était pas à 3500 W par reliquat mais par EMBALLEMENT, et le mécanisme change la conclusion. Deux conditions se composent sur .75 : les Things sont des relais GPIO, dont la ThingClass n'expose pas currentPower — le recrédit anti-clignotement crédite donc le nominal commandé ; et le rootmeter est la vue SunSpec du simulateur, aveugle aux résistances câblées sur ces broches — la conso ECS ne fait jamais baisser l'export mesuré. budget_charge = surplus + palier double-compte, le palier monte d'un cran par cycle tant que le surplus dépasse 500 W, jusqu'au plafond. C'est ce qui explique les quatorze plateaux sans commutation, pas un point de départ malheureux. GARDE-FOU ajouté sous « Correction B » dans AGENTS.md et dans le relevé : c'est un ARTEFACT DE BANC, pas un défaut du moteur. 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 qu'elle vient de retirer. Le supprimer casserait l'anti-oscillation qu'il protège (ECS-404) sans rien régler. Sans cette note, quelqu'un « corrigerait » la correction B sur la foi de cette mesure. ECS-303 n'est donc PAS affaibli : ma remarque valait pour ce banc, où aucune montée pas à pas n'est possible. Avec de la physique réelle, la rampe matinale traverse la bande 1500-2000 W lentement et la frontière est franchie normalement. Le volet 2 n'est pas à refaire depuis le palier 0 sur le simulateur — il est à faire après le câblage, sur la vraie installation. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
554 lines
33 KiB
Markdown
554 lines
33 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`.
|
||
|
||
> **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.
|
||
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`.
|