Compare commits
49 Commits
main
...
landing-si
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ad385ca8fc | ||
|
|
4d2e457ed2 | ||
|
|
88626cfd56 | ||
|
|
e16aca4d1a | ||
|
|
5585a5c483 | ||
|
|
1f976f0189 | ||
|
|
7184fe4e1d | ||
|
|
3cb79f5918 | ||
|
|
51006745ed | ||
|
|
b7bfd58139 | ||
|
|
c43449cafe | ||
|
|
9242a6e28c | ||
|
|
54c378c550 | ||
|
|
65f281cd5a | ||
|
|
dd3503ab52 | ||
|
|
3afef0436d | ||
|
|
f7cb1ba8af | ||
|
|
45b5e8af16 | ||
|
|
67b32be92f | ||
|
|
380823dc9d | ||
|
|
6670bed6cc | ||
|
|
dfdd9884d0 | ||
|
|
51760a7f61 | ||
|
|
e641f289db | ||
|
|
d8079e84e0 | ||
|
|
b06ac15714 | ||
|
|
093fa09b5e | ||
|
|
c6d7831df9 | ||
|
|
83d5ad9ed7 | ||
|
|
cbee13e455 | ||
|
|
54ba2296fa | ||
|
|
dde967da41 | ||
|
|
3a8eb5da86 | ||
|
|
5d67dc943d | ||
|
|
a471a23aeb | ||
|
|
f71e0405b4 | ||
|
|
312a2484ae | ||
|
|
0615e5f39d | ||
|
|
6298d5d42f | ||
|
|
7709057335 | ||
|
|
19951a1e3e | ||
|
|
5bb6da0e9f | ||
|
|
d8ebd65eba | ||
|
|
c3fedfe36b | ||
|
|
7d3fc6e5ea | ||
|
|
08039a3542 | ||
|
|
5f49e4ca3c | ||
|
|
9017a880ac | ||
|
|
4ae1939f93 |
20
.clangd
Normal file
20
.clangd
Normal file
@ -0,0 +1,20 @@
|
||||
# Flags de repli pour les headers ouverts hors contexte compile_commands.json
|
||||
# (notamment quand le repo est ouvert via le symlink ~/Schreibtisch/ alors que
|
||||
# compile_commands.json contient les chemins réels ~/projects/).
|
||||
# Ces flags s'appliquent en fallback ; les entrées compile_commands.json ont priorité.
|
||||
CompileFlags:
|
||||
Add:
|
||||
- -std=c++17
|
||||
- -Ienergyplugin
|
||||
- -I/usr/include/nymea
|
||||
- -I/usr/include/nymea-energy
|
||||
- -I/usr/include/x86_64-linux-gnu/qt6
|
||||
- -I/usr/include/x86_64-linux-gnu/qt6/QtCore
|
||||
- -I/usr/include/x86_64-linux-gnu/qt6/QtGui
|
||||
- -I/usr/include/x86_64-linux-gnu/qt6/QtNetwork
|
||||
- -I/usr/lib/x86_64-linux-gnu/qt6/mkspecs/linux-g++
|
||||
- -DQT_CORE_LIB
|
||||
- -DQT_NETWORK_LIB
|
||||
- -DQT_GUI_LIB
|
||||
- -DQT_PLUGIN
|
||||
- -D_REENTRANT
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@ -8,3 +8,6 @@ Makefile
|
||||
builddir/
|
||||
*_moc.cpp
|
||||
autogenerated/
|
||||
|
||||
# clangd — chemins absolus du poste local, ne pas versionner
|
||||
compile_commands.json
|
||||
|
||||
482
AGENTS.md
482
AGENTS.md
@ -1,21 +1,477 @@
|
||||
# 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 (ECS, PAC, batterie, relais).
|
||||
vers un gestionnaire d'énergie complet (EV, ECS, PAC SG-Ready, batterie).
|
||||
|
||||
- **Licence** : GPL-3.0 · **Miroir public** : OUI
|
||||
- **Agent** : energy-etm · **Branche** : feature/beta-rulebased · **Scope** : energyplugin/
|
||||
- **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.
|
||||
|
||||
## Invariants locaux
|
||||
1. Tourne SANS `etm-powersync-optimizer` (socket absent → repli stratégie règles).
|
||||
2. Sécurité jamais déléguée : `verifyOverloadProtection()` (temps réel) borne toute sortie de l'optimiseur.
|
||||
3. Pas de boucle de feedback : surplus = PV mesurée + compteur, jamais le net.
|
||||
4. `decisionReason` non vide, en français, sur chaque décision.
|
||||
5. Aucun composant propriétaire ici (Héos vit dans `etm-powersync-optimizer`).
|
||||
6. Première tâche (revue) : renommer `nymea-energy-plugin-nymea.pro` → `.pro` ETM
|
||||
(+ TARGET, debian/). NE PAS toucher aux noms de paquets publiés.
|
||||
## ÉTAT
|
||||
|
||||
## Références
|
||||
- `README.md` (architecture), `INTERFACE.md` (fait autorité sur l'API), `etm_powersync_energy.svg`.
|
||||
| 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 — EcsRelayAdapter + waterfall ECS | ✅ FAITE — suite 18/18 + charging 46/46 | `6298d5d`→`54ba229` |
|
||||
| 3e — SgReadyAdapter | ✅ FAITE — suite 19/19 | `83d5ad9`→`d8079e8` |
|
||||
|
||||
Carte globale et frontières : voir `../AGENTS.md`.
|
||||
**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 (morceaux déjà compilés, 0 erreur / 0 warning)** :
|
||||
- **Morceau 0** — `LoadAction.force=false` (bypass verrous sécurité) ✅
|
||||
- **Morceau 1** — `EcsRelayAdapter` (.h + .cpp) : pilote N Things powerswitch,
|
||||
`applyRelayStage()`, verrous `minOnS/minOffS`, bypass si `force==true` ✅
|
||||
- Enregistrement explicite via `EnergyArbitrator::registerEcsAdapter()` (tests + config)
|
||||
- **Morceau 2** — `buildContext()` : `SurplusMeter` brut (`exportW = max(0, -meter->currentPower())`),
|
||||
`loads[]` EV + ECS, `SurpusPv` déféré 3d ✅
|
||||
|
||||
**3c CLÔTURÉE** (commits `6298d5d` waterfall → `54ba229` testMeterSilentFallback) :
|
||||
- Morceaux 3-7 faits. Waterfall ECS (tri priorité ASC = rang) + dispatch + watchdog L2
|
||||
(mode dégradé conservateur, planif suspendue) + degradedMode/notification + tests.
|
||||
- **Correctif clé** `[3c-3-fix]` : surplus **net signé** (délestage en import) + **clamp
|
||||
lock-aware** `minStage/maxStage` (protection compresseur) ; **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.
|
||||
|
||||
**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), **ECS** (paliers, `Stage`), **SG-Ready PAC**
|
||||
(4 états, `State`) — ECS+PAC classables ensemble sur le budget partagé.
|
||||
- **Sécurité** : protection compresseur (verrous lock-aware `minStage/maxState`, seam de temps
|
||||
unifié) ; 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
|
||||
3c/3e — `LoadAction.force` (bypass sécurité L2), `telemetry.minStage/maxStage` et
|
||||
`minState/maxState` (fenêtres de verrou), `degradedMode` (notification). À 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** (API JSON-RPC + UI Flutter drag-and-drop) — cf. `## ROADMAP`.
|
||||
Note : la déclaration des adaptateurs est aujourd'hui **codée** (`energypluginnymea.cpp`),
|
||||
pas configurable à chaud — cf. `docs/TEST_TERRAIN.md` §1.d.
|
||||
- **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`,
|
||||
`libqt5websockets5-dev:arm64`, `qtbase5-dev:arm64` — toutes en `1.15.0`
|
||||
- **`lrelease`** : fourni par `qttools5-dev-tools` (amd64) — 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-qt5/rules` : `DH_OPTIONS=-N nymea-energy-tests` — exclut le paquet tests de tous les outils dh.
|
||||
- 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 (validé, morceaux 0-2 déjà compilés)
|
||||
|
||||
Plan approuvé par Patrick. Corrections A (double déduction EV) et B (anti-clignotement ECS)
|
||||
**intégrées** dans le design ci-dessous.
|
||||
|
||||
### Morceaux déjà compilés (0 erreur/warning)
|
||||
|
||||
| # | Fichier(s) | Ce qui a été fait |
|
||||
|---|-----------|-------------------|
|
||||
| 0 | `types/loadaction.h` | `bool force = false` — bypass verrous sécurité |
|
||||
| 1 | `adapters/ecsrelayadapter.h/.cpp` | Adaptateur N-paliers powerswitch, anti-rebond, `applyRelayStage()`, `etm.pri` |
|
||||
| 2 | `energyarbitrator.h/.cpp` | `buildContext()` : `SurplusMeter` brut, `loads[]` EV+ECS, `registerEcsAdapter()`, `m_ecsAdapters` |
|
||||
|
||||
### Morceaux à venir
|
||||
|
||||
**3 — Waterfall ECS dans `RuleBasedScheduler::getPlan()`**
|
||||
|
||||
Après la boucle EV proxy, ajouter :
|
||||
|
||||
```
|
||||
// Déduction unique (correction A) — ctx.meter.exportW = mesure brute
|
||||
evReservedW = Σ EV en charge dans slot.actions : max(0, commandedA×phases×230 − ev->currentPower())
|
||||
remainingSurplusW = max(0, ctx.meter.exportW − evReservedW)
|
||||
|
||||
// Tri loads ECS par priorité ASC (priority 1 = servi en premier ; protocole §5 + annexe C)
|
||||
pour chaque LoadContext lc où lc.adapter == "relay-stages" :
|
||||
budgetCharge = remainingSurplusW + lc.telemetry.currentPowerW // correction B anti-clignotement
|
||||
bestStage = palier le plus haut dont stages[i] ≤ budgetCharge
|
||||
reason = "Surplus PV — ECS palier N (W)" ou "Surplus insuffisant — ECS éteint"
|
||||
remainingSurplusW = remainingSurplusW + lc.telemetry.currentPowerW − stages[bestStage]
|
||||
// Grid funding : dormant jusqu'à 3f (commente, n'implémente pas)
|
||||
```
|
||||
|
||||
**4 — `syncAdapters()` extension + `applyActionsToAdapters(Slot)` dans `update()`**
|
||||
- `syncAdapters()` : commentaire "découverte ECS via interface 'ecsrelay' déférée 3g"
|
||||
- `applyActionsToAdapters(Slot)` : itère slot.actions, dispatche via `m_ecsAdapters` pour kind==Stage
|
||||
|
||||
**5 — Watchdog L2** (cf. SAFETY.md §L2)
|
||||
- `QTimer m_meterWatchdog` 30 s — picoté sur `powerBalanceChanged` SIGNAL pour `m_lastMeterUpdate`
|
||||
- `onMeterWatchdogTick()` : si `now − m_lastMeterUpdate > 90 s` → `applyDegradedMode()`
|
||||
- `applyDegradedMode()` : ECS stage 0 force=true + EV courant minimum, reason="Compteur muet..."
|
||||
|
||||
**6 — `degradedMode()` + notification + INTERFACE.md**
|
||||
- `virtual bool degradedMode() const` dans `SmartChargingManager` (retourne false, `// [ETM]`)
|
||||
- Override dans `EnergyArbitrator`
|
||||
- Champ `"degradedMode": bool` dans `ChargingSchedulesChanged` (additif, rétro-compatible)
|
||||
- Mise à jour `docs/INTERFACE.md` + limite dans `docs/SAFETY.md` : "valeur strictement constante non détectée"
|
||||
|
||||
**7 — Mock powerswitch + tests**
|
||||
- Mock JSON : ThingClass `mockPowerSwitch` (état `power` bool, état `currentPower` double)
|
||||
- `energytestbase.h` : `mockPowerSwitchThingClassId`
|
||||
- `testEcsSurplusPV` : cas normal + cas "ECS déjà au palier 1, surplus stable → Y RESTE" (anti-clignotement)
|
||||
- `testMeterSilentFallback` : compteur muet 90 s → mode dégradé → ECS off ; **vérifier que
|
||||
l'ECS RESTE à 0 sur plusieurs cycles `update()` pendant le silence** (planification
|
||||
suspendue, pas de rallumage sur cache mort) ; dégel → reprise normale + `degradedMode=false`.
|
||||
Repli EV : seul un VE *déjà en charge* est clampé au minimum (pas d'activation forcée).
|
||||
Seam de test : piloter `onMeterWatchdogTick()` / `m_lastMeterUpdate` sans 90 s d'horloge réelle.
|
||||
|
||||
### Arithmétique du budget (corrections A + B)
|
||||
|
||||
**Correction A — déduction EV unique, dans le scheduler** :
|
||||
```
|
||||
exportW dans ctx.meter = mesure brute (invariant 8, protocole §5)
|
||||
evReservedW = déduction dans getPlan() APRÈS proxy EV, avec m_chargingActions fraîches
|
||||
Pas de déduction dans buildContext().
|
||||
```
|
||||
Exemple : PV 9 kW, EV stable 7360 W, export mesuré 1140 W → evReservedW=0, budget ECS=1140 W ✓
|
||||
|
||||
**Correction B — anti-clignotement par recrédit de la conso actuelle** :
|
||||
```
|
||||
budgetCharge = remainingSurplusW + lc.telemetry.currentPowerW
|
||||
```
|
||||
Identique à SCM ~l.1245 pour l'EV. Sans ce recrédit : ECS palier 1 → export chute → palier 0 → oscillation.
|
||||
|
||||
---
|
||||
|
||||
> ⚠️ 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
|
||||
(in-process, V1, GPL) (client unix://|tcp://, V1 aussi —
|
||||
plan à 1 créneau personne en face en beta : repli rules)
|
||||
│
|
||||
│ distribue le budget en LoadAction typées
|
||||
┌──────────┬────┴─────┬──────────────┐
|
||||
EvAdapter EcsRelayAdapter SgReadyAdapter BatteryAdapter
|
||||
(setpoint, (stage 0/1/2) (state 1-4) (constraint +
|
||||
iface setpoint W réseau)
|
||||
evcharger
|
||||
nymea)
|
||||
```
|
||||
|
||||
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. EcsRelayAdapter (paliers) ; 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 **inactif** jusqu'à 3g — mais `descriptor()` et
|
||||
`telemetry()` sont utilisés dès maintenant pour le `SurplusContext`.
|
||||
|
||||
**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 le palier idéal selon le budget (peut vouloir « palier 0 »).
|
||||
- L'**adaptateur** borne ce choix via `minStage`/`maxStage` (fenêtre `lockWindow()` évaluée
|
||||
au temps de cycle) : une charge verrouillée ON garde son palier ; l'import transitoire
|
||||
est **borné par minOn**, pas illimité. Le scheduler clampe et décrémente le budget au
|
||||
palier réel (puissance engagée non-coupable) → budget correct pour les charges suivantes.
|
||||
- `minOnS`/`minOffS` sont des **paramètres par charge** (constructeur `EcsRelayAdapter`,
|
||||
config installateur) — **jamais codés en dur**.
|
||||
|
||||
**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** : `minStage`/`maxStage` (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`
|
||||
(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 (à coder en 3c) | `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.
|
||||
|
||||
## 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 »** : exposer et persister le `priority` de chaque
|
||||
charge via l'API JSON-RPC, modifiable depuis l'app Flutter (drag-and-drop des
|
||||
priorités de l'UI). C'est le **pont moteur↔UI**, un morceau à part entière
|
||||
(ni 3e ni 3g).
|
||||
- **État actuel** : `priority` est fixé à la création de l'adaptateur (`register*Adapter`),
|
||||
pas d'interface de réglage à chaud.
|
||||
- **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`).
|
||||
- Carte globale du workspace : `../AGENTS.md`.
|
||||
|
||||
12
INTERFACE.md
12
INTERFACE.md
@ -453,10 +453,18 @@ S'abonner via `JSONRPC.SetNotificationStatus` avec le namespace `"NymeaEnergy"`.
|
||||
---
|
||||
|
||||
### `NymeaEnergy.ChargingSchedulesChanged`
|
||||
Émis à chaque recalcul du planning (cycle ~1 min).
|
||||
Émis à chaque recalcul du planning (cycle ~1 min), **et** à chaque transition du mode
|
||||
dégradé L2 (watchdog fraîcheur compteur).
|
||||
```json
|
||||
{ "chargingSchedules": [ ... ] }
|
||||
{
|
||||
"chargingSchedules": [ ... ],
|
||||
"o:degradedMode": false
|
||||
}
|
||||
```
|
||||
- `degradedMode` *(bool, optionnel — [ETM])* : `true` quand le compteur est muet depuis
|
||||
> 90 s et que les consignes de repli L2 sont actives (planification suspendue, ECS coupé,
|
||||
EV en charge clampé au minimum). Repasse à `false` au retour du compteur. Champ additif :
|
||||
les clients antérieurs l'ignorent. Détail : `docs/SAFETY.md` §L2.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@ -1,3 +1,34 @@
|
||||
powersync-energy-plugin-nymea (1.15.2+etm2) trixie; urgency=medium
|
||||
|
||||
* Charge pilotée rév. 3 — frontière optimiseur (watt-pur) ↔ routeur (watts→relais, experience-plugin).
|
||||
* RelayRouter : combinatoire watts→relais ; paliers DÉRIVÉS des relais (things "power"), verrous
|
||||
minOn/minOff internes, off-before-on, agrégation currentPowerW (mesuré sinon nominal commandé).
|
||||
* Optimiseur watt-pur : suppression du kind Stage ; buildSetpointAction (arrondi powerLevels / résidu).
|
||||
* LoadConfig : forme relay-router (relays[]=[{thingId,powerW}] + minOnS/minOffS), validation
|
||||
conditionnelle ; Get/SetLoadConfig (schéma SET "o:" pour les deux formes exclusives).
|
||||
* Repli L2 unifié : compteur muet → Setpoint(0) force=true sur tous les ILoadAdapter (RelayRouter
|
||||
coupe TOUS les relais). EtmVariableLoadAdapter (continu/triac) conservé.
|
||||
* Tests : combinatoire/off-before-on/déduplication, repli L2 relais, chaîne config→routeur, RPC.
|
||||
|
||||
-- Patrick Schurig <etm.schurig@gmail.com> Sun, 28 Jun 2026 13:38:36 +0200
|
||||
|
||||
powersync-energy-plugin-nymea (1.15.2+etm1) trixie; urgency=medium
|
||||
|
||||
* Build contre nymea 1.15.2. EcsRelayAdapter + SgReadyAdapter dans init() (§1.d banc hems).
|
||||
|
||||
-- Patrick Schurig <etm.schurig@gmail.com> Thu, 25 Jun 2026 15:30:00 +0200
|
||||
|
||||
powersync-energy-plugin-nymea (1.15.0+etm1) trixie; urgency=medium
|
||||
|
||||
* Renommage paquet powersync-* + version 1.15.0 alignée sur libnymea-energy cible hems
|
||||
* EnergyArbitrator : waterfall ECS+PAC, RuleBasedScheduler, watchdog L2
|
||||
* EcsRelayAdapter : 8 paliers binaires, off-before-on, verrous minOn/minOff
|
||||
* SgReadyAdapter : 4 états SG-Ready, hystérésis kForceMargin=1.2, zone morte
|
||||
* §1.d banc hems : enregistrement EcsRelayAdapter+SgReadyAdapter dans init()
|
||||
* Build deb : tests exclus (CONFIG+=build_tests requis séparément)
|
||||
|
||||
-- Patrick Schurig / ETM PowerSync <etm.schurig@gmail.com> Wed, 25 Jun 2026 12:00:00 +0200
|
||||
|
||||
nymea-energy-plugin-nymea (1.13.0+etm1) jammy; urgency=medium
|
||||
|
||||
* Fork ETM PowerSync de nymea-energy-plugin-nymea
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
Source: nymea-energy-plugin-nymea
|
||||
Source: powersync-energy-plugin-nymea
|
||||
Section: utils
|
||||
Priority: optional
|
||||
Maintainer: Patrick Schurig / ETM PowerSync <etm.schurig@gmail.com>
|
||||
Standards-Version: 4.6.0
|
||||
Homepage: https://nymea.io
|
||||
Homepage: https://git.etm-powersync.fr/ETM-Schurig/etm-powersync-energy-plugin-etm
|
||||
Build-Depends: debhelper (>= 9.0.0),
|
||||
dpkg-dev (>= 1.16.1~),
|
||||
libnymea-dev (>= 0.17),
|
||||
@ -18,16 +18,21 @@ Build-Depends: debhelper (>= 9.0.0),
|
||||
gnuplot,
|
||||
|
||||
|
||||
Package: nymea-energy-plugin-nymea
|
||||
Package: powersync-energy-plugin-nymea
|
||||
Section: libs
|
||||
Architecture: any
|
||||
Multi-Arch: same
|
||||
Depends: ${shlibs:Depends},
|
||||
${misc:Depends},
|
||||
nymea-experience-plugin-energy,
|
||||
Description: nymea energy plugin for the energy experience
|
||||
This nymea energy plugin adds the smart smart charging and overload protection
|
||||
to the nymea energy experience.
|
||||
Provides: nymea-energy-plugin-nymea
|
||||
Conflicts: nymea-energy-plugin-nymea
|
||||
Replaces: nymea-energy-plugin-nymea
|
||||
Description: ETM PowerSync energy plugin for nymea (fork)
|
||||
Fork ETM PowerSync de nymea-energy-plugin-nymea. Ajoute un arbitrage multi-charges
|
||||
(ECS paliers, SG-Ready PAC, EV, batterie) avec waterfall unifié par priorité,
|
||||
anti-clignotement (recrédit correction B), watchdog L2 compteur muet, et
|
||||
mode dégradé conservateur.
|
||||
|
||||
|
||||
Package: nymea-energy-tests
|
||||
@ -37,3 +42,4 @@ Multi-Arch: same
|
||||
Depends: ${shlibs:Depends},
|
||||
${misc:Depends},
|
||||
Description: Tests and simulations for nymea:energy
|
||||
Built separately with CONFIG+=build_tests ; not included in prod deb builds.
|
||||
|
||||
@ -3,6 +3,9 @@
|
||||
|
||||
export DH_VERBOSE=1
|
||||
export QT_QPA_PLATFORM=minimal
|
||||
# nymea-energy-tests n'est pas buildé dans le paquet de prod (tests compilés
|
||||
# séparément via CONFIG+=build_tests). Exclure globalement de tous les outils dh.
|
||||
export DH_OPTIONS=-N nymea-energy-tests
|
||||
|
||||
DEB_HOST_MULTIARCH ?= $(shell dpkg-architecture -qDEB_HOST_MULTIARCH)
|
||||
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
Source: nymea-energy-plugin-nymea
|
||||
Source: powersync-energy-plugin-nymea
|
||||
Section: utils
|
||||
Priority: optional
|
||||
Maintainer: Patrick Schurig / ETM PowerSync <etm.schurig@gmail.com>
|
||||
Standards-Version: 4.7.2
|
||||
Homepage: https://nymea.io
|
||||
Homepage: https://git.etm-powersync.fr/ETM-Schurig/etm-powersync-energy-plugin-etm
|
||||
Build-Depends: debhelper,
|
||||
dpkg-dev,
|
||||
qt6-base-dev,
|
||||
@ -18,16 +18,21 @@ Build-Depends: debhelper,
|
||||
gnuplot-x11,
|
||||
|
||||
|
||||
Package: nymea-energy-plugin-nymea
|
||||
Package: powersync-energy-plugin-nymea
|
||||
Section: libs
|
||||
Architecture: any
|
||||
Multi-Arch: same
|
||||
Depends: ${shlibs:Depends},
|
||||
${misc:Depends},
|
||||
nymea-experience-plugin-energy,
|
||||
Description: nymea energy plugin for the energy experience
|
||||
This nymea energy plugin adds the smart smart charging and overload protection
|
||||
to the nymea energy experience.
|
||||
Provides: nymea-energy-plugin-nymea
|
||||
Conflicts: nymea-energy-plugin-nymea
|
||||
Replaces: nymea-energy-plugin-nymea
|
||||
Description: ETM PowerSync energy plugin for nymea (fork Qt6)
|
||||
Fork ETM PowerSync de nymea-energy-plugin-nymea. Ajoute un arbitrage multi-charges
|
||||
(ECS paliers, SG-Ready PAC, EV, batterie) avec waterfall unifié par priorité,
|
||||
anti-clignotement (recrédit correction B), watchdog L2 compteur muet, et
|
||||
mode dégradé conservateur.
|
||||
|
||||
|
||||
Package: nymea-energy-tests
|
||||
|
||||
@ -1,2 +1,2 @@
|
||||
usr/lib/@DEB_HOST_MULTIARCH@/nymea/energy/libnymea_energypluginnymea.so
|
||||
energyplugin/translations/*qm usr/share/nymea/translations/
|
||||
usr/share/nymea/translations/nymea-energy-plugin-nymea-*.qm
|
||||
@ -3,6 +3,7 @@
|
||||
|
||||
export DH_VERBOSE=1
|
||||
export QT_QPA_PLATFORM=minimal
|
||||
export DH_OPTIONS=-N nymea-energy-tests
|
||||
|
||||
DEB_HOST_MULTIARCH ?= $(shell dpkg-architecture -qDEB_HOST_MULTIARCH)
|
||||
|
||||
|
||||
238
docs/INTERFACE_etmvariableload.md
Normal file
238
docs/INTERFACE_etmvariableload.md
Normal file
@ -0,0 +1,238 @@
|
||||
# INTERFACE charge pilotée — contrat · **rév. 3**
|
||||
|
||||
> **Source de vérité unique.** Déposé **à l'identique dans les deux repos**
|
||||
> (`etm-powersync-app` et `etm-powersync-energy-plugin-etm`). Toute divergence se tranche **ici**,
|
||||
> pas dans le code. Un agent qui a besoin d'un champ absent met à jour ce doc **d'abord**, dans
|
||||
> les deux repos.
|
||||
>
|
||||
> ### ⚠️ rév. 3 — déplacement de frontière (lire avant de coder)
|
||||
> La rév. 2 plaçait la combinatoire matérielle « dans le thing ». **Vérification faite : un
|
||||
> integration-plugin nymea ne peut PAS piloter les things d'un autre plugin** (`thingManager`
|
||||
> y est privé ; seuls le cœur, le moteur de règles, les scripts et les **experience-plugins**
|
||||
> commandent des things tiers). Donc la combinatoire watts→relais **ne peut vivre que côté
|
||||
> experience-plugin** — là où l'arbitre a déjà le `ThingManager`.
|
||||
>
|
||||
> **rév. 3 acte cette réalité** : un relais est un **thing `power` nu** (comme n'importe quel
|
||||
> relais Modbus/MQTT) ; la combinaison watts→relais vit dans une couche **ROUTEUR** distincte,
|
||||
> **sous** l'optimiseur, côté experience-plugin. L'optimiseur reste **watt-pur** et ne connaît
|
||||
> **jamais** un relais. **Impact app majeur en §10 — l'écran « Configurer » change.**
|
||||
|
||||
---
|
||||
|
||||
## 1. Principe & frontière (rév. 3)
|
||||
|
||||
**Trois couches, la frontière entre OPTIMISEUR et ROUTEUR.**
|
||||
|
||||
| Couche | Où | Raisonne en | Connaît |
|
||||
|---|---|---|---|
|
||||
| **Optimiseur** | `RuleBasedScheduler` / `SocketScheduler` (GPL ou socket) | **watts** | priorités, `powerLevels` (W), surplus, besoins. **Jamais** un relais. |
|
||||
| **— FRONTIÈRE —** | | | l'optimiseur émet `LoadAction{Setpoint, powerW}` ; rien d'autre ne descend |
|
||||
| **Routeur** | couche adaptateur, **experience-plugin** (a le `ThingManager`) | watts → **relais** | la liste de relais, la combinatoire, `minOn/minOff`, l'agrégation `currentPowerW` |
|
||||
| **Relais** | things `power` (plugins GPIO / Modbus / MQTT) | on/off | leur propre matériel |
|
||||
|
||||
> **Invariant directeur (rév. 3).** L'**optimiseur** raisonne uniquement en watts : aucun relais,
|
||||
> aucun index de combinaison ne franchit la frontière vers le haut. Le **routeur** traduit le
|
||||
> setpoint W en commutation de relais et n'expose vers le haut que des watts (`powerLevels`,
|
||||
> `currentPowerW`). **L'optimiseur décide de la puissance ; le routeur décide des relais.**
|
||||
|
||||
---
|
||||
|
||||
## 2. Portée
|
||||
|
||||
| Charge | Couche d'exécution | Nature |
|
||||
|---|---|---|
|
||||
| **ECS résistif multipalier** | **`RelayRouter`** (rév. 3) | N relais `power` → combinaison watts |
|
||||
| **ECS simple on/off** | `RelayRouter` (cas dégénéré : 1 relais, niveaux `[0, nominal]`) | 1 relais `power` |
|
||||
| **EV charger / Routeur PV / triac** (modulation continue) | **`EtmVariableLoadAdapter`** (inchangé depuis rév. 2) | 1 thing à puissance pilotable, setpoint direct |
|
||||
| **PAC SG-Ready** | `SgReadyAdapter` (modèle à **états**, §8) | **exclue** — pas une charge en watts |
|
||||
|
||||
Les deux exécuteurs (`RelayRouter`, `EtmVariableLoadAdapter`) parlent à l'optimiseur **la même
|
||||
langue** : `LoadAction{Setpoint, powerW}`. Chacun exécute selon sa nature — combinaison de relais
|
||||
pour l'un, écriture d'un setpoint continu pour l'autre. Un triac n'est **pas** un routeur de
|
||||
relais (pas de combinatoire) : on ne le force pas dans ce moule.
|
||||
|
||||
---
|
||||
|
||||
## 3. Les exécuteurs sous l'optimiseur
|
||||
|
||||
### 3.a Cas relais (ECS multipalier) — `RelayRouter`
|
||||
- **Pas de thing `etmvariableload` unique.** La charge logique est une **liste de relais `power`**.
|
||||
- Chaque relais est un thing implémentant l'interface nymea **`power`** (state `power` bool
|
||||
writable ; optionnellement `currentPower` W en lecture). Fourni par le plugin device
|
||||
(GPIO/Modbus/MQTT) — **rien de spécifique ECS dans ce plugin**.
|
||||
- Le `RelayRouter` (experience-plugin) :
|
||||
1. reçoit `LoadAction{Setpoint, powerW}` de l'optimiseur ;
|
||||
2. **mappe** `powerW` → la combinaison de relais la plus haute ≤ `powerW` ;
|
||||
3. applique l'anti-rebond **`minOn`/`minOff`** (ici, pas dans le thing) et l'ordre
|
||||
**off-before-on** (pas de sur-puissance transitoire sur une transition non-cascadée) ;
|
||||
4. commande chaque relais via `ThingManager::executeAction` (state `power`) ;
|
||||
5. **agrège `currentPowerW`** = somme des `currentPower` des relais ON, **ou** à défaut de
|
||||
mesure (relais GPIO bool sans wattmètre) la **somme des nominaux commandés**. Dans ce
|
||||
dernier cas `currentPowerW` reflète le *commandé*, pas le *mesuré* — acceptable, à garder
|
||||
en tête pour le diagnostic terrain.
|
||||
- **`powerLevels`/`maxPowerW` sont DÉRIVÉS** des combinaisons atteignables de la liste de
|
||||
relais — **source de vérité unique = les relais**. Le routeur les calcule et les expose à
|
||||
l'optimiseur ; l'assistant app fait le même calcul pour l'affichage.
|
||||
|
||||
### 3.b Cas continu (EV / routeur PV) — interface device `etmvariableload`
|
||||
Inchangé rév. 2 : 1 thing exposant un state `powerSetpoint` (write) + `currentPowerW` (read),
|
||||
piloté par `EtmVariableLoadAdapter`. (`maxPowerW`/`powerLevels` en read optionnels.)
|
||||
|
||||
| State | Accès | Type | Sémantique |
|
||||
|---|---|---|---|
|
||||
| `powerSetpoint` | **write** | uint W | consigne de l'optimiseur |
|
||||
| `currentPowerW` | read | uint W | puissance réellement appliquée |
|
||||
| `maxPowerW` | read | uint W | plafond physique |
|
||||
|
||||
---
|
||||
|
||||
## 4. `LoadConfig` — config app → energymanager (rév. 3)
|
||||
|
||||
Émis par l'écran « Configurer charge pilotée » ; persisté **côté plugin**
|
||||
(`/var/lib/nymea/energy-load-configuration.json`), jamais en SharedPreferences. App = éditeur :
|
||||
`NymeaEnergy.GetLoadConfig` / `SetLoadConfig`.
|
||||
|
||||
### Cas relais (`adapter: "relay-router"`, mode `fixed`)
|
||||
```jsonc
|
||||
{
|
||||
"id": "ecs-chauffe-eau", // charge LOGIQUE (plus un thingId unique)
|
||||
"label": "Chauffe-eau",
|
||||
"adapter": "relay-router",
|
||||
"mode": "fixed",
|
||||
"priority": 2, // rang ; 1 = servi en premier
|
||||
"enabled": true,
|
||||
"needs": { "dailyDeadline": "06:00", "minEnergyWhPerDay": 4000 },
|
||||
"relays": [ // ← la combinatoire vit ICI (routeur), pas chez l'optimiseur
|
||||
{ "thingId": "{uuid-relais-500W}", "powerW": 500 },
|
||||
{ "thingId": "{uuid-relais-1000W}", "powerW": 1000 },
|
||||
{ "thingId": "{uuid-relais-2000W}", "powerW": 2000 }
|
||||
],
|
||||
"minOnS": 60,
|
||||
"minOffS": 60
|
||||
// powerLevels / maxPowerW : ABSENTS de la config — DÉRIVÉS de relays[] par le routeur.
|
||||
}
|
||||
```
|
||||
|
||||
### Cas continu (`adapter: "etmvariableload"`, mode `dynamic`)
|
||||
```jsonc
|
||||
{
|
||||
"id": "{uuid-thing-triac}", "label": "Routeur PV salon", "adapter": "etmvariableload",
|
||||
"mode": "dynamic", "maxPowerW": 3000, "priority": 1, "enabled": true
|
||||
}
|
||||
```
|
||||
|
||||
Règles :
|
||||
- **`relays[]` ne franchit jamais la frontière** : il vit dans le routeur. L'optimiseur ne reçoit
|
||||
que `powerLevels`/`maxPowerW` (watts dérivés) via le contexte.
|
||||
- `relays[].thingId` = ThingId du thing `power`. **Choix par PICKER côté app** (voir §10) — le
|
||||
picker est **UI-only** et ne fait qu'aider la saisie : il résout *nom du relais → ThingId* et
|
||||
écrit des ThingIds dans `relays[]`. **Aucun champ supplémentaire au contrat.**
|
||||
- `minOnS`/`minOffS` : verrous anti-rebond (protection relais / compresseur), **par charge**.
|
||||
- `enabled:false` → rôle déclaré mais **exclu** de l'arbitrage (aucune action, aucun adaptateur
|
||||
construit).
|
||||
|
||||
---
|
||||
|
||||
## 5. Règle d'arrondi (optimiseur) — inchangée
|
||||
|
||||
À chaque cycle, pour une charge dont c'est le tour dans la priorité :
|
||||
- **Fixed** : `level = max( l ∈ powerLevels | l ≤ budget )` puis `Setpoint(level)`. L'optimiseur
|
||||
connaît la granularité **dérivée** → pas de sur-allocation, **zéro cycle de retard**. (Le routeur
|
||||
traduit ensuite `level` → relais.)
|
||||
- **Dynamic** : `Setpoint( clamp(budget, 0, maxPowerW) )`.
|
||||
- **Résidu** : lire `currentPowerW` **de début de cycle** (télémétrie de l'exécuteur — PAS de
|
||||
relecture post-setpoint : invariant 8). Le résidu `budget − currentPowerW` repart vers la
|
||||
charge suivante de la priorité **du même cycle**.
|
||||
|
||||
**Déclaré vs réel :** `powerLevels`/`maxPowerW` (dérivés des relais) = référence de planification.
|
||||
`currentPowerW` = juge runtime. Si le réel plafonne durablement sous le déclaré → erreur de
|
||||
câblage/déclaration, l'installateur corrige. L'optimiseur **n'écrase jamais** la déclaration.
|
||||
|
||||
---
|
||||
|
||||
## 6. `LoadAction` — **deux** kinds (inchangé)
|
||||
|
||||
| Kind | Pour | Porte |
|
||||
|---|---|---|
|
||||
| `Setpoint` | EV / ECS / routeur PV (via `RelayRouter` **ou** `EtmVariableLoadAdapter`) | `powerW` |
|
||||
| *état SG-Ready* | PAC (interface à états, §8) | `state` (1–4) |
|
||||
| `Constraint` | batterie (déféré 3f) | conservé |
|
||||
|
||||
L'index de combinaison de relais **n'existe pas** au niveau `LoadAction` : il est **privé au
|
||||
routeur**. Le kind `Stage` reste supprimé (rév. 2). `reason` reste porté par chaque `LoadAction`.
|
||||
|
||||
---
|
||||
|
||||
## 7. Expo du `reason` — tableau additif `loadActions[]` (inchangé)
|
||||
|
||||
Dans `GetChargingSchedules` / `ChargingSchedulesChanged` :
|
||||
```jsonc
|
||||
"loadActions": [
|
||||
{ "loadId": "ecs-chauffe-eau", "setpointW": 1500, "currentPowerW": 1480, "reason": "Surplus PV 1.6 kW — ECS rang 2" },
|
||||
{ "loadId": "uuid-pac", "state": 3, "reason": "Surplus PV — PAC en état 3" }
|
||||
]
|
||||
```
|
||||
`setpointW`/`currentPowerW` optionnels (absents pour une charge à états) ; `loadId` + `reason`
|
||||
**toujours** présents. C'est le canal de la **decision card** de l'app.
|
||||
|
||||
---
|
||||
|
||||
## 8. PAC SG-Ready — modèle à états (conservé, inchangé)
|
||||
|
||||
Hors charge en watts. `SgReadyAdapter` (états 1–4, combos K1/K2, hystérésis, `minStateHold`)
|
||||
n'est pas réécrit. L'arbitre lui envoie un **état**, pas un setpoint W. Repli L2 : état 2 (normal,
|
||||
mains off), jamais état 1 (blocage).
|
||||
|
||||
---
|
||||
|
||||
## 9. Sécurité — repli L2 (rév. 3)
|
||||
|
||||
- **Watchdog L2** (`evaluateMeterFreshness`, 90 s, `applyDegradedMode`) inchangé. En dégradé :
|
||||
- charges en watts (`RelayRouter` **et** `EtmVariableLoadAdapter`) → `Setpoint(0)` `force=true`.
|
||||
Pour le `RelayRouter`, ça coupe **tous** les relais (`force` bypasse `minOn/minOff`).
|
||||
- PAC → état 2.
|
||||
- Ordre `update()` (sécurité avant planif) intact. `enabled:false` → exclu de l'arbitrage.
|
||||
|
||||
---
|
||||
|
||||
## 10. IMPACT APP — écran « Configurer charge pilotée » (⚠️ lire avant de coder l'UI)
|
||||
|
||||
**Ce qui change pour `etm-powersync-app` en rév. 3.** L'agent app doit lire ceci **avant** de
|
||||
toucher à l'écran Configurer, sinon il code contre une frontière périmée (rév. 2).
|
||||
|
||||
- **AVANT (rév. 2)** : configurer une ECS = choisir **1 thing `etmvariableload`** + saisir des
|
||||
`powerLevels`.
|
||||
- **APRÈS (rév. 3)** : configurer une ECS = **ajouter une liste de relais** (things `power`) avec,
|
||||
pour chacun, sa **puissance (W)**, + les verrous **`minOnS`/`minOffS`**. Plus de « 1 thing
|
||||
etmvariableload » pour le cas relais.
|
||||
- **Picker relais (UI-only)** : l'app présente les things implémentant l'interface `power`
|
||||
(via l'API d'intégration nymea, équivalent `findConfiguredThings("power")`) pour que
|
||||
l'installateur **choisisse ses relais par NOM** (taper un UUID au doigt = erreur garantie).
|
||||
Le picker **résout nom → ThingId** et écrit des ThingIds dans `relays[]`. Il **n'ajoute aucun
|
||||
champ au contrat** (comme l'assistant résistances).
|
||||
- **Assistant résistances (UI-only, conservé)** : à partir des relais et de leurs puissances,
|
||||
calcule et **affiche** les `powerLevels` atteignables (combinaisons) — pour que l'installateur
|
||||
voie ce qu'il obtient. Ces `powerLevels` ne sont **pas** envoyés (dérivés côté plugin).
|
||||
- **`SetLoadConfig`** reçoit donc `relays[]` (+ `minOnS`/`minOffS`) pour une charge relais ; le cas
|
||||
continu (triac) garde un `thingId` + `maxPowerW`. Voir §4.
|
||||
|
||||
---
|
||||
|
||||
## 11. Dépendances liées (côté plugin)
|
||||
|
||||
- Handler `nymeaenergyjsonhandler` : `Get/SetLoadConfig` + persistance (fait ; schéma étendu rév. 3).
|
||||
- `loadActions[]` dans `GetChargingSchedules` / `ChargingSchedulesChanged` (fait).
|
||||
- Arbitre : construit `RelayRouter` (si `relays[]`) **ou** `EtmVariableLoadAdapter` (si continu)
|
||||
depuis `LoadConfig` ; repli L2 unifié sur les exécuteurs en watts.
|
||||
|
||||
---
|
||||
|
||||
## Changelog
|
||||
|
||||
- **rév. 1** : `etmvariableload` interface unique, watts, un seul kind d'action.
|
||||
- **rév. 2** : PAC SG-Ready **exclue** (modèle à états séparé) ; `LoadAction` garde **deux** kinds
|
||||
(`Setpoint` + état SG-Ready) ; suppression du kind `Stage`.
|
||||
- **rév. 3** : **frontière déplacée** entre optimiseur (watt-pur) et **routeur** (watts→relais,
|
||||
experience-plugin) ; un relais = thing `power` nu ; `LoadConfig` du cas ECS devient une **liste
|
||||
de relais** (`relays[]` + `minOnS`/`minOffS`) ; `powerLevels` **dérivés** ; cas continu
|
||||
(`EtmVariableLoadAdapter`) inchangé. **Impact app §10.**
|
||||
209
docs/SAFETY.md
Normal file
209
docs/SAFETY.md
Normal file
@ -0,0 +1,209 @@
|
||||
# SAFETY.md — Modèle de sécurité ETM PowerSync Energy
|
||||
|
||||
Décision Patrick Schurig, validée en session 2026-06-08. Ce document est normatif.
|
||||
|
||||
## Principes
|
||||
|
||||
Cinq couches indépendantes + signalisation locale optionnelle. Une couche supérieure
|
||||
peut tomber sans impacter les couches inférieures. Jamais de contournement logiciel
|
||||
d'une couche matérielle.
|
||||
|
||||
---
|
||||
|
||||
## L0 — Disjoncteur (matériel)
|
||||
|
||||
**Protège contre** : surintensité physique, défaut d'isolement.
|
||||
**Responsable** : installateur électricien, norme C15-100.
|
||||
**ETM ne touche pas à cette couche.** Elle fonctionne que nymead soit vivant ou non.
|
||||
|
||||
---
|
||||
|
||||
## L1 — Failsafe natif des bornes EV et charges pilotées
|
||||
|
||||
**Protège contre** : perte de communication borne ↔ nymead.
|
||||
**Ne couvre PAS** : perte de communication compteur ↔ nymead (c'est le rôle de L2).
|
||||
**Responsable** : configuration installateur sur chaque borne/appareil.
|
||||
|
||||
**Checklist ETM à la mise en service** :
|
||||
- Borne EV : configurer le timeout de communication → repli sur courant minimum
|
||||
défini par l'installateur (jamais 0 A si le VE est branché, jamais maximum abonnement).
|
||||
- Relais ECS : désactivation si absence de commande > N minutes (selon chauffe-eau).
|
||||
- SG-Ready PAC : état 1 (normal, non piloté) si silence > N minutes.
|
||||
|
||||
Cette configuration est documentée dans la checklist de déploiement
|
||||
(`etm-powersync-deploy`), pas ici.
|
||||
|
||||
---
|
||||
|
||||
## L2 — Watchdog fraîcheur compteur (à implémenter en phase 3c)
|
||||
|
||||
**Protège contre** : compteur hors ligne, plugin gelé, nymead bloqué.
|
||||
**Responsable** : code ETM dans `EnergyArbitrator`.
|
||||
|
||||
### Comportement
|
||||
|
||||
Un `QTimer` (pas un signal `meterChanged`) tourne en permanence avec une période de 30 s.
|
||||
À chaque tick : si `QDateTime::currentDateTime() - m_lastMeterUpdate > 90 s`
|
||||
→ **mode dégradé** déclenché.
|
||||
|
||||
**Choix de conception :** `QTimer` et non signal `powerBalanceChanged`, car si le
|
||||
compteur est muet, le signal ne fire plus — le watchdog doit rester actif précisément
|
||||
dans ce cas.
|
||||
|
||||
### Mode dégradé — consignes de repli (**Variante B — décision Patrick**)
|
||||
|
||||
À la **transition** vers le mode dégradé, les charges pilotées reçoivent une consigne de
|
||||
repli **conservatrice** : le repli n'INITIE rien, il borne ce qui tourne déjà.
|
||||
|
||||
| Charge | Consigne de repli |
|
||||
|--------|------------------|
|
||||
| EV **en charge** | Clamp au courant minimum borne (`maxChargingCurrentMinValue`) |
|
||||
| EV branché mais **pas en charge** | Inchangé — reste off (off possiblement volontaire : HC/spot à venir) |
|
||||
| EV débranché | Aucune action |
|
||||
| ECS | Relais coupé (palier 0, `force=true`) |
|
||||
| SG-Ready PAC | État **2** (normal — mains off, la PAC chauffe selon son thermostat). JAMAIS état 1 (blocage) : bloquer une PAC sous compteur muet = maison qui ne chauffe plus sans raison visible. |
|
||||
| Batterie | Aucune charge réseau (surplus uniquement, plafonné à 0 si compteur muet) |
|
||||
|
||||
« Maintenu » ≠ « démarré » : le mode dégradé ne force jamais l'activation d'une charge.
|
||||
La garantie *"jamais 0 A si le VE est branché"* appartient au **failsafe L1 de la borne**
|
||||
(timeout communication → courant minimum installateur), pas au repli logiciel.
|
||||
|
||||
Chaque consigne porte un `decisionReason` explicite :
|
||||
`"Compteur muet depuis >90 s — consigne de repli (L2 watchdog)"`.
|
||||
|
||||
**Suspension de la planification** : tant que le mode dégradé est actif, `update()`
|
||||
exécute la sécurité L4 (position 3, intouchable) puis **retourne immédiatement** — ni
|
||||
`getPlan()` ni dispatch. Sinon `update()` replanifierait sur le **cache** d'un compteur
|
||||
mort et rallumerait les charges que le watchdog vient de couper, que le tick suivant
|
||||
recouperait 30 s plus tard (oscillation). Le repli est donc appliqué **une seule fois à
|
||||
la transition** ; les consignes tiennent jusqu'au retour du compteur.
|
||||
|
||||
**Risque accepté** : ~1,4 kW de tirage EV non supervisé pour un VE déjà en charge au
|
||||
moment de la bascule (minimum borne typique ≈ 6 A × 230 V × 1 phase). Ce tirage est
|
||||
atténué par L0 (disjoncteur) et L1 (failsafe borne). Le client est informé (voir
|
||||
Notification ci-dessous).
|
||||
|
||||
**Limite** : la notification part du contrôleur dégradé lui-même (best-effort). L'alerte
|
||||
indépendante de la défaillance du contrôleur est le rôle de L3 (watchdog systemd).
|
||||
|
||||
### Notification client (dans ce repo)
|
||||
|
||||
- La notification JSON-RPC `NymeaEnergy.ChargingSchedulesChanged` porte un champ additif
|
||||
`degradedMode` (bool). Elle est émise aux **transitions** du mode dégradé (entrée/sortie),
|
||||
en plus des recalculs de planning. Voir `INTERFACE.md`.
|
||||
- L'application affiche : *"Supervision compteur perdue — charge EV maintenue au minimum"*.
|
||||
|
||||
### Limite — détection par fraîcheur uniquement
|
||||
|
||||
Le watchdog L2 détecte l'**absence de mise à jour** du compteur (plus de signal
|
||||
`powerBalanceChanged` depuis > 90 s), pas une **valeur figée**. Un compteur qui continue
|
||||
d'émettre une valeur strictement constante (capteur bloqué mais lien vivant) n'est **pas**
|
||||
détecté par cette couche — `m_lastMeterUpdate` reste frais. Détecter une valeur figée
|
||||
(variance nulle sur fenêtre) est hors scope L2 ; le cas est couvert au niveau matériel/L0
|
||||
et par la supervision externe.
|
||||
|
||||
### Signalisation locale (zéro cloud)
|
||||
|
||||
ETM PowerSync est **100 % autonome, zéro cloud** : aucune alerte ne sort vers un service
|
||||
distant (ni n8n, ni mail, ni push tiers). Le système est conçu pour fonctionner **sans
|
||||
internet** (argument produit : autoconsommation, local-first). Le `degradedMode` est
|
||||
signalé par deux canaux strictement locaux :
|
||||
|
||||
- **Notification nymea in-app** (déjà implémentée : champ `degradedMode`) — canal principal
|
||||
vers le client connecté à l'application.
|
||||
- **Signal sonore local optionnel** (buzzer GPIO ou canal relais) piloté par une **Règle
|
||||
nymea** déclenchée sur `degradedMode` — pour le client sur place, sans application.
|
||||
Aucun code buzzer dans ce repo : le moteur **expose** l'état, la signalisation est une
|
||||
Thing nymea + une règle (configuration d'installation, cf. `## Signalisation locale`).
|
||||
|
||||
**Aucun canal sortant réseau.** Voir l'invariant « ZÉRO cloud » dans `AGENTS.md`.
|
||||
|
||||
### Sortie du mode dégradé
|
||||
|
||||
Dès que le compteur fournit une nouvelle mesure (`m_lastMeterUpdate` remis à jour)
|
||||
→ reprise normale au cycle suivant, `degradedMode: false`.
|
||||
|
||||
### Scénario de simulation obligatoire (DoD 3c)
|
||||
|
||||
Scénario `testMeterSilentFallback` dans `tests/auto/simulation/` :
|
||||
1. Charger branché, surplus PV → charge en cours.
|
||||
2. Geler les updates du compteur (mock).
|
||||
3. Vérifier qu'après 90 s simulés, les consignes de repli sont émises avec le bon `reason`
|
||||
et que `degradedMode` est à `true`.
|
||||
4. Dégeler le compteur → vérifier la reprise normale et `degradedMode: false`.
|
||||
|
||||
---
|
||||
|
||||
## L3 — Watchdog systemd sur nymead
|
||||
|
||||
**Protège contre** : crash de nymead, deadlock process, défaillance de L2.
|
||||
**Responsable** : `etm-powersync-deploy` (hors scope de ce repo).
|
||||
Nymead enregistre `sd_notify(WATCHDOG=1)` ; systemd le relance si absent > timeout.
|
||||
À la reprise, L1 garantit la sécurité matérielle pendant le redémarrage.
|
||||
|
||||
---
|
||||
|
||||
## L4 — Logique signal-driven existante (amont)
|
||||
|
||||
**Protège contre** : surcharge transitoire sur phases.
|
||||
**Responsable** : `SmartChargingManager::verifyOverloadProtection()`.
|
||||
|
||||
### Double déclenchement (code amont vérifié)
|
||||
|
||||
`verifyOverloadProtection()` est déclenchée par **deux mécanismes distincts** :
|
||||
|
||||
1. **Signal temps réel** (`smartchargingmanager.cpp` ligne 127) :
|
||||
```cpp
|
||||
connect(m_energyManager, &EnergyManager::powerBalanceChanged, this, [this]() {
|
||||
verifyOverloadProtection(QDateTime::currentDateTime());
|
||||
});
|
||||
```
|
||||
C'est le mécanisme **principal** : déclenché à chaque nouveau bilan de puissance
|
||||
émis par le compteur, sans attendre le prochain cycle `update()`.
|
||||
|
||||
2. **Appel cyclique** (`smartchargingmanager.cpp` ligne 313, dans `update()`) :
|
||||
```cpp
|
||||
verifyOverloadProtection(currentDateTime); // position 3 du cycle
|
||||
```
|
||||
Filet périodique — garantit une vérification même si le signal précède la mise à
|
||||
jour des états internes.
|
||||
|
||||
Note : en mode simulation, `simulationCallUpdate()` (ligne 295-299) appelle `update()`
|
||||
puis `verifyOverloadProtection()` une seconde fois — double appel intentionnel dans
|
||||
les tests, pas dans la production.
|
||||
|
||||
### Règle de code ETM
|
||||
|
||||
`EnergyArbitrator::update()` appelle `verifyOverloadProtection()` en **position 3**,
|
||||
identique à l'amont. INTERDIT de déplacer cet appel ou de le conditionner.
|
||||
La connexion signal est héritée via le constructeur parent — aucune reconnexion ETM.
|
||||
|
||||
---
|
||||
|
||||
## Signalisation locale (optionnelle — hors de ce repo)
|
||||
|
||||
**Protège contre** : alarme silencieuse non détectée, exigeant une action humaine immédiate.
|
||||
**Ce plugin** émet les événements (`degradedMode`, alarmes overload, etc.).
|
||||
**La signalisation** est une Thing nymea (buzzer GPIO ou canal relais) + une **Règle**
|
||||
déclenchée sur ces événements — configuration installation, documentée dans
|
||||
`etm-powersync-deploy`.
|
||||
|
||||
Aucun code buzzer/relais dans ce repo. Principe : le moteur émet, la configuration
|
||||
d'installation décide quoi signaler.
|
||||
|
||||
**Zéro cloud** : toute la signalisation est locale (notification nymea in-app +
|
||||
signalisation physique). Aucun appel réseau sortant vers un service distant — le système
|
||||
fonctionne sans internet. Invariant gravé dans `AGENTS.md`.
|
||||
|
||||
---
|
||||
|
||||
## Correspondance couches / scénarios de défaillance
|
||||
|
||||
| Défaillance | L0 | L1 | L2 | L3 | L4 | Signal local |
|
||||
|-------------|----|----|----|----|-----|------|
|
||||
| Surintensité réseau | ✅ | ✅ | — | — | ✅ | optionnel |
|
||||
| Borne perd le réseau IP | — | ✅ | — | — | — | — |
|
||||
| Compteur hors ligne > 90 s | ✅ | — | ✅ | — | — | optionnel |
|
||||
| Crash nymead | — | ✅ | — | ✅ | — | — |
|
||||
| Bug dans `update()` | — | ✅ | ✅ | ✅ | ✅ | — |
|
||||
| Optimiseur externe mort | — | — | — | — | — | repli rule-based (AGENTS §6) |
|
||||
358
docs/TEST_TERRAIN.md
Normal file
358
docs/TEST_TERRAIN.md
Normal file
@ -0,0 +1,358 @@
|
||||
# TEST_TERRAIN.md — Procédure de test Palier 1 (nymea-dev arm64)
|
||||
|
||||
Test terrain du moteur **etm-powersync-energy-plugin-etm** sur banc : compteur **mock
|
||||
forçable** (puissance injectée par HTTP) + **relais GPIO réels** (état vérifié via
|
||||
JSON-RPC nymead ou multimètre NO-COM).
|
||||
14 tests (T1–T14). Chaque test : **inject → log attendu → vérif relais → case ✓/✗**.
|
||||
|
||||
> Convention puissance compteur : `currentPower < 0` = **export** (surplus PV) ; `> 0` = **import**.
|
||||
> Le moteur calcule un surplus net SIGNÉ `(exportW − importW)` qui cascade par priorité.
|
||||
|
||||
---
|
||||
|
||||
## §0 — Pré-vol (à remplir SUR LA BOX)
|
||||
|
||||
| Élément | Valeur | Source |
|
||||
|---|---|---|
|
||||
| IP / hostname | `192.168.1.75` — hostname `hems`, user `etm` (sudo) | briefing terrain |
|
||||
| Port JSON-RPC | `nymeas://hems:2222` (TCP+TLS) · `wss://hems:4444` | connu |
|
||||
| Auth requise ? | `__________` | **[À LIRE SUR LA BOX]** `JSONRPC.Hello` → champ `authenticationRequired` |
|
||||
| Token (si auth) | `__________` | `Users.Authenticate {username,password,deviceName}` → `token` |
|
||||
| ThingClassId compteur mock | `2721a051-6e12-471a-baba-21d87c4cebc9` | connu (energymocks) |
|
||||
| ThingId compteur mock | `__________` | **[À LIRE]** après `Integrations.AddThing`, ou `Integrations.GetThings` |
|
||||
| Port HTTP compteur mock | `26655` | connu |
|
||||
| ThingId R500 (Relay1 C1 BCM5, pin 29) | `8538782f-2c8c-4a30-bfce-c8140f791c9b` | briefing terrain |
|
||||
| ThingId R1000 (Relay2 C2 BCM6, pin 31) | `2ebe6bef-829b-4695-9d11-ddb4c16c5448` | briefing terrain |
|
||||
| ThingId R2000 (Relay3 C3 BCM13, pin 33) | `b033b212-1adb-4df0-ba2b-8fa477de52a2` | briefing terrain |
|
||||
| ThingId K1 (Relay4 C4 BCM16, pin 36) | `beaf92e1-aedc-4b84-9ce4-e423648638cc` | briefing terrain |
|
||||
| ThingId K2 (Relay5 C5 BCM19, pin 35) | `bf236e64-5ae7-4bf1-82a6-4ede03de75a6` | briefing terrain |
|
||||
| gpiochip + BCM offsets | `gpiochip0` ; BCM R500=5, R1000=6, R2000=13, K1=16, K2=19 | vérifiés — **`gpioget` valide uniquement nymead ARRÊTÉ** |
|
||||
| Conteneur build cross-arm64 | `__________` | **[À LIRE DANS `etm-powersync-deploy`/DEPLOY.md]** |
|
||||
|
||||
> `[À LIRE SUR LA BOX]` = dépend du déploiement, pas inventé ici. Tout le reste est figé.
|
||||
|
||||
---
|
||||
|
||||
## §1 — Déploiement (option a : build cross-arm64 → scp → dpkg)
|
||||
|
||||
### a. Build cross-arm64 (depuis le poste dev)
|
||||
```bash
|
||||
# Dans le conteneur de build cross-arm64 (cf. DEPLOY.md du repo etm-powersync-deploy).
|
||||
# Produit le .deb arm64 : nymea-energy-plugin-nymea_<ver>_arm64.deb
|
||||
# (TARGET inchangé = libnymea_energypluginnymea.so, drop-in remplaçant l'amont).
|
||||
```
|
||||
> Le nom de paquet/TARGET est **inchangé** (décision Phase 1) → le `.deb` ETM **remplace**
|
||||
> le plugin énergie amont. Un seul plugin énergie chargé.
|
||||
|
||||
### b. Déploiement sur la box
|
||||
```bash
|
||||
BOX=hems
|
||||
scp nymea-energy-plugin-nymea_*_arm64.deb etm@$BOX:/tmp/
|
||||
ssh etm@$BOX 'sudo dpkg -i /tmp/nymea-energy-plugin-nymea_*_arm64.deb && sudo systemctl restart nymead'
|
||||
ssh etm@$BOX 'sudo journalctl -u nymead -f' # suivre les logs
|
||||
```
|
||||
> `.so` installé dans `/usr/lib/<multiarch>/nymea/energy/libnymea_energypluginnymea.so`.
|
||||
|
||||
### c. Activer le logging `[Arbitre]` (SINON aucune trace de décision)
|
||||
La catégorie est exactement **`NymeaEnergy`** (`NYMEA_LOGGING_CATEGORY(dcNymeaEnergy, "NymeaEnergy")`).
|
||||
Méthode robuste — drop-in systemd (Qt logging rules) :
|
||||
```bash
|
||||
ssh etm@$BOX 'sudo mkdir -p /etc/systemd/system/nymead.service.d && \
|
||||
printf "[Service]\nEnvironment=QT_LOGGING_RULES=NymeaEnergy.debug=true\n" | \
|
||||
sudo tee /etc/systemd/system/nymead.service.d/etm-logging.conf >/dev/null && \
|
||||
sudo systemctl daemon-reload && sudo systemctl restart nymead'
|
||||
```
|
||||
> Alternative selon la version : `nymead --logging <règles>` ou la section logging de
|
||||
> `/etc/nymea/nymead.conf`. **[VÉRIFIER `nymead --help` SUR LA BOX]** si le drop-in ne suffit pas.
|
||||
> Les lignes attendues commencent par `[Arbitre]` et `[EcsRelayAdapter]`/`[SgReadyAdapter]`.
|
||||
|
||||
### d. Déclarer les adaptateurs de test — LA VRAIE MÉTHODE
|
||||
⚠️ **Il n'existe pas encore de config runtime des adaptateurs** (déféré : couche « config
|
||||
priorités »). En production, `energypluginnymea.cpp::init()` crée l'`EnergyArbitrator` mais
|
||||
**n'enregistre aucun adaptateur** ECS/SG-Ready. La déclaration se fait donc par un **bloc de
|
||||
code** ajouté à `energypluginnymea.cpp` (juste après la création de `chargingManager`,
|
||||
ligne ~54), recompilé dans le `.deb` de test.
|
||||
|
||||
Workflow : déployer une 1re fois → `Integrations.AddThing` le compteur mock + les relais GPIO
|
||||
→ relever leurs ThingId (`GetThings`) → coller le bloc ci-dessous avec ces ThingId → rebuild → redéployer.
|
||||
|
||||
```cpp
|
||||
// energypluginnymea.cpp, init(), APRÈS la ligne :
|
||||
// EnergyArbitrator *chargingManager = new EnergyArbitrator(...);
|
||||
#ifdef ETM_ARBITRATOR
|
||||
{
|
||||
ThingManager *tm = thingManager();
|
||||
// --- ECS 3 relais (8 niveaux binaires) ---
|
||||
const QString R500 = "{8538782f-2c8c-4a30-bfce-c8140f791c9b}"; // Relay1 C1 BCM5
|
||||
const QString R1000 = "{2ebe6bef-829b-4695-9d11-ddb4c16c5448}"; // Relay2 C2 BCM6
|
||||
const QString R2000 = "{b033b212-1adb-4df0-ba2b-8fa477de52a2}"; // Relay3 C3 BCM13
|
||||
auto *ecs = new EcsRelayAdapter(
|
||||
tm, "ecs-terrain", "ECS banc",
|
||||
QList<int>({0, 500, 1000, 1500, 2000, 2500, 3000, 3500}),
|
||||
QList<QList<QString>>({ {}, {R500}, {R1000}, {R500,R1000},
|
||||
{R2000}, {R500,R2000}, {R1000,R2000}, {R500,R1000,R2000} }),
|
||||
/*minOnS*/ 60, /*minOffS*/ 60, /*priority*/ 1, chargingManager);
|
||||
chargingManager->registerEcsAdapter(ecs);
|
||||
|
||||
// --- SG-Ready (2 bits K1/K2) ---
|
||||
const QString K1 = "{beaf92e1-aedc-4b84-9ce4-e423648638cc}"; // Relay4 C4 BCM16
|
||||
const QString K2 = "{bf236e64-5ae7-4bf1-82a6-4ede03de75a6}"; // Relay5 C5 BCM19
|
||||
auto *pac = new SgReadyAdapter(
|
||||
tm, "pac-terrain", "PAC banc",
|
||||
QHash<int,QList<QString>>({ {1,{K1}}, {2,{}}, {3,{K2}}, {4,{K1,K2}} }),
|
||||
QHash<int,double>({ {1,0.0}, {2,0.0}, {3,1500.0}, {4,3000.0} }),
|
||||
/*minStateHoldS*/ 300, /*priority*/ 2, chargingManager);
|
||||
chargingManager->registerSgReadyAdapter(pac);
|
||||
}
|
||||
#endif
|
||||
```
|
||||
> Inclure `#include "etm/adapters/ecsrelayadapter.h"` et `etm/adapters/sgreadyadapter.h`.
|
||||
> Le **root meter** = le compteur mock : le déclarer via l'expérience énergie nymea
|
||||
> (`Energy.SetRootMeter` / config) avec le ThingId du compteur mock.
|
||||
> Pour l'ECS simple (T1/T2), utiliser un seul relais : `stages {0,2000}`, `mapping {{},{R2000}}`.
|
||||
|
||||
---
|
||||
|
||||
## Helpers bash (poste dev)
|
||||
|
||||
```bash
|
||||
BOX=hems; MPORT=26655
|
||||
# ThingIds relais (banc hems — pré-vérifiés, cf. §0)
|
||||
TID_R500="8538782f-2c8c-4a30-bfce-c8140f791c9b"
|
||||
TID_R1000="2ebe6bef-829b-4695-9d11-ddb4c16c5448"
|
||||
TID_R2000="b033b212-1adb-4df0-ba2b-8fa477de52a2"
|
||||
TID_K1="beaf92e1-aedc-4b84-9ce4-e423648638cc"
|
||||
TID_K2="bf236e64-5ae7-4bf1-82a6-4ede03de75a6"
|
||||
# Injecter une puissance compteur (W). Négatif = export (surplus PV), positif = import.
|
||||
inject(){ curl -s "http://$BOX:$MPORT/setstates?connected=true¤tPowerPhaseA=$(($1/3))¤tPowerPhaseB=$(($1/3))¤tPowerPhaseC=$(($1/3))" >/dev/null; echo "compteur = $1 W"; }
|
||||
# Compteur MUET : on cesse d'injecter (>90 s) → watchdog L2 bascule (QTimer 30 s, seuil 90 s).
|
||||
mute(){ echo "NE PLUS injecter pendant >90 s…"; sleep 95; }
|
||||
# Suivre les décisions de l'arbitre.
|
||||
logs(){ ssh etm@$BOX "sudo journalctl -u nymead -f | grep -E 'Arbitre|EcsRelay|SgReady'"; }
|
||||
```
|
||||
|
||||
```bash
|
||||
# relay <ThingId> → 1 (ON) | 0 (OFF) via JSON-RPC local nymead.
|
||||
# Protocole complet : Hello → auth si requise → GetThingClasses (résolution stateTypeId
|
||||
# 'power') → GetThings. Framing : boucle par id — ignore les notifications push nymea.
|
||||
# gpioget inutilisable nymead actif (libgpiod v2 busy). Filet : multimètre NO-COM nymead arrêté.
|
||||
# Variables optionnelles : NYMEA_USER (défaut admin) NYMEA_PASS (défaut vide).
|
||||
# ACCEPTANCE avant T1 : forcer un relais ON via l'app nymea, vérifier relay $TID_Rxx → 1 ;
|
||||
# forcer OFF → 0. Tant que ce test isolé ne passe pas, la colonne "vérif" de T1–T14 est aveugle.
|
||||
relay(){
|
||||
local tid="$1"
|
||||
local nu="${NYMEA_USER:-admin}" np="${NYMEA_PASS:-}"
|
||||
ssh etm@$BOX "python3 - '$nu' '$np'" << GETSTATE
|
||||
import ssl, socket, json, sys
|
||||
|
||||
user, pw = sys.argv[1], sys.argv[2]
|
||||
|
||||
ctx = ssl.create_default_context()
|
||||
ctx.check_hostname = False; ctx.verify_mode = ssl.CERT_NONE
|
||||
buf = b''
|
||||
|
||||
def call(s, rid, method, params=None, token=None):
|
||||
global buf
|
||||
req = {'id': rid, 'method': method}
|
||||
if params: req['params'] = params
|
||||
if token: req['token'] = token
|
||||
s.sendall((json.dumps(req) + '\n').encode())
|
||||
while True:
|
||||
buf += s.recv(4096)
|
||||
lines = buf.split(b'\n'); buf = lines[-1]
|
||||
for ln in lines[:-1]:
|
||||
ln = ln.strip()
|
||||
if not ln: continue
|
||||
try:
|
||||
m = json.loads(ln)
|
||||
if m.get('id') == rid: return m
|
||||
except: pass
|
||||
|
||||
with socket.create_connection(('localhost', 2222), timeout=10) as raw, \
|
||||
ctx.wrap_socket(raw) as s:
|
||||
hello = call(s, 1, 'JSONRPC.Hello')
|
||||
token = None
|
||||
if hello.get('params', {}).get('authenticationRequired', False):
|
||||
r = call(s, 2, 'Users.Authenticate',
|
||||
{'username': user, 'password': pw, 'deviceName': 'relay-chk'})
|
||||
p = r.get('params', {})
|
||||
if not p.get('success', True) or 'token' not in p:
|
||||
sys.exit('AUTH_FAILED — définir NYMEA_USER / NYMEA_PASS')
|
||||
token = p['token']
|
||||
# Résoudre stateTypeId de 'power' pour chaque ThingClass (les states dans GetThings
|
||||
# ne portent pas le nom, seulement le stateTypeId — cf. nymea JSON-RPC spec).
|
||||
tc = call(s, 3, 'Integrations.GetThingClasses', token=token)
|
||||
power_stid = {}
|
||||
for c in tc.get('params', {}).get('thingClasses', []):
|
||||
for st in c.get('stateTypes', []):
|
||||
if st.get('name') == 'power':
|
||||
power_stid[c['id']] = st['id']
|
||||
resp = call(s, 4, 'Integrations.GetThings', token=token)
|
||||
things = resp.get('params', {}).get('things', [])
|
||||
t = next((x for x in things if x['id'] == '${tid}'), None)
|
||||
if not t: sys.exit('THING_NOT_FOUND: ${tid}')
|
||||
stid = power_stid.get(t.get('thingClassId', ''), '')
|
||||
state = next((st for st in t.get('states', []) if st.get('stateTypeId') == stid), None)
|
||||
print(1 if state and state.get('value') else 0)
|
||||
GETSTATE
|
||||
}
|
||||
```
|
||||
> Le compteur mock pose `currentPower = somme des 3 phases`. `RootMeter::currentPower()` le relit.
|
||||
|
||||
---
|
||||
|
||||
## §2 — ECS simple (1 relais, paliers {0, 2000})
|
||||
|
||||
### T1 — Montée sur surplus
|
||||
- **inject** : `inject -2500`
|
||||
- **log attendu** : `[Arbitre] … Surplus PV … ECS palier 1` puis `[EcsRelayAdapter] … → stage 1`
|
||||
- **vérif** : `relay $TID_R2000` → **1** (ON)
|
||||
- ✓ / ✗ : `____`
|
||||
|
||||
### T2 — Délestage (import)
|
||||
- **inject** : `inject 1000` (import → surplus net négatif)
|
||||
- **log attendu** : `Surplus insuffisant … ECS éteint` ; `→ stage 0`
|
||||
- **vérif** : `relay $TID_R2000` → **0** (OFF)
|
||||
- ✓ / ✗ : `____`
|
||||
|
||||
---
|
||||
|
||||
## §3 — ECS 3 relais (8 niveaux binaires R500/R1000/R2000)
|
||||
|
||||
### T3 — Cascade montante
|
||||
- **inject** : `inject -1700` (budget 1700 → palier **1500**)
|
||||
- **log** : `ECS palier 3 (1500 W)`
|
||||
- **vérif** : `relay $TID_R500`→**1**, `relay $TID_R1000`→**1**, `relay $TID_R2000`→**0**
|
||||
- ✓ / ✗ : `____`
|
||||
|
||||
### T4 — Transition NON-CASCADÉE 1500 → 2000 (le test clé)
|
||||
- **contexte** : on part de T3 (palier 1500, l'ECS mesure 1500 W).
|
||||
- **inject** : `inject -700` (budget = 700 + 1500 recrédit = 2200 → palier **2000**)
|
||||
- **log** : `ECS palier 4 (2000 W)` ; commutation **off-before-on** (R500/R1000 coupés avant R2000)
|
||||
- **vérif** : `relay $TID_R500`→**0**, `relay $TID_R1000`→**0**, `relay $TID_R2000`→**1** ← *set final R2000 SEUL, pas un état parasite durable*
|
||||
- ✓ / ✗ : `____`
|
||||
|
||||
### T5 — Protection minOn (anti court-cycling)
|
||||
- **contexte** : ECS vient de commuter (< minOn 60 s).
|
||||
- **inject** : `inject 1200` (import → le budget voudrait éteindre)
|
||||
- **log attendu** : `Verrou minOn — … maintenu palier …`
|
||||
- **vérif** : relais **inchangés** (maintien). Attendre > 60 s puis re-`inject 1200` → délestage.
|
||||
- ✓ / ✗ : `____`
|
||||
|
||||
### T6 — Délestage complet
|
||||
- **inject** : `inject 2000` (import franc, > minOn écoulé)
|
||||
- **vérif** : `relay $TID_R500`→**0**, `relay $TID_R1000`→**0**, `relay $TID_R2000`→**0**
|
||||
- ✓ / ✗ : `____`
|
||||
|
||||
---
|
||||
|
||||
## §3.5 — Pré-vol SG-Ready (**à cocher avant T7, nymead ARRÊTÉ**)
|
||||
|
||||
> ⚠️ La polarité K1/K2 est le **seul point non vérifié proprement** de toute la chaîne.
|
||||
> Le test initial a échoué sur `Device or resource busy` (nymead tient les lignes GPIO via
|
||||
> libgpiod v2). Cette vérification doit se faire **nymead arrêté**, au multimètre, avant
|
||||
> toute mise sous tension côté PAC.
|
||||
|
||||
```bash
|
||||
ssh etm@$BOX 'sudo systemctl stop nymead'
|
||||
ssh etm@$BOX 'gpioget -c gpiochip0 16 19' # attendu : 0 0 (repos LOW BCM16/BCM19)
|
||||
```
|
||||
|
||||
- [ ] `gpioget` répond **0 0** — K1 (BCM16) et K2 (BCM19) au repos LOW. ✓
|
||||
- [ ] **K1 repos = circuit ouvert** : multimètre en continuité NO-COM sur bornes K1 → **circuit ouvert**
|
||||
= PAC interprète 0:0 = **état 2 « normal »** (jamais 1:0 = blocage au boot). ✓
|
||||
- [ ] **K2 repos = circuit ouvert** (idem). ✓
|
||||
- [ ] **Fail-safe boot confirmé** : nymead démarre K1=K2=0 (repos LOW, NO/NC → circuit ouvert → PAC = normal). ✓
|
||||
- [ ] `sudo systemctl start nymead` avant T7.
|
||||
|
||||
---
|
||||
|
||||
## §4 — SG-Ready (PAC, 2 bits K1/K2 ; P3=1500, P4=3000)
|
||||
|
||||
> **Seuils code** (`rulebasedscheduler.cpp`, `kForceMargin=1.2`, P3=1500, P4=3000) :
|
||||
> - État 3 si budgetW ≥ 1500 ; état 4 si budgetW ≥ P4×1,2 = **3600** ; zone morte état 4 si budgetW ≥ 3000.
|
||||
> - `budgetW = exportW_brut + allocatedNow` ; allocatedNow = 0 (état 2), 1500 (état 3), 3000 (état 4).
|
||||
> - Depuis état 2 (sans recrédit) : min. inject **≤ −3600** pour atteindre état 4 directement.
|
||||
> - Depuis état 3 (recrédit 1500) : min. inject **≤ −2100** pour atteindre état 4.
|
||||
|
||||
### T7 — Montée d'états 2 → 3 → 4
|
||||
- `inject -1000` → état **2** (budget 1000 < 1500, K1=0,K2=0).
|
||||
- `inject -2000` → état **3** (budget 2000 ≥ 1500, K2=1). Attendre > minStateHold (300 s).
|
||||
- `inject -2500` → état **4** (budget = 2500 + 1500 recrédit = **4000 ≥ 3600**, K1=1,K2=1).
|
||||
- **log** : `… recommandée (état 3 …)` puis `… forcée (état 4 …)`
|
||||
- **vérif** : état 3 → `relay $TID_K1`→**0**, `relay $TID_K2`→**1** ; état 4 → `relay $TID_K1`→**1**, `relay $TID_K2`→**1**
|
||||
- ✓ / ✗ : `____`
|
||||
|
||||
### T8 — Atomicité (transitoire bénin)
|
||||
- **contexte** : transition 2→4 (00→11) commute K1 ET K2.
|
||||
- **vérif** : set **final** `relay $TID_K1`→**1**, `relay $TID_K2`→**1** (état 4). Le transitoire
|
||||
passe par K2 d'abord (01=reco), **jamais** 10=blocage — sub-ms, non observable, mais
|
||||
**garanti code** (`transientHarm`). Confirmer simplement le set final correct.
|
||||
- ✓ / ✗ : `____`
|
||||
|
||||
### T9 — Zone morte hystérésis (pas de bascule 4→3 sur fluctuation surplus)
|
||||
- **contexte** : PAC en état 4 (K1=K2=1) depuis > minStateHold (300 s).
|
||||
L'opérateur injecte le **surplus brut au compteur** — le moteur recrédite la puissance
|
||||
allouée (3000 W déclarés) en interne ; ne pas l'ajouter à `inject`.
|
||||
- Faire fluctuer le surplus : `inject -300` → reste **4** ; `inject -500` → reste **4** ;
|
||||
`inject -100` → reste **4** ; `inject 0` (équilibré) → reste **4** (tout inject ≤ 0 tient l'état).
|
||||
- **Sortie** : `inject 300` (import net) → **état 3**.
|
||||
- **vérif** : `relay $TID_K1`→**1**, `relay $TID_K2`→**1** stable pendant la fluctuation ;
|
||||
`relay $TID_K1`→**0** uniquement après `inject 300`.
|
||||
- ✓ / ✗ : `____`
|
||||
|
||||
---
|
||||
|
||||
## §5 — Watchdog L2 (compteur muet)
|
||||
|
||||
### T10 — Compteur muet → mode dégradé
|
||||
- **inject** `-2500` (ECS/PAC servis), puis **`mute`** (>90 s sans injection).
|
||||
- **log attendu** : `[Arbitre] Compteur muet depuis … mode dégradé L2` ; ECS palier 0 ; PAC **état 2**.
|
||||
- **vérif** : `relay $TID_R500`→**0**, `relay $TID_R1000`→**0**, `relay $TID_R2000`→**0** ;
|
||||
`relay $TID_K1`→**0**, `relay $TID_K2`→**0** (état 2, **jamais** blocage).
|
||||
- ✓ / ✗ : `____`
|
||||
|
||||
### T11 — Stabilité (faux surplus piège)
|
||||
- **contexte** : toujours muet ; la **dernière** valeur injectée (-2500) reste « collée » au compteur.
|
||||
- **vérif** : sur plusieurs minutes muettes, ECS **reste 0** malgré ce surplus stale
|
||||
(planification suspendue — pas de replanif sur cache mort).
|
||||
- ✓ / ✗ : `____`
|
||||
|
||||
### T12 — Reprise
|
||||
- **inject** `-2500` (le compteur re-parle).
|
||||
- **log attendu** : `Compteur de nouveau actif — sortie du mode dégradé` ; recalcul normal.
|
||||
- **vérif** : ECS resuit le surplus (palier > 0). Pas de restauration d'ancienne consigne.
|
||||
- ✓ / ✗ : `____`
|
||||
|
||||
---
|
||||
|
||||
## §6 — Interaction budget partagé
|
||||
|
||||
### T13 — Ordre de priorité ECS↔PAC inversable
|
||||
- **contexte** : surplus moyen `inject -3000`, ECS palier 2400 vs PAC P3 1500.
|
||||
- **ECS prio 1 / PAC prio 2** : ECS se sert (palier), PAC voit le reliquat (600) → **état 2**.
|
||||
- **Inverser les priorités** (échanger `priority` dans le bloc §1.d, rebuild/redeploy) →
|
||||
PAC se sert (état 3), ECS voit le reliquat (1500 < 2400) → **palier 0**.
|
||||
- **vérif** : le service s'inverse selon la priorité (preuve du waterfall unifié).
|
||||
- ✓ / ✗ : `____`
|
||||
|
||||
---
|
||||
|
||||
## §7 — OPTIONNEL EV / V2C (si plugin prêt)
|
||||
|
||||
### T14 — Ordre étagé EV → ECS (beta : EV servi avant le waterfall)
|
||||
- **contexte** : borne EV branchée + surplus moyen.
|
||||
- **attendu** : l'EV est servi **en premier** (proxy amont, décision B), l'ECS voit le reliquat.
|
||||
- **vérif** : courant EV puis palier ECS sur le reliquat. *(V2C = session dédiée, hors Palier 1.)*
|
||||
- ✓ / ✗ : `____`
|
||||
|
||||
---
|
||||
|
||||
## Checklist sécurité (AVANT mise sous tension)
|
||||
- [ ] **Disjoncteur L0** du banc **accessible** et identifié (coupure physique immédiate).
|
||||
- [ ] Banc câblé hors tension ; sections/calibres relais conformes aux puissances (R2000 = 2000 W).
|
||||
- [ ] **Polarité SG-Ready vérifiée** (§3.5 fait et coché) — seul point non vérifié avant cette session.
|
||||
- [ ] Relais résistifs ECS : pas de charge inductive sur ces voies.
|
||||
- [ ] Mapping BCM↔relais consigné (`gpioinfo gpiochip0`, nymead arrêté) avant tout test.
|
||||
- [ ] L1 (failsafe bornes/relais) configuré si applicable ; L0 reste le filet ultime.
|
||||
- [ ] Un opérateur à la main sur le disjoncteur pendant T1–T6 (montées de puissance).
|
||||
@ -1,3 +1,12 @@
|
||||
# Rend les headers du répertoire energyplugin/ accessibles aux consommateurs
|
||||
# (simulation, tests) qui incluent ce .pri depuis un autre répertoire.
|
||||
INCLUDEPATH += $$PWD
|
||||
|
||||
# [ETM] Activate ETM arbitrator — replaces SmartChargingManager::update() with EnergyArbitrator.
|
||||
# Propagé à tous les consommateurs du .pri (plugin + simulation + tests).
|
||||
# Uncommenter pour activer. Commité DÉSACTIVÉ jusqu'à preuve iso (3b-iv).
|
||||
DEFINES += ETM_ARBITRATOR
|
||||
|
||||
greaterThan(QT_MAJOR_VERSION, 5) {
|
||||
message("Building using Qt6 support")
|
||||
CONFIG *= c++17
|
||||
@ -37,6 +46,8 @@ HEADERS += \
|
||||
$$PWD/types/smartchargingstate.h \
|
||||
$$PWD/types/timeframe.h \
|
||||
|
||||
include($$PWD/etm/etm.pri)
|
||||
|
||||
SOURCES += \
|
||||
$$PWD/energymanagerconfiguration.cpp \
|
||||
$$PWD/energysettings.cpp \
|
||||
|
||||
@ -28,6 +28,14 @@
|
||||
#include "energymanagerconfiguration.h"
|
||||
#include "spotmarket/spotmarketmanager.h"
|
||||
|
||||
// [ETM] BEGIN — EnergyArbitrator flip. Remove block to revert to upstream SmartChargingManager.
|
||||
#ifdef ETM_ARBITRATOR
|
||||
#include "etm/energyarbitrator.h"
|
||||
#include "etm/adapters/sgreadyadapter.h"
|
||||
#include "etm/config/loadconfigstore.h"
|
||||
#endif
|
||||
// [ETM] END
|
||||
|
||||
#include "plugininfo.h"
|
||||
|
||||
EnergyPluginNymea::EnergyPluginNymea(QObject *parent) : EnergyPlugin(parent)
|
||||
@ -41,8 +49,34 @@ void EnergyPluginNymea::init()
|
||||
|
||||
EnergyManagerConfiguration *configuration = new EnergyManagerConfiguration(this);
|
||||
QNetworkAccessManager *networkManager = new QNetworkAccessManager(this);
|
||||
|
||||
SpotMarketManager *spotMarketManager = new SpotMarketManager(networkManager, this);
|
||||
|
||||
LoadConfigStore *loadConfigStore = nullptr;
|
||||
|
||||
#ifdef ETM_ARBITRATOR
|
||||
qCDebug(dcNymeaEnergy()) << "ETM_ARBITRATOR actif — EnergyArbitrator chargé.";
|
||||
EnergyArbitrator *chargingManager = new EnergyArbitrator(energyManager(), thingManager(), spotMarketManager, configuration, this);
|
||||
|
||||
// T4 — charges pilotables etmvariableload (ECS/routeur) construites depuis la config
|
||||
// persistée (remplace le registre en dur « 3g »). SetLoadConfig pilote ce store.
|
||||
loadConfigStore = new LoadConfigStore(this);
|
||||
chargingManager->setLoadConfigStore(loadConfigStore);
|
||||
|
||||
// §1.d — Banc hems : SG-Ready PAC (ThingIds briefing terrain) — PAC hors config (§8, gelée).
|
||||
{
|
||||
ThingManager *tm = thingManager();
|
||||
const QString K1 = "{beaf92e1-aedc-4b84-9ce4-e423648638cc}";
|
||||
const QString K2 = "{bf236e64-5ae7-4bf1-82a6-4ede03de75a6}";
|
||||
auto *pac = new SgReadyAdapter(
|
||||
tm, "pac-terrain", "PAC banc",
|
||||
QHash<int,QList<QString>>({ {1,{K1}}, {2,{}}, {3,{K2}}, {4,{K1,K2}} }),
|
||||
QHash<int,double>({ {1,0.0}, {2,0.0}, {3,1500.0}, {4,3000.0} }),
|
||||
300, 2, chargingManager);
|
||||
chargingManager->registerSgReadyAdapter(pac);
|
||||
}
|
||||
#else
|
||||
SmartChargingManager *chargingManager = new SmartChargingManager(energyManager(), thingManager(), spotMarketManager, configuration, this);
|
||||
jsonRpcServer()->registerExperienceHandler(new NymeaEnergyJsonHandler(spotMarketManager, chargingManager, this), 0, 8);
|
||||
#endif
|
||||
|
||||
jsonRpcServer()->registerExperienceHandler(new NymeaEnergyJsonHandler(spotMarketManager, chargingManager, loadConfigStore, this), 0, 8);
|
||||
}
|
||||
|
||||
138
energyplugin/etm/adapters/etmvariableloadadapter.cpp
Normal file
138
energyplugin/etm/adapters/etmvariableloadadapter.cpp
Normal file
@ -0,0 +1,138 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync
|
||||
|
||||
#include "etmvariableloadadapter.h"
|
||||
#include "plugininfo.h"
|
||||
|
||||
#include <QDateTime>
|
||||
#include <algorithm>
|
||||
#include <integrations/thingmanager.h>
|
||||
#include <integrations/thing.h>
|
||||
#include <types/action.h>
|
||||
#include <types/param.h>
|
||||
|
||||
EtmVariableLoadAdapter::EtmVariableLoadAdapter(ThingManager *thingManager,
|
||||
const QString &id,
|
||||
const QString &label,
|
||||
const QList<int> &powerLevels,
|
||||
int maxPowerW,
|
||||
int priority,
|
||||
const LoadNeeds &needs,
|
||||
QObject *parent)
|
||||
: QObject(parent)
|
||||
, m_thingManager(thingManager)
|
||||
, m_id(id)
|
||||
, m_label(label)
|
||||
, m_powerLevels(powerLevels)
|
||||
, m_maxPowerW(maxPowerW)
|
||||
, m_priority(priority)
|
||||
, m_needs(needs)
|
||||
{
|
||||
// Re-tri par sécurité : l'UI émet trié, l'energymanager re-trie (contrat §6 cas limites).
|
||||
std::sort(m_powerLevels.begin(), m_powerLevels.end());
|
||||
// Contrat §2 : les paliers incluent 0. En *fixed* (liste non vide), 0 doit être présent.
|
||||
Q_ASSERT(m_powerLevels.isEmpty() || m_powerLevels.first() == 0);
|
||||
}
|
||||
|
||||
LoadDescriptor EtmVariableLoadAdapter::descriptor() const
|
||||
{
|
||||
LoadDescriptor d;
|
||||
d.id = m_id;
|
||||
d.label = m_label;
|
||||
d.adapter = QStringLiteral("etmvariableload");
|
||||
d.priority = m_priority;
|
||||
|
||||
d.declared.powerLevels = m_powerLevels;
|
||||
d.declared.maxPowerW = m_maxPowerW;
|
||||
d.needs = m_needs;
|
||||
|
||||
d.supportedKinds = { LoadAction::Setpoint };
|
||||
return d;
|
||||
}
|
||||
|
||||
LoadTelemetry EtmVariableLoadAdapter::telemetry() const
|
||||
{
|
||||
LoadTelemetry t;
|
||||
Thing *thing = m_thingManager->findConfiguredThing(ThingId(m_id));
|
||||
t.available = (thing != nullptr);
|
||||
t.currentPowerW = readCurrentPowerW(); // juge runtime (contrat §4)
|
||||
t.lastActionAt = m_lastActionAt;
|
||||
return t;
|
||||
}
|
||||
|
||||
LoadContext EtmVariableLoadAdapter::toLoadContext(const QDateTime &now) const
|
||||
{
|
||||
Q_UNUSED(now) // aucune fenêtre de verrou côté moteur : l'anti-rebond vit dans le thing.
|
||||
|
||||
LoadContext ctx;
|
||||
ctx.id = m_id;
|
||||
ctx.adapter = QStringLiteral("etmvariableload");
|
||||
ctx.label = m_label;
|
||||
ctx.priority = m_priority;
|
||||
ctx.declared = descriptor().declared;
|
||||
|
||||
ctx.telemetry.currentPowerW = readCurrentPowerW();
|
||||
return ctx;
|
||||
}
|
||||
|
||||
LoadAction EtmVariableLoadAdapter::applyAction(const LoadAction &action, const QDateTime &now)
|
||||
{
|
||||
if (action.kind != LoadAction::Setpoint)
|
||||
return action;
|
||||
|
||||
if (action.reason.isEmpty()) {
|
||||
qCWarning(dcNymeaEnergy()) << "[EtmVariableLoadAdapter]" << m_label
|
||||
<< "— LoadAction sans reason rejetée.";
|
||||
return action;
|
||||
}
|
||||
|
||||
// Second filet (invariant ILoadAdapter) : borner la consigne au plafond physique.
|
||||
// L'arrondi au powerLevels (mode *fixed*) est fait par le scheduler (contrat §4, T3).
|
||||
const double setpointW = qBound(0.0, action.powerW, static_cast<double>(m_maxPowerW));
|
||||
|
||||
qCInfo(dcNymeaEnergy()) << "[EtmVariableLoadAdapter]" << m_label
|
||||
<< "→ setpoint" << qRound(setpointW) << "W"
|
||||
<< (action.force ? "(force)" : "")
|
||||
<< "|" << action.reason;
|
||||
|
||||
writeSetpoint(setpointW);
|
||||
|
||||
m_currentSetpointW = setpointW;
|
||||
m_lastActionAt = now;
|
||||
|
||||
LoadAction applied = action;
|
||||
applied.powerW = setpointW;
|
||||
applied.estimatedPowerW = setpointW;
|
||||
return applied;
|
||||
}
|
||||
|
||||
// ---- privé ---------------------------------------------------------------
|
||||
|
||||
double EtmVariableLoadAdapter::readCurrentPowerW() const
|
||||
{
|
||||
Thing *thing = m_thingManager->findConfiguredThing(ThingId(m_id));
|
||||
if (!thing)
|
||||
return 0.0;
|
||||
if (thing->thingClass().stateTypes().findByName("currentPowerW").id().isNull())
|
||||
return 0.0;
|
||||
return thing->stateValue("currentPowerW").toDouble();
|
||||
}
|
||||
|
||||
void EtmVariableLoadAdapter::writeSetpoint(double powerW)
|
||||
{
|
||||
Thing *thing = m_thingManager->findConfiguredThing(ThingId(m_id));
|
||||
if (!thing) {
|
||||
qCWarning(dcNymeaEnergy()) << "[EtmVariableLoadAdapter]" << m_label
|
||||
<< "— thing non trouvé:" << m_id;
|
||||
return;
|
||||
}
|
||||
|
||||
StateType setpointStateType = thing->thingClass().stateTypes().findByName("powerSetpoint");
|
||||
if (!setpointStateType.id().isNull()) {
|
||||
Action setpointAction(setpointStateType.id(), thing->id(), Action::TriggeredByRule);
|
||||
setpointAction.setParams(ParamList() << Param(setpointStateType.id(), powerW));
|
||||
m_thingManager->executeAction(setpointAction);
|
||||
} else {
|
||||
thing->setStateValue("powerSetpoint", powerW); // repli mock
|
||||
}
|
||||
}
|
||||
130
energyplugin/etm/adapters/etmvariableloadadapter.h
Normal file
130
energyplugin/etm/adapters/etmvariableloadadapter.h
Normal file
@ -0,0 +1,130 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync
|
||||
#pragma once
|
||||
|
||||
#include <QObject>
|
||||
#include <QDateTime>
|
||||
#include <QList>
|
||||
#include <QString>
|
||||
#include "iloadadapter.h"
|
||||
|
||||
class Thing;
|
||||
class ThingManager;
|
||||
|
||||
/*!
|
||||
* \brief Adaptateur charge à puissance pilotable — interface \c etmvariableload (contrat
|
||||
* INTERFACE_etmvariableload.md). Action \c kind:Setpoint en WATTS.
|
||||
*
|
||||
* Couvre les charges dont la consigne est une puissance : ECS multipalier, routeur PV /
|
||||
* triac, résistance modulable. \b Pas la PAC SG-Ready (modèle à états, \c SgReadyAdapter).
|
||||
*
|
||||
* \par Frontière (contrat §1 / §5) — non négociable
|
||||
* L'energymanager raisonne UNIQUEMENT en watts. \b Aucune résistance, relais, combinaison
|
||||
* ni index de palier matériel ne traverse cet adaptateur. La connaissance matérielle (quelle
|
||||
* résistance, quel triac, sur quelle phase), l'anti-rebond (minOn/minOff) et l'équilibrage
|
||||
* de phases vivent ENTIÈREMENT dans le \b thing qui implémente l'interface. Cet adaptateur
|
||||
* se contente d'écrire une consigne \c powerSetpoint (W) et de relire \c currentPowerW.
|
||||
*
|
||||
* \par States nymea consommés sur le thing cible (\c m_id)
|
||||
* - \c currentPowerW (read) : puissance réellement appliquée — juge runtime (contrat §4).
|
||||
* - \c powerSetpoint (write) : consigne demandée par l'energymanager.
|
||||
* - \c maxPowerW / \c powerLevels (read) : déclarés par le thing pour le wizard ; la
|
||||
* planification utilise la DÉCLARATION de config (passée au constructeur, contrat §4),
|
||||
* pas ces states — l'adaptateur n'écrase jamais la déclaration.
|
||||
*
|
||||
* \invariant applyAction() rejette silencieusement toute action dont \c reason est vide.
|
||||
* \invariant Seul le kind \c Setpoint est traité ; les autres kinds retournent sans effet.
|
||||
* \invariant La consigne est écrêtée à \c [0, maxPowerW] avant envoi matériel (second filet
|
||||
* après l'écrêtage de l'arbitre). L'arrondi au \c powerLevels (mode *fixed*) est la
|
||||
* responsabilité du SCHEDULER (contrat §4), pas de l'adaptateur.
|
||||
* \invariant **Temps = paramètre, jamais l'horloge** (cf. \c ILoadAdapter) : aucune logique
|
||||
* temporelle ici (les verrous vivent dans le thing) — \c now sert uniquement aux estampilles.
|
||||
*
|
||||
* \note T1 (squelette) : l'adaptateur consomme l'interface côté moteur. La déclaration de
|
||||
* l'interface nymea \c etmvariableload et le driver thing relèvent d'une session device dédiée.
|
||||
*/
|
||||
class EtmVariableLoadAdapter : public QObject, public ILoadAdapter
|
||||
{
|
||||
Q_OBJECT
|
||||
public:
|
||||
/*!
|
||||
* \brief Constructeur.
|
||||
* \param thingManager Gestionnaire nymea pour résoudre \p id en Thing.
|
||||
* \param id ThingId (string) du thing implémentant \c etmvariableload, et
|
||||
* identifiant logique de la charge.
|
||||
* \param label Nom lisible (logs, app).
|
||||
* \param powerLevels Paliers atteignables en W (DÉCLARÉS, triés croissants, \c 0 inclus)
|
||||
* en mode *fixed* ; liste vide ⇒ *dynamic* (modulation 0..maxPowerW).
|
||||
* \param maxPowerW Plafond physique (W) — contrat §2.
|
||||
* \param priority Rang dans le waterfall (contrat §5 / OPTIMIZER_PROTOCOL §5) : valeur
|
||||
* plus BASSE = servi en premier (rang 1 = premier servi).
|
||||
* \param needs Besoins déclarés (échéances, énergie min) — exposés au scheduler
|
||||
* via \c descriptor().needs. Vide par défaut.
|
||||
* \param parent Propriétaire Qt.
|
||||
*/
|
||||
explicit EtmVariableLoadAdapter(ThingManager *thingManager,
|
||||
const QString &id,
|
||||
const QString &label,
|
||||
const QList<int> &powerLevels,
|
||||
int maxPowerW,
|
||||
int priority,
|
||||
const LoadNeeds &needs = LoadNeeds(),
|
||||
QObject *parent = nullptr);
|
||||
|
||||
/*!
|
||||
* \brief Description statique : adapter="etmvariableload", powerLevels, maxPowerW, priority.
|
||||
* \return LoadDescriptor déclaratif (référence de planification, contrat §4).
|
||||
*/
|
||||
LoadDescriptor descriptor() const override;
|
||||
|
||||
/*!
|
||||
* \brief Télémétrie runtime. \c currentPowerW = state \c currentPowerW MESURÉ du thing
|
||||
* (juge de quantification, contrat §4) ; \c available faux si le thing est absent.
|
||||
*/
|
||||
LoadTelemetry telemetry() const override;
|
||||
|
||||
/*!
|
||||
* \brief Construit l'entrée loads[] §5 (adapter="etmvariableload").
|
||||
* \param now Temps de cycle (\c ctx.timestamp) — estampille uniquement ; aucune fenêtre de
|
||||
* verrou côté moteur (l'anti-rebond vit dans le thing, contrat §2).
|
||||
* \return LoadContext incluant declared (powerLevels/maxPowerW) et \c currentPowerW.
|
||||
*/
|
||||
LoadContext toLoadContext(const QDateTime &now) const override;
|
||||
|
||||
/*!
|
||||
* \brief Écrit la consigne \c powerSetpoint (W) sur le thing.
|
||||
* \param action LoadAction de kind \c Setpoint ; consigne lue dans \c action.powerW. Autres
|
||||
* kinds : retour sans effet.
|
||||
* \param now Temps de cycle (\c ctx.timestamp) — estampille \c m_lastActionAt.
|
||||
* \return L'action après écrêtage matériel (\c powerW borné à [0, maxPowerW]).
|
||||
*
|
||||
* \invariant Si \c action.reason est vide → retour sans effet (log warning).
|
||||
* \invariant \c action.force == true (repli L2 : \c setPowerSetpoint(0)) est transmis tel
|
||||
* quel ; le bypass anti-rebond est honoré par le thing, pas ici.
|
||||
*/
|
||||
LoadAction applyAction(const LoadAction &action, const QDateTime &now) override;
|
||||
|
||||
/*! \brief Dernière consigne (W) effectivement écrite (avant écrêtage thing). */
|
||||
double currentSetpointW() const { return m_currentSetpointW; }
|
||||
|
||||
private:
|
||||
//! Vrai si aucun palier déclaré n'est fourni ⇒ modulation continue (contrat §2).
|
||||
bool isDynamic() const { return m_powerLevels.isEmpty(); }
|
||||
|
||||
//! Écrit le state \c powerSetpoint (W) via executeAction (repli \c setStateValue pour mock).
|
||||
void writeSetpoint(double powerW);
|
||||
|
||||
//! Lit le state \c currentPowerW du thing cible (0 si absent / non exposé).
|
||||
double readCurrentPowerW() const;
|
||||
|
||||
ThingManager *m_thingManager;
|
||||
QString m_id;
|
||||
QString m_label;
|
||||
QList<int> m_powerLevels; //!< Paliers W déclarés (vide = dynamic).
|
||||
int m_maxPowerW;
|
||||
int m_priority;
|
||||
LoadNeeds m_needs;
|
||||
|
||||
double m_currentSetpointW = 0; //!< Dernière consigne écrite (W).
|
||||
QDateTime m_lastActionAt;
|
||||
};
|
||||
94
energyplugin/etm/adapters/evadapter.cpp
Normal file
94
energyplugin/etm/adapters/evadapter.cpp
Normal file
@ -0,0 +1,94 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync
|
||||
|
||||
#include "evadapter.h"
|
||||
#include "../energyarbitrator.h"
|
||||
#include "../../evcharger.h"
|
||||
#include "../../types/chargingaction.h"
|
||||
|
||||
#include "plugininfo.h"
|
||||
|
||||
EvAdapter::EvAdapter(EvCharger *evCharger, EnergyArbitrator *parent)
|
||||
: QObject(parent)
|
||||
, m_charger(evCharger)
|
||||
, m_parent(parent)
|
||||
{
|
||||
}
|
||||
|
||||
LoadDescriptor EvAdapter::descriptor() const
|
||||
{
|
||||
LoadDescriptor d;
|
||||
d.id = m_charger->thing()->id().toString();
|
||||
d.label = m_charger->name();
|
||||
d.adapter = QStringLiteral("evcharger");
|
||||
d.priority = 100;
|
||||
|
||||
d.declared.minA = m_charger->maxChargingCurrentMinValue();
|
||||
d.declared.maxA = m_charger->maxChargingCurrentMaxValue();
|
||||
d.declared.phases = static_cast<int>(m_charger->phaseCount());
|
||||
|
||||
d.limits.chargingEnabledLockS = static_cast<int>(m_charger->chargingEnabledLockDuration());
|
||||
d.limits.currentChangeLockS = static_cast<int>(m_charger->chargingCurrentLockDuration());
|
||||
|
||||
d.supportedKinds = { LoadAction::Setpoint };
|
||||
return d;
|
||||
}
|
||||
|
||||
LoadTelemetry EvAdapter::telemetry() const
|
||||
{
|
||||
LoadTelemetry t;
|
||||
t.currentPowerW = m_charger->currentPower();
|
||||
t.available = m_charger->available();
|
||||
t.lastActionAt = m_lastActionAt;
|
||||
return t;
|
||||
}
|
||||
|
||||
LoadContext EvAdapter::toLoadContext(const QDateTime &now) const
|
||||
{
|
||||
Q_UNUSED(now) // L'EV n'a pas de verrou de palier (pas de waterfall ECS) — now inutilisé ici.
|
||||
LoadContext ctx;
|
||||
ctx.id = m_charger->thing()->id().toString();
|
||||
ctx.adapter = QStringLiteral("evcharger");
|
||||
ctx.label = m_charger->name();
|
||||
ctx.declared = descriptor().declared;
|
||||
ctx.limits = descriptor().limits;
|
||||
|
||||
ctx.telemetry.currentPowerW = m_charger->currentPower();
|
||||
ctx.telemetry.pluggedIn = m_charger->pluggedIn();
|
||||
ctx.telemetry.charging = m_charger->charging();
|
||||
return ctx;
|
||||
}
|
||||
|
||||
LoadAction EvAdapter::applyAction(const LoadAction &action, const QDateTime &now)
|
||||
{
|
||||
if (action.kind != LoadAction::Setpoint)
|
||||
return action;
|
||||
|
||||
if (action.reason.isEmpty()) {
|
||||
qCWarning(dcNymeaEnergy()) << "[EvAdapter]" << m_charger->name()
|
||||
<< "— LoadAction sans reason rejetée.";
|
||||
return action;
|
||||
}
|
||||
|
||||
const uint minA = m_charger->maxChargingCurrentMinValue();
|
||||
const uint maxA = m_charger->maxChargingCurrentMaxValue();
|
||||
const uint clampedA = static_cast<uint>(
|
||||
qBound(static_cast<double>(minA), action.currentA, static_cast<double>(maxA)));
|
||||
|
||||
const uint phases = (m_charger->canSetPhaseCount() && action.phaseCount > 0)
|
||||
? qBound(1u, action.phaseCount, m_charger->phaseCount())
|
||||
: m_charger->phaseCount();
|
||||
|
||||
const auto issuer = (action.funding == LoadAction::Surplus)
|
||||
? ChargingAction::ChargingActionIssuerSurplusCharging
|
||||
: ChargingAction::ChargingActionIssuerTimeRequirement;
|
||||
|
||||
ChargingAction ca(action.chargingEnabled, clampedA, phases, issuer, false);
|
||||
m_parent->doExecuteChargingAction(m_charger, ca, now); // now = temps de cycle (injectable)
|
||||
m_lastActionAt = now;
|
||||
|
||||
LoadAction applied = action;
|
||||
applied.currentA = clampedA;
|
||||
applied.phaseCount = phases;
|
||||
return applied;
|
||||
}
|
||||
81
energyplugin/etm/adapters/evadapter.h
Normal file
81
energyplugin/etm/adapters/evadapter.h
Normal file
@ -0,0 +1,81 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync
|
||||
#pragma once
|
||||
|
||||
#include <QObject>
|
||||
#include <QDateTime>
|
||||
#include "iloadadapter.h"
|
||||
|
||||
class EvCharger;
|
||||
class EnergyArbitrator;
|
||||
|
||||
/*!
|
||||
* \brief Adaptateur pour une borne de recharge VE (interface evcharger nymea).
|
||||
*
|
||||
* Traduit les LoadAction(Setpoint) en appels matériels via
|
||||
* EnergyArbitrator::doExecuteChargingAction() — le seul chemin d'exécution.
|
||||
*
|
||||
* \invariant applyAction() rejette silencieusement toute LoadAction dont \c reason est vide.
|
||||
* \invariant currentA est écrêté à [maxChargingCurrentMinValue, maxChargingCurrentMaxValue].
|
||||
* \invariant phaseCount est écrêté selon canSetPhaseCount() et phaseCount() du EvCharger.
|
||||
* \invariant Les kinds autres que Setpoint sont retournés sans effet.
|
||||
*/
|
||||
class EvAdapter : public QObject, public ILoadAdapter
|
||||
{
|
||||
Q_OBJECT
|
||||
public:
|
||||
/*!
|
||||
* \brief Constructeur.
|
||||
* \param evCharger Borne VE à piloter (doit rester valide tant que l'adaptateur existe).
|
||||
* \param parent EnergyArbitrator propriétaire — utilisé pour l'exécution matérielle.
|
||||
*/
|
||||
explicit EvAdapter(EvCharger *evCharger, EnergyArbitrator *parent);
|
||||
|
||||
/*!
|
||||
* \brief Retourne la description statique de la charge.
|
||||
* \return LoadDescriptor construit depuis les capacités actuelles du EvCharger.
|
||||
* \note Recalculé à chaque appel depuis l'état nymea du Thing.
|
||||
*/
|
||||
LoadDescriptor descriptor() const override;
|
||||
|
||||
/*!
|
||||
* \brief Retourne la télémétrie runtime (puissance mesurée, disponibilité).
|
||||
* \return LoadTelemetry avec currentPowerW, available et lastActionAt.
|
||||
*/
|
||||
LoadTelemetry telemetry() const override;
|
||||
|
||||
/*!
|
||||
* \brief Construit l'entrée loads[] §5 du SurplusContext.
|
||||
* \param now Temps de cycle (\c ctx.timestamp). Inutilisé ici : l'EV n'a pas de verrou
|
||||
* de palier (hors waterfall ECS/SG-Ready). Présent pour l'uniformité de \c ILoadAdapter.
|
||||
* \return LoadContext incluant declared, limits, needs et télémétrie EV.
|
||||
*/
|
||||
LoadContext toLoadContext(const QDateTime &now) const override;
|
||||
|
||||
/*!
|
||||
* \brief Applique une consigne Setpoint sur la borne VE.
|
||||
*
|
||||
* **Inactif jusqu'à 3g** : non appelée par \c EnergyArbitrator::update() en beta.
|
||||
* Le dispatch EV passe par \c adjustEvChargers() amont (hérité). Cette méthode
|
||||
* sera câblée lors de la transplantation EV dans \c RuleBasedScheduler (phase 3g).
|
||||
* \c descriptor() et \c telemetry() sont eux actifs dès maintenant pour le SurplusContext.
|
||||
*
|
||||
* \param action LoadAction de kind Setpoint. Autres kinds : retour sans effet.
|
||||
* \param now Temps de cycle (\c ctx.timestamp) — passé à \c doExecuteChargingAction()
|
||||
* (locks anti-rebond de la borne). MÊME source que toLoadContext() (contrat ILoadAdapter).
|
||||
* \return L'action après écrêtage matériel (currentA, phaseCount bornés).
|
||||
*
|
||||
* \invariant action.reason non vide requis — log warning et retour sans effet sinon.
|
||||
* \invariant currentA écrêté à [minValue, maxValue] avant envoi à executeChargingAction.
|
||||
* \invariant phaseCount ajusté selon canSetPhaseCount() du EvCharger.
|
||||
*/
|
||||
LoadAction applyAction(const LoadAction &action, const QDateTime &now) override;
|
||||
|
||||
/*! \brief Borne VE sous-jacente (lecture). */
|
||||
EvCharger *evCharger() const { return m_charger; }
|
||||
|
||||
private:
|
||||
EvCharger *m_charger;
|
||||
EnergyArbitrator *m_parent;
|
||||
QDateTime m_lastActionAt;
|
||||
};
|
||||
75
energyplugin/etm/adapters/iloadadapter.h
Normal file
75
energyplugin/etm/adapters/iloadadapter.h
Normal file
@ -0,0 +1,75 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync
|
||||
#pragma once
|
||||
|
||||
#include <QDateTime>
|
||||
#include "../types/loadaction.h"
|
||||
#include "../types/loaddescriptor.h"
|
||||
#include "../types/surpluscontext.h"
|
||||
|
||||
/*!
|
||||
* \brief Vue runtime minimale exposée par un adaptateur à l'arbitre.
|
||||
*/
|
||||
struct LoadTelemetry {
|
||||
double currentPowerW = 0; //!< Puissance mesurée (W).
|
||||
bool available = true; //!< Faux si l'appareil nymea est absent ou en erreur.
|
||||
QDateTime lastActionAt; //!< Dernier instant où applyAction() a produit un effet.
|
||||
};
|
||||
|
||||
/*!
|
||||
* \brief Interface pure des adaptateurs de charge.
|
||||
*
|
||||
* Les implémentations concrètes héritent de QObject + ILoadAdapter et déclarent leurs
|
||||
* propres signaux (telemetryChanged, descriptorChanged).
|
||||
*
|
||||
* \invariant Les adaptateurs EXÉCUTENT, ils ne décident pas (AGENTS règle 2).
|
||||
* \invariant applyAction() écrête les valeurs selon les limites matérielles réelles
|
||||
* (second filet après l'écrêtage de l'arbitre).
|
||||
* \invariant applyAction() avec \c reason vide doit être rejetée silencieusement.
|
||||
* \invariant Les méthodes non-applyAction() retournent immédiatement (pas de I/O bloquant).
|
||||
* \invariant **Temps = paramètre, jamais l'horloge.** Toute logique temporelle d'un
|
||||
* adaptateur (verrous minOn/minOff, fenêtres, fraîcheur…) utilise EXCLUSIVEMENT le
|
||||
* \c now (= \c ctx.timestamp) reçu en paramètre de \c toLoadContext()/applyAction().
|
||||
* JAMAIS \c QDateTime::currentDateTime(). C'est cette source unique, partagée avec le
|
||||
* scheduler, qui rend impossible toute divergence décision/exécution et qui rend la
|
||||
* logique injectable en simulation. Contrat pour tout futur adaptateur (SgReady, Battery).
|
||||
*/
|
||||
class ILoadAdapter {
|
||||
public:
|
||||
virtual ~ILoadAdapter() = default;
|
||||
|
||||
/*!
|
||||
* \brief Description statique de la charge : capacités, limites, priorité, needs.
|
||||
* \return LoadDescriptor construit depuis la configuration matérielle.
|
||||
* \note Peut être rappelé à chaque cycle — l'implémentation doit être légère.
|
||||
*/
|
||||
virtual LoadDescriptor descriptor() const = 0;
|
||||
|
||||
/*!
|
||||
* \brief Télémétrie runtime (puissance, disponibilité, dernière action).
|
||||
* \return LoadTelemetry issue de l'état courant de l'appareil nymea.
|
||||
*/
|
||||
virtual LoadTelemetry telemetry() const = 0;
|
||||
|
||||
/*!
|
||||
* \brief Construit l'entrée §5 loads[] pour le SurplusContext.
|
||||
* \param now Temps de cycle (\c ctx.timestamp). Source unique pour l'évaluation des
|
||||
* verrous (minStage/maxStage) — JAMAIS \c QDateTime::currentDateTime() côté adaptateur,
|
||||
* afin que décision (scheduler) et exécution (applyAction) partagent le même temps.
|
||||
* \return LoadContext incluant declared, limits, needs et télémétrie type-spécifique.
|
||||
*/
|
||||
virtual LoadContext toLoadContext(const QDateTime &now) const = 0;
|
||||
|
||||
/*!
|
||||
* \brief Applique l'action et retourne ce qui a réellement été envoyé au matériel.
|
||||
*
|
||||
* L'arbitre a déjà écrêté selon les limites et le budget — ceci est le second filet.
|
||||
*
|
||||
* \param action Action à appliquer. Doit avoir \c reason non vide.
|
||||
* \param now Temps de cycle (\c ctx.timestamp) — MÊME source que toLoadContext(),
|
||||
* pour que l'évaluation des verrous coïncide avec celle vue par le scheduler.
|
||||
* \return L'action après écrêtage matériel (peut différer de l'entrée).
|
||||
* \note Retour silencieux sans effet si \c action.reason est vide.
|
||||
*/
|
||||
virtual LoadAction applyAction(const LoadAction &action, const QDateTime &now) = 0;
|
||||
};
|
||||
245
energyplugin/etm/adapters/relayrouter.cpp
Normal file
245
energyplugin/etm/adapters/relayrouter.cpp
Normal file
@ -0,0 +1,245 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync
|
||||
|
||||
#include "relayrouter.h"
|
||||
#include "plugininfo.h"
|
||||
|
||||
#include <QDateTime>
|
||||
#include <QMap>
|
||||
#include <QSet>
|
||||
#include <integrations/thingmanager.h>
|
||||
#include <integrations/thing.h>
|
||||
#include <types/action.h>
|
||||
#include <types/param.h>
|
||||
|
||||
namespace {
|
||||
//! Au-delà, 2^N combinaisons explose. 16 relais = 65536 combos — bien au-delà de tout ECS réel.
|
||||
constexpr int MaxRelays = 16;
|
||||
}
|
||||
|
||||
RelayRouter::RelayRouter(ThingManager *thingManager,
|
||||
const QString &id,
|
||||
const QString &label,
|
||||
const QList<LoadConfigRelay> &relays,
|
||||
int minOnS,
|
||||
int minOffS,
|
||||
int priority,
|
||||
const LoadNeeds &needs,
|
||||
QObject *parent)
|
||||
: QObject(parent)
|
||||
, m_thingManager(thingManager)
|
||||
, m_id(id)
|
||||
, m_label(label)
|
||||
, m_minOnS(minOnS)
|
||||
, m_minOffS(minOffS)
|
||||
, m_priority(priority)
|
||||
, m_needs(needs)
|
||||
{
|
||||
int n = relays.size();
|
||||
if (n > MaxRelays) {
|
||||
qCWarning(dcNymeaEnergy()) << "[RelayRouter]" << m_label << "—" << n
|
||||
<< "relais > max" << MaxRelays << ": tronqué.";
|
||||
n = MaxRelays;
|
||||
}
|
||||
|
||||
// Paliers DÉRIVÉS : toutes les sommes de sous-ensembles, dédupliquées par puissance, triées
|
||||
// (QMap = clés croissantes), 0 inclus (sous-ensemble vide = masque 0). Pour des puissances
|
||||
// identiques (ex. deux relais 1000 W), on garde la première combinaison rencontrée.
|
||||
QMap<int, QList<QString>> byPower;
|
||||
for (int mask = 0; mask < (1 << n); ++mask) {
|
||||
int sum = 0;
|
||||
QList<QString> set;
|
||||
for (int i = 0; i < n; ++i) {
|
||||
if (mask & (1 << i)) {
|
||||
sum += relays.at(i).powerW;
|
||||
set.append(relays.at(i).thingId);
|
||||
}
|
||||
}
|
||||
if (!byPower.contains(sum))
|
||||
byPower.insert(sum, set);
|
||||
}
|
||||
for (auto it = byPower.constBegin(); it != byPower.constEnd(); ++it) {
|
||||
m_levels.append(it.key());
|
||||
m_relayMapping.append(it.value());
|
||||
}
|
||||
// byPower contient toujours la clé 0 (masque vide) → m_levels[0] == 0.
|
||||
Q_ASSERT(!m_levels.isEmpty() && m_levels.first() == 0);
|
||||
}
|
||||
|
||||
LoadDescriptor RelayRouter::descriptor() const
|
||||
{
|
||||
LoadDescriptor d;
|
||||
d.id = m_id;
|
||||
d.label = m_label;
|
||||
d.adapter = QStringLiteral("relay-router");
|
||||
d.priority = m_priority;
|
||||
|
||||
// Paliers DÉRIVÉS exposés à l'optimiseur (watts) — JAMAIS les relais.
|
||||
d.declared.powerLevels = m_levels;
|
||||
d.declared.maxPowerW = m_levels.isEmpty() ? 0 : m_levels.last();
|
||||
d.limits.minOnS = m_minOnS;
|
||||
d.limits.minOffS = m_minOffS;
|
||||
d.needs = m_needs;
|
||||
|
||||
d.supportedKinds = { LoadAction::Setpoint };
|
||||
return d;
|
||||
}
|
||||
|
||||
LoadTelemetry RelayRouter::telemetry() const
|
||||
{
|
||||
LoadTelemetry t;
|
||||
t.available = true;
|
||||
t.lastActionAt = m_lastActionAt;
|
||||
|
||||
// currentPowerW :
|
||||
// - MESURÉ dès qu'au moins un relais du palier expose un state "currentPower" (somme).
|
||||
// Un thermostat coupé (relais ON mais 0 W) → 0, jamais de puissance fantôme.
|
||||
// - NOMINAL commandé (repli) si aucun relais actif ne mesure (GPIO bool nu). Dans ce cas
|
||||
// currentPowerW reflète le COMMANDÉ, pas le mesuré (contrat rév. 3 §3.a).
|
||||
double power = 0;
|
||||
bool metered = false;
|
||||
const QList<QString> activeRelays = m_currentStage < m_relayMapping.size()
|
||||
? m_relayMapping.at(m_currentStage)
|
||||
: QList<QString>();
|
||||
for (const QString &thingId : activeRelays) {
|
||||
Thing *relay = m_thingManager->findConfiguredThing(ThingId(thingId));
|
||||
if (!relay)
|
||||
continue;
|
||||
if (!relay->thingClass().stateTypes().findByName("currentPower").id().isNull()) {
|
||||
metered = true;
|
||||
power += relay->stateValue("currentPower").toDouble();
|
||||
}
|
||||
}
|
||||
if (!metered && m_currentStage > 0 && m_currentStage < m_levels.size())
|
||||
power = m_levels.at(m_currentStage); // nominal commandé
|
||||
|
||||
t.currentPowerW = power;
|
||||
return t;
|
||||
}
|
||||
|
||||
LoadContext RelayRouter::toLoadContext(const QDateTime &now) const
|
||||
{
|
||||
Q_UNUSED(now) // pas de fenêtre de verrou exposée : le verrou est INTERNE (frontière rév. 3).
|
||||
|
||||
LoadContext ctx;
|
||||
ctx.id = m_id;
|
||||
ctx.adapter = QStringLiteral("relay-router");
|
||||
ctx.label = m_label;
|
||||
ctx.priority = m_priority;
|
||||
ctx.declared = descriptor().declared; // powerLevels/maxPowerW dérivés (watts)
|
||||
ctx.needs = m_needs;
|
||||
|
||||
ctx.telemetry.currentPowerW = telemetry().currentPowerW;
|
||||
// Aucun stage/minStage/maxStage : ces champs n'existent plus (rév. 2) et le relais ne
|
||||
// franchit pas la frontière. L'optimiseur ne voit que des watts.
|
||||
return ctx;
|
||||
}
|
||||
|
||||
LoadAction RelayRouter::applyAction(const LoadAction &action, const QDateTime &now)
|
||||
{
|
||||
if (action.kind != LoadAction::Setpoint)
|
||||
return action;
|
||||
|
||||
if (action.reason.isEmpty()) {
|
||||
qCWarning(dcNymeaEnergy()) << "[RelayRouter]" << m_label
|
||||
<< "— LoadAction sans reason rejetée.";
|
||||
return action;
|
||||
}
|
||||
|
||||
int newStage = stageForPower(action.powerW);
|
||||
|
||||
// Verrou anti-rebond INTERNE (clamp), au temps de cycle. Bypass si force==true (repli L2).
|
||||
if (!action.force) {
|
||||
int minStage, maxStage;
|
||||
lockWindow(now, minStage, maxStage);
|
||||
newStage = qBound(minStage, newStage, maxStage);
|
||||
}
|
||||
|
||||
LoadAction applied = action;
|
||||
applied.powerW = m_levels.at(newStage);
|
||||
applied.estimatedPowerW = m_levels.at(newStage);
|
||||
|
||||
if (newStage == m_currentStage)
|
||||
return applied; // idempotent — pas de re-commutation
|
||||
|
||||
qCInfo(dcNymeaEnergy()) << "[RelayRouter]" << m_label
|
||||
<< "→ consigne" << qRound(action.powerW) << "W → palier"
|
||||
<< m_levels.at(newStage) << "W"
|
||||
<< (action.force ? "(force)" : "")
|
||||
<< "|" << action.reason;
|
||||
|
||||
applyRelayStage(newStage);
|
||||
|
||||
m_currentStage = newStage;
|
||||
m_lastSwitch = now;
|
||||
m_lastActionAt = now;
|
||||
return applied;
|
||||
}
|
||||
|
||||
// ---- privé ---------------------------------------------------------------
|
||||
|
||||
int RelayRouter::stageForPower(double powerW) const
|
||||
{
|
||||
int stage = 0; // m_levels[0] == 0 garanti
|
||||
for (int i = 0; i < m_levels.size(); ++i) {
|
||||
if (m_levels.at(i) <= powerW)
|
||||
stage = i;
|
||||
else
|
||||
break; // m_levels triés croissants
|
||||
}
|
||||
return stage;
|
||||
}
|
||||
|
||||
void RelayRouter::lockWindow(const QDateTime &now, int &minStage, int &maxStage) const
|
||||
{
|
||||
const int topStage = m_levels.size() - 1;
|
||||
const bool valid = m_lastSwitch.isValid();
|
||||
const qint64 elapsed = valid ? m_lastSwitch.secsTo(now) : 0;
|
||||
|
||||
// Plancher : si ON et minOn non écoulé → interdit de descendre (puissance engagée non-coupable).
|
||||
minStage = (m_currentStage > 0 && valid && elapsed < m_minOnS) ? m_currentStage : 0;
|
||||
// Plafond : si à l'arrêt et minOff non écoulé → interdit de redémarrer.
|
||||
maxStage = (m_currentStage == 0 && valid && elapsed < m_minOffS) ? 0 : topStage;
|
||||
}
|
||||
|
||||
void RelayRouter::applyRelayStage(int stage)
|
||||
{
|
||||
// Set CIBLE du palier (delta complet : chaque relais connu amené à son état on/off cible).
|
||||
// Gère les mappings NON-CASCADÉS (monter d'un palier peut éteindre des relais).
|
||||
const QSet<QString> wantOn = [&]() {
|
||||
QSet<QString> s;
|
||||
if (stage < m_relayMapping.size())
|
||||
for (const QString &id : m_relayMapping.at(stage))
|
||||
s.insert(id);
|
||||
return s;
|
||||
}();
|
||||
|
||||
QSet<QString> allRelays;
|
||||
for (const auto &list : m_relayMapping)
|
||||
for (const QString &id : list)
|
||||
allRelays.insert(id);
|
||||
|
||||
auto writeRelay = [&](const QString &thingId, bool on) {
|
||||
Thing *relay = m_thingManager->findConfiguredThing(ThingId(thingId));
|
||||
if (!relay) {
|
||||
qCWarning(dcNymeaEnergy()) << "[RelayRouter]" << m_label << "— relais non trouvé:" << thingId;
|
||||
return;
|
||||
}
|
||||
StateType powerStateType = relay->thingClass().stateTypes().findByName("power");
|
||||
if (!powerStateType.id().isNull()) {
|
||||
Action powerAction(powerStateType.id(), relay->id(), Action::TriggeredByRule);
|
||||
powerAction.setParams(ParamList() << Param(powerStateType.id(), on));
|
||||
m_thingManager->executeAction(powerAction);
|
||||
} else {
|
||||
relay->setStateValue("power", on); // repli mock
|
||||
}
|
||||
};
|
||||
|
||||
// off-before-on : couper d'abord les relais hors-cible, puis enclencher la cible → pas de
|
||||
// sur-puissance transitoire (somme des deux paliers) sur une transition non-cascadée.
|
||||
for (const QString &id : allRelays)
|
||||
if (!wantOn.contains(id))
|
||||
writeRelay(id, false);
|
||||
for (const QString &id : wantOn)
|
||||
writeRelay(id, true);
|
||||
}
|
||||
109
energyplugin/etm/adapters/relayrouter.h
Normal file
109
energyplugin/etm/adapters/relayrouter.h
Normal file
@ -0,0 +1,109 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync
|
||||
#pragma once
|
||||
|
||||
#include <QObject>
|
||||
#include <QDateTime>
|
||||
#include <QList>
|
||||
#include <QString>
|
||||
#include "iloadadapter.h"
|
||||
#include "../types/loadconfig.h" // LoadConfigRelay
|
||||
|
||||
class Thing;
|
||||
class ThingManager;
|
||||
|
||||
/*!
|
||||
* \brief ROUTEUR de relais (contrat rév. 3) — traduit un \c Setpoint(W) de l'optimiseur en
|
||||
* combinaison de things \c power, côté experience-plugin.
|
||||
*
|
||||
* \par Frontière rév. 3
|
||||
* L'optimiseur (scheduler) est **watt-pur** : il émet \c LoadAction{Setpoint, powerW}. Le
|
||||
* `RelayRouter` est la couche **sous** l'optimiseur qui connaît les relais : il mappe
|
||||
* \c powerW → la combinaison atteignable la plus haute ≤ \c powerW, applique l'anti-rebond
|
||||
* (\c minOn/minOff) **ici** (plus dans le scheduler), commute les relais via
|
||||
* \c ThingManager::executeAction (interface \c power), et agrège \c currentPowerW. **Aucun
|
||||
* relais, aucun index de combinaison ne remonte dans le \c LoadContext** (seuls les watts
|
||||
* dérivés `powerLevels`/`maxPowerW` y figurent).
|
||||
*
|
||||
* \par Paliers DÉRIVÉS
|
||||
* Les \c powerLevels (et \c maxPowerW) sont **calculés** depuis la liste de relais : toutes
|
||||
* les sommes de sous-ensembles atteignables, dédupliquées, triées, \c 0 inclus. **Source de
|
||||
* vérité unique = les relais** (l'app affiche le même calcul, l'optimiseur les lit pour
|
||||
* l'arrondi/résidu).
|
||||
*
|
||||
* \invariant \c supportedKinds == { Setpoint }. Autres kinds : retour sans effet.
|
||||
* \invariant applyAction() rejette silencieusement toute action dont \c reason est vide.
|
||||
* \invariant Verrous \c minOn/minOff appliqués en INTERNE (clamp), bypassés si \c force==true
|
||||
* (repli L2). **Temps = paramètre** (cf. \c ILoadAdapter) : \c now reçu, jamais l'horloge.
|
||||
* \invariant Transition relais en **off-before-on** : coupe d'abord les relais hors-cible.
|
||||
*/
|
||||
class RelayRouter : public QObject, public ILoadAdapter
|
||||
{
|
||||
Q_OBJECT
|
||||
public:
|
||||
/*!
|
||||
* \brief Constructeur.
|
||||
* \param thingManager Résout les ThingIds des relais et exécute les actions \c power.
|
||||
* \param id Identifiant LOGIQUE de la charge (rév. 3 : plus un thingId unique).
|
||||
* \param label Nom lisible (logs, app).
|
||||
* \param relays Liste \c {thingId, powerW} des relais \c power. Les paliers atteignables
|
||||
* sont dérivés de toutes leurs combinaisons.
|
||||
* \param minOnS/minOffS Verrous anti-rebond (protection relais/compresseur), appliqués ici.
|
||||
* \param priority Rang dans le waterfall (1 = servi en premier).
|
||||
* \param needs Besoins déclarés (exposés via descriptor().needs).
|
||||
* \param parent Propriétaire Qt.
|
||||
*/
|
||||
explicit RelayRouter(ThingManager *thingManager,
|
||||
const QString &id,
|
||||
const QString &label,
|
||||
const QList<LoadConfigRelay> &relays,
|
||||
int minOnS,
|
||||
int minOffS,
|
||||
int priority,
|
||||
const LoadNeeds &needs = LoadNeeds(),
|
||||
QObject *parent = nullptr);
|
||||
|
||||
//! \return LoadDescriptor : adapter="relay-router", powerLevels/maxPowerW DÉRIVÉS, needs.
|
||||
LoadDescriptor descriptor() const override;
|
||||
|
||||
//! \return currentPowerW = somme des \c currentPower des relais ON (mesuré), sinon nominal commandé.
|
||||
LoadTelemetry telemetry() const override;
|
||||
|
||||
//! \return LoadContext §5 (watts uniquement : powerLevels, currentPowerW — aucun relais).
|
||||
LoadContext toLoadContext(const QDateTime &now) const override;
|
||||
|
||||
/*!
|
||||
* \brief Applique un \c Setpoint(W) : mappe en combinaison de relais, clampe par les verrous,
|
||||
* commute (off-before-on), publie l'état.
|
||||
* \param action LoadAction kind \c Setpoint (\c powerW). Autres kinds : retour sans effet.
|
||||
* \param now Temps de cycle (verrous + estampille).
|
||||
* \return L'action après écrêtage (\c powerW = palier réellement appliqué).
|
||||
*/
|
||||
LoadAction applyAction(const LoadAction &action, const QDateTime &now) override;
|
||||
|
||||
//! \brief Palier courant (0 = tout coupé).
|
||||
int currentStage() const { return m_currentStage; }
|
||||
//! \brief Puissance (W) du palier courant.
|
||||
double currentSetpointW() const { return m_currentStage < m_levels.size() ? m_levels.at(m_currentStage) : 0.0; }
|
||||
|
||||
private:
|
||||
//! Palier le plus haut dont la puissance ≤ \p powerW (≥ 0).
|
||||
int stageForPower(double powerW) const;
|
||||
//! Fenêtre de paliers autorisée à \p now par minOn/minOff (verrou INTERNE, plus exposé).
|
||||
void lockWindow(const QDateTime &now, int &minStage, int &maxStage) const;
|
||||
void applyRelayStage(int stage);
|
||||
|
||||
ThingManager *m_thingManager;
|
||||
QString m_id;
|
||||
QString m_label;
|
||||
QList<int> m_levels; //!< Paliers W dérivés, triés, [0]=0.
|
||||
QList<QList<QString>> m_relayMapping; //!< ThingIds ON par palier (dérivé des combinaisons).
|
||||
int m_minOnS;
|
||||
int m_minOffS;
|
||||
int m_priority;
|
||||
LoadNeeds m_needs;
|
||||
|
||||
int m_currentStage = 0;
|
||||
QDateTime m_lastSwitch; //!< Dernier changement (null = jamais).
|
||||
QDateTime m_lastActionAt;
|
||||
};
|
||||
235
energyplugin/etm/adapters/sgreadyadapter.cpp
Normal file
235
energyplugin/etm/adapters/sgreadyadapter.cpp
Normal file
@ -0,0 +1,235 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync
|
||||
|
||||
#include "sgreadyadapter.h"
|
||||
#include "plugininfo.h"
|
||||
|
||||
#include <QDateTime>
|
||||
#include <QSet>
|
||||
#include <algorithm>
|
||||
#include <climits>
|
||||
#include <integrations/thingmanager.h>
|
||||
#include <integrations/thing.h>
|
||||
#include <types/action.h>
|
||||
#include <types/param.h>
|
||||
|
||||
SgReadyAdapter::SgReadyAdapter(ThingManager *thingManager,
|
||||
const QString &id,
|
||||
const QString &label,
|
||||
const QHash<int, QList<QString>> &stateRelays,
|
||||
const QHash<int, double> &estimatedPowerW,
|
||||
int minStateHoldS,
|
||||
int priority,
|
||||
QObject *parent)
|
||||
: QObject(parent)
|
||||
, m_thingManager(thingManager)
|
||||
, m_id(id)
|
||||
, m_label(label)
|
||||
, m_stateRelays(stateRelays)
|
||||
, m_estimatedPowerW(estimatedPowerW)
|
||||
, m_minStateHoldS(minStateHoldS)
|
||||
, m_priority(priority)
|
||||
{
|
||||
m_states = m_stateRelays.keys();
|
||||
std::sort(m_states.begin(), m_states.end());
|
||||
Q_ASSERT(!m_states.isEmpty());
|
||||
Q_ASSERT(m_stateRelays.contains(2)); // état 2 (normal) = repli sûr obligatoire
|
||||
}
|
||||
|
||||
LoadDescriptor SgReadyAdapter::descriptor() const
|
||||
{
|
||||
LoadDescriptor d;
|
||||
d.id = m_id;
|
||||
d.label = m_label;
|
||||
d.adapter = QStringLiteral("sg-ready");
|
||||
d.priority = m_priority;
|
||||
|
||||
d.declared.states = m_states;
|
||||
d.declared.estimatedPowerW = m_estimatedPowerW;
|
||||
d.limits.minStateHoldS = m_minStateHoldS;
|
||||
|
||||
d.supportedKinds = { LoadAction::State };
|
||||
return d;
|
||||
}
|
||||
|
||||
LoadTelemetry SgReadyAdapter::telemetry() const
|
||||
{
|
||||
LoadTelemetry t;
|
||||
t.available = true;
|
||||
t.lastActionAt = m_lastActionAt;
|
||||
// Base du recrédit budget = puissance ALLOUÉE de l'état (déclaré), pas la conso mesurée
|
||||
// (états 1/2 → 0 ; états 3/4 → P3/P4). Cf. invariant 8.
|
||||
t.currentPowerW = m_estimatedPowerW.value(m_currentState, 0.0);
|
||||
return t;
|
||||
}
|
||||
|
||||
LoadContext SgReadyAdapter::toLoadContext(const QDateTime &now) const
|
||||
{
|
||||
LoadContext ctx;
|
||||
ctx.id = m_id;
|
||||
ctx.adapter = QStringLiteral("sg-ready");
|
||||
ctx.label = m_label;
|
||||
ctx.priority = m_priority;
|
||||
ctx.declared = descriptor().declared;
|
||||
ctx.limits = descriptor().limits;
|
||||
|
||||
ctx.telemetry.currentPowerW = telemetry().currentPowerW;
|
||||
ctx.telemetry.state = m_currentState;
|
||||
ctx.telemetry.lastSwitch = m_lastSwitch;
|
||||
// Fenêtre de verrou évaluée au temps de cycle (protection court-cycling PAC).
|
||||
lockWindow(now, ctx.telemetry.minState, ctx.telemetry.maxState);
|
||||
return ctx;
|
||||
}
|
||||
|
||||
LoadAction SgReadyAdapter::applyAction(const LoadAction &action, const QDateTime &now)
|
||||
{
|
||||
if (action.kind != LoadAction::State)
|
||||
return action;
|
||||
|
||||
if (action.reason.isEmpty()) {
|
||||
qCWarning(dcNymeaEnergy()) << "[SgReadyAdapter]" << m_label
|
||||
<< "— LoadAction sans reason rejetée.";
|
||||
return action;
|
||||
}
|
||||
|
||||
// Écrêtage à un état déclaré (borne puis exigence d'appartenance).
|
||||
int newState = qBound(m_states.first(), action.state, m_states.last());
|
||||
if (!m_stateRelays.contains(newState)) {
|
||||
qCWarning(dcNymeaEnergy()) << "[SgReadyAdapter]" << m_label
|
||||
<< "— état non déclaré:" << action.state << "→ ignoré.";
|
||||
return action;
|
||||
}
|
||||
|
||||
if (newState == m_currentState)
|
||||
return action; // Idempotent
|
||||
|
||||
// Verrou minStateHold évalué au temps de cycle (même fenêtre que le scheduler) —
|
||||
// bypassé si force == true (L2 watchdog → état 2).
|
||||
if (!action.force && lockActive(newState, now)) {
|
||||
qCDebug(dcNymeaEnergy()) << "[SgReadyAdapter]" << m_label
|
||||
<< "— verrou minStateHold actif, état" << newState << "ignoré.";
|
||||
return action;
|
||||
}
|
||||
|
||||
qCInfo(dcNymeaEnergy()) << "[SgReadyAdapter]" << m_label
|
||||
<< "→ état" << newState
|
||||
<< "(" << m_estimatedPowerW.value(newState, 0.0) << "W estimés)"
|
||||
<< "|" << action.reason;
|
||||
|
||||
applyStateRelays(m_currentState, newState);
|
||||
|
||||
m_currentState = newState;
|
||||
m_lastSwitch = now;
|
||||
m_lastActionAt = now;
|
||||
|
||||
LoadAction applied = action;
|
||||
applied.state = newState;
|
||||
applied.estimatedPowerW = m_estimatedPowerW.value(newState, 0.0);
|
||||
return applied;
|
||||
}
|
||||
|
||||
// ---- privé ---------------------------------------------------------------
|
||||
|
||||
void SgReadyAdapter::lockWindow(const QDateTime &now, int &minState, int &maxState) const
|
||||
{
|
||||
const int lo = m_states.first();
|
||||
const int hi = m_states.last();
|
||||
const bool valid = m_lastSwitch.isValid();
|
||||
const qint64 elapsed = valid ? m_lastSwitch.secsTo(now) : 0;
|
||||
|
||||
if (valid && elapsed < m_minStateHoldS) {
|
||||
// Gel total : la PAC doit tenir son état (protection court-cycling compresseur).
|
||||
minState = maxState = m_currentState;
|
||||
} else {
|
||||
minState = lo;
|
||||
maxState = hi;
|
||||
}
|
||||
}
|
||||
|
||||
bool SgReadyAdapter::lockActive(int newState, const QDateTime &now) const
|
||||
{
|
||||
// MÊME calcul que la fenêtre exposée au scheduler → décision et exécution coïncident.
|
||||
int minState, maxState;
|
||||
lockWindow(now, minState, maxState);
|
||||
return newState < minState || newState > maxState;
|
||||
}
|
||||
|
||||
int SgReadyAdapter::transientHarm(int state)
|
||||
{
|
||||
// Transitoire le plus doux d'abord : neutre (2) < recommandation (3) < blocage (1) < forcé (4).
|
||||
switch (state) {
|
||||
case 2: return 0; // neutre
|
||||
case 3: return 1; // recommandation (run doux)
|
||||
case 1: return 2; // blocage (coupe le chauffage)
|
||||
case 4: return 3; // forcé (démarrage franc compresseur)
|
||||
default: return 2; // combinaison hors-norme : prudence
|
||||
}
|
||||
}
|
||||
|
||||
int SgReadyAdapter::stateForRelays(const QList<QString> &onRelays) const
|
||||
{
|
||||
const QSet<QString> want(onRelays.begin(), onRelays.end());
|
||||
for (auto it = m_stateRelays.constBegin(); it != m_stateRelays.constEnd(); ++it) {
|
||||
const QSet<QString> s(it.value().begin(), it.value().end());
|
||||
if (s == want)
|
||||
return it.key();
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
QSet<QString> SgReadyAdapter::allRelays() const
|
||||
{
|
||||
QSet<QString> all;
|
||||
for (const auto &list : m_stateRelays)
|
||||
for (const QString &id : list)
|
||||
all.insert(id);
|
||||
return all;
|
||||
}
|
||||
|
||||
void SgReadyAdapter::applyStateRelays(int fromState, int toState)
|
||||
{
|
||||
const QList<QString> targetList = m_stateRelays.value(toState);
|
||||
const QSet<QString> targetOn(targetList.begin(), targetList.end());
|
||||
const QList<QString> fromList = m_stateRelays.value(fromState);
|
||||
const QSet<QString> currentOn(fromList.begin(), fromList.end());
|
||||
|
||||
// Relais dont l'état change lors de la transition.
|
||||
QStringList changed;
|
||||
for (const QString &relay : allRelays())
|
||||
if (targetOn.contains(relay) != currentOn.contains(relay))
|
||||
changed << relay;
|
||||
|
||||
auto writeRelay = [&](const QString &thingId, bool on) {
|
||||
Thing *relay = m_thingManager->findConfiguredThing(ThingId(thingId));
|
||||
if (!relay) {
|
||||
qCWarning(dcNymeaEnergy()) << "[SgReadyAdapter]" << m_label << "— relais non trouvé:" << thingId;
|
||||
return;
|
||||
}
|
||||
StateType powerStateType = relay->thingClass().stateTypes().findByName("power");
|
||||
if (!powerStateType.id().isNull()) {
|
||||
Action powerAction(powerStateType.id(), relay->id(), Action::TriggeredByRule);
|
||||
powerAction.setParams(ParamList() << Param(powerStateType.id(), on));
|
||||
m_thingManager->executeAction(powerAction);
|
||||
} else {
|
||||
relay->setStateValue("power", on); // repli mock
|
||||
}
|
||||
};
|
||||
|
||||
// Contrat d'atomicité : si 2 relais (ou +) changent, commuter d'abord celui dont le
|
||||
// TRANSITOIRE est le plus doux (neutre/reco plutôt que blocage/forcé), puis les autres.
|
||||
if (changed.size() >= 2) {
|
||||
QString best;
|
||||
int bestHarm = INT_MAX;
|
||||
for (const QString &r : changed) {
|
||||
QSet<QString> transient = currentOn;
|
||||
if (targetOn.contains(r)) transient.insert(r); else transient.remove(r);
|
||||
const int h = transientHarm(stateForRelays(transient.values()));
|
||||
if (h < bestHarm) { bestHarm = h; best = r; }
|
||||
}
|
||||
writeRelay(best, targetOn.contains(best));
|
||||
changed.removeAll(best);
|
||||
}
|
||||
// Relais restants amenés à leur valeur cible.
|
||||
for (const QString &r : changed)
|
||||
writeRelay(r, targetOn.contains(r));
|
||||
}
|
||||
125
energyplugin/etm/adapters/sgreadyadapter.h
Normal file
125
energyplugin/etm/adapters/sgreadyadapter.h
Normal file
@ -0,0 +1,125 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync
|
||||
#pragma once
|
||||
|
||||
#include <QObject>
|
||||
#include <QDateTime>
|
||||
#include <QList>
|
||||
#include <QHash>
|
||||
#include <QString>
|
||||
#include "iloadadapter.h"
|
||||
|
||||
class Thing;
|
||||
class ThingManager;
|
||||
|
||||
/*!
|
||||
* \brief Adaptateur SG-Ready (PAC) — interface "sg-ready", action \c kind:State.
|
||||
*
|
||||
* Pilote une pompe à chaleur via 2 contacts SG-Ready (encodage 2 bits → 4 états NORMÉS) :
|
||||
* 1 = blocage (EVU-Sperre) · 2 = normal (mains off : la PAC décide)
|
||||
* 3 = recommandation (surplus) · 4 = forcé (boost)
|
||||
*
|
||||
* Les 4 états ne sont PAS des paliers de puissance : ils sont qualitatifs, la PAC les
|
||||
* interprète selon SA logique. \c m_stateRelays[état] = ThingIds powerswitch à mettre ON
|
||||
* pour cet état (encodage câblé par l'installateur ; les autres relais sont OFF).
|
||||
*
|
||||
* \invariant applyAction() rejette silencieusement toute action dont \c reason est vide.
|
||||
* \invariant applyAction() applique le verrou \c minStateHoldS (protection court-cycling
|
||||
* compresseur) SAUF si \c action.force == true (réservé L2 watchdog → état 2).
|
||||
* \invariant L'état est écrêté à l'ensemble \c m_states avant envoi matériel.
|
||||
* \invariant Seul le kind State est traité ; les autres kinds retournent sans effet.
|
||||
*
|
||||
* \par Contrat d'atomicité (transport déporté Shelly/Modbus à venir)
|
||||
* Une transition d'état commute parfois 2 relais (ex. 2→4 : 00→11). Les contacts
|
||||
* doivent être écrits **aussi atomiquement que possible**, et l'ORDRE de commutation
|
||||
* doit éviter tout **état actif parasite** : on passe par le transitoire le plus DOUX
|
||||
* (neutre = état 2, sinon recommandation = état 3) plutôt que par blocage (1) ou forcé
|
||||
* (4). \c applyStateRelays() choisit cet ordre. En GPIO local le transitoire dure des µs,
|
||||
* mais l'intention est portée par l'adaptateur pour rester correcte sur un bus lent.
|
||||
*/
|
||||
class SgReadyAdapter : public QObject, public ILoadAdapter
|
||||
{
|
||||
Q_OBJECT
|
||||
public:
|
||||
/*!
|
||||
* \brief Constructeur.
|
||||
* \param thingManager Gestionnaire nymea pour résoudre les ThingIds.
|
||||
* \param id Identifiant logique de la charge.
|
||||
* \param label Nom lisible (logs, app).
|
||||
* \param stateRelays état → liste de ThingIds powerswitch ON (encodage 2 bits SG-Ready).
|
||||
* \param estimatedPowerW Puissance estimée (W) par état (déclaré installateur, approx.).
|
||||
* \param minStateHoldS Durée minimale de maintien d'état (s) — protection court-cycling.
|
||||
* \param priority Rang dans le waterfall (protocole §5 : valeur plus BASSE = servi en premier).
|
||||
* \param parent Propriétaire Qt.
|
||||
*/
|
||||
explicit SgReadyAdapter(ThingManager *thingManager,
|
||||
const QString &id,
|
||||
const QString &label,
|
||||
const QHash<int, QList<QString>> &stateRelays,
|
||||
const QHash<int, double> &estimatedPowerW,
|
||||
int minStateHoldS,
|
||||
int priority,
|
||||
QObject *parent = nullptr);
|
||||
|
||||
LoadDescriptor descriptor() const override;
|
||||
|
||||
/*!
|
||||
* \brief Télémétrie runtime. \c currentPowerW = puissance ALLOUÉE de l'état courant
|
||||
* (\c declared.estimatedPowerW : 0 pour états 1/2, P3/P4 pour 3/4). C'est la base du
|
||||
* recrédit budget — PAS la conso mesurée de la PAC (l'état 2 autonome est déjà au
|
||||
* compteur, invariant 8 : la recréditer double-compterait).
|
||||
*/
|
||||
LoadTelemetry telemetry() const override;
|
||||
|
||||
/*!
|
||||
* \brief Construit l'entrée loads[] §5 (adapter="sg-ready").
|
||||
* \param now Temps de cycle (\c ctx.timestamp) — source unique de la fenêtre minState/maxState.
|
||||
*/
|
||||
LoadContext toLoadContext(const QDateTime &now) const override;
|
||||
|
||||
/*!
|
||||
* \brief Applique un changement d'état SG-Ready (2 relais, transition atomique-douce).
|
||||
* \param action LoadAction de kind State. Autres kinds : retour sans effet.
|
||||
* \param now Temps de cycle — MÊME source que toLoadContext() (verrou + lastSwitch).
|
||||
* \return L'action après écrêtage (state borné à l'ensemble déclaré).
|
||||
*/
|
||||
LoadAction applyAction(const LoadAction &action, const QDateTime &now) override;
|
||||
|
||||
/*! \brief État SG-Ready courant (1-4). */
|
||||
int currentState() const { return m_currentState; }
|
||||
|
||||
private:
|
||||
/*!
|
||||
* \brief Fenêtre d'états autorisée à \p now par le verrou minStateHold (symétrique).
|
||||
* \param[out] minState / maxState Gel total (== \c m_currentState) si \c minStateHold
|
||||
* non écoulé ; sinon [min, max] des états déclarés.
|
||||
*/
|
||||
void lockWindow(const QDateTime &now, int &minState, int &maxState) const;
|
||||
|
||||
bool lockActive(int newState, const QDateTime &now) const;
|
||||
|
||||
//! Applique l'ensemble de relais de \p toState en passant par le transitoire le plus
|
||||
//! doux (cf. contrat d'atomicité). \p fromState = état courant (pour l'ordre).
|
||||
void applyStateRelays(int fromState, int toState);
|
||||
|
||||
//! Rang de nocivité d'un état comme TRANSITOIRE (2 neutre < 3 reco < 1 blocage < 4 forcé).
|
||||
static int transientHarm(int state);
|
||||
|
||||
//! État correspondant à un ensemble de relais ON (-1 si aucun état ne correspond).
|
||||
int stateForRelays(const QList<QString> &onRelays) const;
|
||||
|
||||
QSet<QString> allRelays() const;
|
||||
|
||||
ThingManager *m_thingManager;
|
||||
QString m_id;
|
||||
QString m_label;
|
||||
QHash<int, QList<QString>> m_stateRelays; //!< état → ThingIds ON.
|
||||
QHash<int, double> m_estimatedPowerW; //!< état → W estimés (déclaré).
|
||||
QList<int> m_states; //!< États déclarés triés croissants.
|
||||
int m_minStateHoldS;
|
||||
int m_priority;
|
||||
|
||||
int m_currentState = 2; //!< Démarrage en NORMAL (mains off).
|
||||
QDateTime m_lastSwitch; //!< Dernier changement d'état (null = jamais).
|
||||
QDateTime m_lastActionAt;
|
||||
};
|
||||
118
energyplugin/etm/config/loadconfigstore.cpp
Normal file
118
energyplugin/etm/config/loadconfigstore.cpp
Normal file
@ -0,0 +1,118 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync
|
||||
|
||||
#include "loadconfigstore.h"
|
||||
#include "plugininfo.h"
|
||||
|
||||
#include <nymeasettings.h>
|
||||
|
||||
#include <QFile>
|
||||
#include <QFileInfo>
|
||||
#include <QSaveFile>
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonObject>
|
||||
#include <QJsonArray>
|
||||
#include <QJsonParseError>
|
||||
|
||||
LoadConfigStore::LoadConfigStore(QObject *parent)
|
||||
: QObject{parent}
|
||||
{
|
||||
if (load())
|
||||
qCDebug(dcNymeaEnergy()) << "[LoadConfigStore]" << m_configs.count()
|
||||
<< "config(s) de charge pilotée chargée(s) depuis" << filePath();
|
||||
else
|
||||
qCDebug(dcNymeaEnergy()) << "[LoadConfigStore] Aucune config de charge pilotée — démarrage à vide.";
|
||||
}
|
||||
|
||||
QString LoadConfigStore::filePath() const
|
||||
{
|
||||
const QString env = QString(qgetenv("NYMEA_ENERGY_LOAD_CONFIG"));
|
||||
if (!env.isEmpty())
|
||||
return env;
|
||||
return NymeaSettings::storagePath() + "/energy-load-configuration.json";
|
||||
}
|
||||
|
||||
bool LoadConfigStore::load()
|
||||
{
|
||||
const QString path = filePath();
|
||||
if (!QFileInfo::exists(path))
|
||||
return false;
|
||||
|
||||
QFile file(path);
|
||||
if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) {
|
||||
qCWarning(dcNymeaEnergy()) << "[LoadConfigStore] Ouverture impossible:" << path << file.errorString();
|
||||
return false;
|
||||
}
|
||||
const QByteArray data = file.readAll();
|
||||
file.close();
|
||||
|
||||
QJsonParseError err;
|
||||
const QJsonDocument doc = QJsonDocument::fromJson(data, &err);
|
||||
if (err.error != QJsonParseError::NoError) {
|
||||
qCWarning(dcNymeaEnergy()) << "[LoadConfigStore] JSON invalide:" << err.errorString();
|
||||
return false;
|
||||
}
|
||||
|
||||
LoadConfigs loaded;
|
||||
const QJsonArray loads = doc.object().value("loads").toArray();
|
||||
for (const QJsonValue &v : loads) {
|
||||
const LoadConfig c = LoadConfig::fromMap(v.toObject().toVariantMap());
|
||||
QString why;
|
||||
if (!c.isValid(&why)) {
|
||||
// Tolérance ascendante : on ignore une entrée corrompue plutôt que de tout perdre.
|
||||
qCWarning(dcNymeaEnergy()) << "[LoadConfigStore] Entrée ignorée (invalide):" << why;
|
||||
continue;
|
||||
}
|
||||
loaded.append(c);
|
||||
}
|
||||
m_configs = loaded;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool LoadConfigStore::setConfigs(const LoadConfigs &configs, QString *error)
|
||||
{
|
||||
// Validation EN BLOC : une seule entrée invalide → rejet total (rien persisté).
|
||||
for (const LoadConfig &c : configs) {
|
||||
QString why;
|
||||
if (!c.isValid(&why)) {
|
||||
if (error) *error = QStringLiteral("Config \"%1\" invalide : %2").arg(c.label(), why);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const LoadConfigs previous = m_configs;
|
||||
m_configs = configs;
|
||||
|
||||
if (!save(error)) {
|
||||
m_configs = previous; // rollback mémoire si l'écriture échoue
|
||||
return false;
|
||||
}
|
||||
|
||||
emit changed();
|
||||
return true;
|
||||
}
|
||||
|
||||
bool LoadConfigStore::save(QString *error) const
|
||||
{
|
||||
QJsonArray loads;
|
||||
for (const LoadConfig &c : m_configs)
|
||||
loads.append(QJsonObject::fromVariantMap(c.toMap()));
|
||||
|
||||
QJsonObject root;
|
||||
root.insert("version", 1);
|
||||
root.insert("loads", loads);
|
||||
|
||||
// Écriture atomique (QSaveFile = tmp + rename) — jamais de fichier mi-écrit.
|
||||
QSaveFile file(filePath());
|
||||
if (!file.open(QIODevice::WriteOnly | QIODevice::Text)) {
|
||||
if (error) *error = QStringLiteral("Écriture impossible : %1").arg(file.errorString());
|
||||
return false;
|
||||
}
|
||||
file.write(QJsonDocument(root).toJson(QJsonDocument::Indented));
|
||||
if (!file.commit()) {
|
||||
if (error) *error = QStringLiteral("Commit échoué : %1").arg(file.errorString());
|
||||
return false;
|
||||
}
|
||||
qCDebug(dcNymeaEnergy()) << "[LoadConfigStore]" << m_configs.count() << "config(s) persistée(s) dans" << filePath();
|
||||
return true;
|
||||
}
|
||||
49
energyplugin/etm/config/loadconfigstore.h
Normal file
49
energyplugin/etm/config/loadconfigstore.h
Normal file
@ -0,0 +1,49 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync
|
||||
#pragma once
|
||||
|
||||
#include <QObject>
|
||||
#include <QString>
|
||||
#include "../types/loadconfig.h"
|
||||
|
||||
/*!
|
||||
* \brief Store persistant des configs de charge pilotée (contrat etmvariableload §4).
|
||||
*
|
||||
* Master store unique : \c setConfigs() valide, **persiste** (\c /var/lib/nymea/\c
|
||||
* energy-load-configuration.json, à côté de \c energy-manager-configuration.json) et émet
|
||||
* \c changed() — l'arbitre reconstruit ses adaptateurs sur ce signal, le handler notifie l'app.
|
||||
*
|
||||
* Format fichier : \c {"version":1,"loads":[ <LoadConfig §4>, ... ]}. Le tableau \c loads[]
|
||||
* est **mot pour mot** le \c LoadDescriptor §4 (jonction inter-repos avec l'app).
|
||||
*
|
||||
* \invariant Écriture atomique (fichier temporaire + \c rename) — jamais de fichier mi-écrit.
|
||||
* \invariant \c setConfigs() rejette en bloc (rien persisté) si une seule entrée est invalide.
|
||||
*/
|
||||
class LoadConfigStore : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
public:
|
||||
explicit LoadConfigStore(QObject *parent = nullptr);
|
||||
|
||||
/*! \brief Configs actuellement en mémoire (chargées au démarrage ou via setConfigs). */
|
||||
LoadConfigs configs() const { return m_configs; }
|
||||
|
||||
/*!
|
||||
* \brief Remplace l'ensemble des configs : valide tout → persiste → \c emit changed().
|
||||
* \param configs Nouvel ensemble (remplace l'existant — l'app envoie la liste complète).
|
||||
* \param[out] error Message FR si rejet (aucune écriture dans ce cas).
|
||||
* \return true si validé + persisté ; false si invalide ou échec d'écriture.
|
||||
*/
|
||||
bool setConfigs(const LoadConfigs &configs, QString *error = nullptr);
|
||||
|
||||
signals:
|
||||
/*! \brief Émis après une persistance réussie de \c setConfigs(). */
|
||||
void changed();
|
||||
|
||||
private:
|
||||
QString filePath() const;
|
||||
bool load();
|
||||
bool save(QString *error) const;
|
||||
|
||||
LoadConfigs m_configs;
|
||||
};
|
||||
373
energyplugin/etm/energyarbitrator.cpp
Normal file
373
energyplugin/etm/energyarbitrator.cpp
Normal file
@ -0,0 +1,373 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync
|
||||
|
||||
#include "energyarbitrator.h"
|
||||
#include "adapters/evadapter.h"
|
||||
#include "adapters/sgreadyadapter.h"
|
||||
#include "adapters/etmvariableloadadapter.h"
|
||||
#include "adapters/relayrouter.h"
|
||||
#include "config/loadconfigstore.h"
|
||||
#include "types/loadconfig.h"
|
||||
#include "scheduler/rulebasedscheduler.h"
|
||||
#include "types/surpluscontext.h"
|
||||
#include "types/plan.h"
|
||||
#include "../rootmeter.h"
|
||||
#include "../evcharger.h"
|
||||
|
||||
#include "plugininfo.h"
|
||||
|
||||
#include <energymanager.h>
|
||||
#include <QTimer>
|
||||
|
||||
namespace {
|
||||
//! Période du watchdog L2 (SAFETY.md §L2) : tick indépendant des signaux compteur.
|
||||
constexpr int MeterWatchdogPeriodMs = 30 * 1000; // 30 s
|
||||
//! Seuil de silence compteur au-delà duquel le mode dégradé L2 est déclenché.
|
||||
constexpr int MeterSilenceThresholdS = 90; // 90 s
|
||||
}
|
||||
|
||||
EnergyArbitrator::EnergyArbitrator(EnergyManager *em, ThingManager *tm,
|
||||
SpotMarketManager *sm,
|
||||
EnergyManagerConfiguration *conf,
|
||||
QObject *parent)
|
||||
: SmartChargingManager(em, tm, sm, conf, parent)
|
||||
, m_scheduler(new RuleBasedScheduler(this, this))
|
||||
, m_tm(tm)
|
||||
{
|
||||
// --- L2 : watchdog fraîcheur compteur (SAFETY.md §L2) ---
|
||||
// La LOGIQUE (recordMeterUpdate / evaluateMeterFreshness) prend le temps en paramètre
|
||||
// et reste testable par injection (symétrique de simulationCallUpdate). Seuls les
|
||||
// DÉCLENCHEURS RÉELS (signal + QTimer, horloge murale) sont câblés ici, et exclus en
|
||||
// simulation — comme les connexions amont powerBalanceEntryAdded→update() (SCM l.108-130).
|
||||
#ifndef ENERGY_SIMULATION
|
||||
m_lastMeterUpdate = QDateTime::currentDateTime(); // grâce au démarrage (évite un dégradé immédiat)
|
||||
// Fraîcheur picotée sur powerBalanceChanged (en plus de la connexion amont L4).
|
||||
connect(em, &EnergyManager::powerBalanceChanged, this, [this]() {
|
||||
recordMeterUpdate(QDateTime::currentDateTime());
|
||||
});
|
||||
// QTimer (et non signal) : doit rester actif quand le compteur est muet.
|
||||
m_meterWatchdog = new QTimer(this);
|
||||
m_meterWatchdog->setInterval(MeterWatchdogPeriodMs);
|
||||
connect(m_meterWatchdog, &QTimer::timeout, this, &EnergyArbitrator::onMeterWatchdogTick);
|
||||
m_meterWatchdog->start();
|
||||
#else
|
||||
Q_UNUSED(em)
|
||||
#endif
|
||||
|
||||
qCDebug(dcNymeaEnergy()) << "[EnergyArbitrator] Arbitre ETM initialisé.";
|
||||
}
|
||||
|
||||
void EnergyArbitrator::runSurplusPlanning(const QDateTime &now)
|
||||
{
|
||||
planSurplusCharging(now);
|
||||
}
|
||||
|
||||
void EnergyArbitrator::runSpotMarketPlanning(const QDateTime &now)
|
||||
{
|
||||
planSpotMarketCharging(now);
|
||||
}
|
||||
|
||||
const QHash<EvCharger *, ChargingActions> &EnergyArbitrator::scheduledActions() const
|
||||
{
|
||||
return internalChargingActions();
|
||||
}
|
||||
|
||||
void EnergyArbitrator::doExecuteChargingAction(EvCharger *charger,
|
||||
const ChargingAction &action,
|
||||
const QDateTime &now)
|
||||
{
|
||||
executeChargingAction(charger, action, now);
|
||||
}
|
||||
|
||||
const QHash<ThingId, EvCharger *> &EnergyArbitrator::registeredEvChargers() const
|
||||
{
|
||||
return internalEvChargers();
|
||||
}
|
||||
|
||||
RootMeter *EnergyArbitrator::registeredRootMeter() const
|
||||
{
|
||||
return internalRootMeter();
|
||||
}
|
||||
|
||||
void EnergyArbitrator::registerSgReadyAdapter(SgReadyAdapter *adapter)
|
||||
{
|
||||
const QString id = adapter->descriptor().id;
|
||||
if (m_sgReadyAdapters.contains(id)) {
|
||||
qCWarning(dcNymeaEnergy()) << "[EnergyArbitrator] SgReadyAdapter déjà enregistré:" << id;
|
||||
return;
|
||||
}
|
||||
adapter->setParent(this);
|
||||
m_sgReadyAdapters[id] = adapter;
|
||||
qCDebug(dcNymeaEnergy()) << "[EnergyArbitrator] SgReadyAdapter enregistré:" << adapter->descriptor().label;
|
||||
}
|
||||
|
||||
void EnergyArbitrator::registerEtmVariableLoadAdapter(EtmVariableLoadAdapter *adapter)
|
||||
{
|
||||
const QString id = adapter->descriptor().id;
|
||||
if (m_loadAdapters.contains(id)) {
|
||||
qCWarning(dcNymeaEnergy()) << "[EnergyArbitrator] charge pilotée déjà enregistrée:" << id;
|
||||
return;
|
||||
}
|
||||
adapter->setParent(this);
|
||||
m_loadAdapters[id] = adapter; // upcast EtmVariableLoadAdapter* → ILoadAdapter*
|
||||
qCDebug(dcNymeaEnergy()) << "[EnergyArbitrator] etmvariableload enregistré:" << adapter->descriptor().label;
|
||||
}
|
||||
|
||||
void EnergyArbitrator::registerRelayRouter(RelayRouter *adapter)
|
||||
{
|
||||
const QString id = adapter->descriptor().id;
|
||||
if (m_loadAdapters.contains(id)) {
|
||||
qCWarning(dcNymeaEnergy()) << "[EnergyArbitrator] charge pilotée déjà enregistrée:" << id;
|
||||
return;
|
||||
}
|
||||
adapter->setParent(this);
|
||||
m_loadAdapters[id] = adapter; // upcast RelayRouter* → ILoadAdapter*
|
||||
qCDebug(dcNymeaEnergy()) << "[EnergyArbitrator] relay-router enregistré:" << adapter->descriptor().label;
|
||||
}
|
||||
|
||||
void EnergyArbitrator::setLoadConfigStore(LoadConfigStore *store)
|
||||
{
|
||||
m_loadConfigStore = store;
|
||||
if (!store)
|
||||
return;
|
||||
connect(store, &LoadConfigStore::changed, this, &EnergyArbitrator::rebuildLoadAdapters);
|
||||
rebuildLoadAdapters(); // construction initiale depuis la config persistée
|
||||
}
|
||||
|
||||
void EnergyArbitrator::rebuildLoadAdapters()
|
||||
{
|
||||
// Purge des adaptateurs construits depuis la config précédente (ILoadAdapter n'est pas QObject :
|
||||
// les concrets le sont — dynamic_cast pour deleteLater).
|
||||
for (ILoadAdapter *a : m_loadAdapters)
|
||||
if (QObject *o = dynamic_cast<QObject *>(a))
|
||||
o->deleteLater();
|
||||
m_loadAdapters.clear();
|
||||
|
||||
if (!m_loadConfigStore)
|
||||
return;
|
||||
|
||||
for (const LoadConfig &c : m_loadConfigStore->configs()) {
|
||||
// enabled==false : rôle déclaré mais EXCLU de l'arbitrage (contrat §9) — pas d'adaptateur.
|
||||
if (!c.enabled())
|
||||
continue;
|
||||
|
||||
LoadNeeds needs;
|
||||
needs.dailyDeadline = c.needs().dailyDeadline();
|
||||
needs.minEnergyWhPerDay = c.needs().minEnergyWhPerDay();
|
||||
|
||||
// La distinction de TYPE vit ICI (rév. 3) ; au-dessus, tout est ILoadAdapter (Setpoint W).
|
||||
ILoadAdapter *adapter = nullptr;
|
||||
if (c.isRelayRouter()) {
|
||||
adapter = new RelayRouter(m_tm, c.id(), c.label(), c.relaysList(),
|
||||
c.minOnS(), c.minOffS(), c.priority(), needs, this);
|
||||
qCDebug(dcNymeaEnergy()) << "[EnergyArbitrator] relay-router construit depuis config:"
|
||||
<< c.label() << "(" << c.relaysList().count() << "relais)";
|
||||
} else {
|
||||
adapter = new EtmVariableLoadAdapter(m_tm, c.id(), c.label(), c.powerLevelsInt(),
|
||||
c.maxPowerW(), c.priority(), needs, this);
|
||||
qCDebug(dcNymeaEnergy()) << "[EnergyArbitrator] etmvariableload construit depuis config:"
|
||||
<< c.label() << "(" << (c.isDynamic() ? "dynamic" : "fixed") << ")";
|
||||
}
|
||||
m_loadAdapters[c.id()] = adapter;
|
||||
}
|
||||
qCInfo(dcNymeaEnergy()) << "[EnergyArbitrator]" << m_loadAdapters.count()
|
||||
<< "charge(s) pilotée(s) active(s) (config).";
|
||||
}
|
||||
|
||||
void EnergyArbitrator::update(const QDateTime ¤tDateTime)
|
||||
{
|
||||
qCDebug(dcNymeaEnergy()) << "Updating smart charging";
|
||||
// Ordre IDENTIQUE à SmartChargingManager::update() — INTERDIT de réordonner.
|
||||
// SCM : 1.updateManual 2.prepareInfo 3.verifyOverload 4.verifyRecovery
|
||||
// 5.planSpot 6.planSurplus 7.adjustEv
|
||||
// ETM : idem 1-4 ; insertions ETM entre 4 et 7 ;
|
||||
// planSpot + planSurplus appelés via m_scheduler->getPlan() (position 5-6).
|
||||
|
||||
// 1-4 : préparation + sécurité (même ordre que l'amont)
|
||||
updateManualSoCsWithoutMeter(currentDateTime);
|
||||
prepareInformation(currentDateTime);
|
||||
verifyOverloadProtection(currentDateTime);
|
||||
verifyOverloadProtectionRecovery(currentDateTime);
|
||||
|
||||
// Mode dégradé L2 : la sécurité (L4 ci-dessus) reste active, mais on SUSPEND la
|
||||
// planification et le dispatch. Replanifier sur le cache d'un compteur mort
|
||||
// rallumerait les charges que le watchdog vient de couper → oscillation. Les
|
||||
// consignes de repli (posées à la transition) tiennent jusqu'au retour du compteur.
|
||||
if (m_degradedMode) {
|
||||
qCDebug(dcNymeaEnergy()) << "[Arbitre] Mode dégradé L2 actif — planification suspendue.";
|
||||
return;
|
||||
}
|
||||
|
||||
// ETM-only : sync adapters + proxy planification → log [Arbitre]
|
||||
// getPlan() appelle planSpotMarketCharging() + planSurplusCharging() (position 5-6 amont).
|
||||
syncAdapters();
|
||||
SurplusContext ctx = buildContext(currentDateTime);
|
||||
Plan plan = m_scheduler->getPlan(ctx);
|
||||
Slot slot = plan.slotCovering(currentDateTime);
|
||||
|
||||
for (const LoadAction &action : slot.actions) {
|
||||
qCInfo(dcNymeaEnergy()) << "[Arbitre]"
|
||||
<< action.loadId << "→" << action.reason
|
||||
<< "| activé:" << action.chargingEnabled
|
||||
<< "| courant:" << action.currentA << "A"
|
||||
<< "| phases:" << action.phaseCount
|
||||
<< "| stratégie:" << plan.strategy;
|
||||
}
|
||||
|
||||
// 7 : dispatch matériel (même position que l'amont — m_chargingActions rempli par getPlan())
|
||||
applyActionsToAdapters(slot, currentDateTime); // PAC (kind==State) → m_sgReadyAdapters
|
||||
adjustEvChargers(currentDateTime); // EV (kind==Setpoint) → proxy amont jusqu'à 3g
|
||||
}
|
||||
|
||||
SurplusContext EnergyArbitrator::buildContext(const QDateTime &now) const
|
||||
{
|
||||
SurplusContext ctx;
|
||||
ctx.timestamp = now;
|
||||
|
||||
// --- Compteur principal (AGENTS invariant 8 : mesure brute, aucune déduction) ---
|
||||
RootMeter *meter = internalRootMeter();
|
||||
if (meter) {
|
||||
// currentPower() < 0 → export ; > 0 → import (convention amont SCM l.1141)
|
||||
const double p = meter->currentPower();
|
||||
ctx.meter.importW = qMax(0.0, p);
|
||||
ctx.meter.exportW = qMax(0.0, -p);
|
||||
ctx.meter.perPhaseA = {
|
||||
meter->currentPhaseA(),
|
||||
meter->currentPhaseB(),
|
||||
meter->currentPhaseC()
|
||||
};
|
||||
}
|
||||
// SurplusPv : interface inverter — déféré (remplissage prévu en 3d)
|
||||
// SurplusBattery : déféré 3f
|
||||
|
||||
// --- loads[] : EV adapters --- (now = ctx.timestamp : source unique des verrous)
|
||||
for (auto it = m_adapters.constBegin(); it != m_adapters.constEnd(); ++it)
|
||||
ctx.loads.append(it.value()->toLoadContext(now));
|
||||
|
||||
// --- loads[] : charges pilotées en watts (relay-router + etmvariableload), polymorphes ---
|
||||
for (auto it = m_loadAdapters.constBegin(); it != m_loadAdapters.constEnd(); ++it)
|
||||
ctx.loads.append(it.value()->toLoadContext(now));
|
||||
|
||||
// --- loads[] : SG-Ready adapters (PAC) ---
|
||||
for (auto it = m_sgReadyAdapters.constBegin(); it != m_sgReadyAdapters.constEnd(); ++it)
|
||||
ctx.loads.append(it.value()->toLoadContext(now));
|
||||
|
||||
return ctx;
|
||||
}
|
||||
|
||||
void EnergyArbitrator::syncAdapters()
|
||||
{
|
||||
// Crée les adapters manquants
|
||||
for (auto it = internalEvChargers().constBegin(); it != internalEvChargers().constEnd(); ++it) {
|
||||
const QString id = it.key().toString();
|
||||
if (!m_adapters.contains(id))
|
||||
m_adapters[id] = new EvAdapter(it.value(), this);
|
||||
}
|
||||
// Supprime les adapters obsolètes
|
||||
for (const QString &id : m_adapters.keys()) {
|
||||
if (!internalEvChargers().contains(ThingId(id)))
|
||||
m_adapters.take(id)->deleteLater();
|
||||
}
|
||||
}
|
||||
|
||||
void EnergyArbitrator::applyActionsToAdapters(const Slot &slot, const QDateTime &now)
|
||||
{
|
||||
for (const LoadAction &action : slot.actions) {
|
||||
// L'adaptateur applique, écrête et verrouille — il ne décide pas (règle 2).
|
||||
if (action.kind == LoadAction::State) {
|
||||
SgReadyAdapter *adapter = m_sgReadyAdapters.value(action.loadId);
|
||||
if (adapter)
|
||||
adapter->applyAction(action, now);
|
||||
else
|
||||
qCWarning(dcNymeaEnergy()) << "[Arbitre] action State sans adaptateur SG-Ready:" << action.loadId;
|
||||
|
||||
} else if (action.kind == LoadAction::Setpoint) {
|
||||
// Dispatch AGNOSTIQUE au type : routage par loadId, le polymorphisme ILoadAdapter
|
||||
// absorbe (relay-router OU etmvariableload). Un Setpoint EV n'est PAS dans cette table
|
||||
// → ignoré ici (dispatché par adjustEvChargers() amont).
|
||||
ILoadAdapter *adapter = m_loadAdapters.value(action.loadId);
|
||||
if (adapter)
|
||||
adapter->applyAction(action, now);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void EnergyArbitrator::onMeterWatchdogTick()
|
||||
{
|
||||
// Déclencheur réel (QTimer, horloge murale) → délègue à la logique injectable.
|
||||
evaluateMeterFreshness(QDateTime::currentDateTime());
|
||||
}
|
||||
|
||||
void EnergyArbitrator::recordMeterUpdate(const QDateTime &now)
|
||||
{
|
||||
m_lastMeterUpdate = now;
|
||||
if (m_degradedMode) {
|
||||
qCInfo(dcNymeaEnergy()) << "[Arbitre] Compteur de nouveau actif — sortie du mode dégradé L2.";
|
||||
m_degradedMode = false;
|
||||
emit chargingSchedulesChanged(); // pousse degradedMode=false (planif reprend au cycle suivant)
|
||||
}
|
||||
}
|
||||
|
||||
void EnergyArbitrator::evaluateMeterFreshness(const QDateTime &now)
|
||||
{
|
||||
if (!m_lastMeterUpdate.isValid())
|
||||
return; // Aucune mesure reçue (démarrage) — pas de dégradé (invariant root meter absent).
|
||||
|
||||
const qint64 silentS = m_lastMeterUpdate.secsTo(now);
|
||||
if (silentS <= MeterSilenceThresholdS)
|
||||
return;
|
||||
|
||||
if (m_degradedMode)
|
||||
return; // Déjà en repli — les consignes tiennent, pas de ré-émission (anti-oscillation).
|
||||
|
||||
qCWarning(dcNymeaEnergy()) << "[Arbitre] Compteur muet depuis" << silentS
|
||||
<< "s (>" << MeterSilenceThresholdS << "s) — mode dégradé L2.";
|
||||
applyDegradedMode(now);
|
||||
}
|
||||
|
||||
void EnergyArbitrator::applyDegradedMode(const QDateTime &now)
|
||||
{
|
||||
m_degradedMode = true;
|
||||
emit chargingSchedulesChanged(); // pousse degradedMode=true (notification client L2)
|
||||
const QString reason =
|
||||
QStringLiteral("Compteur muet depuis >90 s — consigne de repli (L2 watchdog)");
|
||||
|
||||
// Charges pilotées en watts (relay-router ET etmvariableload) : repli Setpoint(0) force=true
|
||||
// (contrat rév. 3 §9). Boucle sur TOUS les ILoadAdapter — ne PAS oublier un type, sinon des
|
||||
// relais ECS resteraient allumés en compteur muet (trou T2 recréé). force=true → pour le
|
||||
// relay-router, coupe TOUS les relais (bypass minOn/minOff) ; pour etmvariableload, setpoint 0.
|
||||
for (ILoadAdapter *adapter : m_loadAdapters) {
|
||||
LoadAction la;
|
||||
la.loadId = adapter->descriptor().id;
|
||||
la.kind = LoadAction::Setpoint;
|
||||
la.powerW = 0;
|
||||
la.force = true;
|
||||
la.reason = reason;
|
||||
adapter->applyAction(la, now);
|
||||
}
|
||||
|
||||
// EV : repli CONSERVATEUR — n'initie aucune charge. On clampe seulement une charge
|
||||
// DÉJÀ en cours au courant minimum (force=true, bypass lock). Une borne branchée mais
|
||||
// non chargeante reste off (off volontaire possible : HC/spot à venir) ; débranchée →
|
||||
// aucune action. La garantie "jamais 0 A si branché" relève du failsafe L1 de la borne.
|
||||
for (auto it = internalEvChargers().constBegin(); it != internalEvChargers().constEnd(); ++it) {
|
||||
EvCharger *ev = it.value();
|
||||
if (ev->available() && ev->charging())
|
||||
ev->setMaxChargingCurrent(ev->maxChargingCurrentMinValue(), now, true);
|
||||
}
|
||||
|
||||
// SG-Ready (PAC) : repli en état 2 (NORMAL — mains off), JAMAIS état 1 (blocage).
|
||||
// Sous compteur muet on cesse de piloter : la PAC chauffe selon son propre thermostat
|
||||
// (la bloquer = maison qui ne chauffe plus sans raison visible). force=true → bypass minStateHold.
|
||||
for (SgReadyAdapter *adapter : m_sgReadyAdapters) {
|
||||
LoadAction la;
|
||||
la.loadId = adapter->descriptor().id;
|
||||
la.kind = LoadAction::State;
|
||||
la.state = 2;
|
||||
la.force = true;
|
||||
la.reason = reason;
|
||||
adapter->applyAction(la, now);
|
||||
}
|
||||
|
||||
// Batterie (aucune charge réseau) : repli ajouté avec son adaptateur (3f).
|
||||
}
|
||||
245
energyplugin/etm/energyarbitrator.h
Normal file
245
energyplugin/etm/energyarbitrator.h
Normal file
@ -0,0 +1,245 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync
|
||||
#pragma once
|
||||
|
||||
#include "../smartchargingmanager.h"
|
||||
#include "scheduler/ischeduler.h"
|
||||
#include "types/surpluscontext.h"
|
||||
#include "types/plan.h"
|
||||
|
||||
#include <QDateTime>
|
||||
|
||||
class QTimer;
|
||||
|
||||
class EvAdapter;
|
||||
class SgReadyAdapter;
|
||||
class EtmVariableLoadAdapter;
|
||||
class RelayRouter;
|
||||
class ILoadAdapter;
|
||||
class LoadConfigStore;
|
||||
class RuleBasedScheduler;
|
||||
|
||||
/*!
|
||||
* \brief Arbitre central ETM — remplace SmartChargingManager::update() (ETM_ARBITRATOR).
|
||||
*
|
||||
* Hérite de SmartChargingManager pour conserver la compatibilité API complète avec
|
||||
* NymeaEnergyJsonHandler sans modifier le code amont.
|
||||
* Seul update() est surchargé : préparation → sécurité → planificateur → adapters.
|
||||
*
|
||||
* \invariant UN seul arbitre : EnergyArbitrator décide, les EvAdapter exécutent (règle 1).
|
||||
* \invariant verifyOverloadProtection() est toujours appelée avant la planification (règle 4).
|
||||
* \invariant Toute LoadAction transmise aux adapters a un \c reason non vide (règle 7).
|
||||
* \invariant L'absence du root meter n'empêche pas le démarrage — cycle ignoré silencieusement.
|
||||
*/
|
||||
class EnergyArbitrator : public SmartChargingManager
|
||||
{
|
||||
Q_OBJECT
|
||||
public:
|
||||
explicit EnergyArbitrator(EnergyManager *energyManager, ThingManager *thingManager,
|
||||
SpotMarketManager *spotMarketManager,
|
||||
EnergyManagerConfiguration *configuration,
|
||||
QObject *parent = nullptr);
|
||||
|
||||
/*!
|
||||
* \brief Déclenche planSurplusCharging() (protégée) — appelé par RuleBasedScheduler.
|
||||
* \param now Instant courant du cycle.
|
||||
*/
|
||||
void runSurplusPlanning(const QDateTime &now);
|
||||
|
||||
/*!
|
||||
* \brief Déclenche planSpotMarketCharging() (protégée) — appelé par RuleBasedScheduler.
|
||||
* \param now Instant courant du cycle.
|
||||
*/
|
||||
void runSpotMarketPlanning(const QDateTime &now);
|
||||
|
||||
/*!
|
||||
* \brief Actions planifiées (résultat de runSurplus/SpotMarket).
|
||||
* \return Référence constante vers la table EvCharger* → ChargingActions.
|
||||
* \note Valide seulement après runSurplusPlanning() / runSpotMarketPlanning().
|
||||
*/
|
||||
const QHash<EvCharger *, ChargingActions> &scheduledActions() const;
|
||||
|
||||
/*!
|
||||
* \brief Pont d'exécution pour EvAdapter — délègue à executeChargingAction() protégée.
|
||||
* \param charger Borne EV cible.
|
||||
* \param action ChargingAction à appliquer.
|
||||
* \param now Instant de l'action (pour les locks anti-rebond).
|
||||
*/
|
||||
void doExecuteChargingAction(EvCharger *charger, const ChargingAction &action, const QDateTime &now);
|
||||
|
||||
/*!
|
||||
* \brief Liste des EvCharger enregistrés (lecture seule).
|
||||
* \return Table ThingId → EvCharger*.
|
||||
*/
|
||||
const QHash<ThingId, EvCharger *> ®isteredEvChargers() const;
|
||||
|
||||
/*!
|
||||
* \brief Root meter courant.
|
||||
* \return Pointeur ou nullptr si aucun compteur principal n'est enregistré.
|
||||
*/
|
||||
RootMeter *registeredRootMeter() const;
|
||||
|
||||
/*!
|
||||
* \brief Enregistre un SgReadyAdapter (PAC) pour inclusion dans le contexte et le dispatch.
|
||||
* \param adapter Adaptateur à enregistrer ; son \c descriptor().id doit être unique.
|
||||
* Adopté comme enfant Qt de l'arbitre. Appelé par le test (setup) ou la config production.
|
||||
*/
|
||||
void registerSgReadyAdapter(SgReadyAdapter *adapter);
|
||||
|
||||
/*!
|
||||
* \brief Enregistre un EtmVariableLoadAdapter (ECS/routeur, interface \c etmvariableload)
|
||||
* pour inclusion dans le contexte et le dispatch \c Setpoint (W).
|
||||
* \param adapter Adaptateur à enregistrer ; son \c descriptor().id doit être unique.
|
||||
* Adopté comme enfant Qt de l'arbitre. Appelé par le test (setup) ou — en T4 — la
|
||||
* construction depuis \c LoadConfig.
|
||||
* \note Le dispatch distingue un \c Setpoint etmvariableload d'un \c Setpoint EV par le
|
||||
* \c loadId : seul l'EV n'est PAS dans \c m_loadAdapters (proxy amont jusqu'à 3g).
|
||||
*/
|
||||
void registerEtmVariableLoadAdapter(EtmVariableLoadAdapter *adapter);
|
||||
|
||||
/*!
|
||||
* \brief Enregistre un RelayRouter (ECS multipalier, rév. 3) dans la même table d'adaptateurs.
|
||||
* \param adapter Routeur à enregistrer ; \c descriptor().id unique. Adopté enfant Qt.
|
||||
* Appelé par le test (setup) ou — en prod — la construction depuis \c LoadConfig (relays[]).
|
||||
*/
|
||||
void registerRelayRouter(RelayRouter *adapter);
|
||||
|
||||
/*!
|
||||
* \brief Branche le store de config charge pilotée : construit les adaptateurs
|
||||
* \c etmvariableload depuis la config et les reconstruit à chaque \c changed().
|
||||
*
|
||||
* Remplace l'enregistrement en dur (le « 3g »). Les charges \c enabled==false ne sont
|
||||
* PAS construites (exclues de l'arbitrage, contrat §9). La PAC SG-Ready reste hors config.
|
||||
* \param store Store persistant (propriété de l'appelant ; non adopté).
|
||||
*/
|
||||
void setLoadConfigStore(LoadConfigStore *store);
|
||||
|
||||
/*!
|
||||
* \brief Mode dégradé L2 actif (compteur muet > 90 s) — override de SmartChargingManager.
|
||||
* \return \c true tant que les consignes de repli L2 tiennent ; \c false en régime normal.
|
||||
* \note Exposé dans la notification \c NymeaEnergy.ChargingSchedulesChanged (champ
|
||||
* \c degradedMode), émise aussi aux transitions de ce flag.
|
||||
*/
|
||||
bool degradedMode() const override { return m_degradedMode; }
|
||||
|
||||
/*!
|
||||
* \brief Enregistre une mesure fraîche du compteur à l'instant \p now (logique L2).
|
||||
*
|
||||
* Met à jour \c m_lastMeterUpdate et, si le mode dégradé était actif, en sort
|
||||
* (\c degradedMode=false + notification). \p now = temps de cycle.
|
||||
* \note Logique injectable (temps en paramètre) — en production appelée par le
|
||||
* handler \c powerBalanceChanged ; en simulation/test appelée directement. Le
|
||||
* déclencheur réel (signal) est câblé sous \c \#ifndef ENERGY_SIMULATION.
|
||||
*/
|
||||
void recordMeterUpdate(const QDateTime &now);
|
||||
|
||||
/*!
|
||||
* \brief Évalue la fraîcheur du compteur à \p now et bascule en mode dégradé si muet >90 s.
|
||||
*
|
||||
* Si \c now − \c m_lastMeterUpdate > 90 s et pas déjà dégradé → \c applyDegradedMode().
|
||||
* Appliqué à la TRANSITION uniquement (idempotent ensuite). \p now = temps de cycle.
|
||||
* \note Logique injectable — en production appelée par \c onMeterWatchdogTick() (QTimer
|
||||
* horloge murale, indépendant car le compteur muet fige aussi \c update()) ; en
|
||||
* simulation/test appelée directement avec le temps simulé. Symétrique de
|
||||
* \c simulationCallUpdate : déclencheur réel en prod, logique testable par injection.
|
||||
*/
|
||||
void evaluateMeterFreshness(const QDateTime &now);
|
||||
|
||||
protected:
|
||||
/*!
|
||||
* \brief Boucle principale ETM — surcharge SmartChargingManager::update().
|
||||
*
|
||||
* Ordre garanti :
|
||||
* 1. updateManualSoCsWithoutMeter()
|
||||
* 2. prepareInformation()
|
||||
* 3. verifyOverloadProtection() + verifyOverloadProtectionRecovery()
|
||||
* (si \c m_degradedMode actif : retour immédiat — planification/dispatch suspendus, L2)
|
||||
* 4. m_scheduler->getPlan() → log des decisionReason
|
||||
* 5. applyActionsToAdapters() (etmvariableload Setpoint W + SG-Ready State) + adjustEvChargers() (EV) → dispatch
|
||||
*
|
||||
* \param currentDateTime Instant courant (timer ou simulation).
|
||||
*/
|
||||
void update(const QDateTime ¤tDateTime) override;
|
||||
|
||||
private:
|
||||
/*!
|
||||
* \brief Construit le SurplusContext §5 : meter brut + loads EV + SG-Ready.
|
||||
*
|
||||
* \c ctx.meter.exportW = mesure brute du compteur (AGENTS invariant 8 — aucune
|
||||
* déduction interne). La déduction evReservedW est faite dans le scheduler.
|
||||
* \param now Temps de cycle (\c ctx.timestamp) : pose \c ctx.timestamp et sert de SOURCE
|
||||
* UNIQUE à la fenêtre de verrou PAC (\c minState/maxState) calculée par le SgReadyAdapter —
|
||||
* cohérence décision (scheduler) / exécution (applyAction). Les charges etmvariableload
|
||||
* (ECS/routeur) n'ont pas de fenêtre côté moteur (verrous dans le thing).
|
||||
*/
|
||||
SurplusContext buildContext(const QDateTime &now) const;
|
||||
|
||||
/*!
|
||||
* \brief Synchronise m_adapters avec les EvCharger actuellement enregistrés.
|
||||
* Crée les adapters manquants, supprime les adapters obsolètes.
|
||||
* \note Découverte etmvariableload/SG-Ready via config — câblé en T4.
|
||||
*/
|
||||
void syncAdapters();
|
||||
|
||||
/*!
|
||||
* \brief Applique les actions d'un Slot aux LoadAdapters non-EV.
|
||||
*
|
||||
* Itère \c slot.actions et dispatche selon le kind : \c State → \c m_sgReadyAdapters
|
||||
* (SgReadyAdapter, PAC). Les actions EV (\c Setpoint) restent dispatchées par
|
||||
* \c adjustEvChargers() amont jusqu'à 3g ; les charges etmvariableload (\c Setpoint W)
|
||||
* seront dispatchées via leur adaptateur en T3/T4. L'adaptateur écrête/verrouille lui-même
|
||||
* et ignore toute action sans \c reason ou de kind non supporté — aucune décision ici (règle 2).
|
||||
* \param slot Créneau courant retourné par le scheduler.
|
||||
* \param now Temps de cycle (\c ctx.timestamp) — transmis aux adaptateurs pour une
|
||||
* évaluation des verrous cohérente avec celle vue par le scheduler.
|
||||
*/
|
||||
void applyActionsToAdapters(const Slot &slot, const QDateTime &now);
|
||||
|
||||
/*!
|
||||
* \brief (Re)construit \c m_loadAdapters depuis \c m_loadConfigStore (rév. 3).
|
||||
* Purge les adaptateurs existants (deleteLater) puis crée, par config \c enabled==true, un
|
||||
* \c RelayRouter (si \c relays[]) ou un \c EtmVariableLoadAdapter (sinon). La distinction de
|
||||
* TYPE vit ici ; au-dessus tout est \c ILoadAdapter (Setpoint W). Appelé au branchement du
|
||||
* store et à chaque \c LoadConfigStore::changed().
|
||||
*/
|
||||
void rebuildLoadAdapters();
|
||||
|
||||
/*!
|
||||
* \brief Déclencheur RÉEL du watchdog L2 (SAFETY.md §L2) — slot de \c m_meterWatchdog
|
||||
* (QTimer 30 s, horloge murale ; câblé sous \c \#ifndef ENERGY_SIMULATION).
|
||||
*
|
||||
* Délègue simplement à \c evaluateMeterFreshness(QDateTime::currentDateTime()) : la
|
||||
* LOGIQUE (seuil 90 s, bascule en dégradé) est dans cette méthode injectable, le QTimer
|
||||
* n'est que le battement. Indépendant des signaux compteur : reste actif précisément
|
||||
* quand le compteur est muet (le signal \c powerBalanceChanged ne fire plus, et
|
||||
* \c update() — piloté par le compteur — s'arrête aussi). Voir \c evaluateMeterFreshness().
|
||||
*/
|
||||
void onMeterWatchdogTick();
|
||||
|
||||
/*!
|
||||
* \brief Applique les consignes de repli L2 (SAFETY.md §L2, Variante B).
|
||||
*
|
||||
* Repli CONSERVATEUR (n'initie aucune charge) : ECS → palier 0 \c force=true (bypass
|
||||
* anti-rebond) ; EV en charge → clamp courant minimum borne ; EV branché non chargeant
|
||||
* ou débranché → aucune action (planification en cours respectée ; "jamais 0 A si
|
||||
* branché" relève du failsafe L1). SG-Ready/Batterie : repli ajouté à l'arrivée de
|
||||
* leurs adaptateurs (3e/3f). Positionne \c m_degradedMode.
|
||||
*
|
||||
* \note Appelé une seule fois à la TRANSITION vers le mode dégradé. Ensuite \c update()
|
||||
* suspend la planification, donc les consignes tiennent sans ré-émission par tick.
|
||||
* \param now Instant courant (locks anti-rebond des bornes EV).
|
||||
*/
|
||||
void applyDegradedMode(const QDateTime &now);
|
||||
|
||||
RuleBasedScheduler *m_scheduler = nullptr;
|
||||
QHash<QString, EvAdapter *> m_adapters; //!< loadId (ThingId string) → EvAdapter*.
|
||||
QHash<QString, SgReadyAdapter *> m_sgReadyAdapters; //!< loadId → SgReadyAdapter* (PAC).
|
||||
QHash<QString, ILoadAdapter *> m_loadAdapters; //!< loadId → charge pilotée (relay-router | etmvariableload).
|
||||
ThingManager *m_tm = nullptr; //!< ThingManager (pour construire les adaptateurs config).
|
||||
LoadConfigStore *m_loadConfigStore = nullptr; //!< Store config charge pilotée (non adopté).
|
||||
|
||||
// --- L2 watchdog fraîcheur compteur (SAFETY.md §L2) ---
|
||||
QTimer *m_meterWatchdog = nullptr; //!< Tick 30 s, indépendant des signaux compteur.
|
||||
QDateTime m_lastMeterUpdate; //!< Horodatage du dernier powerBalanceChanged.
|
||||
bool m_degradedMode = false; //!< Vrai si les consignes de repli L2 sont actives.
|
||||
};
|
||||
25
energyplugin/etm/etm.pri
Normal file
25
energyplugin/etm/etm.pri
Normal file
@ -0,0 +1,25 @@
|
||||
HEADERS += \
|
||||
$$PWD/types/loadaction.h \
|
||||
$$PWD/types/loaddescriptor.h \
|
||||
$$PWD/types/surpluscontext.h \
|
||||
$$PWD/types/plan.h \
|
||||
$$PWD/types/loadconfig.h \
|
||||
$$PWD/config/loadconfigstore.h \
|
||||
$$PWD/adapters/iloadadapter.h \
|
||||
$$PWD/scheduler/ischeduler.h \
|
||||
$$PWD/adapters/evadapter.h \
|
||||
$$PWD/adapters/sgreadyadapter.h \
|
||||
$$PWD/adapters/etmvariableloadadapter.h \
|
||||
$$PWD/adapters/relayrouter.h \
|
||||
$$PWD/scheduler/rulebasedscheduler.h \
|
||||
$$PWD/energyarbitrator.h \
|
||||
|
||||
SOURCES += \
|
||||
$$PWD/types/loadconfig.cpp \
|
||||
$$PWD/config/loadconfigstore.cpp \
|
||||
$$PWD/adapters/evadapter.cpp \
|
||||
$$PWD/adapters/sgreadyadapter.cpp \
|
||||
$$PWD/adapters/etmvariableloadadapter.cpp \
|
||||
$$PWD/adapters/relayrouter.cpp \
|
||||
$$PWD/scheduler/rulebasedscheduler.cpp \
|
||||
$$PWD/energyarbitrator.cpp \
|
||||
31
energyplugin/etm/scheduler/ischeduler.h
Normal file
31
energyplugin/etm/scheduler/ischeduler.h
Normal file
@ -0,0 +1,31 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync
|
||||
#pragma once
|
||||
|
||||
#include "../types/surpluscontext.h"
|
||||
#include "../types/plan.h"
|
||||
|
||||
/*!
|
||||
* \brief Interface pure du planificateur d'énergie.
|
||||
*
|
||||
* Les implémentations concrètes héritent de QObject + IScheduler.
|
||||
*
|
||||
* \invariant getPlan() retourne IMMÉDIATEMENT (modèle cache, AGENTS invariant 5).
|
||||
* SocketScheduler retourne son dernier plan en cache et recalcule en arrière-plan.
|
||||
* \invariant getPlan() retourne TOUJOURS un Plan valide (isValid() == true).
|
||||
* SocketScheduler embarque un RuleBasedScheduler en fallback — jamais d'abstain
|
||||
* qui remonterait à l'arbitre (AGENTS règle 6).
|
||||
* \invariant Toute LoadAction du Plan retourné a \c reason non vide, en français.
|
||||
*/
|
||||
class IScheduler {
|
||||
public:
|
||||
virtual ~IScheduler() = default;
|
||||
|
||||
/*!
|
||||
* \brief Calcule le plan d'optimisation à partir du contexte courant.
|
||||
* \param ctx Contexte surplus (site, compteur, PV, batterie, charges, tarif).
|
||||
* \return Plan avec au moins un Slot — jamais Plan::isValid() == false.
|
||||
* \note Retourne immédiatement depuis le cache ; le recalcul est asynchrone.
|
||||
*/
|
||||
virtual Plan getPlan(const SurplusContext &ctx) = 0;
|
||||
};
|
||||
289
energyplugin/etm/scheduler/rulebasedscheduler.cpp
Normal file
289
energyplugin/etm/scheduler/rulebasedscheduler.cpp
Normal file
@ -0,0 +1,289 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync
|
||||
|
||||
#include "rulebasedscheduler.h"
|
||||
#include "../energyarbitrator.h"
|
||||
#include "../../evcharger.h"
|
||||
#include "../../types/chargingaction.h"
|
||||
#include "../../types/charginginfo.h"
|
||||
|
||||
#include "plugininfo.h"
|
||||
#include <QUuid>
|
||||
#include <algorithm>
|
||||
|
||||
RuleBasedScheduler::RuleBasedScheduler(EnergyArbitrator *arbitrator, QObject *parent)
|
||||
: QObject(parent)
|
||||
, m_arbitrator(arbitrator)
|
||||
{
|
||||
}
|
||||
|
||||
Plan RuleBasedScheduler::getPlan(const SurplusContext &ctx)
|
||||
{
|
||||
// Planification (même logique que l'amont — écrit dans m_chargingActions)
|
||||
m_arbitrator->runSpotMarketPlanning(ctx.timestamp);
|
||||
m_arbitrator->runSurplusPlanning(ctx.timestamp);
|
||||
|
||||
Slot slot;
|
||||
slot.from = ctx.timestamp;
|
||||
slot.to = ctx.timestamp.addSecs(60);
|
||||
|
||||
const auto &cas = m_arbitrator->scheduledActions();
|
||||
const auto &evs = m_arbitrator->registeredEvChargers();
|
||||
|
||||
// Même priorité que adjustEvChargers() — iso-fonctionnel 3b
|
||||
for (auto it = evs.constBegin(); it != evs.constEnd(); ++it) {
|
||||
EvCharger *ev = it.value();
|
||||
if (!ev->available() || !ev->pluggedIn())
|
||||
continue;
|
||||
|
||||
const ChargingActions &actions = cas.value(ev);
|
||||
LoadAction la;
|
||||
|
||||
if (actions.value(ChargingAction::ChargingActionIssuerTimeRequirement).chargingEnabled()) {
|
||||
la = buildTimeRequirementAction(
|
||||
ev, actions.value(ChargingAction::ChargingActionIssuerTimeRequirement));
|
||||
|
||||
} else if (actions.value(ChargingAction::ChargingActionIssuerSurplusCharging).chargingEnabled()) {
|
||||
const auto &ca = actions.value(ChargingAction::ChargingActionIssuerSurplusCharging);
|
||||
la.loadId = ev->thing()->id().toString();
|
||||
la.kind = LoadAction::Setpoint;
|
||||
la.funding = LoadAction::Surplus;
|
||||
la.chargingEnabled = true;
|
||||
la.currentA = ca.maxChargingCurrent();
|
||||
la.phaseCount = ca.desiredPhaseCount();
|
||||
la.reason = QStringLiteral("Surplus PV disponible — recharge solaire");
|
||||
la.estimatedPowerW = la.currentA * 230.0 * la.phaseCount;
|
||||
|
||||
} else if (actions.value(ChargingAction::ChargingActionIssuerSpotMarketCharging).chargingEnabled()) {
|
||||
const auto &ca = actions.value(ChargingAction::ChargingActionIssuerSpotMarketCharging);
|
||||
la.loadId = ev->thing()->id().toString();
|
||||
la.kind = LoadAction::Setpoint;
|
||||
la.funding = LoadAction::Grid;
|
||||
la.chargingEnabled = true;
|
||||
la.currentA = ca.maxChargingCurrent();
|
||||
la.phaseCount = ca.desiredPhaseCount();
|
||||
la.reason = QStringLiteral("Tarif aWATTar favorable — recharge heure creuse");
|
||||
la.estimatedPowerW = la.currentA * 230.0 * la.phaseCount;
|
||||
|
||||
} else {
|
||||
const ChargingInfo::ChargingMode mode =
|
||||
m_arbitrator->chargingInfo(ev->id()).chargingMode();
|
||||
if (mode == ChargingInfo::ChargingModeEcoWithMinCurrent
|
||||
|| mode == ChargingInfo::ChargingModeEcoMinWithTargetTime) {
|
||||
la = buildMinCurrentAction(ev);
|
||||
} else {
|
||||
la = buildIdleAction(ev);
|
||||
}
|
||||
}
|
||||
|
||||
slot.actions.append(la);
|
||||
}
|
||||
|
||||
// ---- Waterfall ECS (charges non-EV à paliers) ---------------------------------
|
||||
// Correction A — déduction EV unique : ctx.meter.exportW est la mesure BRUTE
|
||||
// (invariant 8). Les consignes EV de CE cycle ne sont pas encore visibles au
|
||||
// compteur ; on réserve donc leur puissance commandée non encore mesurée.
|
||||
double evReservedW = 0;
|
||||
for (const LoadAction &la : slot.actions) {
|
||||
if (la.kind != LoadAction::Setpoint || !la.chargingEnabled)
|
||||
continue;
|
||||
EvCharger *ev = evs.value(ThingId(la.loadId));
|
||||
const double measuredW = ev ? ev->currentPower() : 0.0;
|
||||
evReservedW += qMax(0.0, la.estimatedPowerW - measuredW);
|
||||
}
|
||||
// Surplus net SIGNÉ : exportW − importW = −puissance compteur. Négatif en import →
|
||||
// l'ECS déleste (budget < palier) au lieu de rester allumé sur le réseau. (Le maintien
|
||||
// transitoire sous minOn est géré par le verrou de l'adaptateur, pas par le budget.)
|
||||
double remainingSurplusW = (ctx.meter.exportW - ctx.meter.importW) - evReservedW;
|
||||
|
||||
// Charges pilotables non-EV triées par priorité ASCENDANTE : rang 1 = premier servi
|
||||
// (OPTIMIZER_PROTOCOL §5 + annexe C — la priorité est un rang). Le budget de surplus est
|
||||
// UNIQUE et cascade à travers TOUTES ces charges par priorité :
|
||||
// - etmvariableload (ECS/routeur, kind Setpoint W → buildSetpointAction, règle d'arrondi §5) ;
|
||||
// - sg-ready (PAC, kind State → buildSgReadyStateAction, mapping sémantique).
|
||||
// Charges en WATTS (Setpoint) : etmvariableload (continu) ET relay-router (rév. 3, combinaison
|
||||
// de relais dérivée côté routeur). Charge à ÉTATS : sg-ready (PAC). Le scheduler distingue
|
||||
// Setpoint vs State (nature de l'action), PAS la classe concrète d'adaptateur (frontière rév. 3).
|
||||
QList<LoadContext> nonEvLoads;
|
||||
for (const LoadContext &lc : ctx.loads) {
|
||||
if (lc.adapter == QStringLiteral("etmvariableload") || lc.adapter == QStringLiteral("relay-router")
|
||||
|| lc.adapter == QStringLiteral("sg-ready"))
|
||||
nonEvLoads.append(lc);
|
||||
}
|
||||
std::sort(nonEvLoads.begin(), nonEvLoads.end(),
|
||||
[](const LoadContext &a, const LoadContext &b) { return a.priority < b.priority; });
|
||||
|
||||
for (const LoadContext &lc : nonEvLoads) {
|
||||
if (lc.adapter == QStringLiteral("sg-ready"))
|
||||
slot.actions.append(buildSgReadyStateAction(lc, remainingSurplusW));
|
||||
else
|
||||
slot.actions.append(buildSetpointAction(lc, remainingSurplusW)); // etmvariableload + relay-router
|
||||
}
|
||||
|
||||
// Grid funding (ECS/PAC) : dormant jusqu'à 3f (waterfall réseau) — non implémenté ici.
|
||||
|
||||
Plan plan;
|
||||
plan.planId = QUuid::createUuid().toString(QUuid::WithoutBraces);
|
||||
plan.strategy = QStringLiteral("rule-based");
|
||||
plan.timeSlots.append(slot);
|
||||
return plan;
|
||||
}
|
||||
|
||||
LoadAction RuleBasedScheduler::buildTimeRequirementAction(EvCharger *ev,
|
||||
const ChargingAction &ca) const
|
||||
{
|
||||
// Le courant final est affiné par adjustEvChargers() (allowance root-meter).
|
||||
// En 3b on log la valeur brute de la planification — iso-fonctionnel.
|
||||
LoadAction la;
|
||||
la.loadId = ev->thing()->id().toString();
|
||||
la.kind = LoadAction::Setpoint;
|
||||
la.funding = LoadAction::Grid;
|
||||
la.chargingEnabled = true;
|
||||
la.currentA = ca.maxChargingCurrent();
|
||||
la.phaseCount = ca.desiredPhaseCount();
|
||||
la.reason = QStringLiteral("Deadline VE approchante — recharge prioritaire");
|
||||
la.estimatedPowerW = la.currentA * 230.0 * la.phaseCount;
|
||||
return la;
|
||||
}
|
||||
|
||||
LoadAction RuleBasedScheduler::buildMinCurrentAction(EvCharger *ev) const
|
||||
{
|
||||
const uint minA = qMax(EcoMinChargingCurrent, ev->maxChargingCurrentMinValue());
|
||||
const uint phases = ev->phaseCount();
|
||||
|
||||
LoadAction la;
|
||||
la.loadId = ev->thing()->id().toString();
|
||||
la.kind = LoadAction::Setpoint;
|
||||
la.funding = LoadAction::Surplus;
|
||||
la.chargingEnabled = true;
|
||||
la.currentA = minA;
|
||||
la.phaseCount = phases;
|
||||
la.reason = QStringLiteral("Aucun surplus — courant minimum maintenu (mode EcoMin)");
|
||||
la.estimatedPowerW = la.currentA * 230.0 * la.phaseCount;
|
||||
return la;
|
||||
}
|
||||
|
||||
LoadAction RuleBasedScheduler::buildIdleAction(EvCharger *ev) const
|
||||
{
|
||||
LoadAction la;
|
||||
la.loadId = ev->thing()->id().toString();
|
||||
la.kind = LoadAction::Setpoint;
|
||||
la.funding = LoadAction::Surplus;
|
||||
la.chargingEnabled = false;
|
||||
la.currentA = 0;
|
||||
la.phaseCount = 0;
|
||||
la.reason = QStringLiteral("Aucun surplus disponible — recharge suspendue");
|
||||
la.estimatedPowerW = 0;
|
||||
return la;
|
||||
}
|
||||
|
||||
LoadAction RuleBasedScheduler::buildSetpointAction(const LoadContext &lc,
|
||||
double &remainingSurplusW) const
|
||||
{
|
||||
// Recrédit anti-clignotement (Correction B / contrat rév. 2 §5) : la conso de DÉBUT de cycle
|
||||
// est déjà soustraite de l'export mesuré → on la rend au budget local avant d'arrondir.
|
||||
// PAS de relecture post-setpoint (invariant 8 : aucune boucle de feedback).
|
||||
const double budgetW = remainingSurplusW + lc.telemetry.currentPowerW;
|
||||
|
||||
const QList<int> &levels = lc.declared.powerLevels;
|
||||
const bool dynamic = levels.isEmpty();
|
||||
|
||||
double setpointW;
|
||||
if (dynamic) {
|
||||
// Dynamic (routeur PV / triac) : modulation continue bornée au plafond déclaré.
|
||||
const double maxW = lc.declared.maxPowerW > 0 ? lc.declared.maxPowerW : budgetW;
|
||||
setpointW = qBound(0.0, budgetW, maxW);
|
||||
} else {
|
||||
// Fixed : plus haut palier déclaré ≤ budget (levels triés croissants, 0 inclus →
|
||||
// garantit une consigne valide même budget ≤ 0). Granularité connue → zéro cycle de retard.
|
||||
int chosen = 0;
|
||||
for (int l : levels) {
|
||||
if (l <= budgetW) chosen = l;
|
||||
else break;
|
||||
}
|
||||
setpointW = chosen;
|
||||
}
|
||||
|
||||
LoadAction la;
|
||||
la.loadId = lc.id;
|
||||
la.kind = LoadAction::Setpoint;
|
||||
la.funding = LoadAction::Surplus;
|
||||
la.powerW = setpointW;
|
||||
la.estimatedPowerW = setpointW;
|
||||
|
||||
if (setpointW > 0)
|
||||
la.reason = QStringLiteral("Surplus PV %1 W — %2 consigne %3 W%4")
|
||||
.arg(qRound(budgetW)).arg(lc.label).arg(qRound(setpointW))
|
||||
.arg(dynamic ? QString() : QStringLiteral(" (palier)"));
|
||||
else
|
||||
la.reason = QStringLiteral("Surplus insuffisant (%1 W) — %2 à 0 W")
|
||||
.arg(qRound(budgetW)).arg(lc.label);
|
||||
|
||||
// Résidu : budget − consigne engagée → charge suivante de la priorité (même cycle).
|
||||
remainingSurplusW = budgetW - setpointW;
|
||||
return la;
|
||||
}
|
||||
|
||||
LoadAction RuleBasedScheduler::buildSgReadyStateAction(const LoadContext &lc,
|
||||
double &remainingSurplusW) const
|
||||
{
|
||||
const int currentState = lc.telemetry.state;
|
||||
const double p3 = lc.declared.estimatedPowerW.value(3, 0.0);
|
||||
const double p4 = lc.declared.estimatedPowerW.value(4, 0.0);
|
||||
|
||||
// Recrédit (correction B) : la puissance ALLOUÉE de l'état courant (déclaré, 0 pour 1/2)
|
||||
// revient au budget — comme l'ECS. Base : "quel surplus si la PAC n'était pas pilotée ?"
|
||||
const double allocatedNowW = lc.declared.estimatedPowerW.value(currentState, 0.0);
|
||||
const double budgetW = remainingSurplusW + allocatedNowW;
|
||||
|
||||
// Mapping SÉMANTIQUE (pas "le plus haut qui rentre") avec hystérésis d'état anti-oscillation :
|
||||
// monter en 4 si budget ≥ P4×1,2 ; rester en 4 tant que budget ≥ P4×1,0 (zone morte) ;
|
||||
// sinon recommandation (≥ P3) ; sinon normal (état 2, mains off).
|
||||
// État 1 (effacement) jamais déclenché par le surplus seul — déféré (signal tarif/réseau).
|
||||
constexpr double kForceMargin = 1.2; // hystérésis d'entrée en état 4
|
||||
int targetState;
|
||||
if (p4 > 0.0 && budgetW >= p4 * kForceMargin)
|
||||
targetState = 4;
|
||||
else if (currentState == 4 && p4 > 0.0 && budgetW >= p4) // zone morte : reste forcé
|
||||
targetState = 4;
|
||||
else if (p3 > 0.0 && budgetW >= p3)
|
||||
targetState = 3;
|
||||
else
|
||||
targetState = 2;
|
||||
|
||||
// Clamp lock-aware (fenêtre minStateHold, calculée au MÊME ctx.timestamp).
|
||||
const int loW = lc.telemetry.minState > 0 ? lc.telemetry.minState : 2;
|
||||
const int hiW = lc.telemetry.maxState > 0 ? lc.telemetry.maxState : 2;
|
||||
int bestState = qBound(qMin(loW, hiW), targetState, qMax(loW, hiW));
|
||||
// Sécurité : ne jamais commander un état non déclaré (snap au plus haut déclaré ≤ cible).
|
||||
if (!lc.declared.states.contains(bestState)) {
|
||||
int snapped = lc.declared.states.isEmpty() ? 2 : lc.declared.states.first();
|
||||
for (int s : lc.declared.states)
|
||||
if (s <= bestState && s > snapped) snapped = s;
|
||||
bestState = snapped;
|
||||
}
|
||||
|
||||
LoadAction la;
|
||||
la.loadId = lc.id;
|
||||
la.kind = LoadAction::State;
|
||||
la.funding = LoadAction::Surplus;
|
||||
la.state = bestState;
|
||||
la.estimatedPowerW = lc.declared.estimatedPowerW.value(bestState, 0.0);
|
||||
|
||||
if (bestState != targetState)
|
||||
la.reason = QStringLiteral("Verrou minStateHold — %1 maintenue état %2 (court-cycling PAC)")
|
||||
.arg(lc.label).arg(bestState);
|
||||
else if (bestState == 4)
|
||||
la.reason = QStringLiteral("Surplus abondant %1 W — %2 forcée (état 4, ~%3 W)")
|
||||
.arg(qRound(budgetW)).arg(lc.label).arg(qRound(p4));
|
||||
else if (bestState == 3)
|
||||
la.reason = QStringLiteral("Surplus PV %1 W — %2 recommandée (état 3, ~%3 W)")
|
||||
.arg(qRound(budgetW)).arg(lc.label).arg(qRound(p3));
|
||||
else
|
||||
la.reason = QStringLiteral("Surplus insuffisant (%1 W) — %2 en normal (état 2, non pilotée)")
|
||||
.arg(qRound(budgetW)).arg(lc.label);
|
||||
|
||||
// Budget restant : on ne soustrait que la puissance ALLOUÉE (états 1/2 = 0).
|
||||
remainingSurplusW = budgetW - la.estimatedPowerW;
|
||||
return la;
|
||||
}
|
||||
120
energyplugin/etm/scheduler/rulebasedscheduler.h
Normal file
120
energyplugin/etm/scheduler/rulebasedscheduler.h
Normal file
@ -0,0 +1,120 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync
|
||||
#pragma once
|
||||
|
||||
#include <QObject>
|
||||
#include "ischeduler.h"
|
||||
|
||||
class EvCharger;
|
||||
class ChargingAction;
|
||||
class EnergyArbitrator;
|
||||
|
||||
/*!
|
||||
* \brief Planificateur règles GPL : EV (proxy amont) + waterfall surplus SG-Ready (PAC).
|
||||
*
|
||||
* \c getPlan() produit un plan à 1 créneau en DEUX temps :
|
||||
*
|
||||
* 1. **EV — proxy amont (beta, jusqu'à 3g)** : délègue la planification EV à
|
||||
* \c planSurplusCharging() / \c planSpotMarketCharging() héritées de SmartChargingManager,
|
||||
* relit \c m_chargingActions et les reformate en LoadAction(Setpoint) annotées d'un
|
||||
* \c reason français. Le dispatch EV réel reste dans \c adjustEvChargers() amont.
|
||||
*
|
||||
* 2. **Waterfall non-EV** : un budget de surplus UNIQUE — net SIGNÉ
|
||||
* \c (exportW − importW) − evReservedW — cascade par **priorité ASC** (rang, 1 = premier
|
||||
* servi). En T2, seule la PAC \c sg-ready (\c buildSgReadyStateAction, kind State) y est
|
||||
* traitée : anti-clignotement par **recrédit** de la conso allouée, **clamp lock-aware**
|
||||
* \c minState/maxState (verrou \c minStateHold, protection court-cycling compresseur) calculé
|
||||
* au MÊME \c ctx.timestamp que l'exécution (cf. \c ILoadAdapter). Ces LoadAction sont
|
||||
* RÉELLEMENT dispatchées par \c EnergyArbitrator::applyActionsToAdapters().
|
||||
*
|
||||
* Les charges \c etmvariableload (ECS/routeur, kind Setpoint W) rejoindront ce waterfall avec
|
||||
* la règle d'arrondi (contrat rév. 2 §5) en **T3**. À partir de **3g**, l'EV rejoint aussi le
|
||||
* waterfall unifié (toutes charges classables ensemble).
|
||||
*
|
||||
* \invariant getPlan() retourne IMMÉDIATEMENT (AGENTS invariant 5) et toujours un Plan valide.
|
||||
* \invariant Toute LoadAction a un \c reason non vide, en français.
|
||||
* \invariant EV (étape 1) : priorité Deadline VE > Surplus PV > aWATTar > Min courant > Idle
|
||||
* (iso-fonctionnel amont 3b). Non-EV (étape 2) : ordre = \c priority croissant.
|
||||
*/
|
||||
class RuleBasedScheduler : public QObject, public IScheduler
|
||||
{
|
||||
Q_OBJECT
|
||||
public:
|
||||
/*!
|
||||
* \brief Constructeur.
|
||||
* \param arbitrator Arbitre propriétaire — fournit l'accès à la planification et à l'état.
|
||||
* \param parent Propriétaire Qt.
|
||||
*/
|
||||
explicit RuleBasedScheduler(EnergyArbitrator *arbitrator, QObject *parent = nullptr);
|
||||
|
||||
/*!
|
||||
* \brief Retourne le plan pour le slot courant (EV proxy + waterfall non-EV).
|
||||
*
|
||||
* Étape 1 (EV) : \c runSpotMarketPlanning() + \c runSurplusPlanning() puis reformatage
|
||||
* de \c scheduledActions() en LoadAction(Setpoint) — log [Arbitre], dispatch amont.
|
||||
* Étape 2 (non-EV) : waterfall surplus sur la PAC \c sg-ready triée par priorité —
|
||||
* LoadAction(State) réellement dispatchées (etmvariableload ECS/routeur arrive en T3).
|
||||
*
|
||||
* \param ctx SurplusContext courant. Utilisé : \c ctx.timestamp (temps de cycle / verrous),
|
||||
* \c ctx.meter (surplus net signé), \c ctx.loads (déclarés, télémétrie, fenêtres de verrou).
|
||||
* \return Plan à 1 créneau couvrant \c ctx.timestamp + 60 s.
|
||||
*/
|
||||
Plan getPlan(const SurplusContext &ctx) override;
|
||||
|
||||
private:
|
||||
/*!
|
||||
* \brief Construit un LoadAction pour le cas "délai requis" (TimeRequirement).
|
||||
* \param ev EvCharger concerné.
|
||||
* \param ca ChargingAction planifiée (courant et phases déjà calculés par planSurplusCharging).
|
||||
* \return LoadAction avec funding=Grid et reason "Deadline VE".
|
||||
*/
|
||||
LoadAction buildTimeRequirementAction(EvCharger *ev, const ChargingAction &ca) const;
|
||||
|
||||
/*!
|
||||
* \brief Construit un LoadAction "courant minimum" pour les modes EcoMin.
|
||||
* \param ev EvCharger concerné.
|
||||
* \return LoadAction avec funding=Surplus, chargingEnabled=true, currentA=min.
|
||||
*/
|
||||
LoadAction buildMinCurrentAction(EvCharger *ev) const;
|
||||
|
||||
/*!
|
||||
* \brief Construit un LoadAction "idle" (recharge désactivée, aucun surplus).
|
||||
* \param ev EvCharger concerné.
|
||||
* \return LoadAction avec chargingEnabled=false et reason appropriée.
|
||||
*/
|
||||
LoadAction buildIdleAction(EvCharger *ev) const;
|
||||
|
||||
/*!
|
||||
* \brief Construit un LoadAction \c Setpoint (W) pour une charge \c etmvariableload
|
||||
* (ECS résistif / routeur PV) par la règle d'arrondi du contrat rév. 2 §5.
|
||||
*
|
||||
* Recrédit anti-clignotement (Correction B / contrat §5) : le budget local =
|
||||
* \c remainingSurplusW + \c lc.telemetry.currentPowerW (conso de DÉBUT de cycle, déjà
|
||||
* soustraite de l'export mesuré — JAMAIS une relecture post-setpoint, invariant 8). Puis :
|
||||
* - **fixed** (\c powerLevels non vide) : setpoint = plus haut palier déclaré ≤ budget ;
|
||||
* - **dynamic** (\c powerLevels vide) : setpoint = \c clamp(budget, 0, maxPowerW).
|
||||
* Le résidu \c budget − setpoint repart vers la charge suivante de la priorité (même cycle).
|
||||
*
|
||||
* \param lc Charge \c etmvariableload du SurplusContext.
|
||||
* \param[in,out] remainingSurplusW Budget de surplus restant (W) ; mis à jour pour la suite.
|
||||
* \return LoadAction kind=Setpoint, funding=Surplus, \c powerW = consigne, \c reason non vide.
|
||||
*/
|
||||
LoadAction buildSetpointAction(const LoadContext &lc, double &remainingSurplusW) const;
|
||||
|
||||
/*!
|
||||
* \brief Construit un LoadAction "state" SG-Ready (PAC) par mapping SÉMANTIQUE du surplus.
|
||||
*
|
||||
* 4 états normés (qualitatifs, pas des paliers) : surplus abondant stable → 4 (forcé,
|
||||
* hystérésis P4×1,2 entrée / P4×1,0 sortie) ; surplus durable → 3 (recommandation, ≥P3) ;
|
||||
* sinon → 2 (normal, mains off). L'état 1 (effacement) n'est PAS déclenché par le surplus
|
||||
* seul (déféré : signal tarif/réseau). Recrédit (correction B) sur la puissance allouée
|
||||
* (déclaré, 0 pour 1/2). Clamp lock-aware via \c minState/maxState (court-cycling PAC).
|
||||
*
|
||||
* \param lc Charge SG-Ready (adapter == "sg-ready") du SurplusContext.
|
||||
* \param[in,out] remainingSurplusW Budget de surplus restant (W), mis à jour pour la suite.
|
||||
* \return LoadAction kind=State, funding=Surplus, \c reason français non vide.
|
||||
*/
|
||||
LoadAction buildSgReadyStateAction(const LoadContext &lc, double &remainingSurplusW) const;
|
||||
|
||||
EnergyArbitrator *m_arbitrator;
|
||||
};
|
||||
71
energyplugin/etm/types/loadaction.h
Normal file
71
energyplugin/etm/types/loadaction.h
Normal file
@ -0,0 +1,71 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync
|
||||
#pragma once
|
||||
|
||||
#include <QString>
|
||||
|
||||
/*!
|
||||
* \brief Action typée émise par l'arbitre vers un ILoadAdapter.
|
||||
*
|
||||
* Noms de champs identiques à OPTIMIZER_PROTOCOL.md §6 (fait autorité).
|
||||
* \c funding est interne à l'arbitre — absent du JSON protocole socket.
|
||||
*
|
||||
* \invariant \c reason doit être non vide et en français (AGENTS invariant 7).
|
||||
* Un adaptateur doit rejeter toute action avec \c reason vide.
|
||||
* \invariant Pour kind == Setpoint / evcharger : seuls \c chargingEnabled,
|
||||
* \c currentA, \c phaseCount sont significatifs.
|
||||
*/
|
||||
struct LoadAction {
|
||||
/*! \brief Type d'action : consigne en watts (etmvariableload), état SG-Ready ou contrainte.
|
||||
* \note Le kind \c Stage (index de palier ECS) a été RETIRÉ (contrat etmvariableload rév. 2
|
||||
* §6) : l'ECS est piloté en watts via \c Setpoint, l'index de palier vit dans le thing. */
|
||||
enum Kind { Setpoint, State, Constraint };
|
||||
/*! \brief Financement interne : Surplus (PV) ou Grid (réseau). Non sérialisé. */
|
||||
enum Funding { Surplus, Grid };
|
||||
/*! \brief Source d'énergie pour Setpoint batterie : "solar" ou "grid". */
|
||||
enum Source { Solar, GridSource };
|
||||
/*! \brief Permission de charge/décharge pour Constraint batterie. */
|
||||
enum Permission { Allow, Forbid };
|
||||
|
||||
QString loadId; //!< ThingId de la charge cible (string).
|
||||
Kind kind = Setpoint;
|
||||
Funding funding = Surplus;
|
||||
|
||||
// --- Setpoint evcharger ---
|
||||
bool chargingEnabled = false;
|
||||
double currentA = 0; //!< Courant consigne (A), écrêté par l'adaptateur.
|
||||
uint phaseCount = 0; //!< Nombre de phases (1 ou 3, 0 = inchangé).
|
||||
|
||||
// --- Setpoint battery ---
|
||||
double powerW = 0;
|
||||
Source source = Solar;
|
||||
|
||||
// --- State sg-ready ---
|
||||
int state = 0; //!< État SG-Ready (1-4).
|
||||
|
||||
// --- Constraint battery ---
|
||||
Permission charge = Allow;
|
||||
Permission discharge = Allow;
|
||||
|
||||
/*!
|
||||
* \brief Motif de la décision, non vide, en français.
|
||||
* Obligatoire (invariant 7). L'adaptateur rejette silencieusement si vide.
|
||||
*/
|
||||
QString reason;
|
||||
|
||||
/*!
|
||||
* \brief Puissance estimée (W) — hint pour la comptabilité budget de l'arbitre.
|
||||
* Rempli par le scheduler ; peut être 0 si inconnu.
|
||||
*/
|
||||
double estimatedPowerW = 0;
|
||||
|
||||
/*!
|
||||
* \brief Forçage sécurité — bypasse les verrous anti-rebond (minOn/minOff).
|
||||
*
|
||||
* Positionné à \c true uniquement par \c applyDegradedMode() (L2 watchdog)
|
||||
* et les contraintes de sécurité. Les adaptateurs doivent appliquer l'action
|
||||
* immédiatement sans vérifier les verrous temporels.
|
||||
* \warning Réservé à la sécurité. Ne jamais mettre à \c true dans un scheduler.
|
||||
*/
|
||||
bool force = false;
|
||||
};
|
||||
181
energyplugin/etm/types/loadconfig.cpp
Normal file
181
energyplugin/etm/types/loadconfig.cpp
Normal file
@ -0,0 +1,181 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync
|
||||
|
||||
#include "loadconfig.h"
|
||||
#include <algorithm>
|
||||
|
||||
// ---- LoadConfigNeeds -------------------------------------------------------
|
||||
|
||||
QVariantMap LoadConfigNeeds::toMap() const
|
||||
{
|
||||
QVariantMap m;
|
||||
if (!m_dailyDeadline.isEmpty())
|
||||
m.insert("dailyDeadline", m_dailyDeadline);
|
||||
if (m_minEnergyWhPerDay > 0)
|
||||
m.insert("minEnergyWhPerDay", m_minEnergyWhPerDay);
|
||||
return m;
|
||||
}
|
||||
|
||||
LoadConfigNeeds LoadConfigNeeds::fromMap(const QVariantMap &map)
|
||||
{
|
||||
LoadConfigNeeds n;
|
||||
n.m_dailyDeadline = map.value("dailyDeadline").toString();
|
||||
n.m_minEnergyWhPerDay = map.value("minEnergyWhPerDay").toInt();
|
||||
return n;
|
||||
}
|
||||
|
||||
// ---- LoadConfigRelay -------------------------------------------------------
|
||||
|
||||
QVariantMap LoadConfigRelay::toMap() const
|
||||
{
|
||||
QVariantMap m;
|
||||
m.insert("thingId", thingId);
|
||||
m.insert("powerW", powerW);
|
||||
return m;
|
||||
}
|
||||
|
||||
LoadConfigRelay LoadConfigRelay::fromMap(const QVariantMap &map)
|
||||
{
|
||||
LoadConfigRelay r;
|
||||
r.thingId = map.value("thingId").toString();
|
||||
r.powerW = map.value("powerW").toInt();
|
||||
return r;
|
||||
}
|
||||
|
||||
// ---- LoadConfig ------------------------------------------------------------
|
||||
|
||||
QVariantList LoadConfig::relays() const
|
||||
{
|
||||
QVariantList list;
|
||||
for (const LoadConfigRelay &r : m_relays)
|
||||
list.append(r.toMap());
|
||||
return list;
|
||||
}
|
||||
|
||||
void LoadConfig::setRelays(const QVariantList &v)
|
||||
{
|
||||
m_relays.clear();
|
||||
for (const QVariant &item : v)
|
||||
m_relays.append(LoadConfigRelay::fromMap(item.toMap()));
|
||||
}
|
||||
|
||||
QVariantList LoadConfig::powerLevels() const
|
||||
{
|
||||
QVariantList list;
|
||||
for (int l : m_powerLevels)
|
||||
list.append(l);
|
||||
return list;
|
||||
}
|
||||
|
||||
void LoadConfig::setPowerLevels(const QVariantList &v)
|
||||
{
|
||||
m_powerLevels.clear();
|
||||
for (const QVariant &item : v)
|
||||
m_powerLevels.append(item.toInt());
|
||||
// Tri croissant + déduplication (contrat §6 : l'energymanager re-trie par sécurité).
|
||||
std::sort(m_powerLevels.begin(), m_powerLevels.end());
|
||||
m_powerLevels.erase(std::unique(m_powerLevels.begin(), m_powerLevels.end()), m_powerLevels.end());
|
||||
}
|
||||
|
||||
bool LoadConfig::isValid(QString *error) const
|
||||
{
|
||||
auto fail = [&](const QString &msg) { if (error) *error = msg; return false; };
|
||||
|
||||
// Validation CONDITIONNELLE au type (contrat rév. 3) — pas "tous champs requis".
|
||||
// Les deux formes (relay-router / etmvariableload) sont mutuellement exclusives ; le schéma
|
||||
// JSON-RPC SET marque TOUS les champs spécifiques en optionnel, c'est ICI qu'on tranche.
|
||||
if (m_id.isEmpty())
|
||||
return fail(QStringLiteral("id de charge vide"));
|
||||
if (m_mode != QStringLiteral("fixed") && m_mode != QStringLiteral("dynamic"))
|
||||
return fail(QStringLiteral("mode doit être \"fixed\" ou \"dynamic\" (reçu: %1)").arg(m_mode));
|
||||
|
||||
if (m_adapter == QStringLiteral("relay-router")) {
|
||||
// Cas relais (rév. 3) : liste de relais power. powerLevels/maxPowerW DÉRIVÉS (ignorés ici).
|
||||
if (m_mode != QStringLiteral("fixed"))
|
||||
return fail(QStringLiteral("relay-router : mode doit être \"fixed\""));
|
||||
if (m_relays.isEmpty())
|
||||
return fail(QStringLiteral("relay-router : relays[] requis (non vide)"));
|
||||
for (const LoadConfigRelay &r : m_relays) {
|
||||
if (r.thingId.isEmpty())
|
||||
return fail(QStringLiteral("relay-router : un relais a un thingId vide"));
|
||||
if (r.powerW <= 0)
|
||||
return fail(QStringLiteral("relay-router : relais %1 a powerW ≤ 0").arg(r.thingId));
|
||||
}
|
||||
if (m_minOnS < 0 || m_minOffS < 0)
|
||||
return fail(QStringLiteral("relay-router : minOnS/minOffS doivent être ≥ 0"));
|
||||
|
||||
} else if (m_adapter == QStringLiteral("etmvariableload")) {
|
||||
// Cas thing unique : continu (dynamic) ou multipalier natif (fixed + powerLevels, legacy).
|
||||
if (!m_relays.isEmpty())
|
||||
return fail(QStringLiteral("etmvariableload : relays[] interdit (réservé à relay-router)"));
|
||||
if (m_mode == QStringLiteral("fixed")) {
|
||||
if (m_powerLevels.isEmpty())
|
||||
return fail(QStringLiteral("etmvariableload fixed : powerLevels requis (non vide)"));
|
||||
if (m_powerLevels.first() != 0)
|
||||
return fail(QStringLiteral("etmvariableload fixed : powerLevels doit inclure 0"));
|
||||
} else { // dynamic
|
||||
if (m_maxPowerW <= 0)
|
||||
return fail(QStringLiteral("etmvariableload dynamic : maxPowerW requis (> 0)"));
|
||||
if (!m_powerLevels.isEmpty())
|
||||
return fail(QStringLiteral("etmvariableload dynamic : powerLevels doit être absent"));
|
||||
}
|
||||
|
||||
} else {
|
||||
return fail(QStringLiteral("adapter inconnu \"%1\" (attendu \"relay-router\" ou \"etmvariableload\")").arg(m_adapter));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
QVariantMap LoadConfig::toMap() const
|
||||
{
|
||||
QVariantMap m;
|
||||
m.insert("id", m_id);
|
||||
m.insert("label", m_label);
|
||||
m.insert("adapter", m_adapter);
|
||||
m.insert("mode", m_mode);
|
||||
if (!m_powerLevels.isEmpty())
|
||||
m.insert("powerLevels", powerLevels());
|
||||
m.insert("maxPowerW", m_maxPowerW);
|
||||
m.insert("priority", m_priority);
|
||||
m.insert("enabled", m_enabled);
|
||||
const QVariantMap needs = m_needs.toMap();
|
||||
if (!needs.isEmpty())
|
||||
m.insert("needs", needs);
|
||||
// rév. 3 — relais (cas relay-router) : sérialisés seulement s'ils existent.
|
||||
if (!m_relays.isEmpty()) {
|
||||
m.insert("relays", relays());
|
||||
m.insert("minOnS", m_minOnS);
|
||||
m.insert("minOffS", m_minOffS);
|
||||
}
|
||||
return m;
|
||||
}
|
||||
|
||||
LoadConfig LoadConfig::fromMap(const QVariantMap &map)
|
||||
{
|
||||
LoadConfig c;
|
||||
c.m_id = map.value("id").toString();
|
||||
c.m_label = map.value("label").toString();
|
||||
c.m_adapter = map.value("adapter", QStringLiteral("etmvariableload")).toString();
|
||||
c.m_mode = map.value("mode").toString();
|
||||
c.setPowerLevels(map.value("powerLevels").toList()); // trie + déduplique
|
||||
c.m_maxPowerW = map.value("maxPowerW").toInt();
|
||||
c.m_priority = map.value("priority").toInt();
|
||||
c.m_enabled = map.value("enabled", true).toBool();
|
||||
c.m_needs = LoadConfigNeeds::fromMap(map.value("needs").toMap());
|
||||
c.setRelays(map.value("relays").toList());
|
||||
c.m_minOnS = map.value("minOnS").toInt();
|
||||
c.m_minOffS = map.value("minOffS").toInt();
|
||||
return c;
|
||||
}
|
||||
|
||||
// ---- LoadConfigs -----------------------------------------------------------
|
||||
|
||||
QVariant LoadConfigs::get(int index) const
|
||||
{
|
||||
return QVariant::fromValue(at(index));
|
||||
}
|
||||
|
||||
void LoadConfigs::put(const QVariant &variant)
|
||||
{
|
||||
append(variant.value<LoadConfig>());
|
||||
}
|
||||
159
energyplugin/etm/types/loadconfig.h
Normal file
159
energyplugin/etm/types/loadconfig.h
Normal file
@ -0,0 +1,159 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync
|
||||
#pragma once
|
||||
|
||||
#include <QObject>
|
||||
#include <QString>
|
||||
#include <QList>
|
||||
#include <QVariant>
|
||||
#include <QVariantList>
|
||||
#include <QVariantMap>
|
||||
|
||||
/*!
|
||||
* \brief Besoins énergétiques d'une charge pilotée (contrat etmvariableload §4 \c needs).
|
||||
*
|
||||
* Q_GADGET typé pour l'introspection JSON-RPC (champs self-describing côté app).
|
||||
*/
|
||||
class LoadConfigNeeds
|
||||
{
|
||||
Q_GADGET
|
||||
Q_PROPERTY(QString dailyDeadline READ dailyDeadline WRITE setDailyDeadline)
|
||||
Q_PROPERTY(int minEnergyWhPerDay READ minEnergyWhPerDay WRITE setMinEnergyWhPerDay)
|
||||
public:
|
||||
LoadConfigNeeds() {}
|
||||
|
||||
QString dailyDeadline() const { return m_dailyDeadline; }
|
||||
void setDailyDeadline(const QString &v) { m_dailyDeadline = v; }
|
||||
int minEnergyWhPerDay() const { return m_minEnergyWhPerDay; }
|
||||
void setMinEnergyWhPerDay(int v) { m_minEnergyWhPerDay = v; }
|
||||
|
||||
QVariantMap toMap() const;
|
||||
static LoadConfigNeeds fromMap(const QVariantMap &map);
|
||||
|
||||
private:
|
||||
QString m_dailyDeadline; //!< "HH:MM" (vide = pas d'échéance quotidienne).
|
||||
int m_minEnergyWhPerDay = 0;
|
||||
};
|
||||
Q_DECLARE_METATYPE(LoadConfigNeeds)
|
||||
|
||||
/*!
|
||||
* \brief Un relais d'une charge \c relay-router (contrat rév. 3 §4) : un thing \c power + sa
|
||||
* puissance nominale (W). Imbriqué dans \c LoadConfig.relays — vit côté ROUTEUR, ne franchit
|
||||
* JAMAIS la frontière vers l'optimiseur (qui ne voit que des watts dérivés).
|
||||
*/
|
||||
struct LoadConfigRelay
|
||||
{
|
||||
QString thingId; //!< ThingId du thing implémentant l'interface \c power.
|
||||
int powerW = 0; //!< Puissance nominale de ce relais (W).
|
||||
|
||||
QVariantMap toMap() const;
|
||||
static LoadConfigRelay fromMap(const QVariantMap &map);
|
||||
};
|
||||
|
||||
/*!
|
||||
* \brief Déclaration de config d'une charge pilotée — contrat etmvariableload §4.
|
||||
*
|
||||
* \b Source de vérité inter-repos : la forme de \c toMap()/\c fromMap() (et donc des
|
||||
* Q_PROPERTY) est **mot pour mot** le \c LoadDescriptor §4 émis par l'app
|
||||
* (\c id, \c label, \c adapter, \c mode, \c powerLevels, \c maxPowerW, \c priority,
|
||||
* \c enabled, \c needs). Ne PAS diverger — c'est ce que reçoit \c SetLoadConfig.
|
||||
*
|
||||
* Distinct de \c LoadDescriptor (etm/types) qui mire OPTIMIZER_PROTOCOL §5 (sans
|
||||
* \c enabled / \c mode). Ce type est la config persistée, pas le contexte d'arbitrage.
|
||||
*/
|
||||
class LoadConfig
|
||||
{
|
||||
Q_GADGET
|
||||
Q_PROPERTY(QString id READ id WRITE setId)
|
||||
Q_PROPERTY(QString label READ label WRITE setLabel)
|
||||
Q_PROPERTY(QString adapter READ adapter WRITE setAdapter)
|
||||
Q_PROPERTY(QString mode READ mode WRITE setMode)
|
||||
Q_PROPERTY(QVariantList powerLevels READ powerLevels WRITE setPowerLevels)
|
||||
Q_PROPERTY(int maxPowerW READ maxPowerW WRITE setMaxPowerW)
|
||||
Q_PROPERTY(int priority READ priority WRITE setPriority)
|
||||
Q_PROPERTY(bool enabled READ enabled WRITE setEnabled)
|
||||
Q_PROPERTY(LoadConfigNeeds needs READ needs WRITE setNeeds)
|
||||
// rév. 3 — cas relay-router (la combinatoire vit côté routeur, jamais chez l'optimiseur) :
|
||||
Q_PROPERTY(QVariantList relays READ relays WRITE setRelays) //!< [{thingId, powerW}]
|
||||
Q_PROPERTY(int minOnS READ minOnS WRITE setMinOnS)
|
||||
Q_PROPERTY(int minOffS READ minOffS WRITE setMinOffS)
|
||||
public:
|
||||
LoadConfig() {}
|
||||
|
||||
QString id() const { return m_id; }
|
||||
void setId(const QString &v) { m_id = v; }
|
||||
QString label() const { return m_label; }
|
||||
void setLabel(const QString &v) { m_label = v; }
|
||||
QString adapter() const { return m_adapter; }
|
||||
void setAdapter(const QString &v) { m_adapter = v; }
|
||||
QString mode() const { return m_mode; }
|
||||
void setMode(const QString &v) { m_mode = v; }
|
||||
|
||||
//! powerLevels en QVariantList (introspection) ; trié + dédupliqué à l'écriture.
|
||||
QVariantList powerLevels() const;
|
||||
void setPowerLevels(const QVariantList &v);
|
||||
//! Accès typé pour la construction de l'adaptateur.
|
||||
QList<int> powerLevelsInt() const { return m_powerLevels; }
|
||||
|
||||
int maxPowerW() const { return m_maxPowerW; }
|
||||
void setMaxPowerW(int v) { m_maxPowerW = v; }
|
||||
int priority() const { return m_priority; }
|
||||
void setPriority(int v) { m_priority = v; }
|
||||
bool enabled() const { return m_enabled; }
|
||||
void setEnabled(bool v) { m_enabled = v; }
|
||||
LoadConfigNeeds needs() const { return m_needs; }
|
||||
void setNeeds(const LoadConfigNeeds &v) { m_needs = v; }
|
||||
|
||||
//! relays en QVariantList (introspection / sérialisation) ; typé via relaysList().
|
||||
QVariantList relays() const;
|
||||
void setRelays(const QVariantList &v);
|
||||
QList<LoadConfigRelay> relaysList() const { return m_relays; }
|
||||
|
||||
int minOnS() const { return m_minOnS; }
|
||||
void setMinOnS(int v) { m_minOnS = v; }
|
||||
int minOffS() const { return m_minOffS; }
|
||||
void setMinOffS(int v) { m_minOffS = v; }
|
||||
|
||||
//! Vrai si la charge est un routeur de relais (rév. 3 : combinatoire watts→relais côté routeur).
|
||||
bool isRelayRouter() const { return m_adapter == QStringLiteral("relay-router"); }
|
||||
//! Vrai si *dynamic* (modulation continue). Sinon *fixed*.
|
||||
bool isDynamic() const { return m_mode == QStringLiteral("dynamic"); }
|
||||
|
||||
/*!
|
||||
* \brief Valide la cohérence (contrat §4/§6) : adapter, id, mode, paliers/plafond.
|
||||
* \param[out] error Message FR si invalide.
|
||||
* \return true si la config est exploitable.
|
||||
*/
|
||||
bool isValid(QString *error = nullptr) const;
|
||||
|
||||
QVariantMap toMap() const;
|
||||
static LoadConfig fromMap(const QVariantMap &map);
|
||||
|
||||
private:
|
||||
QString m_id;
|
||||
QString m_label;
|
||||
QString m_adapter = QStringLiteral("etmvariableload");
|
||||
QString m_mode;
|
||||
QList<int> m_powerLevels; //!< Trié croissant, 0 inclus en *fixed* ; vide en *dynamic*.
|
||||
int m_maxPowerW = 0;
|
||||
int m_priority = 0;
|
||||
bool m_enabled = true;
|
||||
LoadConfigNeeds m_needs;
|
||||
QList<LoadConfigRelay> m_relays; //!< rév. 3 — relais du routeur (cas relay-router).
|
||||
int m_minOnS = 0;
|
||||
int m_minOffS = 0;
|
||||
};
|
||||
Q_DECLARE_METATYPE(LoadConfig)
|
||||
|
||||
/*! \brief Liste de LoadConfig — gadget pour registerObject<LoadConfig, LoadConfigs>(). */
|
||||
class LoadConfigs : public QList<LoadConfig>
|
||||
{
|
||||
Q_GADGET
|
||||
Q_PROPERTY(int count READ count)
|
||||
public:
|
||||
LoadConfigs() {}
|
||||
LoadConfigs(const QList<LoadConfig> &other) : QList(other) {}
|
||||
Q_INVOKABLE QVariant get(int index) const;
|
||||
Q_INVOKABLE void put(const QVariant &variant);
|
||||
};
|
||||
Q_DECLARE_METATYPE(LoadConfigs)
|
||||
92
energyplugin/etm/types/loaddescriptor.h
Normal file
92
energyplugin/etm/types/loaddescriptor.h
Normal file
@ -0,0 +1,92 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync
|
||||
#pragma once
|
||||
|
||||
#include <QDateTime>
|
||||
#include <QList>
|
||||
#include <QHash>
|
||||
#include <QString>
|
||||
#include "loadaction.h"
|
||||
|
||||
/*!
|
||||
* \brief Capacités déclarées par l'installateur (plaque signalétique, câblage).
|
||||
* Correspond à OPTIMIZER_PROTOCOL.md §5 loads[].declared (noms identiques).
|
||||
* \note Les champs inutilisés pour un type d'adaptateur restent à leur valeur par défaut.
|
||||
*/
|
||||
struct LoadDeclared {
|
||||
// --- evcharger ---
|
||||
double minA = 0; //!< Courant minimum (A).
|
||||
double maxA = 0; //!< Courant maximum (A).
|
||||
int phases = 0; //!< Nombre de phases disponibles (1 ou 3).
|
||||
|
||||
// --- etmvariableload (charge à puissance pilotable : EV / ECS résistif / routeur PV) ---
|
||||
//! Paliers atteignables en W (contrat etmvariableload §3), triés croissants, \c 0 inclus.
|
||||
//! Présent en mode *fixed*. Vide ⇒ modulation continue (*dynamic*, borne \c maxPowerW).
|
||||
//! C'est la DÉCLARATION installateur (référence de planification, contrat §5) — distincte
|
||||
//! de la conso runtime \c currentPowerW.
|
||||
QList<int> powerLevels;
|
||||
//! Plafond physique de la charge (W) — contrat etmvariableload §2 \c maxPowerW. REQUIS en
|
||||
//! *dynamic* ; sert aussi de plafond en *fixed*.
|
||||
double maxPowerW = 0;
|
||||
|
||||
// --- battery ---
|
||||
double maxChargeW = 0; //!< Puissance max de charge (W).
|
||||
double maxDischargeW = 0; //!< Puissance max de décharge (W).
|
||||
double capacityWh = 0; //!< Capacité totale (Wh).
|
||||
int reserveSocPercent = 0; //!< SOC de réserve (%) — non déchargeable.
|
||||
|
||||
// --- sg-ready (PAC) ---
|
||||
//! États supportés (toujours 1-4 ; déclaré pour symétrie avec le protocole §5).
|
||||
QList<int> states;
|
||||
//! Puissance estimée (W) par état, DÉCLARÉE installateur (approximative, cas
|
||||
//! \c declared du protocole §5) : ex. {3: 1800, 4: 2600}. Sert au budget, n'est PAS
|
||||
//! une consigne exacte. États 1 (blocage) et 2 (normal) ≈ 0 du point de vue allocation
|
||||
//! surplus (la conso autonome de l'état 2 est déjà au compteur — invariant 8).
|
||||
QHash<int, double> estimatedPowerW;
|
||||
};
|
||||
|
||||
/*!
|
||||
* \brief Contraintes anti-rebond et lock temporel d'une charge.
|
||||
* Correspond à OPTIMIZER_PROTOCOL.md §5 loads[].limits.
|
||||
*/
|
||||
struct LoadLimits {
|
||||
int minOnS = 0; //!< Durée minimale ON (s).
|
||||
int minOffS = 0; //!< Durée minimale OFF (s).
|
||||
int chargingEnabledLockS = 0; //!< Lock on/off (s) — evcharger.
|
||||
int currentChangeLockS = 0; //!< Lock changement courant (s) — evcharger.
|
||||
int minStateHoldS = 0; //!< Durée minimale maintien état (s) — sg-ready.
|
||||
};
|
||||
|
||||
/*!
|
||||
* \brief Besoins énergétiques déclarés par l'utilisateur pour une charge.
|
||||
* Correspond à OPTIMIZER_PROTOCOL.md §5 loads[].needs.
|
||||
*/
|
||||
struct LoadNeeds {
|
||||
int targetSocPercent = 0; //!< SOC cible (%) — EV / batterie.
|
||||
QDateTime deadline; //!< Échéance absolue de recharge.
|
||||
QString dailyDeadline; //!< Heure limite quotidienne, format "HH:MM".
|
||||
int minEnergyWhPerDay = 0; //!< Énergie minimale par jour (Wh).
|
||||
};
|
||||
|
||||
/*!
|
||||
* \brief Description statique complète d'une charge, exposée par ILoadAdapter.
|
||||
*
|
||||
* L'arbitre lit ce descripteur une fois par cycle pour construire le SurplusContext.
|
||||
* Les valeurs doivent refléter la configuration matérielle réelle (non les setpoints).
|
||||
*
|
||||
* \note \c priority : rang dans la liste ordonnée du client (OPTIMIZER_PROTOCOL §5 +
|
||||
* annexe C). Valeur plus BASSE = servi en premier (rang 1 = premier servi).
|
||||
* Les promotions conditionnelles (deadline, Tempo ROUGE) sont gérées par le scheduler,
|
||||
* pas par un poids numérique.
|
||||
*/
|
||||
struct LoadDescriptor {
|
||||
QString id; //!< ThingId de la charge (string).
|
||||
QString label; //!< Nom lisible (affiché dans les logs).
|
||||
//! Type d'adaptateur : "evcharger"|"etmvariableload"|"sg-ready"|"battery".
|
||||
QString adapter;
|
||||
int priority = 0;
|
||||
LoadDeclared declared;
|
||||
LoadLimits limits;
|
||||
LoadNeeds needs;
|
||||
QList<LoadAction::Kind> supportedKinds; //!< Kinds acceptés par applyAction().
|
||||
};
|
||||
54
energyplugin/etm/types/plan.h
Normal file
54
energyplugin/etm/types/plan.h
Normal file
@ -0,0 +1,54 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync
|
||||
#pragma once
|
||||
|
||||
#include <QDateTime>
|
||||
#include <QList>
|
||||
#include <QString>
|
||||
#include "loadaction.h"
|
||||
|
||||
// Structures miroirs de OPTIMIZER_PROTOCOL.md §6 (noms de champs identiques).
|
||||
|
||||
/*!
|
||||
* \brief Créneau d'un plan — contient les LoadAction à appliquer pendant [from, to[.
|
||||
*
|
||||
* \invariant Les actions sont ordonnées par priorité croissante (rang 1 = premier servi,
|
||||
* OPTIMIZER_PROTOCOL §5 + annexe C).
|
||||
* \invariant Un Slot vide (actions vide) est valide — signifie "aucune action ce créneau".
|
||||
*/
|
||||
struct Slot {
|
||||
QDateTime from;
|
||||
QDateTime to;
|
||||
QList<LoadAction> actions;
|
||||
};
|
||||
|
||||
/*!
|
||||
* \brief Plan d'optimisation retourné par IScheduler::getPlan().
|
||||
*
|
||||
* \invariant \c isValid() == true après tout appel à getPlan() (invariant IScheduler).
|
||||
* \invariant \c planId est unique par plan généré (UUID ou compteur).
|
||||
*/
|
||||
struct Plan {
|
||||
QString planId; //!< Identifiant unique (UUID string).
|
||||
QString strategy; //!< "rule-based" | "socket" | "socket-fallback".
|
||||
//! Créneaux du plan. Nommé \c timeSlots (pas \c slots, mot-clé Qt).
|
||||
//! Sérialisation JSON : sous le nom "slots" — OPTIMIZER_PROTOCOL.md §6
|
||||
//! fait autorité sur le nom de fil, le renommage est purement interne C++.
|
||||
QList<Slot> timeSlots;
|
||||
|
||||
/*!
|
||||
* \brief Retourne le Slot couvrant \p dt.
|
||||
* \param dt Instant à couvrir.
|
||||
* \return Slot dont from ≤ dt < to, ou Slot vide (from/to invalides) si aucun ne correspond.
|
||||
*/
|
||||
Slot slotCovering(const QDateTime &dt) const {
|
||||
for (const Slot &s : timeSlots) {
|
||||
if (dt >= s.from && dt < s.to)
|
||||
return s;
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
/*! \brief Vrai si le plan contient au moins un Slot. */
|
||||
bool isValid() const { return !timeSlots.isEmpty(); }
|
||||
};
|
||||
116
energyplugin/etm/types/surpluscontext.h
Normal file
116
energyplugin/etm/types/surpluscontext.h
Normal file
@ -0,0 +1,116 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync
|
||||
#pragma once
|
||||
|
||||
#include <QDateTime>
|
||||
#include <QList>
|
||||
#include <QString>
|
||||
#include "loaddescriptor.h"
|
||||
|
||||
// Structures miroirs de OPTIMIZER_PROTOCOL.md §5 (noms de champs identiques).
|
||||
|
||||
/*! \brief Paramètres fixes du site (contrat réseau, limite par phase). */
|
||||
struct SurplusSite {
|
||||
double contractedPowerW = 0; //!< Puissance souscrite (W).
|
||||
QList<double> phaseLimitA; //!< Limite par phase (A) : [63, 63, 63].
|
||||
};
|
||||
|
||||
/*! \brief Mesures du compteur principal (rootmeter). */
|
||||
struct SurplusMeter {
|
||||
double importW = 0; //!< Puissance importée depuis le réseau (W, ≥ 0).
|
||||
double exportW = 0; //!< Puissance exportée vers le réseau (W, ≥ 0).
|
||||
QList<double> perPhaseA; //!< Courant par phase (A).
|
||||
};
|
||||
|
||||
/*! \brief Production PV courante. */
|
||||
struct SurplusPv {
|
||||
double currentW = 0; //!< Puissance PV mesurée (W, ≥ 0).
|
||||
};
|
||||
|
||||
/*! \brief État courant du système de stockage (batterie). */
|
||||
struct SurplusBattery {
|
||||
bool present = false;
|
||||
double socPercent = 0;
|
||||
double powerW = 0; //!< Positif = charge, négatif = décharge.
|
||||
double capacityWh = 0;
|
||||
int reserveSocPercent = 0;
|
||||
double maxChargeW = 0;
|
||||
double maxDischargeW = 0;
|
||||
};
|
||||
|
||||
/*! \brief Entrée tarifaire (créneau HP/HC ou spot market). */
|
||||
struct TariffEntry {
|
||||
QDateTime from;
|
||||
QString label; //!< "HP", "HC", "Tempo-Rouge", ...
|
||||
double priceCtkWh = 0; //!< Prix en ct€/kWh.
|
||||
};
|
||||
|
||||
/*! \brief Contexte tarifaire : créneau courant + prochains créneaux. */
|
||||
struct SurplusTariff {
|
||||
QString provider;
|
||||
TariffEntry current;
|
||||
QList<TariffEntry> next; //!< Créneaux suivants, ordre chronologique.
|
||||
};
|
||||
|
||||
/*! \brief Télémétrie d'une charge dans le contexte §5 loads[].telemetry. */
|
||||
struct LoadContextTelemetry {
|
||||
double currentPowerW = 0; //!< Puissance mesurée (W).
|
||||
// --- evcharger ---
|
||||
bool pluggedIn = false;
|
||||
bool charging = false;
|
||||
double sessionWh = 0; //!< Énergie chargée dans la session courante (Wh).
|
||||
// --- etmvariableload (EV/ECS/routeur) : pas de fenêtre de verrou côté moteur —
|
||||
// l'anti-rebond (minOn/minOff) vit dans le thing (contrat rév. 2 §3). Seul
|
||||
// currentPowerW (ci-dessus) sert au résidu de la règle d'arrondi (contrat §5).
|
||||
// --- sg-ready ---
|
||||
int state = 0;
|
||||
//! Fenêtre d'états autorisée MAINTENANT par le verrou minStateHold (protection
|
||||
//! court-cycling PAC) : gel total (minState == maxState == state) si non écoulé,
|
||||
//! sinon [1, 4]. Même mécanique que minStage/maxStage de l'ECS, source = temps de cycle.
|
||||
int minState = 0;
|
||||
int maxState = 0;
|
||||
// --- battery / electricvehicle ---
|
||||
double socPercent = 0;
|
||||
QDateTime lastSwitch; //!< Dernier changement d'état.
|
||||
};
|
||||
|
||||
/*! \brief Données apprises par l'optimiseur §8 (renvoyées pour persistance). */
|
||||
struct LoadLearned {
|
||||
double dailyEnergyWh = 0;
|
||||
//! Confiance 0–1 ; < 0.7 = "profil en apprentissage".
|
||||
double confidence = 0.0;
|
||||
};
|
||||
|
||||
/*!
|
||||
* \brief Entrée loads[] du SurplusContext envoyé au scheduler.
|
||||
* Construit par l'arbitre depuis ILoadAdapter::descriptor() + toLoadContext().
|
||||
*/
|
||||
struct LoadContext {
|
||||
QString id;
|
||||
QString adapter; //!< "evcharger"|"etmvariableload"|"sg-ready"|"battery".
|
||||
QString label;
|
||||
int priority = 0;
|
||||
LoadDeclared declared;
|
||||
LoadLearned learned;
|
||||
LoadContextTelemetry telemetry;
|
||||
LoadNeeds needs;
|
||||
LoadLimits limits;
|
||||
};
|
||||
|
||||
/*!
|
||||
* \brief Contexte complet transmis au scheduler à chaque cycle (OPTIMIZER_PROTOCOL §5).
|
||||
*
|
||||
* \invariant \c timestamp correspond à l'instant du début du cycle.
|
||||
* \invariant \c pv.currentW est la PV mesurée brute — JAMAIS le net après pilotage
|
||||
* (AGENTS invariant 8 : pas de boucle de feedback).
|
||||
*/
|
||||
struct SurplusContext {
|
||||
QDateTime timestamp;
|
||||
SurplusSite site;
|
||||
SurplusMeter meter;
|
||||
SurplusPv pv;
|
||||
SurplusBattery battery;
|
||||
QList<LoadContext> loads;
|
||||
SurplusTariff tariff;
|
||||
// forecast : opaque, transmis tel quel si présent — réservé V2
|
||||
};
|
||||
@ -26,16 +26,19 @@
|
||||
#include "types/charginginfo.h"
|
||||
#include "smartchargingmanager.h"
|
||||
#include "spotmarket/spotmarketmanager.h"
|
||||
#include "etm/types/loadconfig.h"
|
||||
#include "etm/config/loadconfigstore.h"
|
||||
|
||||
#include <energymanager.h>
|
||||
|
||||
#include <QLoggingCategory>
|
||||
Q_DECLARE_LOGGING_CATEGORY(dcNymeaEnergy)
|
||||
|
||||
NymeaEnergyJsonHandler::NymeaEnergyJsonHandler(SpotMarketManager *spotMarketManager, SmartChargingManager *smartChargingManager, QObject *parent):
|
||||
NymeaEnergyJsonHandler::NymeaEnergyJsonHandler(SpotMarketManager *spotMarketManager, SmartChargingManager *smartChargingManager, LoadConfigStore *loadConfigStore, QObject *parent):
|
||||
JsonHandler{parent},
|
||||
m_spotMarketManager{spotMarketManager},
|
||||
m_smartChargingManager{smartChargingManager}
|
||||
m_smartChargingManager{smartChargingManager},
|
||||
m_loadConfigStore{loadConfigStore}
|
||||
{
|
||||
|
||||
registerEnum<ChargingInfo::ChargingMode>();
|
||||
@ -47,6 +50,8 @@ NymeaEnergyJsonHandler::NymeaEnergyJsonHandler(SpotMarketManager *spotMarketMana
|
||||
registerObject<ScoreEntry, ScoreEntries>();
|
||||
registerObject<ChargingAction>();
|
||||
registerObject<ChargingSchedule, ChargingSchedules>();
|
||||
registerObject<LoadConfigNeeds>();
|
||||
registerObject<LoadConfig, LoadConfigs>();
|
||||
|
||||
QVariantMap params, returns;
|
||||
QString description;
|
||||
@ -139,6 +144,45 @@ NymeaEnergyJsonHandler::NymeaEnergyJsonHandler(SpotMarketManager *spotMarketMana
|
||||
registerMethod("GetChargingSchedules", description, params, returns, Types::PermissionScopeControlThings);
|
||||
|
||||
|
||||
// [ETM] Configuration des charges pilotées (interface etmvariableload, contrat §4).
|
||||
params.clear(); returns.clear();
|
||||
description = "Get the configured controllable loads (etmvariableload).";
|
||||
returns.insert("loadConfigs", QVariantList() << objectRef<LoadConfig>());
|
||||
registerMethod("GetLoadConfig", description, params, returns, Types::PermissionScopeControlThings);
|
||||
|
||||
params.clear(); returns.clear();
|
||||
description = "Replace the full set of controllable load configs. Rejected as a whole "
|
||||
"(energyError) if any entry is invalid; persisted and applied atomically.";
|
||||
// Schéma inline (pas objectRef<LoadConfig>) : le contrat rév. 3 a DEUX formes exclusives
|
||||
// (relay-router : relays[]+minOnS/minOffS — etmvariableload : powerLevels|maxPowerW).
|
||||
// objectRef rendrait TOUS les champs requis → nymea rejetterait une des deux formes AVANT
|
||||
// le handler. On marque donc TOUS les champs spécifiques "o:" ; la validation conditionnelle
|
||||
// (par adapter/mode) vit dans LoadConfig::isValid(). Seuls id/label/adapter/mode/priority/
|
||||
// enabled sont requis (présents dans les deux formes). GET reste typé objectRef<LoadConfig>.
|
||||
QVariantMap needsItem;
|
||||
needsItem.insert("o:dailyDeadline", enumValueName(String));
|
||||
needsItem.insert("o:minEnergyWhPerDay", enumValueName(Uint));
|
||||
QVariantMap relayItem;
|
||||
relayItem.insert("thingId", enumValueName(String));
|
||||
relayItem.insert("powerW", enumValueName(Uint));
|
||||
QVariantMap loadItem;
|
||||
loadItem.insert("id", enumValueName(String));
|
||||
loadItem.insert("label", enumValueName(String));
|
||||
loadItem.insert("adapter", enumValueName(String));
|
||||
loadItem.insert("mode", enumValueName(String));
|
||||
loadItem.insert("priority", enumValueName(Uint));
|
||||
loadItem.insert("enabled", enumValueName(Bool));
|
||||
loadItem.insert("o:powerLevels", QVariantList() << enumValueName(Uint)); // etmvariableload fixed
|
||||
loadItem.insert("o:maxPowerW", enumValueName(Uint)); // etmvariableload dynamic
|
||||
loadItem.insert("o:needs", needsItem);
|
||||
loadItem.insert("o:relays", QVariantList() << relayItem); // relay-router (rév. 3)
|
||||
loadItem.insert("o:minOnS", enumValueName(Uint));
|
||||
loadItem.insert("o:minOffS", enumValueName(Uint));
|
||||
params.insert("loadConfigs", QVariantList() << loadItem);
|
||||
returns.insert("energyError", enumRef<EnergyManager::EnergyError>());
|
||||
registerMethod("SetLoadConfig", description, params, returns, Types::PermissionScopeControlThings);
|
||||
|
||||
|
||||
// Notifications
|
||||
params.clear();
|
||||
description = "Emitted whenever the phase power limit configuration changes.";
|
||||
@ -197,6 +241,9 @@ NymeaEnergyJsonHandler::NymeaEnergyJsonHandler(SpotMarketManager *spotMarketMana
|
||||
params.clear();
|
||||
description = "Emitted whenever the planed charging schedules have changed.";
|
||||
params.insert("chargingSchedules", QVariantList() << objectRef<ChargingSchedule>());
|
||||
// [ETM] degradedMode : true quand le watchdog L2 a basculé en repli (compteur muet).
|
||||
// Optionnel (additif, rétro-compatible) ; émis aussi aux transitions du mode dégradé.
|
||||
params.insert("o:degradedMode", enumValueName(Bool));
|
||||
registerNotification("ChargingSchedulesChanged", description, params);
|
||||
|
||||
// Charing manager
|
||||
@ -223,9 +270,27 @@ NymeaEnergyJsonHandler::NymeaEnergyJsonHandler(SpotMarketManager *spotMarketMana
|
||||
schedules << pack<ChargingSchedule>(schedule);
|
||||
}
|
||||
params.insert("chargingSchedules", schedules);
|
||||
params.insert("degradedMode", m_smartChargingManager->degradedMode()); // [ETM] L2
|
||||
emit ChargingSchedulesChanged(params);
|
||||
});
|
||||
|
||||
// [ETM] LoadConfigChanged : émise après toute persistance réussie (SetLoadConfig ou
|
||||
// édition externe du fichier relue). Porte la liste complète à jour.
|
||||
params.clear();
|
||||
description = "Emitted whenever the controllable load configuration changed.";
|
||||
params.insert("loadConfigs", QVariantList() << objectRef<LoadConfig>());
|
||||
registerNotification("LoadConfigChanged", description, params);
|
||||
if (m_loadConfigStore) {
|
||||
connect(m_loadConfigStore, &LoadConfigStore::changed, this, [this](){
|
||||
QVariantMap params;
|
||||
QVariantList list;
|
||||
foreach (const LoadConfig &c, m_loadConfigStore->configs())
|
||||
list << pack<LoadConfig>(c);
|
||||
params.insert("loadConfigs", list);
|
||||
emit LoadConfigChanged(params);
|
||||
});
|
||||
}
|
||||
|
||||
// Spot market manager
|
||||
params.clear();
|
||||
description = "Emitted whenever the spot market manager status changed.";
|
||||
@ -429,6 +494,39 @@ JsonReply *NymeaEnergyJsonHandler::GetChargingSchedules(const QVariantMap ¶m
|
||||
return createReply(returns);
|
||||
}
|
||||
|
||||
JsonReply *NymeaEnergyJsonHandler::GetLoadConfig(const QVariantMap ¶ms)
|
||||
{
|
||||
Q_UNUSED(params)
|
||||
QVariantMap returns;
|
||||
QVariantList list;
|
||||
if (m_loadConfigStore) {
|
||||
foreach (const LoadConfig &c, m_loadConfigStore->configs())
|
||||
list << pack<LoadConfig>(c); // inclut les charges enabled==false (éditables par l'app)
|
||||
}
|
||||
returns.insert("loadConfigs", list);
|
||||
return createReply(returns);
|
||||
}
|
||||
|
||||
JsonReply *NymeaEnergyJsonHandler::SetLoadConfig(const QVariantMap ¶ms)
|
||||
{
|
||||
if (!m_loadConfigStore) {
|
||||
qCWarning(dcNymeaEnergy()) << "[NymeaEnergy] SetLoadConfig sans store configuré.";
|
||||
return createReply({{"energyError", enumValueName(EnergyManager::EnergyErrorInvalidParameter)}});
|
||||
}
|
||||
|
||||
LoadConfigs configs;
|
||||
foreach (const QVariant &item, params.value("loadConfigs").toList())
|
||||
configs.append(LoadConfig::fromMap(item.toMap()));
|
||||
|
||||
QString why;
|
||||
if (!m_loadConfigStore->setConfigs(configs, &why)) {
|
||||
qCWarning(dcNymeaEnergy()) << "[NymeaEnergy] SetLoadConfig rejetée:" << why;
|
||||
return createReply({{"energyError", enumValueName(EnergyManager::EnergyErrorInvalidParameter)}});
|
||||
}
|
||||
// store.setConfigs() a déjà persisté + émis changed() → l'arbitre reconstruit, la notif part.
|
||||
return createReply({{"energyError", enumValueName(EnergyManager::EnergyErrorNoError)}});
|
||||
}
|
||||
|
||||
void NymeaEnergyJsonHandler::sendSpotMarketConfigurationChangedNotification()
|
||||
{
|
||||
QVariantMap params;
|
||||
|
||||
@ -33,12 +33,13 @@
|
||||
|
||||
class SmartChargingManager;
|
||||
class SpotMarketManager;
|
||||
class LoadConfigStore;
|
||||
|
||||
class NymeaEnergyJsonHandler : public JsonHandler
|
||||
{
|
||||
Q_OBJECT
|
||||
public:
|
||||
explicit NymeaEnergyJsonHandler(SpotMarketManager *spotMarketManager, SmartChargingManager *smartChargingManager, QObject *parent = nullptr);
|
||||
explicit NymeaEnergyJsonHandler(SpotMarketManager *spotMarketManager, SmartChargingManager *smartChargingManager, LoadConfigStore *loadConfigStore = nullptr, QObject *parent = nullptr);
|
||||
|
||||
QString name() const override;
|
||||
|
||||
@ -64,6 +65,9 @@ public:
|
||||
|
||||
Q_INVOKABLE JsonReply *GetChargingSchedules(const QVariantMap ¶ms);
|
||||
|
||||
Q_INVOKABLE JsonReply *GetLoadConfig(const QVariantMap ¶ms);
|
||||
Q_INVOKABLE JsonReply *SetLoadConfig(const QVariantMap ¶ms);
|
||||
|
||||
signals:
|
||||
void PhasePowerLimitChanged(const QVariantMap ¶ms);
|
||||
void AcquisitionToleranceChanged(const QVariantMap ¶ms);
|
||||
@ -75,10 +79,12 @@ signals:
|
||||
void SpotMarketScoreEntriesChanged(const QVariantMap ¶ms);
|
||||
void ChargingSchedulesChanged(const QVariantMap ¶ms);
|
||||
void BatteryLevelConsiderationChanged(const QVariantMap ¶ms);
|
||||
void LoadConfigChanged(const QVariantMap ¶ms);
|
||||
|
||||
private:
|
||||
SpotMarketManager *m_spotMarketManager;
|
||||
SmartChargingManager *m_smartChargingManager = nullptr;
|
||||
LoadConfigStore *m_loadConfigStore = nullptr;
|
||||
|
||||
void sendSpotMarketConfigurationChangedNotification();
|
||||
|
||||
|
||||
@ -72,6 +72,10 @@ public:
|
||||
|
||||
ChargingSchedules chargingSchedules() const;
|
||||
|
||||
// [ETM] Mode dégradé L2 (watchdog fraîcheur compteur). Base = false ;
|
||||
// overridé dans EnergyArbitrator. Exposé pour la notification JSON-RPC.
|
||||
virtual bool degradedMode() const { return false; }
|
||||
|
||||
SpotMarketManager *spotMarketManager() const;
|
||||
|
||||
#ifdef ENERGY_SIMULATION
|
||||
@ -93,21 +97,30 @@ signals:
|
||||
void chargingUpdated();
|
||||
#endif
|
||||
|
||||
// [ETM] BEGIN — SmartChargingManager protected API for EnergyArbitrator (etm/).
|
||||
// All changes below are visibility-only (private → protected / virtual added).
|
||||
// Zero logic change. Revert by deleting this block and restoring private slots.
|
||||
protected slots:
|
||||
virtual void update(const QDateTime ¤tDateTime); // [ETM] virtual added
|
||||
void prepareInformation(const QDateTime ¤tDateTime); // [ETM] private → protected
|
||||
void planSpotMarketCharging(const QDateTime ¤tDateTime); // [ETM] private → protected
|
||||
void planSurplusCharging(const QDateTime ¤tDateTime); // [ETM] private → protected
|
||||
void adjustEvChargers(const QDateTime ¤tDateTime); // [ETM] private → protected
|
||||
void updateManualSoCsWithoutMeter(const QDateTime ¤tDateTime); // [ETM] private → protected
|
||||
void verifyOverloadProtection(const QDateTime ¤tDateTime); // [ETM] private → protected
|
||||
void verifyOverloadProtectionRecovery(const QDateTime ¤tDateTime); // [ETM] private → protected
|
||||
|
||||
protected:
|
||||
void executeChargingAction(EvCharger *evCharger, const ChargingAction &chargingAction, const QDateTime ¤tDateTime); // [ETM] private → protected
|
||||
|
||||
// [ETM] Read-only state accessors — inline, no copies, no logic.
|
||||
const QHash<ThingId, EvCharger *> &internalEvChargers() const { return m_evChargers; } // [ETM] new
|
||||
const QHash<EvCharger *, ChargingActions> &internalChargingActions() const { return m_chargingActions; } // [ETM] new
|
||||
RootMeter *internalRootMeter() const { return m_rootMeter; } // [ETM] new
|
||||
// [ETM] END
|
||||
|
||||
private slots:
|
||||
void update(const QDateTime ¤tDateTime);
|
||||
|
||||
// Don't call these methods out of place. it's only meant to keep the otherwise long update() code tidy.
|
||||
// Call update() if you want to trigger the smarties.
|
||||
void prepareInformation(const QDateTime ¤tDateTime);
|
||||
void planSpotMarketCharging(const QDateTime ¤tDateTime);
|
||||
void planSurplusCharging(const QDateTime ¤tDateTime);
|
||||
void adjustEvChargers(const QDateTime ¤tDateTime);
|
||||
void updateManualSoCsWithMeter(EnergyLogs::SampleRate sampleRate, const ThingPowerLogEntry &entry);
|
||||
void updateManualSoCsWithoutMeter(const QDateTime ¤tDateTime);
|
||||
|
||||
void verifyOverloadProtection(const QDateTime ¤tDateTime);
|
||||
void verifyOverloadProtectionRecovery(const QDateTime ¤tDateTime);
|
||||
|
||||
void onThingAdded(Thing *thing);
|
||||
void onThingRemoved(const ThingId &thingId);
|
||||
void onActionExecuted(const Action &action, Thing::ThingError status);
|
||||
@ -129,7 +142,7 @@ private:
|
||||
QString chargerPhaseKey(EvCharger *evCharger) const;
|
||||
|
||||
EnergyManager *m_energyManager = nullptr;
|
||||
ThingManager *m_thingManager = nullptr;
|
||||
ThingManager *m_thingManager = nullptr;
|
||||
SpotMarketManager *m_spotMarketManager = nullptr;
|
||||
EnergyManagerConfiguration *m_configuration = nullptr;
|
||||
|
||||
@ -152,8 +165,6 @@ private:
|
||||
RootMeter *m_rootMeter = nullptr;
|
||||
QHash<ThingId, EvCharger *> m_evChargers;
|
||||
|
||||
void executeChargingAction(EvCharger *evCharger, const ChargingAction &chargingAction, const QDateTime ¤tDateTime);
|
||||
|
||||
};
|
||||
|
||||
#endif // SMARTCHARGINGMANAGER_H
|
||||
|
||||
@ -1,2 +1,7 @@
|
||||
TEMPLATE = subdirs
|
||||
SUBDIRS = energyplugin tests
|
||||
SUBDIRS = energyplugin
|
||||
# Tests compilés uniquement sur CONFIG+=build_tests (CI/dev local).
|
||||
# Pas dans le build deb (dpkg-buildpackage ne passe pas build_tests).
|
||||
build_tests {
|
||||
SUBDIRS += tests
|
||||
}
|
||||
|
||||
@ -257,6 +257,25 @@ QNetworkReply *EnergyTestBase::setEnergyStorageStates(uint batteryLevel, int cur
|
||||
return m_networkAccessManager->post(request, QByteArray());
|
||||
}
|
||||
|
||||
QNetworkReply *EnergyTestBase::setPowerSwitchStates(bool power, double currentPower, quint16 port)
|
||||
{
|
||||
QUrl requestUrl;
|
||||
requestUrl.setScheme("http");
|
||||
requestUrl.setHost("127.0.0.1");
|
||||
requestUrl.setPort(port);
|
||||
requestUrl.setPath("/setstates");
|
||||
|
||||
QUrlQuery query;
|
||||
query.addQueryItem("power", power ? "true" : "false");
|
||||
query.addQueryItem("currentPower", QString::number(currentPower));
|
||||
requestUrl.setQuery(query);
|
||||
|
||||
QNetworkRequest request(requestUrl);
|
||||
request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json");
|
||||
|
||||
return m_networkAccessManager->post(request, QByteArray());
|
||||
}
|
||||
|
||||
QNetworkReply *EnergyTestBase::getActionHistory(quint16 port)
|
||||
{
|
||||
QUrl requestUrl;
|
||||
@ -438,6 +457,49 @@ QUuid EnergyTestBase::addEnergyStorage(uint capacity, double maxChargingPowerUpp
|
||||
return response.toMap().value("params").toMap().value("thingId").toUuid();
|
||||
}
|
||||
|
||||
QUuid EnergyTestBase::addPowerSwitch(double nominalPower, quint16 port)
|
||||
{
|
||||
QVariantList thingParams;
|
||||
QVariantMap portParam;
|
||||
portParam.insert("paramTypeId", "{e3398429-45fd-4add-a789-4d11bfd9560f}");
|
||||
portParam.insert("value", port);
|
||||
|
||||
QVariantMap nominalPowerParam;
|
||||
nominalPowerParam.insert("paramTypeId", "{b850a4d1-af0f-477d-ac73-56071f371884}");
|
||||
nominalPowerParam.insert("value", nominalPower);
|
||||
|
||||
thingParams.append(portParam);
|
||||
thingParams.append(nominalPowerParam);
|
||||
|
||||
QVariantMap params;
|
||||
params.insert("thingClassId", mockPowerSwitchThingClassId.toString());
|
||||
// Nom unique par port : plusieurs relais (paliers ECS) peuvent coexister.
|
||||
params.insert("name", QString("Power switch %1").arg(port));
|
||||
params.insert("thingParams", thingParams);
|
||||
|
||||
QVariant response = injectAndWait("Integrations.AddThing", params);
|
||||
verifyThingError(response);
|
||||
return response.toMap().value("params").toMap().value("thingId").toUuid();
|
||||
}
|
||||
|
||||
QUuid EnergyTestBase::addEtmVariableLoad(quint16 port)
|
||||
{
|
||||
QVariantList thingParams;
|
||||
QVariantMap portParam;
|
||||
portParam.insert("paramTypeId", "{c8d52a1b-4f3e-4b02-a07c-3d9f2e6b8c51}"); // etmVariableLoad port
|
||||
portParam.insert("value", port);
|
||||
thingParams.append(portParam);
|
||||
|
||||
QVariantMap params;
|
||||
params.insert("thingClassId", "{b7c41f0a-3e2d-4a91-9f6b-2c8e1d5a7b40}"); // etmVariableLoadThingClassId
|
||||
params.insert("name", QString("etmVariableLoad %1").arg(port));
|
||||
params.insert("thingParams", thingParams);
|
||||
|
||||
QVariant response = injectAndWait("Integrations.AddThing", params);
|
||||
verifyThingError(response);
|
||||
return response.toMap().value("params").toMap().value("thingId").toUuid();
|
||||
}
|
||||
|
||||
void EnergyTestBase::removeDevices()
|
||||
{
|
||||
QVariant configuredDevices = injectAndWait("Integrations.GetThings");
|
||||
|
||||
@ -48,6 +48,7 @@ static QUuid mockChargerWithPhaseSwitchingThingClassId = QUuid("9208d9f0-280c-46
|
||||
static QUuid mockSimpleChargerThingClassId = QUuid("29bcf255-b654-4764-be92-399bc26fe7c3");
|
||||
static QUuid mockCarThingClassId = QUuid("4513f801-836e-40a7-8784-c02650a9bdc6");
|
||||
static QUuid mockEnergyStorageThingClassId = QUuid("d0d5bbf0-249c-46ed-ac6a-5f271b2b0b0f");
|
||||
static QUuid mockPowerSwitchThingClassId = QUuid("841f8905-d1d7-4053-909f-01123b497747");
|
||||
|
||||
using namespace nymeaserver;
|
||||
|
||||
@ -81,6 +82,7 @@ public:
|
||||
QNetworkReply *setChargerWithPhaseCountSwitchingStates(bool connected, bool power, bool pluggedIn, const QString &phases, int maxChargingCurrent, int maxChargingCurrentMaxValue, uint desiredPhaseCount, quint16 port = 26658);
|
||||
QNetworkReply *setSimpleChargerStates(bool connected, bool power, bool pluggedIn, int phaseCount, int maxChargingCurrent, quint16 port = 26659);
|
||||
QNetworkReply *setEnergyStorageStates(uint batteryLevel, int currentPower, quint16 port = 26660);
|
||||
QNetworkReply *setPowerSwitchStates(bool power, double currentPower, quint16 port = 26661);
|
||||
|
||||
QNetworkReply *getActionHistory(quint16 port);
|
||||
QNetworkReply *clearActionHistroy(quint16 port);
|
||||
@ -91,6 +93,9 @@ public:
|
||||
QUuid addChargerWithPhaseCountSwitching(const QString &phases = "All", double maxChargingCurrentUpperLimit = 32, quint16 port = 26658);
|
||||
QUuid addSimpleCharger(double maxChargingCurrentUpperLimit = 32, quint16 port = 26659);
|
||||
QUuid addEnergyStorage(uint capacity = 10, double maxChargingPowerUpperLimit = 5000, double maxDischargingPowerUpperLimit = 11500, quint16 port = 26660);
|
||||
QUuid addPowerSwitch(double nominalPower = 2000, quint16 port = 26661);
|
||||
//! Mock thing implémentant l'interface etmvariableload (states currentPowerW / powerSetpoint).
|
||||
QUuid addEtmVariableLoad(quint16 port = 27001);
|
||||
|
||||
void removeDevices();
|
||||
QVariant removeDevice(const QUuid &thingId);
|
||||
@ -104,6 +109,7 @@ protected:
|
||||
quint16 m_mockChargerWithPhaseCountSwitchingDefaultPort = 26658;
|
||||
quint16 m_mockSimpleChargerDefaultPort = 26659;
|
||||
quint16 m_mockEnergyStorageDefaultPort = 26660;
|
||||
quint16 m_mockPowerSwitchDefaultPort = 26661;
|
||||
|
||||
bool verifyActionExecuted(const QVariantList &actionHistory, const QString &actionName);
|
||||
QVariant getLastValueFromExecutedAction(const QVariantList &actionHistory, const QString &actionName, const QString ¶mName);
|
||||
|
||||
@ -32,6 +32,10 @@
|
||||
#include "../../../energyplugin/nymeaenergyjsonhandler.h"
|
||||
#include "../../../energyplugin/energymanagerconfiguration.h"
|
||||
#include "../../../energyplugin/spotmarket/spotmarketmanager.h"
|
||||
#ifdef ETM_ARBITRATOR
|
||||
#include "../../../energyplugin/etm/energyarbitrator.h"
|
||||
#include "../../../energyplugin/etm/config/loadconfigstore.h"
|
||||
#endif
|
||||
|
||||
#include <jsonrpc/jsonrpcserver.h>
|
||||
#include <loggingcategories.h>
|
||||
@ -81,9 +85,22 @@ void ExperiencePluginEnergyMock::init()
|
||||
EnergyManagerConfiguration *configuration = new EnergyManagerConfiguration(this);
|
||||
QNetworkAccessManager *networkManager = new QNetworkAccessManager(this);
|
||||
m_spotMarketManager = new SpotMarketManager(networkManager, this);
|
||||
LoadConfigStore *loadConfigStore = nullptr;
|
||||
// [ETM] BEGIN — flip identique à energypluginnymea.cpp
|
||||
#ifdef ETM_ARBITRATOR
|
||||
qCDebug(dcEnergyExperience()) << "ETM_ARBITRATOR actif — EnergyArbitrator chargé (simulation).";
|
||||
EnergyArbitrator *arbitrator = new EnergyArbitrator(m_energyManager, thingManager(), m_spotMarketManager, configuration, this);
|
||||
// Store config charge pilotée câblé comme en prod (energypluginnymea.cpp) : permet le test
|
||||
// d'injection RPC NymeaEnergy.Get/SetLoadConfig end-to-end (handler → store → arbitre).
|
||||
loadConfigStore = new LoadConfigStore(this);
|
||||
arbitrator->setLoadConfigStore(loadConfigStore);
|
||||
m_smartChargingManager = arbitrator;
|
||||
#else
|
||||
m_smartChargingManager = new SmartChargingManager(m_energyManager, thingManager(), m_spotMarketManager, configuration, this);
|
||||
#endif
|
||||
// [ETM] END
|
||||
|
||||
m_nymeaEnergyJsonHandler = new NymeaEnergyJsonHandler(m_spotMarketManager, m_smartChargingManager, this);
|
||||
m_nymeaEnergyJsonHandler = new NymeaEnergyJsonHandler(m_spotMarketManager, m_smartChargingManager, loadConfigStore, this);
|
||||
jsonRpcServer()->registerExperienceHandler(m_nymeaEnergyJsonHandler, 0, 2);
|
||||
|
||||
}
|
||||
|
||||
@ -33,6 +33,20 @@ using namespace nymeaserver;
|
||||
|
||||
#include "../../../energyplugin/smartchargingmanager.h"
|
||||
#include "../../mocks/spotmarketprovider/spotmarketdataprovidermock.h"
|
||||
#ifdef ETM_ARBITRATOR
|
||||
#include "../../../energyplugin/etm/energyarbitrator.h"
|
||||
#include "../../../energyplugin/etm/adapters/sgreadyadapter.h"
|
||||
// [T3] ecsrelayadapter.h retiré : l'ECS est piloté en watts via EtmVariableLoadAdapter
|
||||
// (interface etmvariableload, kind Setpoint). La combinatoire relais vit dans le thing.
|
||||
#include "../../../energyplugin/etm/adapters/etmvariableloadadapter.h"
|
||||
#include "../../../energyplugin/etm/adapters/relayrouter.h"
|
||||
#include "../../../energyplugin/etm/types/loadconfig.h"
|
||||
#include "../../../energyplugin/etm/config/loadconfigstore.h"
|
||||
#endif
|
||||
|
||||
#include <QDir>
|
||||
#include <QFile>
|
||||
#include <QFileInfo>
|
||||
|
||||
#include <QHash>
|
||||
#include <QtMath>
|
||||
@ -41,11 +55,765 @@ using namespace nymeaserver;
|
||||
#include <QDateTime>
|
||||
#include <QSignalSpy>
|
||||
#include <QProcessEnvironment>
|
||||
#include <QCoreApplication>
|
||||
|
||||
#include <nymeacore.h>
|
||||
|
||||
#include "simulationtestpoint.h"
|
||||
|
||||
void Simulation::testEcsSurplusPV()
|
||||
{
|
||||
#ifndef ETM_ARBITRATOR
|
||||
QSKIP("testEcsSurplusPV nécessite ETM_ARBITRATOR.");
|
||||
#else
|
||||
// [T3] ECS piloté en WATTS via l'interface etmvariableload (contrat rév. 2 §3/§5) : l'arbitre
|
||||
// arrondit le surplus au powerLevels déclaré (fixed) ou clampe à maxPowerW (dynamic), écrit
|
||||
// powerSetpoint, et recrédite currentPowerW (début de cycle) pour l'anti-clignotement. La
|
||||
// combinatoire matérielle (relais/triac) vit dans le thing — invisible côté moteur.
|
||||
const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0));
|
||||
|
||||
// =================== FIXED : paliers déclarés [0, 1200, 2400] ===================
|
||||
cleanupTestCase();
|
||||
m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite";
|
||||
initTestCase();
|
||||
|
||||
EnergyArbitrator *arbitrator = dynamic_cast<EnergyArbitrator *>(m_experiencePlugin->smartChargingManager());
|
||||
QVERIFY2(arbitrator, "smartChargingManager n'est pas un EnergyArbitrator (ETM_ARBITRATOR requis)");
|
||||
ThingManager *thingManager = NymeaCore::instance()->thingManager();
|
||||
|
||||
QUuid meterThingId = addMeter();
|
||||
QVERIFY(!meterThingId.isNull());
|
||||
m_experiencePlugin->energyManager()->setRootMeter(meterThingId);
|
||||
Thing *meterThing = thingManager->findConfiguredThing(meterThingId);
|
||||
QVERIFY(meterThing);
|
||||
meterThing->setStateValue("connected", true);
|
||||
|
||||
QUuid ecsId = addEtmVariableLoad(27001);
|
||||
QVERIFY(!ecsId.isNull());
|
||||
Thing *ecsThing = thingManager->findConfiguredThing(ecsId);
|
||||
QVERIFY(ecsThing);
|
||||
|
||||
EtmVariableLoadAdapter *ecs = new EtmVariableLoadAdapter(
|
||||
thingManager, ecsId.toString(), "ECS variable",
|
||||
QList<int>({0, 1200, 2400}), 2400, 1, LoadNeeds(), arbitrator);
|
||||
arbitrator->registerEtmVariableLoadAdapter(ecs);
|
||||
|
||||
auto setMeterW = [&](double signedW){ meterThing->setStateValue("currentPower", signedW); }; // <0 = export
|
||||
auto setLoadW = [&](double w){ ecsThing->setStateValue("currentPowerW", w); }; // mesure réelle simulée
|
||||
auto cycle = [&](const QDateTime &now){ arbitrator->simulationCallUpdate(now); QCoreApplication::processEvents(); };
|
||||
|
||||
// --- Cascade montante (la charge ne tire encore rien : currentPowerW = 0) ---
|
||||
setLoadW(0);
|
||||
setMeterW(-1000); cycle(t0); // budget 1000 < 1200 → 0 W
|
||||
QCOMPARE(qRound(ecs->currentSetpointW()), 0);
|
||||
setMeterW(-1500); cycle(t0); // budget 1500 → palier 1200
|
||||
QCOMPARE(qRound(ecs->currentSetpointW()), 1200);
|
||||
QCOMPARE(qRound(ecsThing->stateValue("powerSetpoint").toDouble()), 1200); // round-trip interface
|
||||
setMeterW(-2500); cycle(t0); // budget 2500 → palier 2400
|
||||
QCOMPARE(qRound(ecs->currentSetpointW()), 2400);
|
||||
QCOMPARE(qRound(ecsThing->stateValue("powerSetpoint").toDouble()), 2400);
|
||||
|
||||
// --- Anti-clignotement (recrédit) : la charge tire 2400, PV 2500 → export net 100.
|
||||
// budget = 100 + 2400 (recrédit currentPowerW début de cycle) = 2500 → RESTE 2400.
|
||||
// Sans recrédit : 100 → 0 → oscillation. C'est précisément le test du recrédit. ---
|
||||
setLoadW(2400);
|
||||
setMeterW(-100); cycle(t0);
|
||||
QCOMPARE(qRound(ecs->currentSetpointW()), 2400);
|
||||
|
||||
// --- Délestage sur import : charge tire 2400, import 600 → budget = -600 + 2400 = 1800 → 1200. ---
|
||||
setMeterW(600); cycle(t0);
|
||||
QCOMPARE(qRound(ecs->currentSetpointW()), 1200);
|
||||
QCOMPARE(qRound(ecsThing->stateValue("powerSetpoint").toDouble()), 1200);
|
||||
|
||||
// =================== DYNAMIC : modulation continue, maxPowerW 3000, sans powerLevels ===================
|
||||
cleanupTestCase();
|
||||
m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite";
|
||||
initTestCase();
|
||||
EnergyArbitrator *arb2 = dynamic_cast<EnergyArbitrator *>(m_experiencePlugin->smartChargingManager());
|
||||
QVERIFY(arb2);
|
||||
ThingManager *tm2 = NymeaCore::instance()->thingManager();
|
||||
QUuid mId = addMeter();
|
||||
m_experiencePlugin->energyManager()->setRootMeter(mId);
|
||||
Thing *m2 = tm2->findConfiguredThing(mId);
|
||||
QVERIFY(m2);
|
||||
m2->setStateValue("connected", true);
|
||||
QUuid rId = addEtmVariableLoad(27002);
|
||||
Thing *rThing = tm2->findConfiguredThing(rId);
|
||||
QVERIFY(rThing);
|
||||
rThing->setStateValue("currentPowerW", 0);
|
||||
|
||||
EtmVariableLoadAdapter *router = new EtmVariableLoadAdapter(
|
||||
tm2, rId.toString(), "Routeur PV", QList<int>(), 3000, 1, LoadNeeds(), arb2); // powerLevels vide → dynamic
|
||||
arb2->registerEtmVariableLoadAdapter(router);
|
||||
|
||||
m2->setStateValue("currentPower", -2000); arb2->simulationCallUpdate(t0); QCoreApplication::processEvents();
|
||||
QCOMPARE(qRound(router->currentSetpointW()), 2000); // clamp(2000, 0, 3000)
|
||||
m2->setStateValue("currentPower", -4000); arb2->simulationCallUpdate(t0); QCoreApplication::processEvents();
|
||||
QCOMPARE(qRound(router->currentSetpointW()), 3000); // plafonné à maxPowerW
|
||||
m2->setStateValue("currentPower", 500); arb2->simulationCallUpdate(t0); QCoreApplication::processEvents();
|
||||
QCOMPARE(qRound(router->currentSetpointW()), 0); // import → 0
|
||||
#endif
|
||||
}
|
||||
|
||||
void Simulation::testMeterSilentFallback()
|
||||
{
|
||||
#ifndef ETM_ARBITRATOR
|
||||
QSKIP("testMeterSilentFallback nécessite ETM_ARBITRATOR.");
|
||||
#else
|
||||
// [T4] Repli L2 charge pilotée recâblé : compteur muet >90 s → setPowerSetpoint(0) force=true
|
||||
// sur l'etmvariableload, planif suspendue (reste 0 sur N cycles), reprise au retour compteur.
|
||||
cleanupTestCase();
|
||||
m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite";
|
||||
initTestCase();
|
||||
|
||||
EnergyArbitrator *arbitrator = dynamic_cast<EnergyArbitrator *>(m_experiencePlugin->smartChargingManager());
|
||||
QVERIFY2(arbitrator, "smartChargingManager n'est pas un EnergyArbitrator (ETM_ARBITRATOR requis)");
|
||||
ThingManager *thingManager = NymeaCore::instance()->thingManager();
|
||||
|
||||
QUuid meterThingId = addMeter();
|
||||
QVERIFY(!meterThingId.isNull());
|
||||
m_experiencePlugin->energyManager()->setRootMeter(meterThingId);
|
||||
Thing *meterThing = thingManager->findConfiguredThing(meterThingId);
|
||||
QVERIFY(meterThing);
|
||||
meterThing->setStateValue("connected", true);
|
||||
|
||||
QUuid ecsId = addEtmVariableLoad(27010);
|
||||
QVERIFY(!ecsId.isNull());
|
||||
Thing *ecsThing = thingManager->findConfiguredThing(ecsId);
|
||||
QVERIFY(ecsThing);
|
||||
|
||||
EtmVariableLoadAdapter *ecs = new EtmVariableLoadAdapter(
|
||||
thingManager, ecsId.toString(), "ECS repli",
|
||||
QList<int>({0, 2400}), 2400, 1, LoadNeeds(), arbitrator);
|
||||
arbitrator->registerEtmVariableLoadAdapter(ecs);
|
||||
|
||||
const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0));
|
||||
auto setMeterW = [&](double signedW){ meterThing->setStateValue("currentPower", signedW); };
|
||||
auto setLoadW = [&](double w){ ecsThing->setStateValue("currentPowerW", w); };
|
||||
auto cycle = [&](const QDateTime &now){ arbitrator->simulationCallUpdate(now); QCoreApplication::processEvents(); };
|
||||
|
||||
// ECS servi sur surplus, compteur frais à T0.
|
||||
arbitrator->recordMeterUpdate(t0);
|
||||
setLoadW(0);
|
||||
setMeterW(-2500); cycle(t0); // budget 2500 → 2400
|
||||
QCOMPARE(qRound(ecs->currentSetpointW()), 2400);
|
||||
QVERIFY(!arbitrator->degradedMode());
|
||||
|
||||
// Compteur muet > 90 s → mode dégradé : setPowerSetpoint(0) force=true.
|
||||
setLoadW(2400); // la charge tirait
|
||||
arbitrator->evaluateMeterFreshness(t0.addSecs(91));
|
||||
QCoreApplication::processEvents();
|
||||
QVERIFY(arbitrator->degradedMode());
|
||||
QCOMPARE(qRound(ecs->currentSetpointW()), 0);
|
||||
QCOMPARE(qRound(ecsThing->stateValue("powerSetpoint").toDouble()), 0);
|
||||
|
||||
// STABILITÉ : muet, plusieurs cycles, faux surplus piège → l'ECS RESTE à 0 (planif suspendue).
|
||||
setMeterW(-3000);
|
||||
foreach (int dt, QList<int>({92, 120, 200, 280})) {
|
||||
cycle(t0.addSecs(dt));
|
||||
QVERIFY2(arbitrator->degradedMode(), "degradedMode doit rester actif pendant le silence");
|
||||
QCOMPARE(qRound(ecs->currentSetpointW()), 0);
|
||||
}
|
||||
|
||||
// REPRISE : compteur reparle → degradedMode retombe → recalcul (pas de restauration d'ancienne consigne).
|
||||
arbitrator->recordMeterUpdate(t0.addSecs(300));
|
||||
QVERIFY(!arbitrator->degradedMode());
|
||||
setLoadW(0);
|
||||
setMeterW(-1000); cycle(t0.addSecs(301)); // 1000 < 2400 → reste 0
|
||||
QCOMPARE(qRound(ecs->currentSetpointW()), 0);
|
||||
setMeterW(-2500); cycle(t0.addSecs(302)); // surplus suffisant → 2400
|
||||
QCOMPARE(qRound(ecs->currentSetpointW()), 2400);
|
||||
|
||||
// ===================== Cas RELAIS (rév. 3) — certification du trou T2 fermé =====================
|
||||
// Compteur muet → le RelayRouter coupe TOUS ses relais, force=true (bypass minOn). Sans ce
|
||||
// volet, le repli L2 du routeur n'est pas certifié : des relais ECS pourraient rester allumés.
|
||||
cleanupTestCase();
|
||||
m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite";
|
||||
initTestCase();
|
||||
EnergyArbitrator *arb2 = dynamic_cast<EnergyArbitrator *>(m_experiencePlugin->smartChargingManager());
|
||||
QVERIFY(arb2);
|
||||
ThingManager *tm2 = NymeaCore::instance()->thingManager();
|
||||
QUuid mId = addMeter();
|
||||
m_experiencePlugin->energyManager()->setRootMeter(mId);
|
||||
Thing *m2 = tm2->findConfiguredThing(mId);
|
||||
QVERIFY(m2);
|
||||
m2->setStateValue("connected", true);
|
||||
|
||||
QUuid rA = addPowerSwitch(1000, 26661);
|
||||
QUuid rB = addPowerSwitch(1500, 26662);
|
||||
Thing *relayA = tm2->findConfiguredThing(rA);
|
||||
Thing *relayB = tm2->findConfiguredThing(rB);
|
||||
QVERIFY(relayA && relayB);
|
||||
|
||||
// RelayRouter : paliers DÉRIVÉS [0, 1000, 1500, 2500] ; minOn=300 s (pour prouver le bypass force).
|
||||
RelayRouter *router = new RelayRouter(
|
||||
tm2, "ecs-relais-repli", "ECS relais (repli)",
|
||||
QList<LoadConfigRelay>({ {rA.toString(), 1000}, {rB.toString(), 1500} }),
|
||||
300, 0, 1, LoadNeeds(), arb2);
|
||||
arb2->registerRelayRouter(router);
|
||||
|
||||
auto cycle2 = [&](const QDateTime &now){ arb2->simulationCallUpdate(now); QCoreApplication::processEvents(); };
|
||||
|
||||
// Surplus 2500 → palier 2500 (A+B) : les DEUX relais ON.
|
||||
arb2->recordMeterUpdate(t0);
|
||||
m2->setStateValue("currentPower", -2500); cycle2(t0);
|
||||
QCOMPARE(qRound(router->currentSetpointW()), 2500);
|
||||
QCOMPARE(relayA->stateValue("power").toBool(), true);
|
||||
QCOMPARE(relayB->stateValue("power").toBool(), true);
|
||||
QVERIFY(!arb2->degradedMode());
|
||||
|
||||
// Compteur muet > 90 s → mode dégradé : TOUS les relais OFF, force=true (bypass minOn 300).
|
||||
arb2->evaluateMeterFreshness(t0.addSecs(91));
|
||||
QCoreApplication::processEvents();
|
||||
QVERIFY(arb2->degradedMode());
|
||||
QCOMPARE(qRound(router->currentSetpointW()), 0);
|
||||
QCOMPARE(relayA->stateValue("power").toBool(), false); // trou T2 fermé : relais coupé
|
||||
QCOMPARE(relayB->stateValue("power").toBool(), false);
|
||||
|
||||
// STABILITÉ : muet, faux surplus piège → les relais RESTENT OFF (planif suspendue).
|
||||
m2->setStateValue("currentPower", -3000);
|
||||
foreach (int dt, QList<int>({92, 200})) {
|
||||
cycle2(t0.addSecs(dt));
|
||||
QVERIFY2(arb2->degradedMode(), "degradedMode doit rester actif pendant le silence");
|
||||
QCOMPARE(relayA->stateValue("power").toBool(), false);
|
||||
QCOMPARE(relayB->stateValue("power").toBool(), false);
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
void Simulation::testLoadConfigPersistence()
|
||||
{
|
||||
#ifndef ETM_ARBITRATOR
|
||||
QSKIP("testLoadConfigPersistence nécessite ETM_ARBITRATOR.");
|
||||
#else
|
||||
// Persistance §4 : SetConfigs → fichier écrit → relecture par un store neuf → round-trip
|
||||
// mot pour mot (id/label/mode/powerLevels/maxPowerW/priority/enabled/needs).
|
||||
const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-persist.json";
|
||||
QFile::remove(cfgPath);
|
||||
qputenv("NYMEA_ENERGY_LOAD_CONFIG", cfgPath.toUtf8());
|
||||
|
||||
const QVariantMap entry{
|
||||
{"id", "{11111111-2222-3333-4444-555555555555}"},
|
||||
{"label", "Chauffe-eau"},
|
||||
{"adapter", "etmvariableload"},
|
||||
{"mode", "fixed"},
|
||||
{"powerLevels", QVariantList() << 0 << 600 << 1200},
|
||||
{"maxPowerW", 1200},
|
||||
{"priority", 2},
|
||||
{"enabled", true},
|
||||
{"needs", QVariantMap{{"dailyDeadline", "06:00"}, {"minEnergyWhPerDay", 4000}}}
|
||||
};
|
||||
LoadConfigs cfgs;
|
||||
cfgs.append(LoadConfig::fromMap(entry));
|
||||
|
||||
{
|
||||
LoadConfigStore store1;
|
||||
QString err;
|
||||
QVERIFY2(store1.setConfigs(cfgs, &err), err.toUtf8());
|
||||
}
|
||||
QVERIFY(QFileInfo::exists(cfgPath));
|
||||
|
||||
LoadConfigStore store2; // relit le fichier
|
||||
QCOMPARE(store2.configs().count(), 1);
|
||||
const LoadConfig r = store2.configs().first();
|
||||
QCOMPARE(r.label(), QString("Chauffe-eau"));
|
||||
QCOMPARE(r.mode(), QString("fixed"));
|
||||
QCOMPARE(r.powerLevelsInt(), QList<int>({0, 600, 1200}));
|
||||
QCOMPARE(r.maxPowerW(), 1200);
|
||||
QCOMPARE(r.priority(), 2);
|
||||
QVERIFY(r.enabled());
|
||||
QCOMPARE(r.needs().dailyDeadline(), QString("06:00"));
|
||||
QCOMPARE(r.needs().minEnergyWhPerDay(), 4000);
|
||||
|
||||
// Validation : une config fixed sans 0 dans powerLevels est rejetée EN BLOC (rien persisté).
|
||||
QVariantMap badEntry = entry;
|
||||
badEntry["powerLevels"] = QVariantList() << 600 << 1200; // pas de 0
|
||||
LoadConfigs bad;
|
||||
bad.append(LoadConfig::fromMap(badEntry));
|
||||
LoadConfigStore store3;
|
||||
QString err2;
|
||||
QVERIFY(!store3.setConfigs(bad, &err2));
|
||||
QVERIFY(!err2.isEmpty());
|
||||
|
||||
qunsetenv("NYMEA_ENERGY_LOAD_CONFIG");
|
||||
QFile::remove(cfgPath);
|
||||
#endif
|
||||
}
|
||||
|
||||
void Simulation::testLoadConfigBuildsAdapters()
|
||||
{
|
||||
#ifndef ETM_ARBITRATOR
|
||||
QSKIP("testLoadConfigBuildsAdapters nécessite ETM_ARBITRATOR.");
|
||||
#else
|
||||
// SetConfigs → store.changed → l'arbitre (re)construit les adaptateurs etmvariableload.
|
||||
// enabled==true servi par le surplus ; enabled==false JAMAIS piloté (exclu, contrat §9).
|
||||
cleanupTestCase();
|
||||
m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite";
|
||||
initTestCase();
|
||||
|
||||
EnergyArbitrator *arbitrator = dynamic_cast<EnergyArbitrator *>(m_experiencePlugin->smartChargingManager());
|
||||
QVERIFY(arbitrator);
|
||||
ThingManager *thingManager = NymeaCore::instance()->thingManager();
|
||||
|
||||
QUuid meterId = addMeter();
|
||||
m_experiencePlugin->energyManager()->setRootMeter(meterId);
|
||||
Thing *meter = thingManager->findConfiguredThing(meterId);
|
||||
QVERIFY(meter);
|
||||
meter->setStateValue("connected", true);
|
||||
|
||||
QUuid id1 = addEtmVariableLoad(27020); // enabled
|
||||
QUuid id2 = addEtmVariableLoad(27021); // disabled
|
||||
Thing *t1 = thingManager->findConfiguredThing(id1);
|
||||
Thing *t2 = thingManager->findConfiguredThing(id2);
|
||||
QVERIFY(t1 && t2);
|
||||
t1->setStateValue("currentPowerW", 0);
|
||||
t2->setStateValue("currentPowerW", 0);
|
||||
|
||||
const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-build.json";
|
||||
QFile::remove(cfgPath);
|
||||
qputenv("NYMEA_ENERGY_LOAD_CONFIG", cfgPath.toUtf8());
|
||||
|
||||
LoadConfigStore *store = new LoadConfigStore(arbitrator);
|
||||
arbitrator->setLoadConfigStore(store);
|
||||
|
||||
LoadConfigs cfgs;
|
||||
cfgs.append(LoadConfig::fromMap(QVariantMap{
|
||||
{"id", id1.toString()}, {"label", "ECS actif"}, {"adapter", "etmvariableload"},
|
||||
{"mode", "fixed"}, {"powerLevels", QVariantList() << 0 << 2400}, {"maxPowerW", 2400},
|
||||
{"priority", 1}, {"enabled", true}}));
|
||||
cfgs.append(LoadConfig::fromMap(QVariantMap{
|
||||
{"id", id2.toString()}, {"label", "ECS désactivé"}, {"adapter", "etmvariableload"},
|
||||
{"mode", "fixed"}, {"powerLevels", QVariantList() << 0 << 1200}, {"maxPowerW", 1200},
|
||||
{"priority", 2}, {"enabled", false}}));
|
||||
QString err;
|
||||
QVERIFY2(store->setConfigs(cfgs, &err), err.toUtf8()); // persiste + changed → rebuild
|
||||
|
||||
// Surplus large (5000 W) : assez pour servir les DEUX si elles étaient actives.
|
||||
meter->setStateValue("currentPower", -5000);
|
||||
arbitrator->simulationCallUpdate(utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0)));
|
||||
QCoreApplication::processEvents();
|
||||
|
||||
QCOMPARE(qRound(t1->stateValue("powerSetpoint").toDouble()), 2400); // enabled → servi
|
||||
QCOMPARE(qRound(t2->stateValue("powerSetpoint").toDouble()), 0); // disabled → jamais piloté
|
||||
|
||||
qunsetenv("NYMEA_ENERGY_LOAD_CONFIG");
|
||||
QFile::remove(cfgPath);
|
||||
#endif
|
||||
}
|
||||
|
||||
void Simulation::testLoadConfigRpc()
|
||||
{
|
||||
#ifndef ETM_ARBITRATOR
|
||||
QSKIP("testLoadConfigRpc nécessite ETM_ARBITRATOR.");
|
||||
#else
|
||||
// End-to-end JSON-RPC (la couche que l'app consomme) : GetLoadConfig vide → SetLoadConfig
|
||||
// (fixed réelle + dynamic) → energyError NoError → GetLoadConfig round-trip → effet RÉEL sur
|
||||
// le thing (RPC → store → changed → arbitre reconstruit → adaptateur → powerSetpoint) →
|
||||
// rejet d'une config invalide.
|
||||
const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-rpc.json";
|
||||
QFile::remove(cfgPath);
|
||||
qputenv("NYMEA_ENERGY_LOAD_CONFIG", cfgPath.toUtf8()); // avant initTestCase → store sur ce chemin
|
||||
|
||||
cleanupTestCase();
|
||||
m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite";
|
||||
initTestCase();
|
||||
|
||||
EnergyArbitrator *arbitrator = dynamic_cast<EnergyArbitrator *>(m_experiencePlugin->smartChargingManager());
|
||||
QVERIFY(arbitrator);
|
||||
ThingManager *thingManager = NymeaCore::instance()->thingManager();
|
||||
|
||||
QUuid meterId = addMeter();
|
||||
m_experiencePlugin->energyManager()->setRootMeter(meterId);
|
||||
Thing *meter = thingManager->findConfiguredThing(meterId);
|
||||
QVERIFY(meter);
|
||||
meter->setStateValue("connected", true);
|
||||
|
||||
QUuid ecsId = addEtmVariableLoad(27030); // thing réel piloté par la config fixed
|
||||
Thing *ecsThing = thingManager->findConfiguredThing(ecsId);
|
||||
QVERIFY(ecsThing);
|
||||
ecsThing->setStateValue("currentPowerW", 0);
|
||||
|
||||
// 1. GetLoadConfig initial → vide.
|
||||
QVariant resp = injectAndWait("NymeaEnergy.GetLoadConfig");
|
||||
QVERIFY(resp.toMap().value("params").toMap().value("loadConfigs").toList().isEmpty());
|
||||
|
||||
// 2. SetLoadConfig : les DEUX formes rév. 3 (mutuellement exclusives) doivent être acceptées
|
||||
// par le MÊME schéma SET — sinon nymea rejette une forme avant le handler (bug objectRef
|
||||
// strict de T4, doublé). Une etmvariableload fixed (thing réel) + une dynamic + une relay-router.
|
||||
QVariantList loadConfigs;
|
||||
loadConfigs << QVariantMap{
|
||||
{"id", ecsId.toString()}, {"label", "Chauffe-eau"}, {"adapter", "etmvariableload"},
|
||||
{"mode", "fixed"}, {"powerLevels", QVariantList() << 0 << 1200 << 2400}, {"maxPowerW", 2400},
|
||||
{"priority", 2}, {"enabled", true},
|
||||
{"needs", QVariantMap{{"dailyDeadline", "06:00"}, {"minEnergyWhPerDay", 4000}}}};
|
||||
loadConfigs << QVariantMap{
|
||||
{"id", "{b033b212-1adb-4df0-ba2b-8fa477de52a2}"}, {"label", "Routeur PV"},
|
||||
{"adapter", "etmvariableload"}, {"mode", "dynamic"}, {"maxPowerW", 3000},
|
||||
{"priority", 1}, {"enabled", true}};
|
||||
loadConfigs << QVariantMap{ // ← rév. 3 : relay-router (relays[])
|
||||
{"id", "ecs-relais"}, {"label", "ECS relais"}, {"adapter", "relay-router"},
|
||||
{"mode", "fixed"}, {"priority", 3}, {"enabled", true},
|
||||
{"relays", QVariantList()
|
||||
<< QVariantMap{{"thingId", "{aaaaaaaa-1111-2222-3333-444444444444}"}, {"powerW", 1000}}
|
||||
<< QVariantMap{{"thingId", "{bbbbbbbb-1111-2222-3333-444444444444}"}, {"powerW", 1500}}},
|
||||
{"minOnS", 60}, {"minOffS", 60}};
|
||||
resp = injectAndWait("NymeaEnergy.SetLoadConfig", {{"loadConfigs", loadConfigs}});
|
||||
QCOMPARE(resp.toMap().value("params").toMap().value("energyError").toString(), QString("EnergyErrorNoError"));
|
||||
|
||||
// 3. GetLoadConfig → round-trip des 3 entrées (les 2 formes), champs préservés (pack/unpack).
|
||||
resp = injectAndWait("NymeaEnergy.GetLoadConfig");
|
||||
const QVariantList got = resp.toMap().value("params").toMap().value("loadConfigs").toList();
|
||||
QCOMPARE(got.size(), 3);
|
||||
QVariantMap fixedGot, relayGot;
|
||||
for (const QVariant &v : got) {
|
||||
const QVariantMap m = v.toMap();
|
||||
if (m.value("id").toString() == ecsId.toString()) fixedGot = m;
|
||||
if (m.value("adapter").toString() == "relay-router") relayGot = m;
|
||||
}
|
||||
QVERIFY(!fixedGot.isEmpty());
|
||||
QCOMPARE(fixedGot.value("mode").toString(), QString("fixed"));
|
||||
QCOMPARE(fixedGot.value("label").toString(), QString("Chauffe-eau"));
|
||||
QVariantList levels = fixedGot.value("powerLevels").toList();
|
||||
QCOMPARE(levels.size(), 3);
|
||||
QCOMPARE(levels.last().toInt(), 2400);
|
||||
QCOMPARE(fixedGot.value("needs").toMap().value("dailyDeadline").toString(), QString("06:00"));
|
||||
// Forme relay-router : relays[] + minOnS round-trip (jamais piloté ici — RelayRouter en étape 4).
|
||||
QVERIFY(!relayGot.isEmpty());
|
||||
QVariantList relays = relayGot.value("relays").toList();
|
||||
QCOMPARE(relays.size(), 2);
|
||||
QCOMPARE(relays.last().toMap().value("powerW").toInt(), 1500);
|
||||
QCOMPARE(relayGot.value("minOnS").toInt(), 60);
|
||||
|
||||
// 4. Fichier persisté.
|
||||
QVERIFY(QFileInfo::exists(cfgPath));
|
||||
|
||||
// 5. EFFET RÉEL : le Set RPC a reconstruit les adaptateurs → un cycle surplus pilote le thing.
|
||||
// Cascade du waterfall unifié construit DEPUIS la config RPC : surplus 6000 → routeur dynamic
|
||||
// (rang 1) prend 3000 (clamp maxPowerW), reliquat 3000 → ECS fixed (rang 2) prend le palier 2400.
|
||||
meter->setStateValue("currentPower", -6000);
|
||||
arbitrator->simulationCallUpdate(utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0)));
|
||||
QCoreApplication::processEvents();
|
||||
QCOMPARE(qRound(ecsThing->stateValue("powerSetpoint").toDouble()), 2400); // reliquat 3000 → palier 2400
|
||||
|
||||
// 6. Validation CONDITIONNELLE — rejet en bloc (energyError InvalidParameter) :
|
||||
// (a) etmvariableload fixed sans 0 dans powerLevels ; (b) relay-router avec relays[] vide.
|
||||
QVariantList badA;
|
||||
badA << QVariantMap{
|
||||
{"id", "{cccccccc-2222-3333-4444-555555555555}"}, {"label", "Bancal"}, {"adapter", "etmvariableload"},
|
||||
{"mode", "fixed"}, {"powerLevels", QVariantList() << 1200 << 2400}, {"maxPowerW", 2400},
|
||||
{"priority", 1}, {"enabled", true}};
|
||||
resp = injectAndWait("NymeaEnergy.SetLoadConfig", {{"loadConfigs", badA}});
|
||||
QCOMPARE(resp.toMap().value("params").toMap().value("energyError").toString(), QString("EnergyErrorInvalidParameter"));
|
||||
|
||||
QVariantList badB;
|
||||
badB << QVariantMap{
|
||||
{"id", "ecs-vide"}, {"label", "ECS sans relais"}, {"adapter", "relay-router"},
|
||||
{"mode", "fixed"}, {"priority", 1}, {"enabled", true},
|
||||
{"relays", QVariantList()}}; // relays[] vide → invalide
|
||||
resp = injectAndWait("NymeaEnergy.SetLoadConfig", {{"loadConfigs", badB}});
|
||||
QCOMPARE(resp.toMap().value("params").toMap().value("energyError").toString(), QString("EnergyErrorInvalidParameter"));
|
||||
|
||||
// Aucun rejet n'a écrasé : GetLoadConfig retourne toujours les 3 valides.
|
||||
resp = injectAndWait("NymeaEnergy.GetLoadConfig");
|
||||
QCOMPARE(resp.toMap().value("params").toMap().value("loadConfigs").toList().size(), 3);
|
||||
|
||||
qunsetenv("NYMEA_ENERGY_LOAD_CONFIG");
|
||||
QFile::remove(cfgPath);
|
||||
#endif
|
||||
}
|
||||
|
||||
void Simulation::testSgReadySurplus()
|
||||
{
|
||||
#ifndef ETM_ARBITRATOR
|
||||
QSKIP("testSgReadySurplus nécessite ETM_ARBITRATOR.");
|
||||
#else
|
||||
// Encodage SG-Ready 2 bits (K1,K2) : 1=[K1] blocage · 2=[] normal · 3=[K2] reco · 4=[K1,K2] forcé.
|
||||
// estimatedPowerW déclaré : P3=1500, P4=3000. Hystérésis état 4 : entrée P4×1,2=3600, sortie P4×1,0=3000.
|
||||
const QHash<int, double> pacPower({ {1, 0.0}, {2, 0.0}, {3, 1500.0}, {4, 3000.0} });
|
||||
const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0));
|
||||
|
||||
// ===================== Volets 1-3 : PAC seule =====================
|
||||
cleanupTestCase();
|
||||
m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite";
|
||||
initTestCase();
|
||||
|
||||
EnergyArbitrator *arbitrator = dynamic_cast<EnergyArbitrator *>(m_experiencePlugin->smartChargingManager());
|
||||
QVERIFY2(arbitrator, "smartChargingManager n'est pas un EnergyArbitrator");
|
||||
ThingManager *tm = NymeaCore::instance()->thingManager();
|
||||
|
||||
QUuid meterId = addMeter();
|
||||
m_experiencePlugin->energyManager()->setRootMeter(meterId);
|
||||
Thing *meter = tm->findConfiguredThing(meterId);
|
||||
QVERIFY(meter);
|
||||
meter->setStateValue("connected", true);
|
||||
|
||||
QUuid k1 = addPowerSwitch(0, 26661);
|
||||
QUuid k2 = addPowerSwitch(0, 26662);
|
||||
Thing *relayK1 = tm->findConfiguredThing(k1);
|
||||
Thing *relayK2 = tm->findConfiguredThing(k2);
|
||||
QVERIFY(relayK1 && relayK2);
|
||||
|
||||
SgReadyAdapter *pac = new SgReadyAdapter(
|
||||
tm, "pac-test", "PAC test",
|
||||
QHash<int, QList<QString>>({ {1, {k1.toString()}}, {2, {}},
|
||||
{3, {k2.toString()}}, {4, {k1.toString(), k2.toString()}} }),
|
||||
pacPower, 300, 1, arbitrator);
|
||||
arbitrator->registerSgReadyAdapter(pac);
|
||||
|
||||
auto setMeterW = [&](double signedW){ meter->setStateValue("currentPower", signedW); }; // <0 export
|
||||
auto cycle = [&](const QDateTime &now){ arbitrator->simulationCallUpdate(now); QCoreApplication::processEvents(); };
|
||||
|
||||
// --- Volet 1 : montée d'états 2 → 3 → 4 (mapping sémantique) ---
|
||||
setMeterW(-1000); cycle(t0); // budget 1000 < P3 → état 2 (normal)
|
||||
QCOMPARE(pac->currentState(), 2);
|
||||
setMeterW(-2000); cycle(t0); // budget 2000 ≥ P3 → état 3 (reco)
|
||||
QCOMPARE(pac->currentState(), 3);
|
||||
QCOMPARE(relayK2->stateValue("power").toBool(), true);
|
||||
QCOMPARE(relayK1->stateValue("power").toBool(), false);
|
||||
setMeterW(-2500); cycle(t0.addSecs(400)); // budget 2500+1500=4000 ≥ P4×1,2 → état 4 (hold écoulé)
|
||||
QCOMPARE(pac->currentState(), 4);
|
||||
QCOMPARE(relayK1->stateValue("power").toBool(), true);
|
||||
QCOMPARE(relayK2->stateValue("power").toBool(), true);
|
||||
|
||||
// --- Volet 2 : hystérésis 3↔4 (budget oscille dans la zone morte [P4×1,0 ; P4×1,2)) ---
|
||||
// hold écoulé à chaque cycle (lastSwitch=T0+400) → c'est la ZONE MORTE qui tient l'état 4, pas le verrou.
|
||||
setMeterW(-300); cycle(t0.addSecs(800)); // budget 300+3000=3300 ∈ [3000,3600) → reste 4
|
||||
QCOMPARE(pac->currentState(), 4);
|
||||
setMeterW(-100); cycle(t0.addSecs(1200)); // budget 3100 → reste 4
|
||||
QCOMPARE(pac->currentState(), 4);
|
||||
setMeterW(-500); cycle(t0.addSecs(1600)); // budget 3500 → reste 4
|
||||
QCOMPARE(pac->currentState(), 4);
|
||||
// En-dessous de P4×1,0 → sort enfin de l'état 4 (vers 3).
|
||||
setMeterW(200); cycle(t0.addSecs(2000)); // import 200 → budget -200+3000=2800 < 3000 → état 3
|
||||
QCOMPARE(pac->currentState(), 3);
|
||||
|
||||
// --- Volet 3 : protection court-cycling (changement avant minStateHold → GELÉ) ---
|
||||
// lastSwitch=T0+2000. À T0+2100 (elapsed 100 < hold 300) : surplus abondant mais GELÉ en 3.
|
||||
setMeterW(-3000); cycle(t0.addSecs(2100));
|
||||
QCOMPARE(pac->currentState(), 3); // gelé malgré budget ≥ P4×1,2 (protection compresseur)
|
||||
// À T0+2400 (elapsed 400 > hold) : MÊME surplus → bascule en 4. Seul le temps simulé a changé.
|
||||
setMeterW(-3000); cycle(t0.addSecs(2400));
|
||||
QCOMPARE(pac->currentState(), 4);
|
||||
|
||||
// ===================== Volet 4 : budget PARTAGÉ ECS(etmvariableload)↔PAC =====================
|
||||
// [T3] Surplus 3000 W ; ECS palier 2400 W (etmvariableload, kind Setpoint), PAC P3 = 1500.
|
||||
// Selon l'ordre de priorité, l'un se sert et l'autre voit le RELIQUAT → waterfall unifié
|
||||
// (un seul budget cascade à travers etmvariableload ET sg-ready, triés par priorité).
|
||||
auto runSharedBudget = [&](int ecsPrio, int pacPrio, double &ecsSetpointOut, int &pacStateOut) {
|
||||
cleanupTestCase();
|
||||
m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite";
|
||||
initTestCase();
|
||||
EnergyArbitrator *arb = dynamic_cast<EnergyArbitrator *>(m_experiencePlugin->smartChargingManager());
|
||||
QVERIFY(arb);
|
||||
ThingManager *tm2 = NymeaCore::instance()->thingManager();
|
||||
|
||||
QUuid mId = addMeter();
|
||||
m_experiencePlugin->energyManager()->setRootMeter(mId);
|
||||
Thing *m2 = tm2->findConfiguredThing(mId);
|
||||
QVERIFY(m2);
|
||||
m2->setStateValue("connected", true);
|
||||
|
||||
QUuid eId = addEtmVariableLoad(27003); // ECS etmvariableload
|
||||
QUuid j1 = addPowerSwitch(0, 26661); // relais PAC K1
|
||||
QUuid j2 = addPowerSwitch(0, 26662); // relais PAC K2
|
||||
Thing *eThing = tm2->findConfiguredThing(eId);
|
||||
QVERIFY(eThing);
|
||||
eThing->setStateValue("currentPowerW", 0);
|
||||
|
||||
// ECS : 1 palier à 2400 W (fixed). La charge ne tire encore rien (currentPowerW=0).
|
||||
EtmVariableLoadAdapter *ecsWf = new EtmVariableLoadAdapter(
|
||||
tm2, eId.toString(), "ECS waterfall", QList<int>({0, 2400}), 2400, ecsPrio, LoadNeeds(), arb);
|
||||
arb->registerEtmVariableLoadAdapter(ecsWf);
|
||||
|
||||
SgReadyAdapter *pacWf = new SgReadyAdapter(
|
||||
tm2, "pac-wf", "PAC waterfall",
|
||||
QHash<int, QList<QString>>({ {1, {j1.toString()}}, {2, {}},
|
||||
{3, {j2.toString()}}, {4, {j1.toString(), j2.toString()}} }),
|
||||
pacPower, 300, pacPrio, arb);
|
||||
arb->registerSgReadyAdapter(pacWf);
|
||||
|
||||
m2->setStateValue("currentPower", -3000); // export 3000 W
|
||||
arb->simulationCallUpdate(t0);
|
||||
QCoreApplication::processEvents();
|
||||
ecsSetpointOut = ecsWf->currentSetpointW();
|
||||
pacStateOut = pacWf->currentState();
|
||||
};
|
||||
|
||||
double ecsSetpoint = -1;
|
||||
int pacState = -1;
|
||||
|
||||
// ECS prioritaire (rang 1) : ECS se sert (2400) → reliquat 600 < P3 → PAC reste NORMAL (2).
|
||||
runSharedBudget(/*ecsPrio*/ 1, /*pacPrio*/ 2, ecsSetpoint, pacState);
|
||||
QCOMPARE(qRound(ecsSetpoint), 2400);
|
||||
QCOMPARE(pacState, 2);
|
||||
|
||||
// Priorités INVERSÉES — PAC prioritaire (rang 1) : PAC se sert (état 3, 1500) → reliquat
|
||||
// 1500 < 2400 → l'ECS reste à 0 W. L'ordre de service s'inverse (même budget unique).
|
||||
runSharedBudget(/*ecsPrio*/ 2, /*pacPrio*/ 1, ecsSetpoint, pacState);
|
||||
QCOMPARE(qRound(ecsSetpoint), 0);
|
||||
QCOMPARE(pacState, 3);
|
||||
#endif
|
||||
}
|
||||
|
||||
void Simulation::testEcsRelayTopologies()
|
||||
{
|
||||
#ifndef ETM_ARBITRATOR
|
||||
QSKIP("testEcsRelayTopologies nécessite ETM_ARBITRATOR.");
|
||||
#else
|
||||
// [rév. 3] La combinatoire watts→relais vit DANS le RelayRouter (couche routeur, frontière
|
||||
// déplacée). On teste directement applyAction(Setpoint W) : paliers DÉRIVÉS, arrondi à la
|
||||
// combinaison ≤ setpoint, off-before-on non-cascadé, et DÉDUPLICATION des niveaux.
|
||||
const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0));
|
||||
auto sp = [&](double w) {
|
||||
LoadAction a;
|
||||
a.kind = LoadAction::Setpoint; a.funding = LoadAction::Surplus;
|
||||
a.powerW = w; a.reason = QStringLiteral("test topo");
|
||||
return a;
|
||||
};
|
||||
auto freshSetup = [&](ThingManager *&tm, QObject *&owner) {
|
||||
cleanupTestCase();
|
||||
m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite";
|
||||
initTestCase();
|
||||
EnergyArbitrator *arb = dynamic_cast<EnergyArbitrator *>(m_experiencePlugin->smartChargingManager());
|
||||
QVERIFY(arb);
|
||||
tm = NymeaCore::instance()->thingManager();
|
||||
owner = arb;
|
||||
};
|
||||
|
||||
// ===================== Topologie 1 : 1 relais (dégénéré [0, 2000]) =====================
|
||||
{
|
||||
ThingManager *tm; QObject *owner; freshSetup(tm, owner);
|
||||
QUuid r = addPowerSwitch(2000, 26661);
|
||||
Thing *relay = tm->findConfiguredThing(r);
|
||||
QVERIFY(relay);
|
||||
RelayRouter *ecs = new RelayRouter(tm, "ecs-1", "ECS 1 relais",
|
||||
QList<LoadConfigRelay>({ {r.toString(), 2000} }), 0, 0, 1, LoadNeeds(), owner);
|
||||
QCOMPARE(ecs->descriptor().declared.powerLevels, QList<int>({0, 2000}));
|
||||
|
||||
ecs->applyAction(sp(2500), t0); // 2500 → palier 2000
|
||||
QCOMPARE(qRound(ecs->currentSetpointW()), 2000);
|
||||
QCOMPARE(relay->stateValue("power").toBool(), true);
|
||||
ecs->applyAction(sp(1000), t0.addSecs(1)); // 1000 < 2000 → 0
|
||||
QCOMPARE(qRound(ecs->currentSetpointW()), 0);
|
||||
QCOMPARE(relay->stateValue("power").toBool(), false);
|
||||
}
|
||||
|
||||
// ============= Topologie 2 : 3 relais 500/1000/2000 (NON-CASCADÉ, off-before-on) =============
|
||||
{
|
||||
ThingManager *tm; QObject *owner; freshSetup(tm, owner);
|
||||
QUuid r500 = addPowerSwitch(500, 26661);
|
||||
QUuid r1000 = addPowerSwitch(1000, 26662);
|
||||
QUuid r2000 = addPowerSwitch(2000, 26663);
|
||||
Thing *t500 = tm->findConfiguredThing(r500);
|
||||
Thing *t1000 = tm->findConfiguredThing(r1000);
|
||||
Thing *t2000 = tm->findConfiguredThing(r2000);
|
||||
QVERIFY(t500 && t1000 && t2000);
|
||||
RelayRouter *ecs = new RelayRouter(tm, "ecs-3", "ECS 3 relais",
|
||||
QList<LoadConfigRelay>({ {r500.toString(), 500}, {r1000.toString(), 1000}, {r2000.toString(), 2000} }),
|
||||
0, 0, 1, LoadNeeds(), owner);
|
||||
// 8 niveaux dérivés (2^3 combinaisons toutes distinctes).
|
||||
QCOMPARE(ecs->descriptor().declared.powerLevels, QList<int>({0, 500, 1000, 1500, 2000, 2500, 3000, 3500}));
|
||||
|
||||
// 1700 → palier 1500 = {r500, r1000} (r2000 OFF).
|
||||
ecs->applyAction(sp(1700), t0);
|
||||
QCOMPARE(qRound(ecs->currentSetpointW()), 1500);
|
||||
QCOMPARE(t500->stateValue("power").toBool(), true);
|
||||
QCOMPARE(t1000->stateValue("power").toBool(), true);
|
||||
QCOMPARE(t2000->stateValue("power").toBool(), false);
|
||||
|
||||
// Transition NON-CASCADÉE 1500 → 2000 = {r2000} SEUL : commute 3 relais (off-before-on).
|
||||
ecs->applyAction(sp(2000), t0.addSecs(1));
|
||||
QCOMPARE(qRound(ecs->currentSetpointW()), 2000);
|
||||
QCOMPARE(t500->stateValue("power").toBool(), false);
|
||||
QCOMPARE(t1000->stateValue("power").toBool(), false);
|
||||
QCOMPARE(t2000->stateValue("power").toBool(), true);
|
||||
}
|
||||
|
||||
// ============= Topologie 3 : DÉDUPLICATION (deux relais identiques 1000 W) =============
|
||||
{
|
||||
ThingManager *tm; QObject *owner; freshSetup(tm, owner);
|
||||
QUuid rA = addPowerSwitch(1000, 26661);
|
||||
QUuid rB = addPowerSwitch(1000, 26662);
|
||||
Thing *tA = tm->findConfiguredThing(rA);
|
||||
Thing *tB = tm->findConfiguredThing(rB);
|
||||
QVERIFY(tA && tB);
|
||||
RelayRouter *ecs = new RelayRouter(tm, "ecs-dup", "ECS dédup",
|
||||
QList<LoadConfigRelay>({ {rA.toString(), 1000}, {rB.toString(), 1000} }), 0, 0, 1, LoadNeeds(), owner);
|
||||
// 4 combinaisons MAIS deux donnent 1000 W → FUSIONNÉES : 3 niveaux, pas 4.
|
||||
QCOMPARE(ecs->descriptor().declared.powerLevels, QList<int>({0, 1000, 2000}));
|
||||
|
||||
// 1200 → palier 1000 = UN SEUL relais (le premier de la combinaison dédupliquée).
|
||||
ecs->applyAction(sp(1200), t0);
|
||||
QCOMPARE(qRound(ecs->currentSetpointW()), 1000);
|
||||
QCOMPARE(tA->stateValue("power").toBool(), true);
|
||||
QCOMPARE(tB->stateValue("power").toBool(), false);
|
||||
// 2200 → palier 2000 = les deux.
|
||||
ecs->applyAction(sp(2200), t0.addSecs(1));
|
||||
QCOMPARE(qRound(ecs->currentSetpointW()), 2000);
|
||||
QCOMPARE(tA->stateValue("power").toBool(), true);
|
||||
QCOMPARE(tB->stateValue("power").toBool(), true);
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
void Simulation::testLoadConfigRelayRouter()
|
||||
{
|
||||
#ifndef ETM_ARBITRATOR
|
||||
QSKIP("testLoadConfigRelayRouter nécessite ETM_ARBITRATOR.");
|
||||
#else
|
||||
// Chaîne COMPLÈTE rév. 3 : SetConfigs(relays[]) → store.changed → rebuild construit un
|
||||
// RelayRouter → cycle surplus → arrondi scheduler → routeur → commutation relais → currentPowerW.
|
||||
cleanupTestCase();
|
||||
m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite";
|
||||
initTestCase();
|
||||
EnergyArbitrator *arbitrator = dynamic_cast<EnergyArbitrator *>(m_experiencePlugin->smartChargingManager());
|
||||
QVERIFY(arbitrator);
|
||||
ThingManager *tm = NymeaCore::instance()->thingManager();
|
||||
|
||||
QUuid meterId = addMeter();
|
||||
m_experiencePlugin->energyManager()->setRootMeter(meterId);
|
||||
Thing *meter = tm->findConfiguredThing(meterId);
|
||||
QVERIFY(meter);
|
||||
meter->setStateValue("connected", true);
|
||||
|
||||
QUuid rA = addPowerSwitch(1000, 26661);
|
||||
QUuid rB = addPowerSwitch(1500, 26662);
|
||||
Thing *relayA = tm->findConfiguredThing(rA);
|
||||
Thing *relayB = tm->findConfiguredThing(rB);
|
||||
QVERIFY(relayA && relayB);
|
||||
|
||||
const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-relayrouter.json";
|
||||
QFile::remove(cfgPath);
|
||||
qputenv("NYMEA_ENERGY_LOAD_CONFIG", cfgPath.toUtf8());
|
||||
LoadConfigStore *store = new LoadConfigStore(arbitrator);
|
||||
arbitrator->setLoadConfigStore(store);
|
||||
|
||||
LoadConfigs cfgs;
|
||||
cfgs.append(LoadConfig::fromMap(QVariantMap{
|
||||
{"id", "ecs-relais"}, {"label", "ECS relais"}, {"adapter", "relay-router"}, {"mode", "fixed"},
|
||||
{"priority", 1}, {"enabled", true},
|
||||
{"relays", QVariantList()
|
||||
<< QVariantMap{{"thingId", rA.toString()}, {"powerW", 1000}}
|
||||
<< QVariantMap{{"thingId", rB.toString()}, {"powerW", 1500}}},
|
||||
{"minOnS", 0}, {"minOffS", 0}}));
|
||||
QString err;
|
||||
QVERIFY2(store->setConfigs(cfgs, &err), err.toUtf8()); // persiste + changed → rebuild → RelayRouter
|
||||
|
||||
// Surplus 2500 → paliers dérivés [0,1000,1500,2500] → palier 2500 (rA+rB) → les deux ON.
|
||||
meter->setStateValue("currentPower", -2500);
|
||||
arbitrator->simulationCallUpdate(utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0)));
|
||||
QCoreApplication::processEvents();
|
||||
QCOMPARE(relayA->stateValue("power").toBool(), true);
|
||||
QCOMPARE(relayB->stateValue("power").toBool(), true);
|
||||
// currentPowerW remonte (mock : relais ON → currentPower = nominal) → source du recrédit.
|
||||
QCOMPARE(qRound(relayA->stateValue("currentPower").toDouble()), 1000);
|
||||
QCOMPARE(qRound(relayB->stateValue("currentPower").toDouble()), 1500);
|
||||
|
||||
qunsetenv("NYMEA_ENERGY_LOAD_CONFIG");
|
||||
QFile::remove(cfgPath);
|
||||
#endif
|
||||
}
|
||||
|
||||
void Simulation::run_data()
|
||||
{
|
||||
// Simulation infos
|
||||
|
||||
@ -56,6 +56,31 @@ private slots:
|
||||
void run_data();
|
||||
void run();
|
||||
|
||||
// [T3] etmvariableload (ECS/routeur) : arrondi fixed (powerLevels) + clamp dynamic
|
||||
// (maxPowerW), recrédit currentPowerW (anti-clignotement), délestage, round-trip powerSetpoint.
|
||||
void testEcsSurplusPV();
|
||||
|
||||
// [T4] Watchdog L2 : compteur muet >90 s → etmvariableload setPowerSetpoint(0) force=true,
|
||||
// planif suspendue (reste 0 sur N cycles), reprise au retour compteur.
|
||||
void testMeterSilentFallback();
|
||||
|
||||
// [T4] Persistance LoadConfig (§4) : SetConfigs → fichier → relecture round-trip + validation.
|
||||
void testLoadConfigPersistence();
|
||||
// [T4] Construction des adaptateurs depuis la config : enabled servi, disabled exclu (§9).
|
||||
void testLoadConfigBuildsAdapters();
|
||||
// [T4] Injection RPC end-to-end : NymeaEnergy.Get/SetLoadConfig (couche consommée par l'app).
|
||||
void testLoadConfigRpc();
|
||||
// [rév.3] Chaîne complète : SetConfigs(relays[]) → rebuild → RelayRouter → cycle → commutation relais.
|
||||
void testLoadConfigRelayRouter();
|
||||
|
||||
// SG-Ready (PAC) : montée d'états sur surplus, hystérésis 3↔4, protection court-cycling,
|
||||
// + Volet 4 budget PARTAGÉ etmvariableload(ECS)↔PAC (waterfall unifié, inversion priorité).
|
||||
void testSgReadySurplus();
|
||||
|
||||
// [rév.3] Combinatoire watts→relais DANS le RelayRouter : paliers dérivés, off-before-on,
|
||||
// transition non-cascadée, et DÉDUPLICATION des niveaux (relais identiques fusionnés).
|
||||
void testEcsRelayTopologies();
|
||||
|
||||
void printStates(Thing *thing);
|
||||
void updateChargerMeter(Thing *thing);
|
||||
|
||||
|
||||
@ -358,6 +358,55 @@ void IntegrationPluginEnergyMocks::setupThing(ThingSetupInfo *info)
|
||||
thing->setStateValue("capacity", thing->paramValue(energyStorageThingCapacityParamTypeId));
|
||||
|
||||
return;
|
||||
|
||||
} else if (thing->thingClassId() == powerSwitchThingClassId) {
|
||||
EnergyMockController *controller = new EnergyMockController(thing, this);
|
||||
ParamType paramType = thing->thingClass().paramTypes().findByName("port");
|
||||
quint16 port = thing->paramValue(paramType.id()).toUInt();
|
||||
if (!controller->listen(QHostAddress::Any, port)) {
|
||||
qCWarning(dcEnergyMocks()) << "Failed to start mock controller on port" << controller->errorString();
|
||||
delete controller;
|
||||
info->finish(Thing::ThingErrorThingInUse);
|
||||
return;
|
||||
}
|
||||
|
||||
connect(controller, &EnergyMockController::updateStateRequestReceived, thing, [=](const QUrlQuery &query){
|
||||
// Permet au test d'imposer power / currentPower directement (ex. émuler une
|
||||
// mesure dérivée, ou un thermostat coupé : power=true mais currentPower=0).
|
||||
if (query.hasQueryItem("power"))
|
||||
thing->setStateValue("power", QVariant(query.queryItemValue("power")).toBool());
|
||||
if (query.hasQueryItem("currentPower"))
|
||||
thing->setStateValue("currentPower", QVariant(query.queryItemValue("currentPower")).toDouble());
|
||||
});
|
||||
|
||||
m_controllers.insert(thing, controller);
|
||||
qCDebug(dcEnergyMocks()) << "Setting up power switch" << thing->name() << "finished successfully";
|
||||
info->finish(Thing::ThingErrorNoError);
|
||||
return;
|
||||
|
||||
} else if (thing->thingClassId() == etmVariableLoadThingClassId) {
|
||||
// Mock etmvariableload : un controller (nécessaire pour logActionExecuted dans
|
||||
// executeAction) + injection éventuelle de currentPowerW. Le test impose plutôt
|
||||
// currentPowerW directement via setStateValue (mesure simulée de la charge).
|
||||
EnergyMockController *controller = new EnergyMockController(thing, this);
|
||||
ParamType paramType = thing->thingClass().paramTypes().findByName("port");
|
||||
quint16 port = thing->paramValue(paramType.id()).toUInt();
|
||||
if (!controller->listen(QHostAddress::Any, port)) {
|
||||
qCWarning(dcEnergyMocks()) << "Failed to start mock controller on port" << controller->errorString();
|
||||
delete controller;
|
||||
info->finish(Thing::ThingErrorThingInUse);
|
||||
return;
|
||||
}
|
||||
|
||||
connect(controller, &EnergyMockController::updateStateRequestReceived, thing, [=](const QUrlQuery &query){
|
||||
if (query.hasQueryItem("currentPowerW"))
|
||||
thing->setStateValue("currentPowerW", QVariant(query.queryItemValue("currentPowerW")).toDouble());
|
||||
});
|
||||
|
||||
m_controllers.insert(thing, controller);
|
||||
qCDebug(dcEnergyMocks()) << "Setting up etmVariableLoad" << thing->name() << "finished successfully";
|
||||
info->finish(Thing::ThingErrorNoError);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
@ -474,6 +523,30 @@ void IntegrationPluginEnergyMocks::executeAction(ThingActionInfo *info)
|
||||
}
|
||||
}
|
||||
|
||||
if (thing->thingClassId() == powerSwitchThingClassId) {
|
||||
if (actionType.name() == "power") {
|
||||
bool power = action.paramValue(actionType.paramTypes().findByName("power").id()).toBool();
|
||||
double nominal = thing->paramValue(thing->thingClass().paramTypes().findByName("nominalPower").id()).toDouble();
|
||||
// Relais ON → consomme sa puissance nominale ; OFF → 0 W. Le test peut écraser
|
||||
// currentPower via /setstates (ex. émuler une mesure dérivée).
|
||||
thing->setStateValue("power", power);
|
||||
thing->setStateValue("currentPower", power ? nominal : 0.0);
|
||||
qCDebug(dcEnergyMocks()) << "Mock power switch" << thing->name() << "power" << power
|
||||
<< "currentPower" << thing->stateValue("currentPower");
|
||||
}
|
||||
}
|
||||
|
||||
if (thing->thingClassId() == etmVariableLoadThingClassId) {
|
||||
if (actionType.name() == "powerSetpoint") {
|
||||
// L'EtmVariableLoadAdapter écrit la consigne ; le mock l'enregistre dans le state.
|
||||
// currentPowerW (puissance réellement appliquée) reste piloté par le test —
|
||||
// c'est le thing réel qui choisirait la combinaison matérielle (contrat §3).
|
||||
double setpoint = action.paramValue(actionType.paramTypes().findByName("powerSetpoint").id()).toDouble();
|
||||
thing->setStateValue("powerSetpoint", setpoint);
|
||||
qCDebug(dcEnergyMocks()) << "Mock etmVariableLoad" << thing->name() << "powerSetpoint" << setpoint;
|
||||
}
|
||||
}
|
||||
|
||||
info->finish(Thing::ThingErrorNoError);
|
||||
}
|
||||
|
||||
|
||||
@ -194,7 +194,7 @@
|
||||
"name": "maxChargingCurrent",
|
||||
"displayName": "Maximum charging current",
|
||||
"displayNameAction": "Set maximum charging current",
|
||||
"type": "uint",
|
||||
"type": "double",
|
||||
"defaultValue":6,
|
||||
"minValue": 6,
|
||||
"maxValue": 32,
|
||||
@ -387,7 +387,7 @@
|
||||
"name": "maxChargingCurrent",
|
||||
"displayName": "Maximum charging current",
|
||||
"displayNameAction": "Set maximum charging current",
|
||||
"type": "uint",
|
||||
"type": "double",
|
||||
"defaultValue":6,
|
||||
"minValue": 6,
|
||||
"maxValue": 32,
|
||||
@ -584,7 +584,7 @@
|
||||
"name": "maxChargingCurrent",
|
||||
"displayName": "Maximum charging current",
|
||||
"displayNameAction": "Set maximum charging current",
|
||||
"type": "uint",
|
||||
"type": "double",
|
||||
"defaultValue":6,
|
||||
"minValue": 6,
|
||||
"maxValue": 32,
|
||||
@ -816,6 +816,84 @@
|
||||
"defaultValue": false
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "powerSwitch",
|
||||
"displayName": "Mocked Power Switch (relais ECS)",
|
||||
"id": "841f8905-d1d7-4053-909f-01123b497747",
|
||||
"createMethods": ["user"],
|
||||
"interfaces": ["power"],
|
||||
"paramTypes": [
|
||||
{
|
||||
"id": "e3398429-45fd-4add-a789-4d11bfd9560f",
|
||||
"name": "port",
|
||||
"displayName": "Port",
|
||||
"type": "uint",
|
||||
"defaultValue": 26661
|
||||
},
|
||||
{
|
||||
"id": "b850a4d1-af0f-477d-ac73-56071f371884",
|
||||
"name": "nominalPower",
|
||||
"displayName": "Nominal power when ON",
|
||||
"type": "double",
|
||||
"unit": "Watt",
|
||||
"defaultValue": 2000
|
||||
}
|
||||
],
|
||||
"stateTypes": [
|
||||
{
|
||||
"id": "9fa6457c-6adb-4d4a-8d47-7bdb2db2c271",
|
||||
"name": "power",
|
||||
"displayName": "Power",
|
||||
"displayNameAction": "Switch power",
|
||||
"type": "bool",
|
||||
"defaultValue": false,
|
||||
"writable": true
|
||||
},
|
||||
{
|
||||
"id": "0e7e6cd5-601b-4616-8bc9-191c10e9dac7",
|
||||
"name": "currentPower",
|
||||
"displayName": "Current power",
|
||||
"type": "double",
|
||||
"unit": "Watt",
|
||||
"defaultValue": 0
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "etmVariableLoad",
|
||||
"displayName": "Mocked etmvariableload (ECS/routeur à puissance pilotable)",
|
||||
"id": "b7c41f0a-3e2d-4a91-9f6b-2c8e1d5a7b40",
|
||||
"createMethods": ["user"],
|
||||
"paramTypes": [
|
||||
{
|
||||
"id": "c8d52a1b-4f3e-4b02-a07c-3d9f2e6b8c51",
|
||||
"name": "port",
|
||||
"displayName": "Port",
|
||||
"type": "uint",
|
||||
"defaultValue": 27001
|
||||
}
|
||||
],
|
||||
"stateTypes": [
|
||||
{
|
||||
"id": "d9e63b2c-5a4f-4c13-b18d-4e0a3f7c9d62",
|
||||
"name": "currentPowerW",
|
||||
"displayName": "Current power (W)",
|
||||
"type": "double",
|
||||
"unit": "Watt",
|
||||
"defaultValue": 0
|
||||
},
|
||||
{
|
||||
"id": "e0f74c3d-6b5a-4d24-9f2e-5f1b408ad073",
|
||||
"name": "powerSetpoint",
|
||||
"displayName": "Power setpoint",
|
||||
"displayNameAction": "Set power setpoint",
|
||||
"type": "double",
|
||||
"unit": "Watt",
|
||||
"defaultValue": 0,
|
||||
"writable": true
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user