feat(telemetry): lot B — frontière de télémétrie d'arbitrage + o:domain — 1.15.2+etm14
Constat d'origine, rejoué et daté au banc : 90 s d'écoute, arbitre actif, ~2 kW exportés
→ ZÉRO notification NymeaEnergy.*. L'état runtime de l'arbitre (allocation, budget, verrous,
paliers, disponibilité, motifs) ne franchissait aucune frontière RPC : il vivait exclusivement
dans qCInfo(dcNymeaEnergy). Expliquer les décisions au client n'avait donc aucun transport.
Après déploiement, même geste : 13 notifications en 480 s.
Aucune phrase composée par la box. La locale nymea est PAR CONNEXION (JSONRPC.Hello o:locale),
et NymeaEnergyJsonHandler est un JsonHandler sans pluginId — donc hors du catalogue de
traduction : rien ne rattraperait une phrase française émise là. La box transporte un CODE et
des PARAMÈTRES ; la phrase est fabriquée par le client.
LoadAction::reason devient DecisionReason{code, params}. La ligne française du journal est
RENDUE depuis ce couple par renderFr(), source unique — jamais maintenue en parallèle. Les 19
codes sont EXTRAITS des sites existants, pas inventés ; les motifs d'ECS-309 et ECS-309-b sont
repris mot pour mot. Prouvé par comparaison de traces (1726 lignes de journal identiques en
simulation), par une table d'or qui échoue au premier code sans phrase attendue, et au banc sur
7 motifs distincts. Le libellé de la charge n'entre pas dans la charge utile : il est substitué
au RENDU, par l'appelant qui le connaît.
Les verrous sortent en SECONDES RESTANTES, pas en watts. Ceci n'enfreint pas ECS-306 : cette
exigence interdit qu'un index de PALIER franchisse la frontière d'ALLOCATION ; une valeur
d'AFFICHAGE sortante est un autre chemin. Le mécanisme sort en union discriminée
(relay|variable|sgReady), toutes clés internes optionnelles au schéma (LM-302).
ILoadAdapter::runtimeView() est PURE VIRTUELLE à dessein — mais la pure virtuelle force à
RÉPONDRE, pas à répondre quelque chose. testRuntimeViewIsSubstantial() est le second verrou :
les quatre adaptateurs, amenés dans un état non trivial, doivent rendre les valeurs runtime
réelles, les verrous en secondes ET leur expiration, et les codes de défaut. Deux mutants
« vue vide » introduits puis retirés : le test échoue sur chacun.
budget publié : surplusW, evReservedW, recreditedW, allocatedW, remainingW, avec l'identité
surplus + recrédit − alloué == restant. recreditedW n'est pas décoratif : sans lui l'identité
serait fausse DÈS QU'UNE CHARGE CONSOMME, et le client chercherait un écart inexplicable. Le
banc l'a montré aussitôt — surplusW −250 W avec recreditedW 3000 W — rendant lisible l'artefact
de banc documenté dans AGENTS.md (relais sans mesure + rootmeter aveugle).
Champs omis, jamais nuls : timestamp absent = aucun cycle depuis le démarrage ; budget absent =
mode dégradé, planification suspendue. Publier un zéro se lirait « exécuté, rien à allouer ».
Cadence : changement qualitatif ou variation d'allocation > 50 W, plus un battement de 60 s.
C'est le battement qui rend un arbitre FIGÉ détectable — les notifications continuent tandis que
le timestamp cesse d'avancer. Observé 3 fois au banc. Volume mesuré : ≈ 1,7 Mo/jour, un client.
GetChargingSchedules rend enfin degradedMode : il était POUSSÉ en notification seule, donc
illisible par un client connecté après la bascule (famille ECS-411). Règles absolues 11 et 12
ajoutées à AGENTS.md.
LoadConfig : champ optionnel domain (ecs·ev·heating·hvac·battery), PURE métadonnée d'intention
que l'arbitre ne lit pas. Absente ou vide = non classée. Forme exacte découverte au banc et
corrigée dans la doc ET le test : GetLoadConfig sérialise via le méta-objet, donc la propriété
sort TOUJOURS, à "" — comme sgReady:{} ou powerLevels:[]. C'est le corollaire LM-302.
Règle 7-c, deux silences fermés : les retours idempotents du RelayRouter et du SgReadyAdapter
étaient MUETS — « consigne reçue, palier déjà bon » ne se distinguait pas de « l'action n'est
jamais parvenue à l'adaptateur », et c'est le cas ordinaire qu'on vient vérifier.
⚠️ Porté au contrat : loads[] n'est PAS en correspondance 1:1 avec GetLoadConfig. « pac-terrain »
est la PAC SG-Ready CODÉE EN DUR d'energypluginnymea.cpp (K1/K2 = Relay4/Relay5, {3:1500,
4:3000}) — arbitrée et publiée, mais inconfigurable : ni rang, ni domain, ni enabled:false.
INTERFACE.md porte la règle de détection pour l'app. Son sort reste À TRANCHER (RELEVE_LOTB §8-bis b).
Déploiement : la recette de TEST_TERRAIN.md §b portait un glob « dpkg -i /tmp/…_*_arm64.deb »,
de la même famille que celui qui a rétrogradé .75 de +etm12 à +etm9. Remplacée par
etm-powersync-deploy/deploy.sh (archivage avant dépôt, installation par nom, refus de
rétrogradation, contrôle de version après coup).
Vérifié sur .75 (1.15.2+etm13 puis +etm14) : critères 1, 2, 4, 5, 6, 7, 8 du brief. Critère 3
(épisode dégradé) vérifié en simulation seulement — le déclencher demande de faire taire un
simulateur SunSpec partagé, hors périmètre. Relevé complet, critère par critère, avec ce qui n'a
PAS été vérifié sur la machine et pourquoi : docs/RELEVE_LOTB.md.
Suites : simulation 38/38, charging 46/46, spotmarket 32/32, y compris en build DEBUG où les
Q_ASSERT_X du validateur JSON-RPC sont actifs (le schéma déclaré correspond donc à la charge
utile émise, retours ET notifications). Cross-arm64 : 0 avertissement. Doxygen : 10, inchangé.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016MfvSskkYrrFriYcj4683D
This commit is contained in:
parent
cd1a0c83a5
commit
74465375c4
68
AGENTS.md
68
AGENTS.md
@ -20,6 +20,7 @@ vers un gestionnaire d'énergie complet (EV, ECS, PAC SG-Ready, batterie).
|
||||
| 3c — adaptateur ECS à paliers + waterfall ECS | ✅ FAITE — suite 18/18 + charging 46/46 | `6298d5d`→`54ba229` |
|
||||
| 3e — SgReadyAdapter | ✅ FAITE — suite 19/19 | `83d5ad9`→`d8079e8` |
|
||||
| rév. 3 — frontière optimiseur↔routeur | ✅ FAITE — `RelayRouter`, `LoadConfig`/`LoadConfigStore`, kind `Stage` retiré | `5100674`, `e16aca4`, `5585a5c`, `7184fe4`, `88626cf` |
|
||||
| lot B — frontière de TÉLÉMÉTRIE + `o:domain` | ✅ FAITE — `GetLoadTelemetry`/`LoadTelemetryChanged`, motifs en `{code, params}`, `degradedMode` lisible | `1.15.2+etm13` → `+etm14` |
|
||||
|
||||
**Détail 3b** :
|
||||
- `EnergyArbitrator : public SmartChargingManager` — justification dans `## DÉCISIONS DE DESIGN`
|
||||
@ -71,6 +72,31 @@ vers un gestionnaire d'énergie complet (EV, ECS, PAC SG-Ready, batterie).
|
||||
- **Audit Doxygen** fait (5 findings → 0 : `\param now`, docs périmées 3c/3e) — commit `51760a7`.
|
||||
- **`docs/TEST_TERRAIN.md`** créé : procédure Palier 1 (14 tests) pour le banc nymea-dev arm64.
|
||||
|
||||
**Détail lot B (frontière de télémétrie)** :
|
||||
- **Le constat** : 90 s d'écoute sur `.75`, arbitre actif, ~2 kW exportés → **zéro notification
|
||||
`NymeaEnergy.*`**. L'état runtime de l'arbitre vivait exclusivement dans
|
||||
`qCInfo(dcNymeaEnergy)`. Ce lot lui donne un transport.
|
||||
- `NymeaEnergy.GetLoadTelemetry` + `LoadTelemetryChanged`, **même charge utile**. Contrat :
|
||||
`INTERFACE.md`.
|
||||
- `LoadAction::reason` est un **`DecisionReason {code, params}`**, plus une `QString`. La ligne
|
||||
française du journal est **rendue** depuis ce couple par `renderFr()` — source unique, jamais
|
||||
maintenue en parallèle (`etm/types/decisionreason.h`).
|
||||
- `ILoadAdapter::runtimeView(now)` : nouvelle méthode **pure virtuelle** — défaut, verrou en
|
||||
secondes restantes, charge utile de mécanisme. Pure à dessein : un mécanisme ajouté qui
|
||||
hériterait d'un défaut vide sortirait de la télémétrie sans bruit.
|
||||
- `PlanBudget` (interne au plan, jamais sérialisé vers l'optimiseur socket, comme
|
||||
`LoadAction::funding`) : `surplusW`, `evReservedW`, `recreditedW`, `allocatedW`, `remainingW`.
|
||||
- `LoadConfig::domain` — métadonnée d'intention, énumération fermée, **non lue par l'arbitre**.
|
||||
- ⚠️ **`loads[]` n'est PAS en correspondance 1:1 avec `GetLoadConfig`.** Le `SgReadyAdapter` du
|
||||
banc, codé en dur (`energypluginnymea.cpp:66-76`), est **arbitré et publié** en télémétrie
|
||||
tout en restant **inconfigurable** — ni rang, ni `domain`, ni `enabled: false`. Porté au
|
||||
contrat (`INTERFACE.md`) pour que l'app le prévoie. **Sort à trancher** :
|
||||
`docs/RELEVE_LOTB.md` §8-bis (b). La précondition qui bloquait sa mise en configuration — le
|
||||
`Q_ASSERT(m_stateRelays.contains(2))` — a disparu au profit du refus explicite `m_usable`.
|
||||
- **Déploiement sur box : `etm-powersync-deploy/deploy.sh`, jamais un `dpkg -i` à la main.**
|
||||
Un glob sur `/var/lib/etm-deploy/*.deb` a rétrogradé `.75` de `+etm12` à `+etm9` le
|
||||
2026-08-25. Détail : `DEPLOY.md` §7.6 (dépôt deploy) et `docs/RELEVE_LOTB.md` §8-bis (a).
|
||||
|
||||
### Ce que le moteur sait faire aujourd'hui
|
||||
- **Arbitrage central unique** : un budget de surplus net signé, cascade par **priorité** (rang).
|
||||
- **Charges** : EV (proxy amont, décision B), **charges pilotées en watts** (kind `Setpoint` —
|
||||
@ -89,8 +115,18 @@ vers un gestionnaire d'énergie complet (EV, ECS, PAC SG-Ready, batterie).
|
||||
— `LoadAction.force` (bypass sécurité L2), `telemetry.minState/maxState` (fenêtre de verrou
|
||||
SG-Ready), `degradedMode` (notification), et la forme `relay-router` de `LoadConfig`.
|
||||
`minStage`/`maxStage` ne sont plus à documenter : les champs ont été retirés du contexte en
|
||||
rév. 3. À documenter dans le protocole publié + README architecture.
|
||||
rév. 3. **Ajout du lot B** : `PlanBudget` (comptabilité interne du plan, non sérialisée) et
|
||||
`ILoadAdapter::runtimeView()` n'y figurent pas non plus. À documenter dans le protocole publié
|
||||
+ README architecture. `INTERFACE.md` est, lui, à jour du lot B.
|
||||
**Prochaine session doc** (pas avant le terrain).
|
||||
- **Silences de REFUS restants (règle 7-c/7-d), signalés et NON corrigés au lot B.** Trois
|
||||
refus rendent la main sans une ligne, alors que l'application correspondante est en `qCInfo` :
|
||||
`relayrouter.cpp` (charge en défaut → action refusée), `etmvariableloadadapter.cpp` (idem), et
|
||||
`sgreadyadapter.cpp` **deux fois** (charge en défaut, et encodage sans état 2 → `m_usable`
|
||||
faux, donc PLUS AUCUNE commande depuis la construction). Écartés du lot B à dessein : le
|
||||
défaut est déjà annoncé une fois en `qCCritical` à son apparition, et il est désormais publié
|
||||
en continu par `GetLoadTelemetry` (`faultCode`) — mais l'asymétrie de journal reste, et le cas
|
||||
`m_usable` n'a, lui, **aucune** annonce à sa naissance. À traiter avec ECS-414.
|
||||
- **Waveshare D8** : plugin DEVICE (8 powerswitch) sous l'adaptateur — **session dédiée**
|
||||
(chantier transport Modbus RTU/RS485).
|
||||
- **V2C** (borne EV) : intégration — **session dédiée**.
|
||||
@ -379,6 +415,34 @@ Règles absolues :
|
||||
Exception : le plugin est CLIENT d'un optimiseur sur socket local/LAN (OPTIMIZER_PROTOCOL,
|
||||
`unix://` ou `tcp://` du réseau de l'installation) — jamais un service cloud externe.
|
||||
|
||||
11. **Tout élément runtime a un `Get*` ET un `*Changed`. Jamais une notification seule.**
|
||||
Une notification sans lecture laisse le client connecté APRÈS le changement dans un
|
||||
silence ambigu : il ne peut pas distinguer « rien n'a changé » de « j'ai raté la
|
||||
transition ». C'est la famille ECS-411, appliquée à la frontière RPC.
|
||||
> `degradedMode` était poussé par `ChargingSchedulesChanged` sans figurer dans
|
||||
> `GetChargingSchedules`. Corrigé au lot B.
|
||||
- **Corollaire — la charge utile d'un `Get*` et celle de son `*Changed` se déclarent UNE
|
||||
fois.** Deux déclarations divergent, et c'est l'appel de reprise à froid qui casse : celui
|
||||
qu'on n'exerce pas tous les jours. Cf. `NymeaEnergyJsonHandler::loadTelemetrySchema()`.
|
||||
- **Corollaire — le `Get*` rend ce qui a été PUBLIÉ, il ne recalcule pas.** Un recalcul
|
||||
donne des grandeurs fraîches sous un horodatage périmé : un état qui n'a jamais existé, et
|
||||
que le client ne peut rapprocher d'aucune notification.
|
||||
12. **Aucune phrase en langue naturelle dans une charge utile RPC.** La box transporte un
|
||||
**code** et des **paramètres** ; la phrase est fabriquée par le client, dans la langue de
|
||||
son utilisateur. La locale nymea est **par connexion** (`JSONRPC.Hello` accepte `o:locale`,
|
||||
rangée dans `m_clientLocales`) : deux utilisateurs d'une même box peuvent avoir deux
|
||||
langues, ce qu'une phrase composée en C++ ne peut pas servir. Et
|
||||
`NymeaEnergyJsonHandler` est un `JsonHandler` **sans `pluginId`** : structurellement hors du
|
||||
catalogue de traduction nymea — rien ne rattraperait une phrase française émise là.
|
||||
- Le **libellé de la charge** est de la langue naturelle : il n'entre pas dans les
|
||||
paramètres, il est substitué au **rendu**, par l'appelant qui le connaît.
|
||||
- Le **journal reste en français**, mais **rendu depuis le code** par une fonction unique
|
||||
(`renderFr`). Une ligne maintenue en parallèle du code divergerait, et c'est le
|
||||
diagnostic terrain qui deviendrait faux.
|
||||
- Corollaire de forme : un champ absent vaut mieux qu'un champ nul ou forgé. `timestamp`
|
||||
absent = aucun cycle ; `budget` absent = planification suspendue. Un zéro publié se lirait
|
||||
« exécuté, rien à faire ».
|
||||
|
||||
## RÉPONSES FIGÉES (ne plus poser ces questions)
|
||||
|
||||
- Plages HC/HP et tarifs : **configuration JSON**, jamais hardcodé. Prévoir Tempo
|
||||
@ -610,6 +674,8 @@ supérieures n'affecte pas les couches inférieures. Voir `docs/SAFETY.md` pour
|
||||
§7 (repli), annexe C (priorités).
|
||||
- `README.md` — architecture (deux boucles, frontière), `etm_powersync_energy.svg`.
|
||||
- `INTERFACE.md` — API JSON-RPC existante (`NymeaEnergy`, cible future `Ems`).
|
||||
- `docs/RELEVE_LOTB.md` — relevé de vérification du lot B (frontière de télémétrie + `o:domain`)
|
||||
sur `.75`, critère par critère, avec ce qui n'a PAS été vérifié sur la machine et pourquoi.
|
||||
- `specs/spec_ecs.md` — **autorité sur l'ECS multi-palier** : état des exigences après
|
||||
audit, ordre de traitement, arbitrages ouverts. Subordonné à ce fichier.
|
||||
- `specs/spec_loadmodel.md` — modèle de charges (domaine × mécanisme).
|
||||
|
||||
210
INTERFACE.md
210
INTERFACE.md
@ -282,7 +282,8 @@ Met à jour la configuration d'une borne. Seuls les champs `USER true` sont modi
|
||||
---
|
||||
|
||||
#### `NymeaEnergy.GetChargingSchedules`
|
||||
Retourne le planning de recharge calculé (toutes les bornes, horizon ~24h).
|
||||
Retourne le planning de recharge calculé (toutes les bornes, horizon ~24h), **et** l'état du
|
||||
mode dégradé L2.
|
||||
|
||||
**Params :** aucun
|
||||
|
||||
@ -301,10 +302,197 @@ Retourne le planning de recharge calculé (toutes les bornes, horizon ~24h).
|
||||
"issuer": "ChargingActionIssuerSpotMarketCharging"
|
||||
}
|
||||
}
|
||||
],
|
||||
"degradedMode": false
|
||||
}
|
||||
```
|
||||
> `degradedMode` *(bool, [ETM])* : `true` quand le watchdog L2 a basculé en repli (compteur muet
|
||||
> > 90 s, planification suspendue). Il était auparavant **poussé en notification seule** par
|
||||
> `ChargingSchedulesChanged` : un client connecté après la bascule ne pouvait pas savoir dans
|
||||
> quel état il se trouvait. Règle de ce dépôt : **tout élément runtime a un `Get*` ET un
|
||||
> `*Changed`**, jamais une notification seule.
|
||||
|
||||
---
|
||||
|
||||
#### `NymeaEnergy.GetLoadConfig` / `SetLoadConfig` — champ `domain`
|
||||
|
||||
`LoadConfig` porte un champ optionnel `domain` : catégorie d'**intention** déclarée par
|
||||
l'installateur, énumération **fermée** — `ecs` · `ev` · `heating` · `hvac` · `battery`.
|
||||
|
||||
- **Pure métadonnée.** L'arbitre ne la lit pas ; aucun comportement moteur n'en dépend.
|
||||
- **Un code, jamais un libellé** — même raison que pour `decision.code` ci-dessous.
|
||||
- **Absente ou vide = charge « non classée ».** Le plugin ne la range pas d'office : un
|
||||
classement inventé serait indistinguable d'un classement choisi.
|
||||
`GetLoadConfig` sérialise via le **méta-objet**, donc la propriété sort **toujours**, à `""`
|
||||
pour une charge non classée — comme `"sgReady": {}` ou `"powerLevels": []`. C'est ce qui rend
|
||||
l'aller-retour `Get` → `Set` possible (corollaire LM-302). La forme **persistée** omet la clé.
|
||||
Un client traite `""` **et** l'absence comme « non classée ».
|
||||
- `SetLoadConfig` **rejette en bloc** (`EnergyErrorInvalidParameter`) une valeur hors
|
||||
énumération.
|
||||
|
||||
Détail complet : `docs/INTERFACE_etmvariableload.md` §4.
|
||||
|
||||
---
|
||||
|
||||
### Télémétrie d'arbitrage
|
||||
|
||||
#### `NymeaEnergy.GetLoadTelemetry`
|
||||
Instantané de l'état runtime de l'arbitrage : budget de surplus, allocation par charge,
|
||||
disponibilité, code de défaut, verrou actif en **secondes restantes**, charge utile du
|
||||
mécanisme, et **motif de décision sous forme de code**.
|
||||
|
||||
**Params :** aucun
|
||||
|
||||
**Returns :** voir la charge utile ci-dessous — **identique** à celle de la notification
|
||||
`LoadTelemetryChanged`. Le `Get` ne recalcule rien : il rend l'objet qui a été publié.
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"timestamp": "2026-08-25T14:32:07Z", // fin du DERNIER cycle d'arbitrage — o:, voir plus bas
|
||||
"degradedMode": false,
|
||||
"budget": { // o: — absent en mode dégradé, voir plus bas
|
||||
"surplusW": 8348, // budget entrant du waterfall non-EV (net signé − réservation EV)
|
||||
"evReservedW": 0, // puissance EV commandée ce cycle, pas encore vue au compteur
|
||||
"recreditedW": 1500, // recrédits anti-clignotement rendus aux charges (correction B)
|
||||
"allocatedW": 3500, // somme des allocations du cycle
|
||||
"remainingW": 6348 // résidu de fin de cascade
|
||||
},
|
||||
"loads": [
|
||||
{
|
||||
"loadId": "ecs-chauffe-eau",
|
||||
"allocatedW": 3500, // ce que le waterfall a ALLOUÉ (pas la mesure)
|
||||
"available": true,
|
||||
"faultCode": "WRITE_FAILED", // o: — absent = aucun défaut
|
||||
"lock": { "kind": "minOff", "remainingS": 42 }, // o: — absent = aucun verrou actif
|
||||
"mechanism": { "kind": "relay", "stageW": 3500, "maxStageW": 4500 }, // union discriminée
|
||||
"decision": { "code": "LOCK_MIN_OFF",
|
||||
"params": { "budgetW": 8348, "requestedW": 3500 } }
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Identité vérifiable :** `surplusW + recreditedW − allocatedW == remainingW`.
|
||||
`recreditedW` n'est pas décoratif : le waterfall rend à chaque charge sa propre consommation de
|
||||
début de cycle avant d'arrondir (anti-clignotement, `AGENTS.md` correction B). Sans ce terme,
|
||||
`surplus − alloué == restant` serait faux **dès qu'une charge consomme déjà**, c'est-à-dire dans
|
||||
le cas normal.
|
||||
|
||||
**Champs omis, jamais nuls** (règle maison, cf. `GetEnergyRatios`) :
|
||||
- `timestamp` **absent** = aucun cycle d'arbitrage depuis le démarrage du service. En forger un
|
||||
se lirait « cycle exécuté, rien à allouer » — exactement le mensonge que ce champ existe pour
|
||||
empêcher. Présent, c'est la **fin du dernier cycle** : c'est la preuve de fraîcheur.
|
||||
- `budget` **absent** = mode dégradé L2 : la planification est **suspendue**, il n'existe aucun
|
||||
budget pour ce cycle. Publier des zéros se lirait « arbitrage exécuté, rien à allouer ».
|
||||
|
||||
**Charges présentes.** Uniquement les charges **arbitrées par le waterfall** : `relay-router`,
|
||||
`etmvariableload`, `sg-ready`. Le VE n'y figure pas en beta — il est décidé par l'amont **avant**
|
||||
le waterfall (décision B, `AGENTS.md`), et sa puissance est réservée dans `budget.evReservedW`,
|
||||
pas allouée depuis le budget publié. L'y ajouter ferait une somme `loads[]` qui ne retomberait
|
||||
pas sur `budget.allocatedW`. Il rejoindra la liste avec l'étape **3g**.
|
||||
|
||||
#### ⚠️ `loads[]` n'est PAS en correspondance 1:1 avec `GetLoadConfig`
|
||||
|
||||
**Une charge peut être arbitrée et publiée ici sans exister dans `GetLoadConfig`.** Un client
|
||||
qui suppose l'inverse affichera une charge sans nom, sans domaine et sans écran de réglage — ou
|
||||
plantera en cherchant sa configuration.
|
||||
|
||||
Cas réel, constaté sur le banc `.75` le 2026-08-25 :
|
||||
|
||||
| | `GetLoadConfig` | `GetLoadTelemetry` |
|
||||
|---|---|---|
|
||||
| `chauffe-eau` (relay-router) | ✔ | ✔ |
|
||||
| `pac-terrain` (sg-ready) | **absent** | ✔ |
|
||||
|
||||
`pac-terrain` est une PAC SG-Ready **codée en dur** dans `energypluginnymea.cpp` (ThingIds
|
||||
K1/K2, états `{3: 1500 W, 4: 3000 W}`, `minStateHoldS: 300`), enregistrée par
|
||||
`registerSgReadyAdapter()` hors de toute configuration. Elle est **pleinement arbitrée** — elle
|
||||
consomme du budget, elle porte des verrous, elle apparaît dans la télémétrie — mais elle est
|
||||
**inconfigurable** : ni renommage, ni rang, ni `domain`, ni `enabled: false`.
|
||||
|
||||
**Règle pour le client :** `loadId` est la clé. Une entrée de `loads[]` dont le `loadId` n'a pas
|
||||
d'équivalent dans `GetLoadConfig` est une **charge non configurable** :
|
||||
- afficher son `loadId` comme identité (il n'y a pas de libellé à en tirer) ;
|
||||
- la traiter comme **non classée** (aucun `domain` ne peut lui être attaché) ;
|
||||
- ne proposer **aucun** écran de réglage pour elle.
|
||||
|
||||
Ce n'est pas une propriété permanente du contrat : c'est l'état actuel du banc, hérité de
|
||||
l'époque où le mécanisme `sg-ready` n'était pas configurable (il l'est depuis LM-300). La
|
||||
décision de la faire entrer dans la configuration — ou de la faire disparaître — n'est pas
|
||||
prise. Elle est suivie dans `docs/RELEVE_LOTB.md` §9. **Prévoir le cas, ne pas le supposer
|
||||
transitoire.**
|
||||
|
||||
---
|
||||
|
||||
#### Codes — aucune phrase composée par la box
|
||||
|
||||
> La box transporte un **code** et des **paramètres**. La phrase est fabriquée par le client,
|
||||
> dans la langue de son utilisateur.
|
||||
|
||||
Ce n'est pas une préférence de style. La locale nymea est **par connexion** (`JSONRPC.Hello`
|
||||
accepte `o:locale`) : deux utilisateurs d'une même box peuvent avoir deux langues, ce qu'une
|
||||
phrase composée en C++ ne peut pas servir. Par ailleurs `NymeaEnergyJsonHandler` est un
|
||||
`JsonHandler` **sans `pluginId`** : il est structurellement hors du catalogue de traduction
|
||||
nymea, et rien ne rattraperait une phrase française émise ici.
|
||||
|
||||
**`decision.code`** — catalogue fermé. `params` ne contient que des nombres et des booléens ;
|
||||
le **libellé de la charge n'y figure jamais** (le client le résout via `GetLoadConfig`).
|
||||
|
||||
| Code | `params` | Sens |
|
||||
|---|---|---|
|
||||
| `EV_SURPLUS` | — | VE rechargé sur surplus PV |
|
||||
| `EV_SPOT_MARKET` | — | VE rechargé sur créneau tarifaire favorable |
|
||||
| `EV_DEADLINE` | — | VE prioritaire, échéance approchante |
|
||||
| `EV_ECO_MIN` | — | Pas de surplus, courant minimum maintenu (mode EcoMin) |
|
||||
| `EV_IDLE` | — | Pas de surplus, recharge suspendue |
|
||||
| `LOAD_UNAVAILABLE` | `frozenW` | Charge en défaut : aucune commande émise, `frozenW` reste compté au budget. Levée par `ClearLoadFault` |
|
||||
| `LOCK_MIN_ON` | `appliedW`, `budgetW` | Le verrou `minOn` a **relevé** la consigne : puissance engagée non coupable |
|
||||
| `LOCK_MIN_OFF` | `budgetW`, `requestedW` | Le verrou `minOff` interdit tout enclenchement — le budget, lui, payait `requestedW` |
|
||||
| `LOCK_MIN_OFF_CAPPED` | `appliedW`, `budgetW`, `requestedW` | Le verrou `minOff` plafonne sans interdire |
|
||||
| `SURPLUS_SETPOINT` | `budgetW`, `setpointW`, `stepped` | Consigne servie par le surplus ; `stepped` = arrondie sur un palier déclaré |
|
||||
| `SURPLUS_INSUFFICIENT` | `budgetW` | Le budget ne payait aucun palier — **aucun verrou n'a mordu** |
|
||||
| `LOCK_MIN_STATE_HOLD` | `state` | Verrou `minStateHold` : état PAC maintenu (protection court-cycling) |
|
||||
| `SG_FORCED` | `budgetW`, `estimatedW` | PAC en état 4 (forcé) |
|
||||
| `SG_RECOMMENDED` | `budgetW`, `estimatedW` | PAC en état 3 (recommandation) |
|
||||
| `SG_NORMAL` | `budgetW` | PAC en état 2 (normal, mains off) |
|
||||
| `DEGRADED_L2` | — | Consigne de repli du watchdog L2 (compteur muet) |
|
||||
| `SAFE_STATE_RELAY` | — | État sûr avant retrait : tous relais ouverts (ECS-413) |
|
||||
| `SAFE_STATE_SETPOINT` | — | État sûr avant retrait : consigne 0 W (ECS-413) |
|
||||
| `SAFE_STATE_SG_READY` | — | État sûr avant retrait : état 2, **jamais** le blocage (ECS-413) |
|
||||
|
||||
> `LOCK_MIN_ON` / `LOCK_MIN_OFF` / `SURPLUS_INSUFFICIENT` se lisent sur le **même axe** : le
|
||||
> verrou a relevé la consigne, il l'a rabaissée, ou aucun verrou n'a mordu et c'est le budget.
|
||||
> La distinction est le fruit d'ECS-309 et ECS-309-b (corrections de terrain) — un motif faux
|
||||
> envoie diagnostiquer le mauvais problème, avec l'autorité d'une explication.
|
||||
|
||||
**`faultCode`** — absent quand la charge est saine.
|
||||
|
||||
| Code | Sens |
|
||||
|---|---|
|
||||
| `WRITE_FAILED` | Échelle d'écriture épuisée (ECS-410/414) : plus aucune commande émise jusqu'à `ClearLoadFault` |
|
||||
| `THING_MISSING` | Le Thing piloté est absent de la configuration nymea |
|
||||
| `UNUSABLE_ENCODING` | PAC SG-Ready dont l'encodage ne permet pas d'exprimer l'état 2 : jamais pilotable (ECS-110) |
|
||||
|
||||
**`lock.kind`** — absent quand aucun verrou ne mord. `minOn` · `minOff` · `minStateHold`.
|
||||
`remainingS` = secondes restantes.
|
||||
|
||||
> **Pourquoi des secondes et pas des watts.** `lockMinPowerW`/`lockMaxPowerW` sont un artefact
|
||||
> du mécanisme d'allocation, illisible pour un utilisateur ; le temps restant, lui, se lit.
|
||||
> Ceci **n'enfreint pas ECS-306** : cette exigence interdit qu'un index de **palier** franchisse
|
||||
> la frontière d'**allocation**. Une valeur d'**affichage** sortante est un autre chemin — et
|
||||
> `stageW`/`state` sont des watts et des états normés, pas des index.
|
||||
|
||||
**`mechanism`** — union discriminée par `kind`, comme `LoadConfig`. Chaque variante porte sa
|
||||
charge utile, et **seulement la sienne**. Toutes les clés internes sont optionnelles au schéma
|
||||
(règle LM-302) ; tout mécanisme ajouté embarque son test.
|
||||
|
||||
| `kind` | Clés |
|
||||
|---|---|
|
||||
| `relay` | `stageW` (palier appliqué, W), `maxStageW` |
|
||||
| `variable` | `setpointW`, `percent` (0–100, dérivé), `maxPowerW` |
|
||||
| `sgReady` | `state` (1–4), `estimatedPowerW` (**estimation** déclarée, jamais un engagement) |
|
||||
| `evcharger` | `chargingEnabled`, `pluggedIn`, `currentA`, `phaseCount` — **réservé 3g**, non émis en beta |
|
||||
|
||||
---
|
||||
|
||||
### Spot market
|
||||
@ -468,6 +656,26 @@ dégradé L2 (watchdog fraîcheur compteur).
|
||||
|
||||
---
|
||||
|
||||
### `NymeaEnergy.LoadTelemetryChanged`
|
||||
Émise quand l'état runtime d'arbitrage a **changé significativement**, **et** périodiquement
|
||||
(battement de cœur, 60 s) même sans changement.
|
||||
|
||||
Charge utile **identique** à `GetLoadTelemetry` (schéma déclaré une seule fois côté plugin :
|
||||
deux déclarations divergeraient, et c'est l'appel de reprise à froid qui casserait).
|
||||
|
||||
**« Changé significativement »** = mode dégradé, jeu de charges, `available`, `faultCode`,
|
||||
`lock.kind`, `mechanism.kind`, `decision.code`, ou variation d'allocation **> 50 W** (par charge
|
||||
ou sur le total). Sont volontairement **ignorés** : les secondes restantes d'un verrou (elles
|
||||
décroissent à chaque seconde) et les paramètres numériques d'un motif (le budget bouge à chaque
|
||||
cycle) — les comparer ferait notifier en permanence et le seuil ne servirait à rien.
|
||||
|
||||
**Le battement de cœur est ce qui rend un arbitre FIGÉ détectable** : les notifications
|
||||
continuent d'arriver tandis que leur `timestamp` cesse d'avancer. Sans lui, « installation
|
||||
stable » et « moteur arrêté » sont le même silence. Toute notification relance le décompte du
|
||||
battement : la cadence totale reste d'environ **une notification par minute**.
|
||||
|
||||
---
|
||||
|
||||
### `NymeaEnergy.SpotMarketConfigurationChanged`
|
||||
Émis quand le provider actif, l'état enabled ou l'état available change.
|
||||
```json
|
||||
|
||||
@ -1,3 +1,120 @@
|
||||
powersync-energy-plugin-nymea (1.15.2+etm14) trixie; urgency=medium
|
||||
|
||||
* Contrat : loads[] de GetLoadTelemetry n'est PAS en correspondance 1:1 avec GetLoadConfig.
|
||||
Une charge peut être arbitrée et publiée sans exister dans la configuration. Cas réel,
|
||||
vérifié au banc : « pac-terrain » est la PAC SG-Ready CODÉE EN DUR d'energypluginnymea.cpp
|
||||
(contacts K1/K2 = Relay4/Relay5, états {3: 1500 W, 4: 3000 W}, minStateHoldS 300), donc
|
||||
pleinement arbitrée — elle consomme du budget, porte des verrous, apparaît en télémétrie —
|
||||
et pourtant inconfigurable : ni renommage, ni rang, ni domain, ni enabled:false. Un client
|
||||
qui suppose la correspondance affichera une charge sans nom ni domaine, ou cherchera une
|
||||
configuration inexistante. INTERFACE.md porte désormais la règle de détection : un loadId
|
||||
de loads[] sans équivalent dans GetLoadConfig est une charge NON CONFIGURABLE — afficher le
|
||||
loadId, la traiter comme non classée, ne proposer aucun écran de réglage. Aucun champ n'est
|
||||
ajouté à la charge utile pour le signaler : « configurable: false » préjugerait du sort de
|
||||
cette charge, qui n'est pas tranché.
|
||||
* Aucun changement de comportement moteur. Seuls la documentation de contrat, le journal de
|
||||
vérification et les tests bougent — le binaire est fonctionnellement identique à +etm13,
|
||||
dont il ne se distingue que par ce changelog. La version est incrémentée parce qu'un
|
||||
artefact publié doit se relire dans sa propre source : livrer +etm13 avec un changelog
|
||||
embarqué différent de celui du dépôt aurait recréé, en petit, la divergence que ce lot
|
||||
passe son temps à fermer.
|
||||
* Test : runtimeView() est SUBSTANTIELLE pour les quatre adaptateurs, et c'est prouvé par
|
||||
mutation. La pure virtuelle force à RÉPONDRE, pas à répondre quelque chose : un
|
||||
« return LoadRuntimeView{}; » compilerait, passerait le dispatch, et sortirait de la
|
||||
télémétrie sans bruit — le client verrait une charge sans mécanisme ni verrou,
|
||||
indistinguable d'une charge libre. testRuntimeViewIsSubstantial() amène chaque adaptateur
|
||||
dans un état non trivial puis exige les valeurs runtime réelles (palier appliqué, consigne,
|
||||
état normé, pourcentage dérivé à 75 % — ni 0, ni 100, ni la consigne), les verrous en
|
||||
secondes restantes ET leur expiration, et les codes de défaut THING_MISSING et
|
||||
UNUSABLE_ENCODING. Deux mutants « vue vide » ont été introduits puis retirés : le test
|
||||
échoue sur chacun.
|
||||
* Le déploiement sur box entre au dépôt : etm-powersync-deploy/deploy.sh, après l'incident du
|
||||
2026-08-25 où .75 a été rétrogradée de +etm12 à +etm9. Trois causes composées — un glob sur
|
||||
un répertoire de dépôt qui accumulait quatre paquets périmés, un dpkg -i qui autorise la
|
||||
rétrogradation par défaut, et aucune relecture de version après coup. La recette fautive
|
||||
figurait aussi dans docs/TEST_TERRAIN.md §b ; elle est remplacée. Le verrou décisif est
|
||||
l'ARCHIVAGE avant dépôt : la règle sudoers de la box porte elle-même un « *.deb » et ne peut
|
||||
pas être resserrée sans root, mais avec un seul paquet dans le répertoire ce glob ne peut
|
||||
plus désigner qu'un fichier — y compris pour un geste fait à la main.
|
||||
|
||||
-- Patrick Schurig <etm.schurig@gmail.com> Tue, 25 Aug 2026 14:00:00 +0200
|
||||
|
||||
powersync-energy-plugin-nymea (1.15.2+etm13) trixie; urgency=medium
|
||||
|
||||
* Frontière de télémétrie d'arbitrage. Constat d'origine, vérifié au banc : 90 s d'écoute,
|
||||
arbitre actif, ~2,9 kW produits et ~2 kW exportés — ZÉRO notification NymeaEnergy.*.
|
||||
L'état runtime de l'arbitre (allocation, budget, verrous, paliers, disponibilité, motifs)
|
||||
ne franchissait aucune frontière RPC : il vivait exclusivement dans qCInfo(dcNymeaEnergy).
|
||||
Expliquer les décisions au client — promesse présente dans les deux versions de gamme —
|
||||
n'avait donc aucun transport. Ajout de NymeaEnergy.GetLoadTelemetry et de sa notification
|
||||
LoadTelemetryChanged, MÊME charge utile pour les deux.
|
||||
* Aucune phrase composée par la box. La box transporte un CODE et des PARAMÈTRES ; la phrase
|
||||
est fabriquée par le client, dans la langue de son utilisateur. Justification vérifiée :
|
||||
la locale nymea est PAR CONNEXION (JSONRPC.Hello o:locale), donc deux utilisateurs d'une
|
||||
même box peuvent avoir deux langues — impossible à servir avec une phrase composée en C++ ;
|
||||
et NymeaEnergyJsonHandler est un JsonHandler sans pluginId, donc structurellement hors du
|
||||
catalogue de traduction nymea : rien ne rattraperait une phrase française émise là.
|
||||
* LoadAction::reason devient {code, params}. La ligne FRANÇAISE du journal est désormais
|
||||
RENDUE à partir du code par une fonction de rendu unique (renderFr), et non maintenue en
|
||||
parallèle : deux écritures parallèles divergeraient, et c'est le diagnostic terrain qui
|
||||
deviendrait faux. Le catalogue de 19 codes est EXTRAIT des sites existants, pas inventé ;
|
||||
les motifs d'ECS-309 et ECS-309-b sont repris mot pour mot. Prouvé par comparaison de
|
||||
traces (1726 lignes de journal identiques en simulation) et par un test de rendu à table
|
||||
d'or qui échoue au premier code ajouté sans phrase attendue.
|
||||
* Le libellé de la charge n'entre PAS dans la charge utile RPC : c'est de la langue naturelle
|
||||
saisie par l'installateur. Il est substitué au moment du RENDU, par l'appelant qui le
|
||||
connaît. Le client résout le sien via GetLoadConfig.
|
||||
* Les verrous sortent en SECONDES RESTANTES, pas en watts. lockMinPowerW/lockMaxPowerW sont
|
||||
un artefact du mécanisme d'allocation, illisible pour un utilisateur. Ceci n'enfreint pas
|
||||
ECS-306 : cette exigence interdit qu'un index de PALIER franchisse la frontière
|
||||
d'ALLOCATION ; une valeur d'AFFICHAGE sortante est un autre chemin.
|
||||
* Le mécanisme sort en union discriminée (relay | variable | sgReady), comme LoadConfig :
|
||||
kind discrimine, chaque variante porte SA charge utile et seulement la sienne. Toutes les
|
||||
clés internes optionnelles au schéma (règle LM-302). ILoadAdapter::runtimeView() est PURE
|
||||
VIRTUELLE à dessein : un mécanisme ajouté qui hériterait d'un défaut vide sortirait de la
|
||||
télémétrie sans bruit, et le client verrait une charge sans mécanisme ni verrou —
|
||||
indistinguable d'une charge libre.
|
||||
* budget publié : surplusW, evReservedW, recreditedW, allocatedW, remainingW, avec l'identité
|
||||
surplus + recrédit − alloué == restant. recreditedW n'est pas décoratif : le waterfall rend
|
||||
à chaque charge sa consommation de début de cycle avant d'arrondir (anti-clignotement,
|
||||
correction B). Sans ce terme, l'identité serait fausse DÈS QU'UNE CHARGE CONSOMME, c'est-à-
|
||||
dire dans le cas normal, et le client chercherait un écart inexplicable. Il rend aussi
|
||||
lisible l'artefact de banc documenté dans AGENTS.md (relais sans mesure + rootmeter aveugle
|
||||
→ recrédit du nominal commandé).
|
||||
* Champs omis, jamais nuls (règle maison) : timestamp absent = aucun cycle d'arbitrage depuis
|
||||
le démarrage — en forger un se lirait « cycle exécuté, rien à allouer », soit le mensonge
|
||||
même que ce champ existe pour empêcher ; budget absent = mode dégradé L2, où la
|
||||
planification est suspendue et où publier des zéros se lirait « rien à allouer ».
|
||||
* Cadence : émission sur changement QUALITATIF ou variation d'allocation > 50 W, plus un
|
||||
battement de cœur de 60 s même sans changement. C'est le battement qui rend un arbitre
|
||||
FIGÉ détectable : les notifications continuent d'arriver tandis que le timestamp cesse
|
||||
d'avancer. Sans lui, « installation stable » et « moteur arrêté » sont le même silence.
|
||||
Toute notification relance le décompte : la cadence totale reste d'environ une par minute.
|
||||
* GetChargingSchedules rend désormais degradedMode. Il était POUSSÉ en notification seule :
|
||||
un client connecté après la bascule ne pouvait pas savoir dans quel état il se trouvait.
|
||||
Règle : tout élément runtime a un Get* ET un *Changed, jamais une notification seule.
|
||||
La bascule ET la sortie du mode dégradé publient aussi la télémétrie immédiatement.
|
||||
* LoadConfig : champ optionnel domain, énumération FERMÉE (ecs · ev · heating · hvac ·
|
||||
battery). PURE métadonnée d'intention — l'arbitre ne la lit pas, aucun comportement moteur
|
||||
ne change ; elle enregistre le choix de l'installateur. Codes, jamais libellés. Valeur
|
||||
absente = charge non classée : le plugin ne la range pas d'office, parce qu'un classement
|
||||
inventé serait indistinguable d'un classement choisi. Valeur hors énumération rejetée en
|
||||
bloc. Elle ne préempte pas la couche domaine à venir : elle en deviendra le discriminant.
|
||||
* Règle 7-c, deux silences fermés : le retour idempotent du RelayRouter et celui du
|
||||
SgReadyAdapter étaient MUETS, si bien que « consigne reçue, palier déjà bon » ne se
|
||||
distinguait pas de « l'action n'est jamais parvenue à l'adaptateur » — et c'est justement
|
||||
le cas ordinaire qu'on vient vérifier quand une charge ne bouge pas. Journalisés au même
|
||||
niveau que la branche qui commute (7-d, symétrie).
|
||||
* Tests : rendu français à table d'or (19 codes + repli d'un code hors catalogue),
|
||||
télémétrie de bout en bout (notification non nulle, snapshot ≡ notification, union de
|
||||
mécanisme entière, verrous en secondes, seuil qui ne mord pas sur 10 W, battement à
|
||||
timestamp figé, reprise à froid du mode dégradé par les DEUX portes, contrôle mécanique
|
||||
« zéro langue naturelle »), et domain (aller-retour, rejet hors énumération, charge non
|
||||
classée, absence d'effet sur l'arbitrage). Suites : simulation 37/37, charging 46/46,
|
||||
spotmarket 32/32.
|
||||
|
||||
-- Patrick Schurig <etm.schurig@gmail.com> Tue, 25 Aug 2026 12:00:00 +0200
|
||||
|
||||
powersync-energy-plugin-nymea (1.15.2+etm12) trixie; urgency=medium
|
||||
|
||||
* ECS-411-b — la reprise du palier au démarrage s'annonce toujours, et publie ce qu'elle
|
||||
|
||||
@ -101,6 +101,7 @@ piloté par `EtmVariableLoadAdapter`. (`maxPowerW`/`powerLevels` en read optionn
|
||||
"mode": "fixed",
|
||||
"priority": 2, // rang ; 1 = servi en premier
|
||||
"enabled": true,
|
||||
"domain": "ecs", // o: — catégorie d'INTENTION, cf. ci-dessous
|
||||
"needs": { "dailyDeadline": "06:00", "minEnergyWhPerDay": 4000 },
|
||||
"relays": [ // ← la combinatoire vit ICI (routeur), pas chez l'optimiseur
|
||||
{ "thingId": "{uuid-relais-500W}", "powerW": 500 },
|
||||
@ -130,6 +131,30 @@ Règles :
|
||||
- `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).
|
||||
- **`domain` (optionnel)** — énumération FERMÉE : `ecs` · `ev` · `heating` · `hvac` · `battery`.
|
||||
- **Pure métadonnée d'intention.** L'arbitre ne la lit pas ; aucun comportement moteur n'en
|
||||
dépend. Elle enregistre le choix de l'installateur, qui sélectionne une catégorie puis lui
|
||||
rattache ses Things.
|
||||
- **Des codes, jamais des libellés affichables** — même raison que pour les motifs de décision
|
||||
(locale nymea par connexion, handler hors catalogue de traduction ; cf. `INTERFACE.md`).
|
||||
Le nom montré à l'utilisateur est fabriqué par l'app.
|
||||
- **Absente ou vide → charge « non classée ».** Le plugin ne la range pas d'office : il n'a
|
||||
aucun moyen de deviner juste, et un classement inventé serait indistinguable d'un
|
||||
classement choisi.
|
||||
**Forme exacte, vérifiée au banc** : `GetLoadConfig` sérialise via le **méta-objet**, donc
|
||||
**toutes** les propriétés déclarées sortent, y compris vides — une charge non classée revient
|
||||
avec `"domain": ""`, exactement comme `"sgReady": {}`, `"powerLevels": []` ou
|
||||
`"needs": {"dailyDeadline": ""}` sur une charge qui ne les porte pas. C'est le corollaire
|
||||
LM-302, et c'est ce qui rend l'aller-retour `Get` → `Set` possible. La forme **persistée**
|
||||
(fichier JSON), elle, omet la clé. Le client doit donc traiter `""` **et** l'absence comme
|
||||
« non classée » — et ne jamais afficher la chaîne vide comme un domaine.
|
||||
- `SetLoadConfig` **rejette en bloc** une valeur hors énumération. Une valeur libre acceptée
|
||||
aujourd'hui deviendrait une valeur à supporter le jour où la couche domaine en fera son
|
||||
discriminant.
|
||||
- Elle **ne préempte pas** la couche domaine de `specs/spec_loadmodel.md` : elle en deviendra
|
||||
le discriminant quand cette couche arrivera. L'énumération de ce spec (§2 :
|
||||
`Ecs`/`Hvac`/`Ev`/`SmartHome`) est une intention plus ancienne ; c'est celle ci-dessus qui
|
||||
est implémentée.
|
||||
|
||||
---
|
||||
|
||||
@ -163,7 +188,22 @@ routeur**. Le kind `Stage` reste supprimé (rév. 2). `reason` reste porté par
|
||||
|
||||
---
|
||||
|
||||
## 7. Expo du `reason` — tableau additif `loadActions[]` (inchangé)
|
||||
## 7. Expo du `reason` — ⚠️ REMPLACÉ par `NymeaEnergy.GetLoadTelemetry`
|
||||
|
||||
> **Ce paragraphe décrit une intention qui n'a jamais été implémentée, et qui ne le sera pas.**
|
||||
> `loadActions[]` n'existe pas dans `GetChargingSchedules` ni dans `ChargingSchedulesChanged`.
|
||||
> Le canal réel de la *decision card* est `NymeaEnergy.GetLoadTelemetry` + sa notification
|
||||
> `LoadTelemetryChanged` (contrat : `INTERFACE.md`).
|
||||
>
|
||||
> Une différence de fond, et non de forme : le `reason` ci-dessous est une **phrase française
|
||||
> composée par la box**. C'est intenable — la locale nymea est par CONNEXION, et ce handler est
|
||||
> hors du catalogue de traduction. La télémétrie transporte donc `{code, params}`, et la phrase
|
||||
> est fabriquée par le client. La ligne française du journal, elle, est **rendue depuis le même
|
||||
> code** par une fonction unique (`renderFr`), pour qu'elle ne puisse pas en diverger.
|
||||
|
||||
Forme historique, conservée pour mémoire :
|
||||
|
||||
## 7-bis. Ancienne forme (mémoire)
|
||||
|
||||
Dans `GetChargingSchedules` / `ChargingSchedulesChanged` :
|
||||
```jsonc
|
||||
@ -232,6 +272,9 @@ toucher à l'écran Configurer, sinon il code contre une frontière périmée (r
|
||||
- **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`.
|
||||
- **lot B (télémétrie)** : `domain` ajouté à `LoadConfig` (§4, optionnel, énumération fermée) ;
|
||||
§7 remplacé — le `reason` sort désormais en `{code, params}` par
|
||||
`NymeaEnergy.GetLoadTelemetry` / `LoadTelemetryChanged`, plus jamais en phrase composée.
|
||||
- **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
|
||||
|
||||
501
docs/RELEVE_LOTB.md
Normal file
501
docs/RELEVE_LOTB.md
Normal file
@ -0,0 +1,501 @@
|
||||
# RELEVÉ — lot B : frontière de télémétrie + `o:domain`
|
||||
|
||||
Version du paquet vérifié : **`1.15.2+etm13`**, republiée en **`1.15.2+etm14`** (binaire
|
||||
fonctionnellement identique ; seuls changelog, contrat et tests diffèrent — cf. §8-bis) · Machine : **`.75` (`hems`, arm64, nymead
|
||||
`1.15.2+202606191336~trixie1`)** · Date : **2026-08-25**.
|
||||
|
||||
> **Ce qui compte dans ce document, c'est la colonne « où ».** Un extrait issu d'un test
|
||||
> unitaire ne vaut pas vérification machine, et il est annoncé comme tel. Une seule exigence
|
||||
> n'a PAS été vérifiée sur `.75` — le critère 3 — et la raison est écrite en toutes lettres au
|
||||
> §3, avec la procédure exacte pour la clore.
|
||||
|
||||
---
|
||||
|
||||
## 0. Le constat d'origine, rejoué AVANT le déploiement
|
||||
|
||||
Sur `.75`, **paquet `1.15.2+etm9` alors installé**, abonnement `NymeaEnergy` par WebSocket
|
||||
(`ws://localhost:4444`, auth désactivée au banc) :
|
||||
|
||||
```
|
||||
2026-08-25T11:18:24+01:00
|
||||
# abonnement: {"enabled": true, "namespaces": ["NymeaEnergy"]}
|
||||
# ecoute de 90 s terminee
|
||||
# 0 notification(s) au total
|
||||
2026-08-25T11:19:54+01:00
|
||||
```
|
||||
|
||||
L'arbitre était **actif** pendant cette même fenêtre — journal `nymead`, 11:19:00 :
|
||||
|
||||
```
|
||||
[Arbitre] "chauffe-eau" → "Surplus PV 10907 W — chauffe-eau consigne 3500 W (palier)" | …
|
||||
[Arbitre] "pac-terrain" → "Surplus abondant 10407 W — PAC banc forcée (état 4, ~3000 W)" | …
|
||||
```
|
||||
|
||||
**Constat reproduit : arbitre qui décide, frontière RPC muette.** C'est le point de départ du
|
||||
lot, et il est désormais daté et mesuré plutôt que rapporté.
|
||||
|
||||
Déploiement : `1.15.2+etm13` installé à 11:21:35, `nymead` redémarré à 11:21:45. Reprise
|
||||
ECS-411 au démarrage, lue dans le journal :
|
||||
|
||||
```
|
||||
[RelayRouter] chauffe-eau — reprise au démarrage : palier 3500 W [correspondance exacte] ;
|
||||
relais lus : {2ebe6be(1000W)=fermé, {8538782(500W)=fermé, {b033b21(2000W)=fermé ; somme lue 3500 W
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 1. Critère 1 — le test du constat, rejoué APRÈS
|
||||
|
||||
**Vérifié sur `.75`.** Même geste, même durée, paquet `etm13` :
|
||||
|
||||
| Fenêtre | Durée | Notifications `NymeaEnergy.*` |
|
||||
|---|---|---|
|
||||
| 11:18:24 → 11:19:54 (**etm9**) | 90 s | **0** |
|
||||
| 11:22:07 → 11:23:37 (**etm13**) | 90 s | **2** |
|
||||
| 11:25:31 → 11:28:32 (**etm13**) | 180 s | **4** |
|
||||
| 11:31:44 → 11:39:45 (**etm13**) | 480 s | **13** |
|
||||
|
||||
**Cohérence charge utile ↔ journal, même fenêtre.** Exemple le plus instructif — cycle du
|
||||
11:33:00, où **deux verrous** mordent en même temps.
|
||||
|
||||
Notification reçue :
|
||||
|
||||
```jsonc
|
||||
{ "timestamp": "2026-08-25T10:33:00Z", "degradedMode": false,
|
||||
"budget": { "surplusW": -250.5, "evReservedW": 0, "recreditedW": 3000,
|
||||
"allocatedW": 3000, "remainingW": -250.5 },
|
||||
"loads": [
|
||||
{ "loadId": "chauffe-eau", "allocatedW": 1500, "available": true,
|
||||
"lock": { "kind": "minOn", "remainingS": 1 },
|
||||
"mechanism": { "kind": "relay", "stageW": 1500, "maxStageW": 3500 },
|
||||
"decision": { "code": "LOCK_MIN_ON", "params": { "appliedW": 1500, "budgetW": 1250 } } },
|
||||
{ "loadId": "pac-terrain", "allocatedW": 1500, "available": true,
|
||||
"lock": { "kind": "minStateHold", "remainingS": 181 },
|
||||
"mechanism": { "kind": "sgReady", "state": 3, "estimatedPowerW": 1500 },
|
||||
"decision": { "code": "LOCK_MIN_STATE_HOLD", "params": { "state": 3 } } } ] }
|
||||
```
|
||||
|
||||
Journal `nymead` du même instant :
|
||||
|
||||
```
|
||||
11:33:00 [Arbitre] "chauffe-eau" → "Verrou minOn — chauffe-eau maintenue à 1500 W
|
||||
(puissance engagée, budget 1250 W) ; le résidu en tient compte"
|
||||
11:33:00 [Arbitre] "pac-terrain" → "Verrou minStateHold — PAC banc maintenue état 3 (court-cycling PAC)"
|
||||
```
|
||||
|
||||
`appliedW: 1500` / `budgetW: 1250` ↔ « maintenue à 1500 W … budget 1250 W » : **le code et ses
|
||||
paramètres, et la phrase rendue depuis eux, disent la même chose au mot près.** C'est la preuve
|
||||
que le rendu n'est pas une seconde écriture.
|
||||
|
||||
**Identité du budget vérifiée sur la machine** : `−250,5 + 3000 − 3000 = −250,5`. ✔
|
||||
|
||||
**Et ce chiffre rend visible l'artefact de banc.** Le compteur SunSpec est **aveugle** aux
|
||||
résistances câblées sur les GPIO, et les relais n'exposent pas de `currentPower` : le recrédit
|
||||
crédite donc le **nominal commandé**. On lit ici, en clair, `surplusW = −250 W` (le site
|
||||
soutire) avec `recreditedW = 3000 W` — le double comptage documenté dans `AGENTS.md` §
|
||||
« Correction B », désormais **lisible dans la charge utile** au lieu d'être à redéduire. Sans le
|
||||
champ `recreditedW`, un client verrait « surplus −250 W, alloué 3000 W » et conclurait à un bug.
|
||||
|
||||
**Codes observés sur la machine** (6 des 19) : `SURPLUS_SETPOINT`, `SURPLUS_INSUFFICIENT`,
|
||||
`LOCK_MIN_ON`, `SG_FORCED`, `SG_RECOMMENDED`, `SG_NORMAL`, `LOCK_MIN_STATE_HOLD`.
|
||||
|
||||
**Volume mesuré sur `.75`** (demandé au brief) : 13 notifications / 480 s, **9 411 octets** de
|
||||
charge utile cumulée, soit **≈ 1,2 ko/min** et **≈ 1,7 Mo/jour pour UN client abonné**, avec
|
||||
2 charges. Négligeable pour la cible RPi Zero 2 W.
|
||||
|
||||
---
|
||||
|
||||
## 2. Critère 2 — snapshot ≡ notification
|
||||
|
||||
**Vérifié sur `.75`** (paquets `etm13` puis `etm14`). `GetLoadTelemetry` appelé **immédiatement**
|
||||
après une notification :
|
||||
|
||||
```
|
||||
# ecoute de 5 s terminee (arretee a la 1re notification, pour interroger JUSTE APRES)
|
||||
# snapshot == derniere notification ? OUI
|
||||
```
|
||||
|
||||
Comparaison faite sur le JSON complet, clés triées. Le `Get` **ne recalcule pas** : il rend
|
||||
l'objet publié. Un recalcul donnerait des `remainingS` rafraîchis sous un `timestamp` périmé —
|
||||
un état qui n'a jamais existé.
|
||||
|
||||
> **« Juste après » est une condition du critère, pas une formule.** Une première mesure a
|
||||
> répondu `NON` : l'outil écoutait 100 s **puis** interrogeait, et un cycle d'arbitrage s'était
|
||||
> intercalé — un cycle **sous le seuil**, donc sans notification. Le `Get` rendait alors,
|
||||
> légitimement, un instantané **plus récent** (`11:16:00Z` contre `11:15:00Z`).
|
||||
>
|
||||
> Ce n'était pas un défaut du moteur mais de la mesure, et il enseigne une propriété qu'il vaut
|
||||
> mieux écrire que redécouvrir : **le cache suit CHAQUE cycle, y compris ceux qui ne notifient
|
||||
> pas.** L'égalité « snapshot ≡ notification » vaut donc **à l'instant d'une notification**, pas
|
||||
> indéfiniment — c'est même souhaitable : un client qui interroge apprend l'état le plus récent,
|
||||
> pas le dernier qu'on a jugé digne d'un réveil. L'outil `/tmp/nymea_rpc.py` s'arrête désormais
|
||||
> à la première notification avant d'interroger.
|
||||
|
||||
Également couvert en test (`testLoadTelemetryRpc`) sous les deux angles : `QCOMPARE` du Get avec
|
||||
la notification, **et** l'assertion inverse — après une variation de 10 W qui ne notifie pas, le
|
||||
`Get` a bien avancé d'un cycle.
|
||||
|
||||
---
|
||||
|
||||
## 3. Critère 3 — reprise à froid du mode dégradé ⚠️ **NON vérifié sur `.75`**
|
||||
|
||||
**Ce qui est vérifié sur `.75`** : les **deux portes** existent et concordent.
|
||||
|
||||
```
|
||||
# GetLoadTelemetry.degradedMode = false
|
||||
# GetChargingSchedules.degradedMode = False ← champ ABSENT avant ce lot
|
||||
```
|
||||
|
||||
Le défaut corrigé — `degradedMode` poussé en notification seule, illisible par un client
|
||||
connecté après la bascule — est donc **fermé et constaté sur la machine**.
|
||||
|
||||
**Ce qui n'est PAS vérifié sur `.75`** : l'**épisode dégradé lui-même**. Le déclencher demande
|
||||
de faire taire le compteur plus de 90 s. Le rootmeter du banc est un **Fronius SunSpec réel sur
|
||||
`192.168.1.211:502`** — le simulateur `etm-powersync-hems-sim`, une infrastructure **partagée,
|
||||
hors de ce dépôt**. L'arrêter n'entrait ni dans le périmètre du lot ni dans ce qui m'a été
|
||||
demandé, et le laisser arrêté par accident coûterait plus cher que la vérification ne rapporte.
|
||||
|
||||
**Vérifié en SIMULATION uniquement** (`testLoadTelemetryRpc`), dans les deux sens :
|
||||
- bascule → notification `degradedMode: true`, **sans clé `budget`** (planification suspendue :
|
||||
publier des zéros se lirait « arbitrage exécuté, rien à allouer ») ; chaque charge porte
|
||||
`decision.code == "DEGRADED_L2"` et `allocatedW == 0` ;
|
||||
- **les deux portes** relues après la bascule : `GetLoadTelemetry.degradedMode == true` **et**
|
||||
`GetChargingSchedules.degradedMode == true` ;
|
||||
- sortie → notification immédiate `degradedMode: false` par les deux portes, sans attendre le
|
||||
cycle suivant.
|
||||
|
||||
**Pour clore le critère au banc**, trois gestes (≈ 4 min) :
|
||||
|
||||
```bash
|
||||
# 1. faire taire le simulateur SunSpec plus de 90 s
|
||||
# (sur l'hôte docker du simulateur, PAS sur .75)
|
||||
docker compose stop <service-fronius> ; sleep 100
|
||||
# 2. lire les DEUX portes depuis .75
|
||||
ssh etm@192.168.1.75 'python3 /tmp/nymea_rpc.py snapshot 30 NymeaEnergy'
|
||||
# 3. relancer le simulateur, vérifier la sortie du mode dégradé
|
||||
docker compose start <service-fronius>
|
||||
ssh etm@192.168.1.75 'python3 /tmp/nymea_rpc.py snapshot 30 NymeaEnergy'
|
||||
```
|
||||
|
||||
Attendu : `degradedMode: true`, **pas de clé `budget`**, `decision.code == "DEGRADED_L2"` sur
|
||||
les deux charges, et `GetChargingSchedules.degradedMode` à `true` ; puis retour à `false`.
|
||||
|
||||
---
|
||||
|
||||
## 4. Critère 4 — arbitre figé détectable
|
||||
|
||||
**Vérifié sur `.75`.** Le battement de cœur republie l'instantané **sans que le `timestamp`
|
||||
avance**. Extrait de la fenêtre de 480 s (`t` = seconde d'écoute) :
|
||||
|
||||
| `t` | `timestamp` publié | origine |
|
||||
|---|---|---|
|
||||
| 75,1 s | `10:33:00Z` | cycle |
|
||||
| 135,1 s | `10:33:00Z` | **battement — horodatage INCHANGÉ** |
|
||||
| 135,1 s | `10:34:00Z` | cycle suivant |
|
||||
| 195,1 s | `10:34:00Z` | **battement — horodatage INCHANGÉ** |
|
||||
| 315,1 s | `10:37:00Z` | cycle |
|
||||
| 375,3 s | `10:37:00Z` | **battement — horodatage INCHANGÉ** |
|
||||
|
||||
Un client reçoit donc **toujours** un état, et c'est le `timestamp` qui lui dit si l'arbitre
|
||||
décide encore. Sans battement, « installation stable » et « moteur arrêté » sont le même
|
||||
silence — c'est la règle 7-c appliquée à la frontière RPC.
|
||||
|
||||
Le battement ne double pas une notification fraîche : **chaque publication relance son
|
||||
décompte**, d'où la cadence observée d'environ une notification par 45 s (cycle 60 s +
|
||||
battement 60 s décalé), et non deux par minute.
|
||||
|
||||
**Le seuil, vérifié en test** (`testLoadTelemetryRpc`) : une variation de **10 W** ne notifie
|
||||
pas, alors que le `Get` a bien avancé — l'état interne est à jour sans réveiller les abonnés.
|
||||
Sur la machine, le même effet se lit au cycle 11:28:00 : le surplus passe de 6321 W à 4473 W
|
||||
sans qu'aucune allocation ne bouge (`allocatedW` reste 6500 W) → **aucune notification**.
|
||||
|
||||
---
|
||||
|
||||
## 5. Critère 5 — zéro langue naturelle dans la charge utile
|
||||
|
||||
**Vérifié sur `.75`** (inspection des 13 charges utiles capturées) **et mécaniquement en test**.
|
||||
|
||||
Le test `testLoadTelemetryRpc` parcourt récursivement la charge utile et exige de **toute**
|
||||
valeur de type chaîne : ASCII strict **et sans espace**. Une phrase française en porterait
|
||||
forcément (accents et/ou espaces).
|
||||
|
||||
Inventaire des littéraux réellement émis (extrait du code, pas de la doc) :
|
||||
|
||||
```
|
||||
allocatedW available budget chargingEnabled currentA decision degradedMode estimatedPowerW
|
||||
evReservedW evcharger faultCode kind loadId loads lock maxPowerW maxStageW mechanism minOff
|
||||
minOn minStateHold percent phaseCount pluggedIn recreditedW relay remainingS remainingW
|
||||
setpointW sgReady stageW state surplusW timestamp variable
|
||||
THING_MISSING UNUSABLE_ENCODING WRITE_FAILED
|
||||
```
|
||||
|
||||
plus les 19 codes de `DecisionCode` (`EV_SURPLUS` … `SAFE_STATE_SG_READY`). Tous ASCII, tous
|
||||
des codes.
|
||||
|
||||
**Le libellé de la charge n'entre pas dans la charge utile** : « chauffe-eau », « PAC banc »
|
||||
n'apparaissent nulle part dans le JSON — seulement les `loadId`. Le libellé est substitué au
|
||||
**rendu**, côté journal, par l'appelant qui le connaît.
|
||||
|
||||
---
|
||||
|
||||
## 6. Critère 6 — journal INCHANGÉ
|
||||
|
||||
Prouvé **trois fois**, par comparaison et non par relecture.
|
||||
|
||||
**(a) Sur `.75`** — mêmes situations avant/après le déploiement, formes identiques :
|
||||
|
||||
| Situation | `etm9` (avant) | `etm13` (après) |
|
||||
|---|---|---|
|
||||
| consigne servie | `[Arbitre] "chauffe-eau" → "Surplus PV 10907 W — chauffe-eau consigne 3500 W (palier)"` | `[Arbitre] "chauffe-eau" → "Surplus PV 8445 W — chauffe-eau consigne 3500 W (palier)"` |
|
||||
| PAC forcée | `"Surplus abondant 10407 W — PAC banc forcée (état 4, ~3000 W)"` | `"Surplus abondant 7945 W — PAC banc forcée (état 4, ~3000 W)"` |
|
||||
| commutation relais | `[RelayRouter] "chauffe-eau" → consigne 3000 W → palier 3000 W \| "…"` | `[RelayRouter] "chauffe-eau" → consigne 2500 W → palier 2500 W \| "…"` |
|
||||
|
||||
Seuls les nombres diffèrent (le profil PV n'est pas le même à deux minutes d'écart). Cinq
|
||||
motifs supplémentaires ont été observés inchangés au fil de la descente du profil :
|
||||
`Surplus insuffisant (247 W) — chauffe-eau à 0 W`, `… recommandée (état 3, ~1500 W)`,
|
||||
`… en normal (état 2, non pilotée)`, `Verrou minOn — … (puissance engagée, budget 1250 W) ; le
|
||||
résidu en tient compte`, `Verrou minStateHold — … (court-cycling PAC)`.
|
||||
|
||||
**(b) En simulation, diff binaire** — les 1 726 lignes de motif produites par la suite complète
|
||||
sont **identiques** avant et après la refonte (seuls les ThingIds, aléatoires par exécution,
|
||||
sont normalisés) :
|
||||
|
||||
```
|
||||
diff baseline-motifs.norm after-motifs.norm → aucun écart
|
||||
```
|
||||
|
||||
**(c) Table d'or** — `testDecisionReasonRendering` vérifie les **19** codes du catalogue contre
|
||||
la phrase attendue, mot pour mot, et **échoue si un code est ajouté sans ligne de rendu**. Un
|
||||
test « motif non vide » n'aurait rien vu ici : c'est exactement la leçon d'ECS-309.
|
||||
|
||||
**Un ajout assumé au journal** : le retour idempotent du `RelayRouter` et celui du
|
||||
`SgReadyAdapter` étaient **muets** (règle 7-c). Ils produisent désormais une ligne
|
||||
`… INCHANGÉ (aucune commutation)`, au même niveau que la branche qui commute. Ce n'est pas une
|
||||
modification d'un motif existant : c'est un silence comblé. Coût mesuré : une ligne par charge
|
||||
et par cycle, soit ≈ 2 900 lignes/jour pour les 2 charges du banc.
|
||||
|
||||
---
|
||||
|
||||
## 7. Critère 7 — `o:domain`
|
||||
|
||||
**Vérifié sur `.75`**, sur la charge réelle du banc, puis **configuration restaurée à
|
||||
l'identique** :
|
||||
|
||||
```
|
||||
# config d'origine → domain = "" (charge NON classée)
|
||||
# hors énumération ("chauffe-eau") → EnergyErrorInvalidParameter
|
||||
# domain="ecs" → EnergyErrorNoError
|
||||
# relu → domain = 'ecs'
|
||||
# aller-retour Get→Set → EnergyErrorNoError
|
||||
# relu après aller-retour → domain = 'ecs'
|
||||
# restauration → EnergyErrorNoError | domain = ''
|
||||
# config identique à l'origine ? → OUI
|
||||
```
|
||||
|
||||
**Une découverte de forme, faite au banc et corrigée dans la doc et le test.** `GetLoadConfig`
|
||||
sérialise via le **méta-objet**, donc une charge non classée revient avec `"domain": ""`, et non
|
||||
avec la clé absente — exactement comme `"sgReady": {}` ou `"powerLevels": []` sur une charge qui
|
||||
ne les porte pas. C'est le corollaire LM-302, et c'est ce qui rend l'aller-retour possible. La
|
||||
forme **persistée** (fichier JSON), elle, omet la clé. La documentation le dit maintenant, et
|
||||
l'assertion du test est **exacte** plutôt que permissive : une tolérance masquerait un
|
||||
changement de forme, or c'est la forme que l'app consomme.
|
||||
|
||||
**Aucun effet sur l'arbitrage**, vérifié en test (charge servie à l'identique avec et sans
|
||||
domaine) et sur la machine : les écritures de configuration ci-dessus n'ont produit **aucune
|
||||
commutation de relais** — ECS-412 conserve l'adaptateur, le domaine n'étant pas du « matériel ».
|
||||
|
||||
---
|
||||
|
||||
## 8. Critère 8 — suites de tests, paquet, déploiement
|
||||
|
||||
| Élément | Où | Résultat |
|
||||
|---|---|---|
|
||||
| Suite simulation | sandbox amd64 | **37/37** (34 avant le lot + 3 ajoutés) |
|
||||
| Suite charging | sandbox amd64 | **46/46** |
|
||||
| Suite spotmarket | sandbox amd64 | **32/32** |
|
||||
| Suites en build **debug** (`Q_ASSERT_X` du validateur JSON-RPC **actifs**) | sandbox amd64 | **37/37** — le schéma déclaré correspond donc à la charge utile émise, retours **et** notifications |
|
||||
| Build amd64 | sandbox | 0 erreur, 0 avertissement neuf |
|
||||
| Cross-build arm64 | LXD `build-cross-arm64` | 0 erreur, **0 avertissement de compilation** |
|
||||
| Paquet | — | `powersync-energy-plugin-nymea_1.15.2+etm13_arm64.deb`, `Architecture: arm64`, `Depends: libnymea-energy (>= 1.15.2+202606191336~trixie1)` — **aligné sur la box**, `Provides/Conflicts/Replaces: nymea-energy-plugin-nymea` (Voie B) |
|
||||
| Déploiement | `.75` | installé 11:21:35, `nymead` redémarré 11:21:45, `dpkg -l` → `ii … 1.15.2+etm13` |
|
||||
| Doxygen (`./tools/gen-doc.sh`) | sandbox | **10 avertissements — inchangé**, aucun n'appartient à ce lot |
|
||||
|
||||
> Rollback disponible sans rebuild : `/var/lib/etm-deploy/` contient toujours
|
||||
> `…_1.15.2+etm9_arm64.deb` (la version qui tournait avant), et le compte `etm` a la règle
|
||||
> `NOPASSWD` qui permet `dpkg -i` + `systemctl restart nymead`.
|
||||
|
||||
---
|
||||
|
||||
## 8-bis. Trois points repris après première revue
|
||||
|
||||
### (a) `deploy.sh` — la faille de déploiement, fermée sur la machine
|
||||
|
||||
**Enquête.** Le script incriminé **n'existait dans aucun dépôt** : il vivait dans `/tmp` sur la
|
||||
box (`/tmp/etm-deploy-etm12.sh`, `/tmp/etm-deploy-and-capture.sh`, retrouvés dans
|
||||
`~/.bash_history` de `.75`), donc hors de tout suivi de version — exactement la cause racine
|
||||
déjà documentée pour `publish-to-repo.sh` dans `DEPLOY.md` §7.4-bis. Ce n'est pas un
|
||||
« deploy.sh à corriger », c'est un **script à faire entrer au dépôt**. Il est entré :
|
||||
`etm-powersync-deploy/deploy.sh`.
|
||||
|
||||
Le `docs/TEST_TERRAIN.md` de CE dépôt portait aussi la recette fautive — `scp … /tmp/` puis
|
||||
`sudo dpkg -i /tmp/…_*_arm64.deb` — avec le même glob. Remplacée.
|
||||
|
||||
**Les trois causes, et il fallait les trois** : un glob sur un répertoire qui accumule ·
|
||||
`dpkg -i` autorise la rétrogradation par défaut (`dpkg --force-help` : `[*] downgrade`,
|
||||
**vérifié sur `.75`**) · aucune relecture de la version après coup.
|
||||
|
||||
**Les quatre verrous, vérifiés sur `.75`** :
|
||||
|
||||
| Verrou | Preuve |
|
||||
|---|---|
|
||||
| Archivage AVANT dépôt (`archive/`, sous-répertoire hors de portée de `*.deb`) | le répertoire de dépôt contient désormais **exactement 1** paquet ; les 4 périmés (`etm4/8/9/10`) sont dans `archive/` |
|
||||
| Installation par NOM | `→ dpkg -i /var/lib/etm-deploy/powersync-energy-plugin-nymea_….deb` |
|
||||
| Contrôle AVANT | tentative de repose d'`etm9` sur `etm13` → `RÉTROGRADATION REFUSÉE`, **code de sortie 1** |
|
||||
| Contrôle APRÈS + état du service | `✓ version installée confirmée` puis `✓ nymead actif` |
|
||||
|
||||
> **Le verrou qui compte le plus est l'archivage, et ce n'est pas celui qu'on attend.** La règle
|
||||
> sudoers de la box est *exactement* `/usr/bin/dpkg -i /var/lib/etm-deploy/*.deb` : elle porte
|
||||
> le glob, et la resserrer demanderait root sur chaque box (le nom de fichier change à chaque
|
||||
> version, on ne peut pas le figer). L'archivage la rend **inoffensive** : avec un seul `.deb`
|
||||
> dans le répertoire, le glob ne peut désigner qu'un fichier — y compris pour un geste fait à
|
||||
> la main, hors script. C'est le seul des quatre verrous qui protège ce que le script ne
|
||||
> contrôle pas.
|
||||
>
|
||||
> `dpkg --refuse-downgrade` a été **écarté volontairement** : toute option supplémentaire fait
|
||||
> échouer la correspondance sudoers, et le déploiement ne passerait plus sans mot de passe. Le
|
||||
> contrôle vit donc dans le script, où il est en prime visible et journalisé.
|
||||
|
||||
**Correction factuelle.** `+etm12` **n'est pas perdu**. Il est dans le dépôt APT, canal
|
||||
`powersync-nightly`, avec son binaire en pool :
|
||||
|
||||
```
|
||||
$ reprepro -b /mnt/builddisk/apt-repo list powersync-nightly powersync-energy-plugin-nymea
|
||||
powersync-nightly|main|arm64: powersync-energy-plugin-nymea 1.15.2+etm12
|
||||
$ ls /mnt/builddisk/apt-repo/pool/main/p/powersync-energy-plugin-nymea/
|
||||
powersync-energy-plugin-nymea_1.15.2+etm12_arm64.deb
|
||||
powersync-energy-plugin-nymea-dbgsym_1.15.2+etm12_arm64.deb
|
||||
```
|
||||
|
||||
Ce qui a été perdu, c'est **l'état déployé**, pas la version. `.75` était réparable depuis le
|
||||
dépôt APT à tout moment. Les canaux `stable` et `testing`, eux, ne contiennent aucun
|
||||
`powersync-energy-plugin-nymea` — c'est peut-être là qu'a porté le regard.
|
||||
|
||||
### (b) `pac-terrain` — d'où elle vient, et ce que ça change pour l'app
|
||||
|
||||
**Vérifié sur `.75`, verdict sans ambiguïté : c'est bien l'exemplaire CODÉ EN DUR.**
|
||||
|
||||
| Preuve | Constat |
|
||||
|---|---|
|
||||
| Relais commandés | `beaf92e1-…` → **Relay4**, `bf236e64-…` → **Relay5** — exactement K1/K2 d'`energypluginnymea.cpp:66-76` |
|
||||
| Estimations | `{3: 1500 W, 4: 3000 W}` — les valeurs codées en dur, retrouvées telles quelles dans `mechanism.estimatedPowerW` |
|
||||
| `minStateHoldS` | 300 s, cohérent avec les `lock.remainingS` observés (181 s, 300 s) |
|
||||
| `GetLoadConfig` | **une seule** charge : `chauffe-eau` — aucune entrée `sg-ready` |
|
||||
| Code | `registerSgReadyAdapter()` n'a **qu'un** appelant en production : `energypluginnymea.cpp:75` |
|
||||
|
||||
**Conséquence directe sur ce lot, portée au contrat.** `GetLoadTelemetry` publie **deux**
|
||||
charges, `GetLoadConfig` **une**. `loads[]` n'est donc **pas** en correspondance 1:1 avec la
|
||||
configuration : `pac-terrain` est pleinement arbitrée — elle consomme du budget, porte des
|
||||
verrous, apparaît dans la télémétrie — et reste **inconfigurable** : ni renommage, ni rang, ni
|
||||
`domain`, ni `enabled: false`.
|
||||
|
||||
C'est écrit dans `INTERFACE.md`, section `GetLoadTelemetry`, avec la règle de détection pour le
|
||||
client : *un `loadId` de `loads[]` sans équivalent dans `GetLoadConfig` est une charge non
|
||||
configurable ; afficher le `loadId`, la traiter comme non classée, ne proposer aucun écran de
|
||||
réglage.* Objectif : que l'agent app **prévoie** ce cas au lieu de le découvrir.
|
||||
|
||||
**Aucun champ n'a été ajouté à la charge utile pour le signaler.** Un `configurable: false`
|
||||
serait utile — mais il préjuge de la réponse à la question ci-dessous, et deviendrait du poids
|
||||
mort si la PAC entre en configuration. La règle de détection est dérivable sans lui.
|
||||
|
||||
**❓ QUESTION OUVERTE — à trancher, pas par moi.** Deux issues, exclusives :
|
||||
|
||||
1. **L'exposer comme charge normale** — assumer qu'une charge arbitrée peut être hors
|
||||
configuration, et ajouter alors `configurable: false` à la charge utile pour que l'app n'ait
|
||||
pas à le déduire par différence d'ensembles.
|
||||
2. **La faire disparaître** au profit d'une vraie config `sg-ready` — techniquement **possible
|
||||
depuis LM-300** : le mécanisme est configurable, `rebuildLoadAdapters()` sait déjà retirer
|
||||
l'exemplaire en dur quand une charge de configuration revendique ses contacts
|
||||
(`energyarbitrator.cpp`, bloc `revendiquesParConfig`). Il ne resterait qu'à écrire la config
|
||||
du banc et à supprimer le bloc `energypluginnymea.cpp:66-76`. Effet de bord à mesurer : la
|
||||
reconstruction **réarme les verrous** (ECS-412), donc `minStateHold` repart à 900 s sur une
|
||||
PAC réelle.
|
||||
|
||||
`AGENTS.md` la classait déjà comme dette (« reste codé en dur : le SgReadyAdapter du banc »),
|
||||
avec une **précondition levée depuis** : le `Q_ASSERT(m_stateRelays.contains(2))` a disparu au
|
||||
profit du refus explicite `m_usable`. La voie 2 n'est donc plus bloquée.
|
||||
|
||||
### (c) `runtimeView()` — substantielle, prouvé par mutation
|
||||
|
||||
**La pure virtuelle force à RÉPONDRE, pas à répondre quelque chose.** Un
|
||||
`return LoadRuntimeView{};` compilerait, passerait le dispatch, et sortirait de la télémétrie
|
||||
sans bruit — le client verrait une charge sans mécanisme ni verrou, indistinguable d'une charge
|
||||
libre. Le compilateur ne voit pas ça.
|
||||
|
||||
Second verrou posé : `testRuntimeViewIsSubstantial()`, sur **les quatre** adaptateurs. Chacun
|
||||
est amené dans un état **non trivial** avant lecture, et les valeurs attendues sont choisies
|
||||
pour qu'aucun défaut plausible ne passe par accident :
|
||||
|
||||
| Adaptateur | Vue exigée |
|
||||
|---|---|
|
||||
| `RelayRouter` | `relay` · `stageW = 2500` (palier réellement appliqué) · `maxStageW = 2500` · `lock{minOn, 255 s}` à t+45 s sur `minOnS = 300` · **et le verrou expire** à t+301 s |
|
||||
| `EtmVariableLoadAdapter` | `variable` · `setpointW = 3000` · `maxPowerW = 4000` · `percent = 75,0` (dérivé — ni 0, ni 100, ni la consigne) · **aucun** verrou, asserté et non toléré · `THING_MISSING` sur un thing absent |
|
||||
| `SgReadyAdapter` | `sgReady` · `state = 3` (le défaut est 2) · `estimatedPowerW = 1500` · `lock{minStateHold, 780 s}` · expire à t+901 s · `UNUSABLE_ENCODING` sur un encodage sans état 2 |
|
||||
| `EvAdapter` | `evcharger` + ses 4 clés — hors charge utile en beta, mais écrit dès maintenant : en 3g il n'y aura rien à écrire |
|
||||
|
||||
Plus un **verrou générique** sur la liste des quatre : `mechanismKind` non vide, `mechanism` non
|
||||
vide, et **au moins deux clés** — une charge utile réduite au seul discriminant serait une vue
|
||||
vide déguisée.
|
||||
|
||||
**Preuve que le test mord** (un test qui passe sans pouvoir attraper le défaut ne vaut rien).
|
||||
Deux mutants introduits puis retirés :
|
||||
|
||||
| Mutant | Résultat |
|
||||
|---|---|
|
||||
| `EtmVariableLoadAdapter::runtimeView()` → `return LoadRuntimeView();` | **FAIL** |
|
||||
| `SgReadyAdapter::runtimeView()` → `return LoadRuntimeView();` | **FAIL** |
|
||||
|
||||
Arbre vérifié propre après retrait (`grep -rn MUTANT energyplugin/` → rien ; `git diff` sans
|
||||
trace).
|
||||
|
||||
**Où la substance est constatée sur la MACHINE** : la vue `sgReady` de `pac-terrain` et la vue
|
||||
`relay` de `chauffe-eau` sont lues telles quelles dans les charges utiles capturées au §1 —
|
||||
`{kind: sgReady, state: 3, estimatedPowerW: 1500}` avec `lock{minStateHold, 181 s}`, et
|
||||
`{kind: relay, stageW: 1500, maxStageW: 3500}` avec `lock{minOn, 1 s}`.
|
||||
`EtmVariableLoadAdapter` n'a **pas** d'instance sur `.75` — **aucun matériel réel n'implémente
|
||||
encore l'interface `etmvariableload`** (`AGENTS.md`, réserve connue) : sa vue n'est vérifiée
|
||||
qu'en test, et c'est dit ici plutôt que sous-entendu.
|
||||
|
||||
---
|
||||
|
||||
## 9. Ce qui reste ouvert
|
||||
|
||||
1. **Critère 3 sur la machine** — §3 ci-dessus, procédure incluse.
|
||||
1-bis. **Sort de `pac-terrain`** — §8-bis (b), question ouverte à trancher : l'exposer comme
|
||||
charge non configurable (et ajouter alors `configurable: false`), ou la faire entrer en
|
||||
configuration `sg-ready` et supprimer le bloc codé en dur.
|
||||
2. **Silences de REFUS** (règle 7-c/7-d), signalés et **non corrigés** : quatre refus rendent la
|
||||
main sans une ligne alors que l'application correspondante est en `qCInfo` — charge en défaut
|
||||
dans `RelayRouter`, `EtmVariableLoadAdapter` et `SgReadyAdapter`, et surtout
|
||||
`SgReadyAdapter::m_usable == false` (encodage sans état 2 → **plus aucune commande depuis la
|
||||
construction**, sans **aucune** annonce à sa naissance). Le défaut est désormais publié en
|
||||
continu dans `faultCode`, mais l'asymétrie de journal reste. À traiter avec **ECS-414**.
|
||||
3. **`docs/OPTIMIZER_PROTOCOL.md`** ne documente pas `PlanBudget` ni
|
||||
`ILoadAdapter::runtimeView()` — comme il ne documentait déjà pas `LoadAction::force`,
|
||||
`telemetry.minState/maxState`, `degradedMode` ni la forme `relay-router`. `INTERFACE.md`, lui,
|
||||
est à jour de ce lot.
|
||||
4. **L'EV est hors de `loads[]`** en beta : décidé par l'amont **avant** le waterfall
|
||||
(décision B), sa puissance est réservée dans `budget.evReservedW`. Il y entrera avec **3g** —
|
||||
`EvAdapter::runtimeView()` est déjà écrit pour ce jour-là.
|
||||
|
||||
---
|
||||
|
||||
## 10. Outils laissés sur `.75`
|
||||
|
||||
- `/tmp/nymea_rpc.py` — client JSON-RPC minimal (WebSocket en clair, port 4444) :
|
||||
`call <Namespace.Method> [params-json]`, `watch <s> [namespaces…]`,
|
||||
`snapshot <s>` (écoute puis `GetLoadTelemetry` + `GetChargingSchedules`, et compare le
|
||||
snapshot à la dernière notification).
|
||||
- `/tmp/domain_check.py` — aller-retour `domain` avec **restauration** de la configuration.
|
||||
|
||||
`/tmp` est volatile : ces scripts disparaissent au redémarrage. Leur source est dans le journal
|
||||
de la session, pas dans le dépôt.
|
||||
@ -46,11 +46,18 @@ JSON-RPC nymead ou multimètre NO-COM).
|
||||
|
||||
### 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
|
||||
~/projects/etm-powersync/etm-powersync-deploy/deploy.sh \
|
||||
powersync-energy-plugin-nymea_<ver>_arm64.deb --host 192.168.1.75
|
||||
ssh etm@hems 'journalctl -u nymead -f' # suivre les logs
|
||||
```
|
||||
> **Ne PAS installer à la main, et surtout pas par glob.** La recette qui figurait ici —
|
||||
> `scp … /tmp/` puis `sudo dpkg -i /tmp/…_*_arm64.deb` — porte les trois défauts qui ont
|
||||
> **rétrogradé `.75` de `+etm12` à `+etm9` le 2026-08-25** : un glob sur un répertoire qui
|
||||
> accumule, un `dpkg -i` qui autorise la rétrogradation par défaut (`dpkg --force-help` :
|
||||
> `[*] downgrade`), et aucune relecture de la version après coup. `+etm12` a été perdu.
|
||||
> `deploy.sh` archive avant de déposer, installe par nom, refuse une rétrogradation non
|
||||
> demandée et **échoue bruyamment** si la version installée n'est pas celle attendue.
|
||||
> Détail et rollback : `DEPLOY.md` §7.6 du dépôt `etm-powersync-deploy`.
|
||||
> `.so` installé dans `/usr/lib/<multiarch>/nymea/energy/libnymea_energypluginnymea.so`.
|
||||
|
||||
### c. Activer le logging `[Arbitre]` (SINON aucune trace de décision)
|
||||
|
||||
@ -111,7 +111,7 @@ LoadAction EtmVariableLoadAdapter::applyAction(const LoadAction &action, const Q
|
||||
qCInfo(dcNymeaEnergy()) << "[EtmVariableLoadAdapter]" << m_label
|
||||
<< "→ setpoint" << qRound(setpointW) << "W"
|
||||
<< (action.force ? "(force)" : "")
|
||||
<< "|" << action.reason;
|
||||
<< "|" << renderFr(action.reason, m_label);
|
||||
|
||||
m_setpointPrev = m_currentSetpointW;
|
||||
m_setpointTarget = setpointW;
|
||||
@ -127,6 +127,30 @@ LoadAction EtmVariableLoadAdapter::applyAction(const LoadAction &action, const Q
|
||||
return applied;
|
||||
}
|
||||
|
||||
LoadRuntimeView EtmVariableLoadAdapter::runtimeView(const QDateTime &now) const
|
||||
{
|
||||
Q_UNUSED(now) // aucun verrou côté moteur : l'anti-rebond vit dans le thing (cf. toLoadContext).
|
||||
|
||||
LoadRuntimeView v;
|
||||
v.mechanismKind = QStringLiteral("variable");
|
||||
v.mechanism.insert(QStringLiteral("setpointW"), m_currentSetpointW);
|
||||
// Le pourcentage est la lecture NATURELLE d'une modulation continue — c'est ce qu'un
|
||||
// utilisateur reconnaît d'un routeur PV. Il reste dérivé de la consigne, jamais l'inverse :
|
||||
// les watts sont la grandeur commandée, le pourcentage n'est qu'une vue.
|
||||
if (m_maxPowerW > 0)
|
||||
v.mechanism.insert(QStringLiteral("percent"),
|
||||
qRound(1000.0 * m_currentSetpointW / m_maxPowerW) / 10.0);
|
||||
v.mechanism.insert(QStringLiteral("maxPowerW"), static_cast<double>(m_maxPowerW));
|
||||
|
||||
// ECS-414 — deux causes DISTINCTES d'indisponibilité, deux codes. Les confondre enverrait
|
||||
// diagnostiquer un matériel muet là où c'est un Thing absent de la configuration.
|
||||
if (m_faulted)
|
||||
v.faultCode = QStringLiteral("WRITE_FAILED");
|
||||
else if (!m_thingManager || !m_thingManager->findConfiguredThing(ThingId(m_id)))
|
||||
v.faultCode = QStringLiteral("THING_MISSING");
|
||||
return v;
|
||||
}
|
||||
|
||||
void EtmVariableLoadAdapter::applySafeState(const QDateTime &now)
|
||||
{
|
||||
LoadAction sur;
|
||||
@ -134,7 +158,7 @@ void EtmVariableLoadAdapter::applySafeState(const QDateTime &now)
|
||||
sur.kind = LoadAction::Setpoint;
|
||||
sur.powerW = 0;
|
||||
sur.force = true;
|
||||
sur.reason = QStringLiteral("Charge désactivée — mise en état sûr (0 W) avant retrait (ECS-413)");
|
||||
sur.reason = { DecisionCode::SafeStateSetpoint };
|
||||
applyAction(sur, now);
|
||||
}
|
||||
|
||||
|
||||
@ -122,6 +122,15 @@ public:
|
||||
//! \return Vrai si la charge est en défaut (plus aucune consigne écrite).
|
||||
bool faulted() const { return m_faulted; }
|
||||
|
||||
/*!
|
||||
* \brief Vue runtime pour la télémétrie RPC : consigne, pourcentage, défaut.
|
||||
* \param now Inutilisé — cet adaptateur n'a AUCUNE fenêtre de verrou côté moteur :
|
||||
* l'anti-rebond vit dans le thing. Le client lit donc \c lock absent, ce qui est la
|
||||
* vérité, et non un verrou nul qui laisserait croire à une fenêtre expirée.
|
||||
* \return \c mechanismKind "variable" ; \c mechanism \c {setpointW, percent, maxPowerW}.
|
||||
*/
|
||||
LoadRuntimeView runtimeView(const QDateTime &now) const override;
|
||||
|
||||
//! \brief État sûr : consigne **0 W**, en \c force = true (ECS-413). \param now Temps de cycle.
|
||||
void applySafeState(const QDateTime &now) override;
|
||||
|
||||
|
||||
@ -59,6 +59,21 @@ LoadContext EvAdapter::toLoadContext(const QDateTime &now) const
|
||||
return ctx;
|
||||
}
|
||||
|
||||
LoadRuntimeView EvAdapter::runtimeView(const QDateTime &now) const
|
||||
{
|
||||
Q_UNUSED(now) // les verrous EV vivent dans SmartChargingManager amont, pas dans cet adaptateur.
|
||||
|
||||
LoadRuntimeView v;
|
||||
v.mechanismKind = QStringLiteral("evcharger");
|
||||
v.mechanism.insert(QStringLiteral("chargingEnabled"), m_charger->charging());
|
||||
v.mechanism.insert(QStringLiteral("pluggedIn"), m_charger->pluggedIn());
|
||||
v.mechanism.insert(QStringLiteral("currentA"), m_charger->maxChargingCurrent());
|
||||
v.mechanism.insert(QStringLiteral("phaseCount"), m_charger->phaseCount());
|
||||
if (!m_charger->available())
|
||||
v.faultCode = QStringLiteral("THING_MISSING");
|
||||
return v;
|
||||
}
|
||||
|
||||
LoadAction EvAdapter::applyAction(const LoadAction &action, const QDateTime &now)
|
||||
{
|
||||
if (action.kind != LoadAction::Setpoint)
|
||||
|
||||
@ -83,6 +83,22 @@ public:
|
||||
//! explicitement plutôt que de laisser un corps vide (règle 7-c).
|
||||
bool clearFault() override { return false; }
|
||||
|
||||
/*!
|
||||
* \brief Vue runtime — déclarée, mais HORS de la charge utile \c GetLoadTelemetry.
|
||||
*
|
||||
* \param now Ignoré : les verrous EV (chargingEnabledLock, currentChangeLock) vivent dans
|
||||
* \c SmartChargingManager amont, pas ici.
|
||||
* \return \c mechanismKind "evcharger" et l'état de la borne.
|
||||
*
|
||||
* \note L'EV n'apparaît PAS dans \c NymeaEnergy.GetLoadTelemetry en beta : il est décidé
|
||||
* par l'amont EN AMONT du waterfall (décision B), sa puissance est réservée dans
|
||||
* \c PlanBudget::evReservedW et non allouée depuis le budget publié. L'y faire figurer
|
||||
* ferait une somme \c loads[] qui ne retomberait pas sur \c budget.allocatedW. La
|
||||
* méthode est néanmoins IMPLÉMENTÉE, et non laissée en \c Q_UNIMPLEMENTED : quand 3g
|
||||
* transplantera l'EV dans le waterfall unifié, il n'y aura rien à écrire ici.
|
||||
*/
|
||||
LoadRuntimeView runtimeView(const QDateTime &now) const override;
|
||||
|
||||
//! \brief Sans effet : l'EV n'est pas construit depuis \c LoadConfig et ne peut donc pas
|
||||
//! être « désactivé » par ce chemin. \param now Ignoré.
|
||||
void applySafeState(const QDateTime &now) override { Q_UNUSED(now) }
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
#pragma once
|
||||
|
||||
#include <QDateTime>
|
||||
#include <QVariantMap>
|
||||
#include "../types/loadaction.h"
|
||||
#include "../types/loaddescriptor.h"
|
||||
#include "../types/surpluscontext.h"
|
||||
@ -16,6 +17,42 @@ struct LoadTelemetry {
|
||||
QDateTime lastActionAt; //!< Dernier instant où applyAction() a produit un effet.
|
||||
};
|
||||
|
||||
/*!
|
||||
* \brief Vue runtime d'une charge destinée à la FRONTIÈRE DE TÉLÉMÉTRIE (RPC), et à elle seule.
|
||||
*
|
||||
* Distincte de \c LoadTelemetry (que l'arbitre lit pour DÉCIDER) et de \c LoadContext (ce que
|
||||
* le scheduler reçoit). Elle porte ce qui n'a de sens que pour EXPLIQUER une décision à un
|
||||
* humain : le défaut, le verrou en secondes restantes, et l'état du mécanisme.
|
||||
*
|
||||
* \par Des codes, jamais des phrases
|
||||
* \c faultCode, \c lockKind et \c mechanismKind sont des CODES d'énumération fermée. La
|
||||
* phrase est fabriquée par le client, dans la langue de son utilisateur — même raison que
|
||||
* pour \c DecisionReason (cf. \c decisionreason.h).
|
||||
*
|
||||
* \par Des secondes, pas des watts
|
||||
* \c lockMinPowerW / \c lockMaxPowerW (dans \c LoadContextTelemetry) sont un artefact du
|
||||
* mécanisme d'allocation : illisibles pour un utilisateur. Le temps restant, lui, se lit.
|
||||
* Ceci n'enfreint pas ECS-306 : cette exigence interdit qu'un index de PALIER franchisse la
|
||||
* frontière d'ALLOCATION. Une valeur d'AFFICHAGE sortante est un autre chemin, et
|
||||
* \c stageW / \c state sont des watts et des états normés, pas des index.
|
||||
*
|
||||
* \par Union discriminée par mécanisme
|
||||
* \c mechanismKind discrimine ; \c mechanism porte la charge utile de la variante, et
|
||||
* d'elle seule (LM-300). Ajouter un mécanisme = une variante ici et son test — rien d'autre.
|
||||
*/
|
||||
struct LoadRuntimeView {
|
||||
//! Code de défaut ; VIDE = aucun défaut. Énumération fermée, cf. \c INTERFACE.md.
|
||||
QString faultCode;
|
||||
//! Verrou ACTIF maintenant : "minOn" | "minOff" | "minStateHold" ; vide = aucun.
|
||||
QString lockKind;
|
||||
//! Secondes restantes du verrou nommé par \c lockKind ; sans objet si \c lockKind est vide.
|
||||
int lockRemainingS = 0;
|
||||
//! Mécanisme : "relay" | "variable" | "sgReady" | "evcharger".
|
||||
QString mechanismKind;
|
||||
//! Charge utile du mécanisme (clés propres à la variante) ; jamais celle d'un autre.
|
||||
QVariantMap mechanism;
|
||||
};
|
||||
|
||||
/*!
|
||||
* \brief Interface pure des adaptateurs de charge.
|
||||
*
|
||||
@ -25,7 +62,7 @@ struct LoadTelemetry {
|
||||
* \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 applyAction() avec un \c reason.code 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
|
||||
@ -65,11 +102,11 @@ public:
|
||||
*
|
||||
* 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 action Action à appliquer. Doit avoir \c reason.code 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.
|
||||
* \note Retour silencieux sans effet si \c action.reason.code est vide.
|
||||
*/
|
||||
virtual LoadAction applyAction(const LoadAction &action, const QDateTime &now) = 0;
|
||||
|
||||
@ -118,6 +155,23 @@ public:
|
||||
*/
|
||||
virtual bool clearFault() = 0;
|
||||
|
||||
/*!
|
||||
* \brief Vue runtime destinée à la frontière de télémétrie RPC (\c GetLoadTelemetry).
|
||||
*
|
||||
* \param now Temps de cycle — MÊME source que \c toLoadContext()/\c applyAction(), afin
|
||||
* que le verrou annoncé au client soit celui qui a réellement borné la décision.
|
||||
* \return Défaut, verrou en secondes restantes, et charge utile du mécanisme.
|
||||
*
|
||||
* \note **Pure virtuelle à dessein**, sans implémentation par défaut. Un mécanisme ajouté
|
||||
* qui hériterait d'un défaut vide sortirait de la télémétrie SANS BRUIT : le client
|
||||
* verrait une charge sans mécanisme et sans verrou, indistinguable d'une charge libre.
|
||||
* C'est précisément le silence ambigu de la règle 7-c. Le compilateur pose donc la
|
||||
* question à chaque nouvel adaptateur.
|
||||
* \note Cette méthode n'entre JAMAIS dans une décision : l'arbitre ne la lit que pour
|
||||
* publier. Un adaptateur qui s'en servirait pour décider violerait la règle 2.
|
||||
*/
|
||||
virtual LoadRuntimeView runtimeView(const QDateTime &now) const = 0;
|
||||
|
||||
/*!
|
||||
* \brief Amène le matériel dans l'ÉTAT SÛR de cet adaptateur (ECS-413).
|
||||
*
|
||||
|
||||
@ -225,14 +225,26 @@ LoadAction RelayRouter::applyAction(const LoadAction &action, const QDateTime &n
|
||||
applied.powerW = m_levels.at(newStage);
|
||||
applied.estimatedPowerW = m_levels.at(newStage);
|
||||
|
||||
if (newStage == m_currentStage)
|
||||
// Règle 7-c — journaliser L'ISSUE, pas seulement l'issue remarquable. Ce retour était MUET :
|
||||
// « consigne reçue, palier déjà bon » ne se distinguait pas de « l'action n'est jamais
|
||||
// parvenue au routeur », et c'est précisément le cas ordinaire qu'on vient vérifier quand
|
||||
// une charge ne bouge pas. Même niveau que la branche qui commute (7-d, symétrie) — et le
|
||||
// volume reste borné : une ligne par charge et par cycle, comme l'adaptateur à consigne
|
||||
// continue en produit déjà une inconditionnellement.
|
||||
if (newStage == m_currentStage) {
|
||||
qCInfo(dcNymeaEnergy()) << "[RelayRouter]" << m_label
|
||||
<< "→ consigne" << qRound(action.powerW) << "W → palier"
|
||||
<< m_levels.at(newStage) << "W INCHANGÉ (aucune commutation)"
|
||||
<< (action.force ? "(force)" : "")
|
||||
<< "|" << renderFr(action.reason, m_label);
|
||||
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;
|
||||
<< "|" << renderFr(action.reason, m_label);
|
||||
|
||||
applyRelayStage(newStage);
|
||||
|
||||
@ -475,6 +487,36 @@ void RelayRouter::settleTransition()
|
||||
}
|
||||
}
|
||||
|
||||
LoadRuntimeView RelayRouter::runtimeView(const QDateTime &now) const
|
||||
{
|
||||
LoadRuntimeView v;
|
||||
v.mechanismKind = QStringLiteral("relay");
|
||||
// Charge utile du mécanisme : des WATTS, jamais un index de palier ni un ThingId de relais.
|
||||
// La frontière rév. 3 tient aussi dans le sens sortant.
|
||||
v.mechanism.insert(QStringLiteral("stageW"), currentSetpointW());
|
||||
v.mechanism.insert(QStringLiteral("maxStageW"),
|
||||
m_levels.isEmpty() ? 0.0 : static_cast<double>(m_levels.last()));
|
||||
|
||||
// ECS-410 — le défaut sort en CODE. Il n'a aujourd'hui qu'une cause : l'échelle d'écriture
|
||||
// épuisée. Une cause nouvelle vaudra un code nouveau, jamais une phrase.
|
||||
if (m_faulted)
|
||||
v.faultCode = QStringLiteral("WRITE_FAILED");
|
||||
|
||||
// Verrou en SECONDES RESTANTES. Le calcul dérive du MÊME m_lastSwitch et du même \c now que
|
||||
// lockWindow() : ce que le client lit est ce qui a borné la décision, pas une seconde vue.
|
||||
// m_lastSwitch nul = « commutation venant d'avoir lieu » (ECS-412, armement à froid) —
|
||||
// même convention que lockWindow(), sans quoi l'affichage contredirait le comportement.
|
||||
const qint64 elapsed = m_lastSwitch.isValid() ? m_lastSwitch.secsTo(now) : 0;
|
||||
if (m_currentStage > 0 && elapsed < m_minOnS) {
|
||||
v.lockKind = QStringLiteral("minOn");
|
||||
v.lockRemainingS = static_cast<int>(m_minOnS - elapsed);
|
||||
} else if (m_currentStage == 0 && elapsed < m_minOffS) {
|
||||
v.lockKind = QStringLiteral("minOff");
|
||||
v.lockRemainingS = static_cast<int>(m_minOffS - elapsed);
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
void RelayRouter::applySafeState(const QDateTime &now)
|
||||
{
|
||||
// ECS-413 — état sûr du routeur : tous relais ouverts. Passe par le chemin d'action
|
||||
@ -485,7 +527,7 @@ void RelayRouter::applySafeState(const QDateTime &now)
|
||||
sur.kind = LoadAction::Setpoint;
|
||||
sur.powerW = 0;
|
||||
sur.force = true;
|
||||
sur.reason = QStringLiteral("Charge désactivée — mise en état sûr avant retrait (ECS-413)");
|
||||
sur.reason = { DecisionCode::SafeStateRelay };
|
||||
applyAction(sur, now);
|
||||
}
|
||||
|
||||
|
||||
@ -104,6 +104,15 @@ public:
|
||||
*/
|
||||
bool clearFault() override;
|
||||
|
||||
/*!
|
||||
* \brief Vue runtime pour la télémétrie RPC : palier en watts, défaut, verrou en secondes.
|
||||
* \param now Temps de cycle — même source que \c lockWindow(), donc le verrou annoncé est
|
||||
* celui qui a réellement borné la décision.
|
||||
* \return \c mechanismKind "relay" ; \c mechanism \c {stageW, maxStageW} — des WATTS,
|
||||
* jamais un index de palier ni un ThingId (la frontière rév. 3 tient aussi en sortie).
|
||||
*/
|
||||
LoadRuntimeView runtimeView(const QDateTime &now) const override;
|
||||
|
||||
//! \brief État sûr du routeur : TOUS relais ouverts (ECS-413).
|
||||
//! \param now Temps de cycle — l'action passe en \c force = true, verrous bypassés.
|
||||
void applySafeState(const QDateTime &now) override;
|
||||
|
||||
@ -142,8 +142,16 @@ LoadAction SgReadyAdapter::applyAction(const LoadAction &action, const QDateTime
|
||||
return action;
|
||||
}
|
||||
|
||||
if (newState == m_currentState)
|
||||
// Règle 7-c — même défaut que celui corrigé au RelayRouter : ce retour était MUET, si bien
|
||||
// que « état déjà bon » ne se distinguait pas de « action jamais reçue ». Une PAC qui ne
|
||||
// bouge pas est exactement le cas où l'on vient lire le journal.
|
||||
if (newState == m_currentState) {
|
||||
qCInfo(dcNymeaEnergy()) << "[SgReadyAdapter]" << m_label
|
||||
<< "→ état" << newState << "INCHANGÉ (aucune commutation)"
|
||||
<< (action.force ? "(force)" : "")
|
||||
<< "|" << renderFr(action.reason, m_label);
|
||||
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).
|
||||
@ -162,7 +170,7 @@ LoadAction SgReadyAdapter::applyAction(const LoadAction &action, const QDateTime
|
||||
qCInfo(dcNymeaEnergy()) << "[SgReadyAdapter]" << m_label
|
||||
<< "→ état" << newState
|
||||
<< "(" << m_estimatedPowerW.value(newState, 0.0) << "W estimés)"
|
||||
<< "|" << action.reason;
|
||||
<< "|" << renderFr(action.reason, m_label);
|
||||
|
||||
m_statePrev = m_currentState;
|
||||
m_stateTarget = newState;
|
||||
@ -179,6 +187,38 @@ LoadAction SgReadyAdapter::applyAction(const LoadAction &action, const QDateTime
|
||||
return applied;
|
||||
}
|
||||
|
||||
LoadRuntimeView SgReadyAdapter::runtimeView(const QDateTime &now) const
|
||||
{
|
||||
LoadRuntimeView v;
|
||||
v.mechanismKind = QStringLiteral("sgReady");
|
||||
// L'état normé EST la grandeur commandée d'une PAC SG-Ready : 4 états constructeur, non
|
||||
// exprimables en watts (c'est la raison d'être du kind State). La puissance qui l'accompagne
|
||||
// est une ESTIMATION déclarée, jamais un engagement — le nom de la clé le dit.
|
||||
v.mechanism.insert(QStringLiteral("state"), m_currentState);
|
||||
v.mechanism.insert(QStringLiteral("estimatedPowerW"),
|
||||
m_estimatedPowerW.value(m_currentState, 0.0));
|
||||
|
||||
// ECS-414 et ECS-110 — deux indisponibilités de natures opposées, deux codes. Un encodage
|
||||
// sans état 2 refuse TOUTE commande depuis le constructeur : le taire ferait passer une
|
||||
// PAC jamais pilotable pour une PAC au repos.
|
||||
if (m_faulted)
|
||||
v.faultCode = QStringLiteral("WRITE_FAILED");
|
||||
else if (!m_usable)
|
||||
v.faultCode = QStringLiteral("UNUSABLE_ENCODING");
|
||||
|
||||
// Verrou en secondes restantes, calculé sur le MÊME m_lastSwitch que lockWindow(). Un
|
||||
// m_lastSwitch nul vaut « jamais commuté » et ne verrouille rien — convention identique
|
||||
// à lockWindow(), pour que l'affichage ne contredise pas le comportement.
|
||||
if (m_lastSwitch.isValid()) {
|
||||
const qint64 elapsed = m_lastSwitch.secsTo(now);
|
||||
if (elapsed < m_minStateHoldS) {
|
||||
v.lockKind = QStringLiteral("minStateHold");
|
||||
v.lockRemainingS = static_cast<int>(m_minStateHoldS - elapsed);
|
||||
}
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
void SgReadyAdapter::applySafeState(const QDateTime &now)
|
||||
{
|
||||
// ECS-413 — état sûr d'une PAC = état 2 (normal, mains off). PAS l'état 1 : bloquer une
|
||||
@ -189,7 +229,7 @@ void SgReadyAdapter::applySafeState(const QDateTime &now)
|
||||
sur.kind = LoadAction::State;
|
||||
sur.state = 2;
|
||||
sur.force = true;
|
||||
sur.reason = QStringLiteral("Charge désactivée — mise en état sûr (état 2) avant retrait (ECS-413)");
|
||||
sur.reason = { DecisionCode::SafeStateSgReady };
|
||||
applyAction(sur, now); // force = true : minStateHoldS vaut 900 s, attendre est indéfendable
|
||||
}
|
||||
|
||||
|
||||
@ -111,6 +111,14 @@ public:
|
||||
//! \return Vrai si la PAC est en défaut (plus aucune commande émise).
|
||||
bool faulted() const { return m_faulted; }
|
||||
|
||||
/*!
|
||||
* \brief Vue runtime pour la télémétrie RPC : état normé, estimation, défaut, verrou.
|
||||
* \param now Temps de cycle — même source que \c lockWindow(), donc les secondes annoncées
|
||||
* sont celles du verrou qui a réellement borné la décision.
|
||||
* \return \c mechanismKind "sgReady" ; \c mechanism \c {state, estimatedPowerW}.
|
||||
*/
|
||||
LoadRuntimeView runtimeView(const QDateTime &now) const override;
|
||||
|
||||
/*!
|
||||
* \brief État sûr de la PAC : **état 2** (normal, mains off) — JAMAIS l'état 1 (blocage).
|
||||
* \param now Temps de cycle ; l'action passe en \c force = true (bypass minStateHold).
|
||||
|
||||
@ -9,6 +9,7 @@
|
||||
#include "config/loadconfigstore.h"
|
||||
#include "types/loadconfig.h"
|
||||
#include "scheduler/rulebasedscheduler.h"
|
||||
#include "types/decisionreason.h"
|
||||
#include "types/surpluscontext.h"
|
||||
#include "types/plan.h"
|
||||
#include "../rootmeter.h"
|
||||
@ -24,6 +25,17 @@ namespace {
|
||||
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
|
||||
|
||||
//! Battement de cœur de la télémétrie : période MAXIMALE entre deux notifications, même sans
|
||||
//! changement. C'est ce qui permet au client de dater la dernière décision et donc de repérer
|
||||
//! un arbitre figé — un état qui ne bouge pas n'émet rien de lui-même.
|
||||
constexpr int TelemetryHeartbeatPeriodMs = 60 * 1000; // 60 s
|
||||
|
||||
//! Seuil d'allocation (W) en deçà duquel une variation ne déclenche PAS de notification.
|
||||
//! Une charge à consigne continue module en permanence de quelques watts ; sans seuil, chaque
|
||||
//! oscillation réveillerait tous les clients abonnés. 50 W est en dessous du plus petit palier
|
||||
//! réel d'un ECS (≈ 500 W) et au-dessus du bruit de mesure d'un routeur.
|
||||
constexpr double TelemetryAllocationThresholdW = 50.0;
|
||||
}
|
||||
|
||||
EnergyArbitrator::EnergyArbitrator(EnergyManager *em, ThingManager *tm,
|
||||
@ -50,6 +62,14 @@ EnergyArbitrator::EnergyArbitrator(EnergyManager *em, ThingManager *tm,
|
||||
m_meterWatchdog->setInterval(MeterWatchdogPeriodMs);
|
||||
connect(m_meterWatchdog, &QTimer::timeout, this, &EnergyArbitrator::onMeterWatchdogTick);
|
||||
m_meterWatchdog->start();
|
||||
|
||||
// Battement de cœur de la télémétrie. QTimer d'horloge murale, comme le watchdog et pour la
|
||||
// MÊME raison : il doit continuer à battre quand le cycle d'arbitrage, lui, s'est arrêté.
|
||||
// Un battement piloté par le cycle ne pourrait pas révéler l'arrêt du cycle.
|
||||
m_telemetryHeartbeat = new QTimer(this);
|
||||
m_telemetryHeartbeat->setInterval(TelemetryHeartbeatPeriodMs);
|
||||
connect(m_telemetryHeartbeat, &QTimer::timeout, this, &EnergyArbitrator::publishTelemetryHeartbeat);
|
||||
m_telemetryHeartbeat->start();
|
||||
#else
|
||||
Q_UNUSED(em)
|
||||
#endif
|
||||
@ -368,9 +388,15 @@ void EnergyArbitrator::update(const QDateTime ¤tDateTime)
|
||||
Plan plan = m_scheduler->getPlan(ctx);
|
||||
Slot slot = plan.slotCovering(currentDateTime);
|
||||
|
||||
// Le LIBELLÉ n'est pas dans le motif : il est substitué AU RENDU (cf. decisionreason.h).
|
||||
// On le prend dans le contexte du cycle, seul endroit qui connaisse toutes les charges.
|
||||
QHash<QString, QString> libelles;
|
||||
for (const LoadContext &lc : ctx.loads)
|
||||
libelles.insert(lc.id, lc.label);
|
||||
|
||||
for (const LoadAction &action : slot.actions) {
|
||||
qCInfo(dcNymeaEnergy()) << "[Arbitre]"
|
||||
<< action.loadId << "→" << action.reason
|
||||
<< action.loadId << "→" << renderFr(action.reason, libelles.value(action.loadId))
|
||||
<< "| activé:" << action.chargingEnabled
|
||||
<< "| courant:" << action.currentA << "A"
|
||||
<< "| phases:" << action.phaseCount
|
||||
@ -380,6 +406,13 @@ void EnergyArbitrator::update(const QDateTime ¤tDateTime)
|
||||
// 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
|
||||
|
||||
// 8 : frontière de télémétrie. APRÈS le dispatch, jamais avant : la charge utile de mécanisme
|
||||
// (palier appliqué, état PAC) doit refléter ce qui a réellement été envoyé au matériel, pas
|
||||
// ce que la stratégie espérait. Publier avant produirait un instantané qu'aucun relais ne
|
||||
// confirme — la même famille de défaut qu'annoncer une puissance non appliquée (ECS-410).
|
||||
buildTelemetry(plan, slot, currentDateTime);
|
||||
publishTelemetry(false);
|
||||
}
|
||||
|
||||
SurplusContext EnergyArbitrator::buildContext(const QDateTime &now) const
|
||||
@ -476,6 +509,14 @@ void EnergyArbitrator::recordMeterUpdate(const QDateTime &now)
|
||||
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)
|
||||
|
||||
// Symétrie de la bascule : la SORTIE se publie aussi tout de suite. La planification ne
|
||||
// reprend qu'au cycle suivant ; sans cette republication, la télémétrie annoncerait
|
||||
// encore degradedMode=true pendant une minute entière, après que le repli a été levé.
|
||||
if (!m_telemetry.isEmpty()) {
|
||||
m_telemetry.insert(QStringLiteral("degradedMode"), false);
|
||||
publishTelemetry(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -500,8 +541,7 @@ 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)");
|
||||
const DecisionReason reason = { DecisionCode::DegradedL2 };
|
||||
|
||||
// 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
|
||||
@ -541,4 +581,201 @@ void EnergyArbitrator::applyDegradedMode(const QDateTime &now)
|
||||
}
|
||||
|
||||
// Batterie (aucune charge réseau) : repli ajouté avec son adaptateur (3f).
|
||||
|
||||
// Reprise à froid (critère ECS-411) : un client qui se connecte APRÈS la bascule doit lire
|
||||
// degradedMode. update() rend la main avant toute publication tant que le repli tient ; sans
|
||||
// cet instantané forcé, la frontière de télémétrie resterait figée sur l'avant-dernier cycle
|
||||
// NORMAL, et publierait donc l'inverse de la vérité.
|
||||
buildDegradedTelemetry(now);
|
||||
publishTelemetry(true);
|
||||
}
|
||||
|
||||
// ---- Frontière de télémétrie (NymeaEnergy.GetLoadTelemetry / LoadTelemetryChanged) ---------
|
||||
|
||||
void EnergyArbitrator::buildTelemetry(const Plan &plan, const Slot &slot, const QDateTime &now)
|
||||
{
|
||||
QVariantMap snap;
|
||||
// ISO 8601 UTC — auto-descriptif et sans ambiguïté de fuseau. C'est la FIN du cycle
|
||||
// d'arbitrage : deux notifications au même timestamp disent « rien n'a été décidé entre-temps ».
|
||||
snap.insert(QStringLiteral("timestamp"), now.toUTC().toString(Qt::ISODate));
|
||||
snap.insert(QStringLiteral("degradedMode"), m_degradedMode);
|
||||
|
||||
QVariantMap budget;
|
||||
budget.insert(QStringLiteral("surplusW"), plan.budget.surplusW);
|
||||
budget.insert(QStringLiteral("evReservedW"), plan.budget.evReservedW);
|
||||
budget.insert(QStringLiteral("allocatedW"), plan.budget.allocatedW);
|
||||
budget.insert(QStringLiteral("recreditedW"), plan.budget.recreditedW);
|
||||
budget.insert(QStringLiteral("remainingW"), plan.budget.remainingW);
|
||||
snap.insert(QStringLiteral("budget"), budget);
|
||||
|
||||
// On itère les ACTIONS, pas les tables d'adaptateurs : l'ordre du waterfall (rang 1 servi en
|
||||
// premier) est porté par le slot, et QHash n'en garantit aucun. Un ordre instable ferait
|
||||
// aussi diverger la comparaison de seuil d'un cycle à l'autre, pour rien.
|
||||
QVariantList loads;
|
||||
QSet<QString> couvertes;
|
||||
for (const LoadAction &action : slot.actions) {
|
||||
ILoadAdapter *adapter = m_loadAdapters.value(action.loadId, nullptr);
|
||||
if (!adapter)
|
||||
adapter = m_sgReadyAdapters.value(action.loadId, nullptr);
|
||||
if (!adapter)
|
||||
continue; // EV : décidé en amont du waterfall, hors de cette charge utile.
|
||||
couvertes.insert(action.loadId);
|
||||
|
||||
QVariantMap entry;
|
||||
entry.insert(QStringLiteral("loadId"), action.loadId);
|
||||
// ALLOCATION du waterfall, celle qui a décrémenté le budget — pas la mesure. La mesure
|
||||
// vit dans la charge utile de mécanisme ; les confondre rendrait la somme des
|
||||
// allocations irréconciliable avec budget.allocatedW.
|
||||
entry.insert(QStringLiteral("allocatedW"), action.estimatedPowerW);
|
||||
entry.insert(QStringLiteral("available"), adapter->telemetry().available);
|
||||
entry.insert(QStringLiteral("decision"), action.reason.toMap());
|
||||
|
||||
const LoadRuntimeView v = adapter->runtimeView(now);
|
||||
if (!v.faultCode.isEmpty())
|
||||
entry.insert(QStringLiteral("faultCode"), v.faultCode);
|
||||
if (!v.lockKind.isEmpty()) {
|
||||
QVariantMap lock;
|
||||
lock.insert(QStringLiteral("kind"), v.lockKind);
|
||||
lock.insert(QStringLiteral("remainingS"), v.lockRemainingS);
|
||||
entry.insert(QStringLiteral("lock"), lock);
|
||||
}
|
||||
if (!v.mechanismKind.isEmpty()) {
|
||||
QVariantMap mech = v.mechanism;
|
||||
mech.insert(QStringLiteral("kind"), v.mechanismKind);
|
||||
entry.insert(QStringLiteral("mechanism"), mech);
|
||||
}
|
||||
loads.append(entry);
|
||||
}
|
||||
|
||||
// Règle 7-c — une charge arbitrée sans décision serait un TROU dans la charge utile, et le
|
||||
// client ne pourrait pas distinguer « charge absente » de « charge oubliée du plan ». Cela ne
|
||||
// devrait pas arriver (toute charge du contexte reçoit une action) : on l'annonce donc au
|
||||
// journal plutôt que de le laisser passer sans bruit.
|
||||
for (auto it = m_loadAdapters.constBegin(); it != m_loadAdapters.constEnd(); ++it)
|
||||
if (!couvertes.contains(it.key()))
|
||||
qCWarning(dcNymeaEnergy()) << "[Arbitre] Télémétrie : charge" << it.key()
|
||||
<< "arbitrée mais SANS décision dans le plan — omise.";
|
||||
for (auto it = m_sgReadyAdapters.constBegin(); it != m_sgReadyAdapters.constEnd(); ++it)
|
||||
if (!couvertes.contains(it.key()))
|
||||
qCWarning(dcNymeaEnergy()) << "[Arbitre] Télémétrie : charge" << it.key()
|
||||
<< "arbitrée mais SANS décision dans le plan — omise.";
|
||||
|
||||
snap.insert(QStringLiteral("loads"), loads);
|
||||
m_telemetry = snap;
|
||||
}
|
||||
|
||||
void EnergyArbitrator::buildDegradedTelemetry(const QDateTime &now)
|
||||
{
|
||||
QVariantMap snap;
|
||||
snap.insert(QStringLiteral("timestamp"), now.toUTC().toString(Qt::ISODate));
|
||||
snap.insert(QStringLiteral("degradedMode"), true);
|
||||
// PAS de « budget » : en mode dégradé la planification est SUSPENDUE, il n'existe aucun
|
||||
// budget pour ce cycle. Publier des zéros se lirait « arbitrage exécuté, rien à allouer » —
|
||||
// le contraire de la vérité. Le champ est omis, jamais nul (règle maison, cf. GetEnergyRatios).
|
||||
|
||||
QVariantList loads;
|
||||
auto entree = [&](const QString &id, ILoadAdapter *adapter) {
|
||||
QVariantMap entry;
|
||||
entry.insert(QStringLiteral("loadId"), id);
|
||||
entry.insert(QStringLiteral("allocatedW"), 0.0);
|
||||
entry.insert(QStringLiteral("available"), adapter->telemetry().available);
|
||||
entry.insert(QStringLiteral("decision"), DecisionReason(DecisionCode::DegradedL2).toMap());
|
||||
const LoadRuntimeView v = adapter->runtimeView(now);
|
||||
if (!v.faultCode.isEmpty())
|
||||
entry.insert(QStringLiteral("faultCode"), v.faultCode);
|
||||
if (!v.lockKind.isEmpty()) {
|
||||
QVariantMap lock;
|
||||
lock.insert(QStringLiteral("kind"), v.lockKind);
|
||||
lock.insert(QStringLiteral("remainingS"), v.lockRemainingS);
|
||||
entry.insert(QStringLiteral("lock"), lock);
|
||||
}
|
||||
if (!v.mechanismKind.isEmpty()) {
|
||||
QVariantMap mech = v.mechanism;
|
||||
mech.insert(QStringLiteral("kind"), v.mechanismKind);
|
||||
entry.insert(QStringLiteral("mechanism"), mech);
|
||||
}
|
||||
loads.append(entry);
|
||||
};
|
||||
// Ordre STABLE : QHash n'en garantit aucun, et un instantané se compare.
|
||||
QStringList ids = m_loadAdapters.keys();
|
||||
ids.sort();
|
||||
for (const QString &id : ids)
|
||||
entree(id, m_loadAdapters.value(id));
|
||||
QStringList sgIds = m_sgReadyAdapters.keys();
|
||||
sgIds.sort();
|
||||
for (const QString &id : sgIds)
|
||||
entree(id, m_sgReadyAdapters.value(id));
|
||||
|
||||
snap.insert(QStringLiteral("loads"), loads);
|
||||
m_telemetry = snap;
|
||||
}
|
||||
|
||||
void EnergyArbitrator::publishTelemetry(bool force)
|
||||
{
|
||||
if (m_telemetry.isEmpty())
|
||||
return; // aucun cycle encore exécuté — rien à dire, et rien à inventer.
|
||||
if (!force && !telemetrySignificantlyDiffers(m_publishedTelemetry, m_telemetry))
|
||||
return;
|
||||
|
||||
m_publishedTelemetry = m_telemetry;
|
||||
m_lastTelemetryPublish = QDateTime::currentDateTime();
|
||||
// Le battement est un PLANCHER de cadence, pas un ajout : toute notification relance son
|
||||
// décompte. Sur une box qui cycle chaque minute, le total reste donc d'environ UNE
|
||||
// notification par minute — ce que la cible RPi Zero 2W tient sans y penser. Porter cette
|
||||
// règle par le timer plutôt que par un test d'horloge dans le battement le rend aussi
|
||||
// injectable : un test peut le déclencher sans attendre soixante secondes.
|
||||
if (m_telemetryHeartbeat)
|
||||
m_telemetryHeartbeat->start();
|
||||
emit loadTelemetryChanged(m_telemetry);
|
||||
}
|
||||
|
||||
void EnergyArbitrator::publishTelemetryHeartbeat()
|
||||
{
|
||||
publishTelemetry(true);
|
||||
}
|
||||
|
||||
bool EnergyArbitrator::telemetrySignificantlyDiffers(const QVariantMap &a, const QVariantMap &b)
|
||||
{
|
||||
if (a.isEmpty())
|
||||
return true; // première publication
|
||||
if (a.value(QStringLiteral("degradedMode")) != b.value(QStringLiteral("degradedMode")))
|
||||
return true;
|
||||
if (a.contains(QStringLiteral("budget")) != b.contains(QStringLiteral("budget")))
|
||||
return true; // entrée ou sortie du mode où il n'y a pas de budget
|
||||
|
||||
const double allocA = a.value(QStringLiteral("budget")).toMap()
|
||||
.value(QStringLiteral("allocatedW")).toDouble();
|
||||
const double allocB = b.value(QStringLiteral("budget")).toMap()
|
||||
.value(QStringLiteral("allocatedW")).toDouble();
|
||||
if (qAbs(allocA - allocB) > TelemetryAllocationThresholdW)
|
||||
return true;
|
||||
|
||||
const QVariantList la = a.value(QStringLiteral("loads")).toList();
|
||||
const QVariantList lb = b.value(QStringLiteral("loads")).toList();
|
||||
if (la.size() != lb.size())
|
||||
return true;
|
||||
|
||||
for (int i = 0; i < la.size(); ++i) {
|
||||
const QVariantMap ea = la.at(i).toMap();
|
||||
const QVariantMap eb = lb.at(i).toMap();
|
||||
// Identité, disponibilité, défaut, NATURE du verrou, mécanisme, CODE du motif : tout
|
||||
// changement qualitatif se notifie immédiatement.
|
||||
for (const char *k : {"loadId", "available", "faultCode"})
|
||||
if (ea.value(QLatin1String(k)) != eb.value(QLatin1String(k)))
|
||||
return true;
|
||||
if (ea.value(QStringLiteral("lock")).toMap().value(QStringLiteral("kind"))
|
||||
!= eb.value(QStringLiteral("lock")).toMap().value(QStringLiteral("kind")))
|
||||
return true; // les SECONDES restantes, elles, décroissent en continu : ignorées.
|
||||
if (ea.value(QStringLiteral("mechanism")).toMap().value(QStringLiteral("kind"))
|
||||
!= eb.value(QStringLiteral("mechanism")).toMap().value(QStringLiteral("kind")))
|
||||
return true;
|
||||
if (ea.value(QStringLiteral("decision")).toMap().value(QStringLiteral("code"))
|
||||
!= eb.value(QStringLiteral("decision")).toMap().value(QStringLiteral("code")))
|
||||
return true; // les PARAMÈTRES du motif portent le budget, qui bouge à chaque cycle.
|
||||
if (qAbs(ea.value(QStringLiteral("allocatedW")).toDouble()
|
||||
- eb.value(QStringLiteral("allocatedW")).toDouble())
|
||||
> TelemetryAllocationThresholdW)
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@ -11,6 +11,7 @@
|
||||
#include "types/loadconfig.h"
|
||||
|
||||
#include <QDateTime>
|
||||
#include <QVariantMap>
|
||||
|
||||
class QTimer;
|
||||
|
||||
@ -157,6 +158,52 @@ public:
|
||||
*/
|
||||
void evaluateMeterFreshness(const QDateTime &now);
|
||||
|
||||
/*!
|
||||
* \brief Dernier instantané de télémétrie d'arbitrage — CE QUI A ÉTÉ PUBLIÉ, pas un recalcul.
|
||||
*
|
||||
* \return La charge utile de \c NymeaEnergy.GetLoadTelemetry : \c timestamp (fin du dernier
|
||||
* cycle d'arbitrage), \c degradedMode, \c budget, \c loads[]. Vide tant qu'aucun cycle
|
||||
* n'a eu lieu (démarrage) — voir la note ci-dessous.
|
||||
*
|
||||
* \note **Snapshot ≡ notification, par construction.** C'est le MÊME objet que celui émis
|
||||
* par \c loadTelemetryChanged() ; il n'est pas recalculé à la demande. Un recalcul
|
||||
* donnerait des verrous rafraîchis sous un \c timestamp périmé — un état qui n'a jamais
|
||||
* existé, et que le client ne pourrait rapprocher d'aucune notification.
|
||||
* \note **Vide avant le premier cycle.** Un instantané forgé au démarrage (budget nul,
|
||||
* aucune charge) se lirait comme « cycle exécuté, rien à allouer » — exactement le
|
||||
* mensonge que \c timestamp existe pour empêcher. La carte vide est la seule réponse
|
||||
* vraie ; le handler la rend telle quelle, sans \c timestamp (règle « omis, jamais nul »).
|
||||
*/
|
||||
QVariantMap loadTelemetry() const { return m_telemetry; }
|
||||
|
||||
/*!
|
||||
* \brief Battement de cœur : republie l'instantané COURANT sans condition ni recalcul.
|
||||
*
|
||||
* C'est ce qui rend un arbitre FIGÉ détectable : les notifications continuent d'arriver
|
||||
* tandis que leur \c timestamp cesse d'avancer. Sans battement, « installation stable » et
|
||||
* « moteur arrêté » se ressemblent — un silence, donc un silence ambigu (règle 7-c).
|
||||
*
|
||||
* \note Logique injectable (symétrique de \c evaluateMeterFreshness) : en production
|
||||
* appelée par un \c QTimer d'horloge murale câblé sous \c \#ifndef ENERGY_SIMULATION ;
|
||||
* en simulation/test appelée directement. Le \c QTimer ne peut pas dépendre du cycle
|
||||
* d'arbitrage : c'est précisément l'arrêt de ce cycle qu'il sert à révéler.
|
||||
* \note Le battement ne double pas une notification fraîche : chaque publication RELANCE
|
||||
* le timer. La règle vit donc dans le timer, et cette méthode reste inconditionnelle —
|
||||
* testable sans attendre une période entière.
|
||||
*/
|
||||
void publishTelemetryHeartbeat();
|
||||
|
||||
signals:
|
||||
/*!
|
||||
* \brief Émise quand l'état runtime d'arbitrage a CHANGÉ, ou au battement de cœur.
|
||||
* \param telemetry Charge utile identique à celle de \c loadTelemetry().
|
||||
* \note « Changé » exclut les grandeurs qui bougent en continu : les secondes restantes
|
||||
* d'un verrou et les paramètres numériques d'un motif ne déclenchent rien, et une
|
||||
* variation d'allocation inférieure au seuil non plus. Voir
|
||||
* \c telemetrySignificantlyDiffers().
|
||||
*/
|
||||
void loadTelemetryChanged(const QVariantMap &telemetry);
|
||||
|
||||
protected:
|
||||
/*!
|
||||
* \brief Boucle principale ETM — surcharge SmartChargingManager::update().
|
||||
@ -249,6 +296,46 @@ private:
|
||||
*/
|
||||
void applyDegradedMode(const QDateTime &now);
|
||||
|
||||
/*!
|
||||
* \brief Construit l'instantané de télémétrie du cycle qui vient de s'exécuter.
|
||||
*
|
||||
* \param plan Plan retourné par le scheduler — source de \c budget (jamais recalculé ici).
|
||||
* \param slot Créneau courant : ses actions donnent l'ordre du waterfall ET les motifs.
|
||||
* \param now Temps de cycle, publié tel quel comme \c timestamp.
|
||||
* \note Les charges EV sont EXCLUES : décidées par l'amont en amont du waterfall (décision
|
||||
* B), leur puissance est réservée dans \c budget.evReservedW, pas allouée depuis le
|
||||
* budget publié. Les inclure ferait une somme \c loads[] qui ne retomberait pas sur
|
||||
* \c budget.allocatedW.
|
||||
*/
|
||||
void buildTelemetry(const Plan &plan, const Slot &slot, const QDateTime &now);
|
||||
|
||||
/*!
|
||||
* \brief Construit l'instantané du mode dégradé L2 et le publie sans condition.
|
||||
* \param now Instant de la bascule — c'est bien une décision, elle se date.
|
||||
* \note Sans cet appel, un client qui se connecte APRÈS la bascule ne lirait jamais
|
||||
* \c degradedMode : \c update() rend la main avant toute publication tant que le repli
|
||||
* tient. C'est la reprise à froid, et c'est la famille ECS-411.
|
||||
*/
|
||||
void buildDegradedTelemetry(const QDateTime &now);
|
||||
|
||||
/*!
|
||||
* \brief Publie \c m_telemetry si elle diffère significativement de la dernière publiée.
|
||||
* \param force Vrai pour publier sans condition (bascule de mode dégradé, battement).
|
||||
*/
|
||||
void publishTelemetry(bool force);
|
||||
|
||||
/*!
|
||||
* \brief Vrai si deux instantanés diffèrent assez pour mériter une notification.
|
||||
* \param a Dernier instantané publié. \param b Instantané courant.
|
||||
* \return Vrai sur tout changement d'état QUALITATIF (mode dégradé, jeu de charges,
|
||||
* disponibilité, code de défaut, nature du verrou, mécanisme, CODE de motif) ou sur une
|
||||
* variation d'allocation dépassant le seuil.
|
||||
* \note Sont volontairement IGNORÉS : les secondes restantes d'un verrou (elles décroissent
|
||||
* à chaque seconde), et les paramètres numériques d'un motif (le budget bouge à chaque
|
||||
* cycle). Les comparer ferait notifier en permanence et le seuil ne servirait à rien.
|
||||
*/
|
||||
static bool telemetrySignificantlyDiffers(const QVariantMap &a, const QVariantMap &b);
|
||||
|
||||
RuleBasedScheduler *m_scheduler = nullptr;
|
||||
QHash<QString, EvAdapter *> m_adapters; //!< loadId (ThingId string) → EvAdapter*.
|
||||
QHash<QString, SgReadyAdapter *> m_sgReadyAdapters; //!< loadId → SgReadyAdapter* (PAC).
|
||||
@ -263,4 +350,10 @@ private:
|
||||
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.
|
||||
|
||||
// --- Frontière de télémétrie (NymeaEnergy.GetLoadTelemetry / LoadTelemetryChanged) ---
|
||||
QVariantMap m_telemetry; //!< Instantané courant ; vide avant le premier cycle.
|
||||
QVariantMap m_publishedTelemetry; //!< Dernier instantané ÉMIS — base de comparaison du seuil.
|
||||
QDateTime m_lastTelemetryPublish;//!< Horloge murale du dernier envoi (cadence du battement).
|
||||
QTimer *m_telemetryHeartbeat = nullptr; //!< Battement 60 s, indépendant du cycle d'arbitrage.
|
||||
};
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
HEADERS += \
|
||||
$$PWD/types/loadaction.h \
|
||||
$$PWD/types/decisionreason.h \
|
||||
$$PWD/types/loaddescriptor.h \
|
||||
$$PWD/types/surpluscontext.h \
|
||||
$$PWD/types/plan.h \
|
||||
@ -16,6 +17,7 @@ HEADERS += \
|
||||
$$PWD/energyarbitrator.h \
|
||||
|
||||
SOURCES += \
|
||||
$$PWD/types/decisionreason.cpp \
|
||||
$$PWD/types/loadconfig.cpp \
|
||||
$$PWD/config/loadconfigstore.cpp \
|
||||
$$PWD/ratios/energyratioscalculator.cpp \
|
||||
|
||||
@ -51,7 +51,7 @@ Plan RuleBasedScheduler::getPlan(const SurplusContext &ctx)
|
||||
la.chargingEnabled = true;
|
||||
la.currentA = ca.maxChargingCurrent();
|
||||
la.phaseCount = ca.desiredPhaseCount();
|
||||
la.reason = QStringLiteral("Surplus PV disponible — recharge solaire");
|
||||
la.reason = { DecisionCode::EvSurplus };
|
||||
la.estimatedPowerW = la.currentA * 230.0 * la.phaseCount;
|
||||
|
||||
} else if (actions.value(ChargingAction::ChargingActionIssuerSpotMarketCharging).chargingEnabled()) {
|
||||
@ -62,7 +62,7 @@ Plan RuleBasedScheduler::getPlan(const SurplusContext &ctx)
|
||||
la.chargingEnabled = true;
|
||||
la.currentA = ca.maxChargingCurrent();
|
||||
la.phaseCount = ca.desiredPhaseCount();
|
||||
la.reason = QStringLiteral("Tarif aWATTar favorable — recharge heure creuse");
|
||||
la.reason = { DecisionCode::EvSpotMarket };
|
||||
la.estimatedPowerW = la.currentA * 230.0 * la.phaseCount;
|
||||
|
||||
} else {
|
||||
@ -96,6 +96,14 @@ Plan RuleBasedScheduler::getPlan(const SurplusContext &ctx)
|
||||
// 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;
|
||||
|
||||
// Comptabilité PUBLIÉE du budget (télémétrie RPC). Elle s'alimente DANS la cascade,
|
||||
// pendant que celle-ci se déroule : c'est le seul endroit qui sache ce qui a réellement
|
||||
// été alloué et ce qui a été recrédité. La recalculer ailleurs donnerait deux comptes à
|
||||
// tenir d'accord, et le chiffre publié serait le premier à mentir.
|
||||
PlanBudget compta;
|
||||
compta.surplusW = remainingSurplusW;
|
||||
compta.evReservedW = 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é :
|
||||
@ -115,9 +123,9 @@ Plan RuleBasedScheduler::getPlan(const SurplusContext &ctx)
|
||||
|
||||
for (const LoadContext &lc : nonEvLoads) {
|
||||
if (lc.adapter == QStringLiteral("sg-ready"))
|
||||
slot.actions.append(buildSgReadyStateAction(lc, remainingSurplusW));
|
||||
slot.actions.append(buildSgReadyStateAction(lc, remainingSurplusW, compta));
|
||||
else
|
||||
slot.actions.append(buildSetpointAction(lc, remainingSurplusW)); // etmvariableload + relay-router
|
||||
slot.actions.append(buildSetpointAction(lc, remainingSurplusW, compta)); // etmvariableload + relay-router
|
||||
}
|
||||
|
||||
// Grid funding (ECS/PAC) : dormant jusqu'à 3f (waterfall réseau) — non implémenté ici.
|
||||
@ -126,6 +134,9 @@ Plan RuleBasedScheduler::getPlan(const SurplusContext &ctx)
|
||||
plan.planId = QUuid::createUuid().toString(QUuid::WithoutBraces);
|
||||
plan.strategy = QStringLiteral("rule-based");
|
||||
plan.timeSlots.append(slot);
|
||||
|
||||
compta.remainingW = remainingSurplusW;
|
||||
plan.budget = compta;
|
||||
return plan;
|
||||
}
|
||||
|
||||
@ -141,7 +152,7 @@ LoadAction RuleBasedScheduler::buildTimeRequirementAction(EvCharger *ev,
|
||||
la.chargingEnabled = true;
|
||||
la.currentA = ca.maxChargingCurrent();
|
||||
la.phaseCount = ca.desiredPhaseCount();
|
||||
la.reason = QStringLiteral("Deadline VE approchante — recharge prioritaire");
|
||||
la.reason = { DecisionCode::EvDeadline };
|
||||
la.estimatedPowerW = la.currentA * 230.0 * la.phaseCount;
|
||||
return la;
|
||||
}
|
||||
@ -158,7 +169,7 @@ LoadAction RuleBasedScheduler::buildMinCurrentAction(EvCharger *ev) const
|
||||
la.chargingEnabled = true;
|
||||
la.currentA = minA;
|
||||
la.phaseCount = phases;
|
||||
la.reason = QStringLiteral("Aucun surplus — courant minimum maintenu (mode EcoMin)");
|
||||
la.reason = { DecisionCode::EvEcoMin };
|
||||
la.estimatedPowerW = la.currentA * 230.0 * la.phaseCount;
|
||||
return la;
|
||||
}
|
||||
@ -172,18 +183,20 @@ LoadAction RuleBasedScheduler::buildIdleAction(EvCharger *ev) const
|
||||
la.chargingEnabled = false;
|
||||
la.currentA = 0;
|
||||
la.phaseCount = 0;
|
||||
la.reason = QStringLiteral("Aucun surplus disponible — recharge suspendue");
|
||||
la.reason = { DecisionCode::EvIdle };
|
||||
la.estimatedPowerW = 0;
|
||||
return la;
|
||||
}
|
||||
|
||||
LoadAction RuleBasedScheduler::buildSetpointAction(const LoadContext &lc,
|
||||
double &remainingSurplusW) const
|
||||
double &remainingSurplusW,
|
||||
PlanBudget &budget) 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;
|
||||
budget.recreditedW += lc.telemetry.currentPowerW;
|
||||
|
||||
const QList<int> &levels = lc.declared.powerLevels;
|
||||
const bool dynamic = levels.isEmpty();
|
||||
@ -220,10 +233,12 @@ LoadAction RuleBasedScheduler::buildSetpointAction(const LoadContext &lc,
|
||||
la.funding = LoadAction::Surplus;
|
||||
la.powerW = gele;
|
||||
la.estimatedPowerW = gele;
|
||||
la.reason = QStringLiteral("%1 INDISPONIBLE — aucune commande émise ; %2 W toujours "
|
||||
"comptés au budget (levée par ClearLoadFault)")
|
||||
.arg(lc.label).arg(qRound(gele));
|
||||
remainingSurplusW = budgetW - gele;
|
||||
// Le LIBELLÉ n'entre pas dans les paramètres : c'est de la langue naturelle saisie
|
||||
// par l'installateur, et la charge utile RPC doit en rester exempte. Il est substitué
|
||||
// au rendu, par renderFr(reason, label).
|
||||
la.reason = { DecisionCode::LoadUnavailable, {{"frozenW", qRound(gele)}} };
|
||||
remainingSurplusW = budgetW - gele;
|
||||
budget.allocatedW += gele; // une puissance qu'on ne sait plus couper reste allouée
|
||||
return la;
|
||||
}
|
||||
|
||||
@ -262,34 +277,35 @@ LoadAction RuleBasedScheduler::buildSetpointAction(const LoadContext &lc,
|
||||
// suivant un palier 0. Les deux branches se lisent maintenant sur le même axe :
|
||||
// le verrou a relevé la consigne (minOn), ou il l'a rabaissée (minOff).
|
||||
if (setpointW > vouluW)
|
||||
la.reason = QStringLiteral("Verrou minOn — %1 maintenue à %2 W (puissance engagée, "
|
||||
"budget %3 W) ; le résidu en tient compte")
|
||||
.arg(lc.label).arg(qRound(setpointW)).arg(qRound(budgetW));
|
||||
la.reason = { DecisionCode::LockMinOn,
|
||||
{{"appliedW", qRound(setpointW)}, {"budgetW", qRound(budgetW)}} };
|
||||
else if (setpointW < vouluW)
|
||||
// Le budget payait vouluW ; c'est la fenêtre minOff qui interdit de monter. Le motif
|
||||
// cite le budget DISPONIBLE, précisément pour couper court à l'explication budgétaire.
|
||||
la.reason = setpointW <= 0
|
||||
? QStringLiteral("Verrou minOff — %1 ne peut pas s'enclencher avant la fin de la "
|
||||
"fenêtre (budget %2 W disponible, palier %3 W refusé)")
|
||||
.arg(lc.label).arg(qRound(budgetW)).arg(qRound(vouluW))
|
||||
: QStringLiteral("Verrou minOff — %1 plafonnée à %2 W avant la fin de la fenêtre "
|
||||
"(budget %3 W disponible, palier %4 W refusé)")
|
||||
.arg(lc.label).arg(qRound(setpointW)).arg(qRound(budgetW)).arg(qRound(vouluW));
|
||||
? DecisionReason(DecisionCode::LockMinOff,
|
||||
{{"budgetW", qRound(budgetW)}, {"requestedW", qRound(vouluW)}})
|
||||
: DecisionReason(DecisionCode::LockMinOffCapped,
|
||||
{{"appliedW", qRound(setpointW)}, {"budgetW", qRound(budgetW)},
|
||||
{"requestedW", qRound(vouluW)}});
|
||||
else 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)"));
|
||||
// « stepped » remplace le suffixe « (palier) » composé en dur : le client décide
|
||||
// comment il le dit, la box dit seulement que la consigne est arrondie sur un palier.
|
||||
la.reason = { DecisionCode::SurplusSetpoint,
|
||||
{{"budgetW", qRound(budgetW)}, {"setpointW", qRound(setpointW)},
|
||||
{"stepped", !dynamic}} };
|
||||
else
|
||||
la.reason = QStringLiteral("Surplus insuffisant (%1 W) — %2 à 0 W")
|
||||
.arg(qRound(budgetW)).arg(lc.label);
|
||||
la.reason = { DecisionCode::SurplusInsufficient, {{"budgetW", qRound(budgetW)}} };
|
||||
|
||||
// Résidu : budget − consigne engagée → charge suivante de la priorité (même cycle).
|
||||
remainingSurplusW = budgetW - setpointW;
|
||||
remainingSurplusW = budgetW - setpointW;
|
||||
budget.allocatedW += setpointW;
|
||||
return la;
|
||||
}
|
||||
|
||||
LoadAction RuleBasedScheduler::buildSgReadyStateAction(const LoadContext &lc,
|
||||
double &remainingSurplusW) const
|
||||
double &remainingSurplusW,
|
||||
PlanBudget &budget) const
|
||||
{
|
||||
const int currentState = lc.telemetry.state;
|
||||
const double p3 = lc.declared.estimatedPowerW.value(3, 0.0);
|
||||
@ -299,6 +315,7 @@ LoadAction RuleBasedScheduler::buildSgReadyStateAction(const LoadContext &lc,
|
||||
// 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;
|
||||
budget.recreditedW += 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) ;
|
||||
@ -335,19 +352,18 @@ LoadAction RuleBasedScheduler::buildSgReadyStateAction(const LoadContext &lc,
|
||||
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);
|
||||
la.reason = { DecisionCode::LockMinStateHold, {{"state", 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));
|
||||
la.reason = { DecisionCode::SgForced,
|
||||
{{"budgetW", qRound(budgetW)}, {"estimatedW", 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));
|
||||
la.reason = { DecisionCode::SgRecommended,
|
||||
{{"budgetW", qRound(budgetW)}, {"estimatedW", qRound(p3)}} };
|
||||
else
|
||||
la.reason = QStringLiteral("Surplus insuffisant (%1 W) — %2 en normal (état 2, non pilotée)")
|
||||
.arg(qRound(budgetW)).arg(lc.label);
|
||||
la.reason = { DecisionCode::SgNormal, {{"budgetW", qRound(budgetW)}} };
|
||||
|
||||
// Budget restant : on ne soustrait que la puissance ALLOUÉE (états 1/2 = 0).
|
||||
remainingSurplusW = budgetW - la.estimatedPowerW;
|
||||
remainingSurplusW = budgetW - la.estimatedPowerW;
|
||||
budget.allocatedW += la.estimatedPowerW;
|
||||
return la;
|
||||
}
|
||||
|
||||
@ -97,9 +97,14 @@ private:
|
||||
*
|
||||
* \param lc Charge \c etmvariableload du SurplusContext.
|
||||
* \param[in,out] remainingSurplusW Budget de surplus restant (W) ; mis à jour pour la suite.
|
||||
* \param[in,out] budget Comptabilité PUBLIÉE : \c allocatedW et \c recreditedW sont
|
||||
* accumulés ici, dans la cascade même. Le recrédit y figure parce que sans lui
|
||||
* l'identité \c surplus + \c recrédit − \c alloué == \c restant serait fausse dès
|
||||
* qu'une charge consomme déjà — c'est-à-dire dans le cas normal.
|
||||
* \return LoadAction kind=Setpoint, funding=Surplus, \c powerW = consigne, \c reason non vide.
|
||||
*/
|
||||
LoadAction buildSetpointAction(const LoadContext &lc, double &remainingSurplusW) const;
|
||||
LoadAction buildSetpointAction(const LoadContext &lc, double &remainingSurplusW,
|
||||
PlanBudget &budget) const;
|
||||
|
||||
/*!
|
||||
* \brief Construit un LoadAction "state" SG-Ready (PAC) par mapping SÉMANTIQUE du surplus.
|
||||
@ -112,9 +117,12 @@ private:
|
||||
*
|
||||
* \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.
|
||||
* \param[in,out] budget Comptabilité publiée (\c allocatedW / \c recreditedW) — ici le
|
||||
* recrédit est la puissance DÉCLARÉE de l'état courant, pas une mesure.
|
||||
* \return LoadAction kind=State, funding=Surplus, \c reason non vide.
|
||||
*/
|
||||
LoadAction buildSgReadyStateAction(const LoadContext &lc, double &remainingSurplusW) const;
|
||||
LoadAction buildSgReadyStateAction(const LoadContext &lc, double &remainingSurplusW,
|
||||
PlanBudget &budget) const;
|
||||
|
||||
EnergyArbitrator *m_arbitrator;
|
||||
};
|
||||
|
||||
102
energyplugin/etm/types/decisionreason.cpp
Normal file
102
energyplugin/etm/types/decisionreason.cpp
Normal file
@ -0,0 +1,102 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync
|
||||
|
||||
#include "decisionreason.h"
|
||||
#include <QStringList>
|
||||
|
||||
/*
|
||||
* TABLE DE RENDU FRANÇAIS — source unique de la ligne de journal.
|
||||
*
|
||||
* Chaque phrase ci-dessous est REPRISE MOT POUR MOT du QStringLiteral qu'elle remplace. Les
|
||||
* motifs d'ECS-309 et ECS-309-b sont le fruit de corrections coûteuses : ils ne doivent pas
|
||||
* être reformulés au passage. Toute retouche de texte ici change le journal de production —
|
||||
* elle se prouve par comparaison de traces, jamais par relecture.
|
||||
*
|
||||
* Aucun appel à tr() : ce n'est PAS de la traduction. La traduction est le travail du client,
|
||||
* qui reçoit le code (voir la note de decisionreason.h). Cette fonction ne fabrique que la
|
||||
* trace locale de diagnostic, qui est en français par choix d'exploitation.
|
||||
*/
|
||||
QString renderFr(const DecisionReason &reason, const QString &label)
|
||||
{
|
||||
const QVariantMap &p = reason.params;
|
||||
const QString &c = reason.code;
|
||||
|
||||
auto w = [&p](const char *key) { return p.value(QString::fromLatin1(key)).toInt(); };
|
||||
|
||||
// --- EV (proxy amont) ---------------------------------------------------
|
||||
if (c == QLatin1String(DecisionCode::EvSurplus))
|
||||
return QStringLiteral("Surplus PV disponible — recharge solaire");
|
||||
if (c == QLatin1String(DecisionCode::EvSpotMarket))
|
||||
return QStringLiteral("Tarif aWATTar favorable — recharge heure creuse");
|
||||
if (c == QLatin1String(DecisionCode::EvDeadline))
|
||||
return QStringLiteral("Deadline VE approchante — recharge prioritaire");
|
||||
if (c == QLatin1String(DecisionCode::EvEcoMin))
|
||||
return QStringLiteral("Aucun surplus — courant minimum maintenu (mode EcoMin)");
|
||||
if (c == QLatin1String(DecisionCode::EvIdle))
|
||||
return QStringLiteral("Aucun surplus disponible — recharge suspendue");
|
||||
|
||||
// --- Charges pilotées en watts -----------------------------------------
|
||||
if (c == QLatin1String(DecisionCode::LoadUnavailable))
|
||||
return QStringLiteral("%1 INDISPONIBLE — aucune commande émise ; %2 W toujours "
|
||||
"comptés au budget (levée par ClearLoadFault)")
|
||||
.arg(label).arg(w("frozenW"));
|
||||
|
||||
// ECS-309-b — les deux branches de verrou se lisent sur le MÊME axe : le verrou a relevé
|
||||
// la consigne (minOn), ou il l'a rabaissée (minOff). Le critère est ce que le verrou a
|
||||
// FAIT à la consigne voulue, jamais une comparaison au budget.
|
||||
if (c == QLatin1String(DecisionCode::LockMinOn))
|
||||
return QStringLiteral("Verrou minOn — %1 maintenue à %2 W (puissance engagée, "
|
||||
"budget %3 W) ; le résidu en tient compte")
|
||||
.arg(label).arg(w("appliedW")).arg(w("budgetW"));
|
||||
if (c == QLatin1String(DecisionCode::LockMinOff))
|
||||
return QStringLiteral("Verrou minOff — %1 ne peut pas s'enclencher avant la fin de la "
|
||||
"fenêtre (budget %2 W disponible, palier %3 W refusé)")
|
||||
.arg(label).arg(w("budgetW")).arg(w("requestedW"));
|
||||
if (c == QLatin1String(DecisionCode::LockMinOffCapped))
|
||||
return QStringLiteral("Verrou minOff — %1 plafonnée à %2 W avant la fin de la fenêtre "
|
||||
"(budget %3 W disponible, palier %4 W refusé)")
|
||||
.arg(label).arg(w("appliedW")).arg(w("budgetW")).arg(w("requestedW"));
|
||||
if (c == QLatin1String(DecisionCode::SurplusSetpoint))
|
||||
return QStringLiteral("Surplus PV %1 W — %2 consigne %3 W%4")
|
||||
.arg(w("budgetW")).arg(label).arg(w("setpointW"))
|
||||
.arg(p.value(QStringLiteral("stepped")).toBool() ? QStringLiteral(" (palier)")
|
||||
: QString());
|
||||
if (c == QLatin1String(DecisionCode::SurplusInsufficient))
|
||||
return QStringLiteral("Surplus insuffisant (%1 W) — %2 à 0 W")
|
||||
.arg(w("budgetW")).arg(label);
|
||||
|
||||
// --- SG-Ready (PAC) -----------------------------------------------------
|
||||
if (c == QLatin1String(DecisionCode::LockMinStateHold))
|
||||
return QStringLiteral("Verrou minStateHold — %1 maintenue état %2 (court-cycling PAC)")
|
||||
.arg(label).arg(w("state"));
|
||||
if (c == QLatin1String(DecisionCode::SgForced))
|
||||
return QStringLiteral("Surplus abondant %1 W — %2 forcée (état 4, ~%3 W)")
|
||||
.arg(w("budgetW")).arg(label).arg(w("estimatedW"));
|
||||
if (c == QLatin1String(DecisionCode::SgRecommended))
|
||||
return QStringLiteral("Surplus PV %1 W — %2 recommandée (état 3, ~%3 W)")
|
||||
.arg(w("budgetW")).arg(label).arg(w("estimatedW"));
|
||||
if (c == QLatin1String(DecisionCode::SgNormal))
|
||||
return QStringLiteral("Surplus insuffisant (%1 W) — %2 en normal (état 2, non pilotée)")
|
||||
.arg(w("budgetW")).arg(label);
|
||||
|
||||
// --- Sécurité et cycle de vie ------------------------------------------
|
||||
if (c == QLatin1String(DecisionCode::DegradedL2))
|
||||
return QStringLiteral("Compteur muet depuis >90 s — consigne de repli (L2 watchdog)");
|
||||
if (c == QLatin1String(DecisionCode::SafeStateRelay))
|
||||
return QStringLiteral("Charge désactivée — mise en état sûr avant retrait (ECS-413)");
|
||||
if (c == QLatin1String(DecisionCode::SafeStateSetpoint))
|
||||
return QStringLiteral("Charge désactivée — mise en état sûr (0 W) avant retrait (ECS-413)");
|
||||
if (c == QLatin1String(DecisionCode::SafeStateSgReady))
|
||||
return QStringLiteral("Charge désactivée — mise en état sûr (état 2) avant retrait (ECS-413)");
|
||||
|
||||
// Code hors catalogue. Règle 7-c : ne JAMAIS rendre une chaîne vide, qui ferait passer un
|
||||
// motif présent-mais-inconnu pour un motif absent. On publie le code brut et ses
|
||||
// paramètres — assez pour retrouver le site d'émission fautif dans le journal.
|
||||
if (c.isEmpty())
|
||||
return QStringLiteral("(motif absent)");
|
||||
QStringList kv;
|
||||
for (auto it = p.constBegin(); it != p.constEnd(); ++it)
|
||||
kv << QStringLiteral("%1=%2").arg(it.key(), it.value().toString());
|
||||
return QStringLiteral("(motif inconnu %1%2%3)")
|
||||
.arg(c, kv.isEmpty() ? QString() : QStringLiteral(" "), kv.join(QStringLiteral(", ")));
|
||||
}
|
||||
139
energyplugin/etm/types/decisionreason.h
Normal file
139
energyplugin/etm/types/decisionreason.h
Normal file
@ -0,0 +1,139 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync
|
||||
#pragma once
|
||||
|
||||
#include <QString>
|
||||
#include <QVariantMap>
|
||||
|
||||
/*!
|
||||
* \file decisionreason.h
|
||||
* \brief Motif de décision sous forme \c {code, params} — la seule forme qui franchit la
|
||||
* frontière RPC, et la seule source de la ligne française du journal.
|
||||
*
|
||||
* \par Pourquoi un code et pas une phrase
|
||||
* La locale nymea est **par connexion** (\c JSONRPC.Hello accepte \c o:locale, rangée dans
|
||||
* \c m_clientLocales) : deux utilisateurs d'une même box peuvent avoir deux langues. Une
|
||||
* phrase composée en C++ ne peut donc être juste que pour l'un des deux. Par ailleurs
|
||||
* \c NymeaEnergyJsonHandler est un \c JsonHandler **sans \c pluginId** : il est
|
||||
* structurellement hors du catalogue de traduction nymea, et rien ne rattraperait une
|
||||
* phrase française émise ici. La box transporte donc un CODE et des PARAMÈTRES ; la phrase
|
||||
* est fabriquée par le client, dans la langue de son utilisateur.
|
||||
*
|
||||
* \par Le journal reste en français — mais RENDU depuis le code
|
||||
* \c renderFr() est la fonction de rendu UNIQUE. La ligne française n'est pas maintenue en
|
||||
* parallèle du code : elle en est dérivée. Deux écritures parallèles divergeraient, et
|
||||
* c'est le diagnostic terrain qui deviendrait faux.
|
||||
*
|
||||
* \par Aucun libellé dans \c params
|
||||
* Le nom de la charge (« Chauffe-eau ») est de la langue naturelle saisie par
|
||||
* l'installateur. Il n'entre PAS dans \c params : il est passé à \c renderFr() au moment du
|
||||
* rendu, par l'appelant qui le connaît (l'adaptateur connaît le sien, l'arbitre a le
|
||||
* contexte). La charge utile RPC reste donc exempte de toute langue naturelle, et le client
|
||||
* résout le libellé lui-même via \c GetLoadConfig.
|
||||
*/
|
||||
|
||||
/*!
|
||||
* \brief Catalogue FERMÉ des codes de motif.
|
||||
*
|
||||
* Un code par site d'émission — la liste est EXTRAITE des \c QStringLiteral qui existaient
|
||||
* avant la refonte, pas inventée. Ajouter un motif = ajouter une constante ici, une branche
|
||||
* de rendu dans \c renderFr(), et une ligne au tableau d'\c INTERFACE.md.
|
||||
*/
|
||||
namespace DecisionCode {
|
||||
|
||||
// --- EV (proxy amont, beta) -------------------------------------------------
|
||||
constexpr const char *EvSurplus = "EV_SURPLUS"; //!< params: —
|
||||
constexpr const char *EvSpotMarket = "EV_SPOT_MARKET"; //!< params: —
|
||||
constexpr const char *EvDeadline = "EV_DEADLINE"; //!< params: —
|
||||
constexpr const char *EvEcoMin = "EV_ECO_MIN"; //!< params: —
|
||||
constexpr const char *EvIdle = "EV_IDLE"; //!< params: —
|
||||
|
||||
// --- Charges pilotées en watts (relay-router / etmvariableload) -------------
|
||||
//! params: \c frozenW — charge en défaut (ECS-410/414), gelée et toujours comptée au budget.
|
||||
constexpr const char *LoadUnavailable = "LOAD_UNAVAILABLE";
|
||||
//! params: \c appliedW, \c budgetW — le verrou minOn a RELEVÉ la consigne voulue.
|
||||
constexpr const char *LockMinOn = "LOCK_MIN_ON";
|
||||
//! params: \c budgetW, \c requestedW — minOff interdit tout enclenchement (consigne à 0 W).
|
||||
constexpr const char *LockMinOff = "LOCK_MIN_OFF";
|
||||
//! params: \c appliedW, \c budgetW, \c requestedW — minOff plafonne sans interdire.
|
||||
constexpr const char *LockMinOffCapped = "LOCK_MIN_OFF_CAPPED";
|
||||
//! params: \c budgetW, \c setpointW, \c stepped (bool) — consigne servie par le surplus.
|
||||
constexpr const char *SurplusSetpoint = "SURPLUS_SETPOINT";
|
||||
//! params: \c budgetW — le budget ne payait aucun palier ; aucun verrou n'a mordu.
|
||||
constexpr const char *SurplusInsufficient = "SURPLUS_INSUFFICIENT";
|
||||
|
||||
// --- SG-Ready (PAC) ---------------------------------------------------------
|
||||
//! params: \c state — le verrou minStateHold maintient l'état (court-cycling compresseur).
|
||||
constexpr const char *LockMinStateHold = "LOCK_MIN_STATE_HOLD";
|
||||
//! params: \c budgetW, \c estimatedW — état 4 (forcé).
|
||||
constexpr const char *SgForced = "SG_FORCED";
|
||||
//! params: \c budgetW, \c estimatedW — état 3 (recommandation).
|
||||
constexpr const char *SgRecommended = "SG_RECOMMENDED";
|
||||
//! params: \c budgetW — état 2 (normal, mains off).
|
||||
constexpr const char *SgNormal = "SG_NORMAL";
|
||||
|
||||
// --- Sécurité et cycle de vie ----------------------------------------------
|
||||
//! params: — · consigne de repli du watchdog L2 (compteur muet, SAFETY.md §L2).
|
||||
constexpr const char *DegradedL2 = "DEGRADED_L2";
|
||||
//! params: — · état sûr d'un routeur de relais avant retrait (ECS-413) : tous relais ouverts.
|
||||
constexpr const char *SafeStateRelay = "SAFE_STATE_RELAY";
|
||||
//! params: — · état sûr d'une charge à consigne continue avant retrait (ECS-413) : 0 W.
|
||||
constexpr const char *SafeStateSetpoint = "SAFE_STATE_SETPOINT";
|
||||
//! params: — · état sûr d'une PAC SG-Ready avant retrait (ECS-413) : état 2, JAMAIS le blocage.
|
||||
constexpr const char *SafeStateSgReady = "SAFE_STATE_SG_READY";
|
||||
|
||||
}
|
||||
|
||||
/*!
|
||||
* \brief Motif d'une décision d'arbitrage : un code du catalogue + ses paramètres numériques.
|
||||
*
|
||||
* \invariant \c code non vide sur toute \c LoadAction transmise à un adaptateur
|
||||
* (AGENTS règle 7). Un adaptateur rejette silencieusement une action au code vide.
|
||||
* \invariant \c params ne contient QUE des nombres et des booléens — jamais de langue
|
||||
* naturelle (voir la note de fichier).
|
||||
*/
|
||||
struct DecisionReason
|
||||
{
|
||||
//! \brief Motif vide — état par défaut, refusé par les adaptateurs.
|
||||
DecisionReason() = default;
|
||||
|
||||
/*!
|
||||
* \brief Construit un motif.
|
||||
* \param decisionCode Constante de \c DecisionCode (jamais une chaîne libre).
|
||||
* \param decisionParams Paramètres du code, nombres et booléens uniquement.
|
||||
*/
|
||||
DecisionReason(const char *decisionCode, const QVariantMap &decisionParams = QVariantMap())
|
||||
: code(QString::fromLatin1(decisionCode)), params(decisionParams) {}
|
||||
|
||||
QString code; //!< Code du catalogue \c DecisionCode ; vide = aucun motif.
|
||||
QVariantMap params; //!< Paramètres du code ; clés documentées par code.
|
||||
|
||||
//! \return Vrai si aucun motif n'est porté (\c code vide).
|
||||
bool isEmpty() const { return code.isEmpty(); }
|
||||
|
||||
//! \return Forme sérialisée \c {code, params} — la charge utile RPC.
|
||||
//! \note \c params est toujours présent, fût-il vide : un client n'a pas à distinguer
|
||||
//! « pas de paramètre » de « clé oubliée ».
|
||||
QVariantMap toMap() const
|
||||
{
|
||||
QVariantMap m;
|
||||
m.insert(QStringLiteral("code"), code);
|
||||
m.insert(QStringLiteral("params"), params);
|
||||
return m;
|
||||
}
|
||||
};
|
||||
|
||||
/*!
|
||||
* \brief Rend la ligne FRANÇAISE du journal à partir du code et de ses paramètres.
|
||||
*
|
||||
* Fonction de rendu UNIQUE : toute ligne française de motif du moteur passe par ici. C'est ce
|
||||
* qui garantit qu'elle ne peut pas diverger du code publié en RPC.
|
||||
*
|
||||
* \param reason Motif à rendre.
|
||||
* \param label Libellé de la charge, substitué là où les phrases historiques le nommaient.
|
||||
* Vide pour les motifs qui ne nomment aucune charge (EV, repli L2, états sûrs).
|
||||
* \return La phrase française, mot pour mot celle d'avant la refonte pour la même situation.
|
||||
* Pour un code inconnu : une forme de repli \c "code(params)" — jamais une chaîne vide, qui
|
||||
* ferait passer un motif manquant pour un motif absent.
|
||||
*/
|
||||
QString renderFr(const DecisionReason &reason, const QString &label = QString());
|
||||
@ -3,6 +3,7 @@
|
||||
#pragma once
|
||||
|
||||
#include <QString>
|
||||
#include "decisionreason.h"
|
||||
|
||||
/*!
|
||||
* \brief Action typée émise par l'arbitre vers un ILoadAdapter.
|
||||
@ -10,8 +11,8 @@
|
||||
* 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 \c reason doit porter un CODE non vide du catalogue \c DecisionCode
|
||||
* (AGENTS invariant 7). Un adaptateur doit rejeter toute action au code vide.
|
||||
* \invariant Pour kind == Setpoint / evcharger : seuls \c chargingEnabled,
|
||||
* \c currentA, \c phaseCount sont significatifs.
|
||||
*/
|
||||
@ -48,10 +49,16 @@ struct LoadAction {
|
||||
Permission discharge = Allow; //!< Autorisation de la décharger (arbitrage grid-funding).
|
||||
|
||||
/*!
|
||||
* \brief Motif de la décision, non vide, en français.
|
||||
* Obligatoire (invariant 7). L'adaptateur rejette silencieusement si vide.
|
||||
* \brief Motif de la décision : un CODE du catalogue \c DecisionCode + ses paramètres.
|
||||
*
|
||||
* Obligatoire (invariant 7) : l'adaptateur rejette silencieusement si \c reason.code est
|
||||
* vide. **Jamais une phrase.** La ligne française du journal est RENDUE à partir de ce
|
||||
* couple par \c renderFr(), fonction de rendu unique — elle n'est pas maintenue en
|
||||
* parallèle. Ce même couple franchit la frontière RPC dans \c NymeaEnergy.GetLoadTelemetry
|
||||
* et sa notification, où le client fabrique la phrase dans la langue de son utilisateur
|
||||
* (la locale nymea est par CONNEXION, une phrase composée ici ne pourrait pas l'être).
|
||||
*/
|
||||
QString reason;
|
||||
DecisionReason reason;
|
||||
|
||||
/*!
|
||||
* \brief Puissance estimée (W) — hint pour la comptabilité budget de l'arbitre.
|
||||
|
||||
@ -95,6 +95,16 @@ bool LoadConfigSgReady::hasState(int s) const
|
||||
|
||||
// ---- LoadConfig ------------------------------------------------------------
|
||||
|
||||
QStringList LoadConfig::knownDomains()
|
||||
{
|
||||
// Énumération FERMÉE, déclarée UNE fois. isValid() et la documentation en dérivent ; les
|
||||
// redéclarer ailleurs ferait diverger le refus du contrat publié.
|
||||
// Ce sont des CODES, jamais des libellés : le nom affiché à l'utilisateur est fabriqué par
|
||||
// le client, dans sa langue (la locale nymea est par connexion).
|
||||
return { QStringLiteral("ecs"), QStringLiteral("ev"), QStringLiteral("heating"),
|
||||
QStringLiteral("hvac"), QStringLiteral("battery") };
|
||||
}
|
||||
|
||||
QVariantMap LoadConfig::sgReady() const
|
||||
{
|
||||
return m_sgReady.states.isEmpty() ? QVariantMap() : m_sgReady.toMap();
|
||||
@ -177,6 +187,16 @@ bool LoadConfig::isValid(QString *error) const
|
||||
if (m_mode != QStringLiteral("fixed") && m_mode != QStringLiteral("dynamic"))
|
||||
return fail(QStringLiteral("mode doit être \"fixed\" ou \"dynamic\" (reçu: %1)").arg(m_mode));
|
||||
|
||||
// Domaine : métadonnée d'intention, OPTIONNELLE. Absent = charge non classée — le plugin ne
|
||||
// la range pas d'office, parce qu'il n'a aucun moyen de deviner juste et qu'un classement
|
||||
// inventé serait indistinguable d'un classement choisi. Présent, il doit appartenir à
|
||||
// l'énumération : une valeur libre acceptée aujourd'hui deviendrait une valeur à supporter
|
||||
// le jour où la couche domaine en fera son discriminant.
|
||||
if (!m_domain.isEmpty() && !knownDomains().contains(m_domain))
|
||||
return fail(QStringLiteral("domain \"%1\" hors énumération (attendu : %2, ou absent "
|
||||
"pour une charge non classée)")
|
||||
.arg(m_domain, knownDomains().join(QStringLiteral(", "))));
|
||||
|
||||
// LM-300 — UNION DISCRIMINÉE par mécanisme. Le discriminant est \c adapter ; chaque
|
||||
// mécanisme a UNE charge utile, et les charges utiles des autres mécanismes DOIVENT être
|
||||
// absentes (LM-302 : les états invalides doivent être inexprimables). Ajouter un
|
||||
@ -285,6 +305,10 @@ QVariantMap LoadConfig::toMap() const
|
||||
m.insert("label", m_label);
|
||||
m.insert("adapter", m_adapter);
|
||||
m.insert("mode", m_mode);
|
||||
// Omis quand la charge n'est pas classée — jamais une chaîne vide, qui se lirait comme un
|
||||
// domaine inconnu plutôt que comme une absence de classement (règle « omis, jamais nul »).
|
||||
if (!m_domain.isEmpty())
|
||||
m.insert("domain", m_domain);
|
||||
if (!m_powerLevels.isEmpty())
|
||||
m.insert("powerLevels", powerLevels());
|
||||
m.insert("maxPowerW", m_maxPowerW);
|
||||
@ -312,6 +336,7 @@ LoadConfig LoadConfig::fromMap(const QVariantMap &map)
|
||||
c.m_label = map.value("label").toString();
|
||||
c.m_adapter = map.value("adapter", QStringLiteral("etmvariableload")).toString();
|
||||
c.m_mode = map.value("mode").toString();
|
||||
c.m_domain = map.value("domain").toString(); // absent → vide → charge non classée
|
||||
c.setPowerLevels(map.value("powerLevels").toList()); // trie + déduplique
|
||||
c.m_maxPowerW = map.value("maxPowerW").toInt();
|
||||
c.m_priority = map.value("priority").toInt();
|
||||
|
||||
@ -9,6 +9,7 @@
|
||||
#include <QVariantList>
|
||||
#include <QVariantMap>
|
||||
#include <QSet>
|
||||
#include <QStringList>
|
||||
|
||||
/*!
|
||||
* \brief Besoins énergétiques d'une charge pilotée (contrat etmvariableload §4 \c needs).
|
||||
@ -125,6 +126,8 @@ class LoadConfig
|
||||
Q_PROPERTY(QString label READ label WRITE setLabel)
|
||||
Q_PROPERTY(QString adapter READ adapter WRITE setAdapter)
|
||||
Q_PROPERTY(QString mode READ mode WRITE setMode)
|
||||
//! Catégorie d'intention déclarée par l'installateur — énumération FERMÉE, code jamais libellé.
|
||||
Q_PROPERTY(QString domain READ domain WRITE setDomain)
|
||||
Q_PROPERTY(QVariantList powerLevels READ powerLevels WRITE setPowerLevels)
|
||||
Q_PROPERTY(int maxPowerW READ maxPowerW WRITE setMaxPowerW)
|
||||
Q_PROPERTY(int priority READ priority WRITE setPriority)
|
||||
@ -152,6 +155,36 @@ public:
|
||||
QString adapter() const { return m_adapter; }
|
||||
//! \param v Type d'adaptateur ; toute autre valeur fait échouer isValid().
|
||||
void setAdapter(const QString &v) { m_adapter = v; }
|
||||
/*!
|
||||
* \brief Catégorie d'intention de la charge — PURE MÉTADONNÉE.
|
||||
*
|
||||
* \return Un code de l'énumération fermée \c {ecs, ev, heating, hvac, battery}, ou une
|
||||
* chaîne VIDE : la charge est alors « non classée ». Le plugin ne la range jamais d'office.
|
||||
*
|
||||
* \note **L'arbitre ne lit pas ce champ** et aucun comportement moteur n'en dépend. Il
|
||||
* enregistre le choix de l'installateur, qui sélectionne une catégorie puis lui rattache
|
||||
* ses Things. Le jour où la couche domaine de \c specs/spec_loadmodel.md arrivera, ce
|
||||
* champ en deviendra le discriminant — il ne la préempte pas.
|
||||
* \note **Attention à la forme en sortie RPC.** \c toMap() (persistance) OMET la clé quand
|
||||
* le domaine est vide, mais \c GetLoadConfig sérialise via le MÉTA-OBJET : la propriété
|
||||
* sort donc toujours, à \c "" pour une charge non classée — comme \c sgReady vide ou
|
||||
* \c powerLevels vide. C'est le corollaire LM-302, et c'est ce qui rend l'aller-retour
|
||||
* \c Get → \c Set possible. Vérifié au banc le 2026-08-25.
|
||||
* \note **Un code, jamais un libellé affichable** : la locale nymea est par connexion, et
|
||||
* ce handler est hors du catalogue de traduction (cf. \c decisionreason.h). Le libellé
|
||||
* affiché est fabriqué par le client.
|
||||
* \note L'énumération de \c spec_loadmodel.md §2 (\c Ecs/\c Hvac/\c Ev/\c SmartHome) est
|
||||
* une INTENTION de conception plus ancienne ; c'est celle-ci qui est implémentée.
|
||||
*/
|
||||
QString domain() const { return m_domain; }
|
||||
//! \param v Code de domaine, ou chaîne vide pour « non classée ». Toute autre valeur fait
|
||||
//! échouer \c isValid() — c'est le seul endroit où l'énumération est refermée.
|
||||
void setDomain(const QString &v) { m_domain = v; }
|
||||
|
||||
//! \return Codes de domaine acceptés, ordre stable. Source unique de l'énumération :
|
||||
//! la validation et la documentation en dérivent, elles ne la redéclarent pas.
|
||||
static QStringList knownDomains();
|
||||
|
||||
//! \return Mode : "fixed" (paliers) ou "dynamic" (modulation continue).
|
||||
QString mode() const { return m_mode; }
|
||||
//! \param v Mode "fixed" ou "dynamic" ; toute autre valeur fait échouer isValid().
|
||||
@ -256,6 +289,7 @@ private:
|
||||
QString m_label;
|
||||
QString m_adapter = QStringLiteral("etmvariableload");
|
||||
QString m_mode;
|
||||
QString m_domain; //!< Code d'énumération fermée ; vide = charge non classée.
|
||||
QList<int> m_powerLevels; //!< Trié croissant, 0 inclus en *fixed* ; vide en *dynamic*.
|
||||
int m_maxPowerW = 0;
|
||||
int m_priority = 0;
|
||||
|
||||
@ -22,6 +22,47 @@ struct Slot {
|
||||
QList<LoadAction> actions; //!< Actions à appliquer, triées par priorité croissante.
|
||||
};
|
||||
|
||||
/*!
|
||||
* \brief Comptabilité du budget de surplus du cycle — INTERNE à l'arbitre.
|
||||
*
|
||||
* Non sérialisée vers l'optimiseur socket, exactement comme \c LoadAction::funding : ce n'est
|
||||
* pas une entrée du contrat OPTIMIZER_PROTOCOL §6, c'est la trace de ce que le waterfall a
|
||||
* fait de son budget, publiée telle quelle par \c NymeaEnergy.GetLoadTelemetry.
|
||||
*
|
||||
* \invariant \c surplusW + \c recreditedW − \c allocatedW == \c remainingW (aux arrondis
|
||||
* près). C'est ce qui rend la charge utile RPC vérifiable par un client, et le calcul
|
||||
* auditable contre le journal de la même fenêtre.
|
||||
*
|
||||
* \par Pourquoi \c recreditedW existe
|
||||
* Sans lui l'identité serait FAUSSE, et elle le serait silencieusement : le waterfall rend à
|
||||
* chaque charge sa propre consommation de début de cycle avant d'arrondir (correction B,
|
||||
* anti-clignotement). Un client qui poserait \c surplus − \c alloué == \c restant
|
||||
* trouverait un écart inexpliqué dès qu'une charge consomme déjà — c'est-à-dire dans le cas
|
||||
* NORMAL. Publier le recrédit rend aussi visible l'artefact de banc documenté dans
|
||||
* \c AGENTS.md (relais sans mesure + rootmeter aveugle → recrédit du nominal commandé, donc
|
||||
* double comptage) : le chiffre est là, lisible, au lieu d'être à redéduire.
|
||||
* \note L'arbitre ne RECALCULE pas ces valeurs : il les reçoit du scheduler qui les a
|
||||
* réellement utilisées. Un second calcul, même juste le jour où il est écrit, divergerait
|
||||
* du premier à la première correction — et c'est le chiffre publié qui deviendrait faux.
|
||||
*/
|
||||
struct PlanBudget {
|
||||
//! Budget entrant du waterfall non-EV : surplus net signé (export − import) MOINS la
|
||||
//! réservation EV du cycle. Négatif = soutirage réseau, le waterfall déleste.
|
||||
double surplusW = 0;
|
||||
//! Puissance réservée aux consignes EV de CE cycle, pas encore visible au compteur
|
||||
//! (correction A). Publiée pour que \c surplusW reste rapprochable de la mesure brute :
|
||||
//! sans elle, l'écart avec le compteur serait inexplicable côté client.
|
||||
double evReservedW = 0;
|
||||
//! Somme des puissances ALLOUÉES aux charges non-EV par le waterfall de ce cycle — le
|
||||
//! total, pas l'incrément. C'est la somme des \c estimatedPowerW des actions du créneau.
|
||||
double allocatedW = 0;
|
||||
//! Somme des RECRÉDITS de début de cycle (correction B) : pour chaque charge, la puissance
|
||||
//! qu'elle consommait déjà et qui lui est rendue avant l'arrondi. Voir la note de classe.
|
||||
double recreditedW = 0;
|
||||
//! Résidu non alloué à l'issue de la cascade.
|
||||
double remainingW = 0;
|
||||
};
|
||||
|
||||
/*!
|
||||
* \brief Plan d'optimisation retourné par IScheduler::getPlan().
|
||||
*
|
||||
@ -35,6 +76,9 @@ struct Plan {
|
||||
//! 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;
|
||||
//! Comptabilité du budget du cycle — INTERNE, jamais sérialisée vers l'optimiseur socket
|
||||
//! (même statut que \c LoadAction::funding). Alimente la télémétrie RPC.
|
||||
PlanBudget budget;
|
||||
|
||||
/*!
|
||||
* \brief Retourne le Slot couvrant \p dt.
|
||||
|
||||
@ -143,8 +143,14 @@ NymeaEnergyJsonHandler::NymeaEnergyJsonHandler(SpotMarketManager *spotMarketMana
|
||||
|
||||
|
||||
params.clear(); returns.clear();
|
||||
description = "Get the planed charging schedules.";
|
||||
description = "Get the planed charging schedules, and whether the L2 degraded mode is "
|
||||
"currently active (meter silent > 90s, planning suspended).";
|
||||
returns.insert("chargingSchedules", QVariantList() << objectRef<ChargingSchedule>());
|
||||
// [ETM] degradedMode était POUSSÉ par ChargingSchedulesChanged sans être LISIBLE ici : un
|
||||
// client connecté après la bascule ne pouvait pas savoir dans quel état il se trouvait.
|
||||
// C'est la famille ECS-411 — un silence ambigu. Tout élément runtime a un Get* ET un
|
||||
// *Changed ; jamais une notification seule.
|
||||
returns.insert("degradedMode", enumValueName(Bool));
|
||||
registerMethod("GetChargingSchedules", description, params, returns, Types::PermissionScopeControlThings);
|
||||
|
||||
|
||||
@ -174,6 +180,11 @@ NymeaEnergyJsonHandler::NymeaEnergyJsonHandler(SpotMarketManager *spotMarketMana
|
||||
loadItem.insert("label", enumValueName(String));
|
||||
loadItem.insert("adapter", enumValueName(String));
|
||||
loadItem.insert("mode", enumValueName(String));
|
||||
// Métadonnée d'INTENTION (§3 du lot) : un code d'énumération fermée — ecs · ev · heating ·
|
||||
// hvac · battery — jamais un libellé affichable. Optionnelle : absente = charge non classée.
|
||||
// L'arbitre ne la lit pas ; la validation de l'énumération vit dans LoadConfig::isValid(),
|
||||
// comme pour « adapter » et « mode » (LM-302 : l'exigence réelle n'est jamais dans le schéma).
|
||||
loadItem.insert("o:domain", enumValueName(String));
|
||||
loadItem.insert("priority", enumValueName(Uint));
|
||||
loadItem.insert("enabled", enumValueName(Bool));
|
||||
loadItem.insert("o:powerLevels", QVariantList() << enumValueName(Uint)); // etmvariableload fixed
|
||||
@ -223,6 +234,21 @@ NymeaEnergyJsonHandler::NymeaEnergyJsonHandler(SpotMarketManager *spotMarketMana
|
||||
returns.insert("energyError", enumRef<EnergyManager::EnergyError>());
|
||||
registerMethod("ClearLoadFault", description, params, returns, Types::PermissionScopeControlThings);
|
||||
|
||||
// [ETM] Frontière de télémétrie d'arbitrage. AUCUNE phrase composée ici : des codes et des
|
||||
// paramètres. La locale nymea est par CONNEXION (JSONRPC.Hello o:locale) et ce handler n'a
|
||||
// pas de pluginId, donc il est hors du catalogue de traduction : une phrase française émise
|
||||
// ici ne serait rattrapée par rien. Cf. etm/types/decisionreason.h.
|
||||
params.clear(); returns.clear();
|
||||
description = "Get a snapshot of the arbitration runtime state: surplus budget, per-load "
|
||||
"allocation, availability, fault code, active lock (seconds remaining), "
|
||||
"mechanism payload and decision reason. Reason and fault are CODES with "
|
||||
"numeric params - never human-readable sentences: the client renders them in "
|
||||
"the user's language. Identical payload to the LoadTelemetryChanged "
|
||||
"notification. 'timestamp' is the end of the last arbitration cycle and is "
|
||||
"omitted while no cycle has run yet.";
|
||||
returns = loadTelemetrySchema();
|
||||
registerMethod("GetLoadTelemetry", description, params, returns, Types::PermissionScopeControlThings);
|
||||
|
||||
|
||||
// Notifications
|
||||
params.clear();
|
||||
@ -332,6 +358,28 @@ NymeaEnergyJsonHandler::NymeaEnergyJsonHandler(SpotMarketManager *spotMarketMana
|
||||
});
|
||||
}
|
||||
|
||||
// [ETM] LoadTelemetryChanged : MÊME charge utile que GetLoadTelemetry (schéma partagé, une
|
||||
// seule déclaration). Émise sur changement SIGNIFICATIF (mode dégradé, jeu de charges,
|
||||
// disponibilité, défaut, nature de verrou, mécanisme, code de motif, ou variation
|
||||
// d'allocation au-delà du seuil) ET à intervalle périodique même sans changement — c'est
|
||||
// ce battement qui rend un arbitre FIGÉ détectable, le timestamp cessant d'avancer.
|
||||
params.clear();
|
||||
description = "Emitted whenever the arbitration runtime state changed significantly, and "
|
||||
"periodically (heartbeat) even when unchanged, so that a frozen arbitrator is "
|
||||
"detectable by a timestamp that stops advancing. Same payload as "
|
||||
"GetLoadTelemetry.";
|
||||
params = loadTelemetrySchema();
|
||||
registerNotification("LoadTelemetryChanged", description, params);
|
||||
#ifdef ETM_ARBITRATOR
|
||||
if (EnergyArbitrator *arb = dynamic_cast<EnergyArbitrator *>(m_smartChargingManager)) {
|
||||
connect(arb, &EnergyArbitrator::loadTelemetryChanged, this, [this](const QVariantMap &t) {
|
||||
Q_UNUSED(t) // on republie la vue NORMALISÉE, pas la carte brute : c'est
|
||||
// currentLoadTelemetry() qui garantit les clés requises du schéma.
|
||||
emit LoadTelemetryChanged(currentLoadTelemetry());
|
||||
});
|
||||
}
|
||||
#endif
|
||||
|
||||
// [Phase 2] EnergyRatiosChanged : émise sur powerBalanceChanged() de l'energymanager,
|
||||
// uniquement quand une valeur (ou sa disponibilité) change. Mêmes champs que GetEnergyRatios.
|
||||
params.clear();
|
||||
@ -545,6 +593,7 @@ JsonReply *NymeaEnergyJsonHandler::GetChargingSchedules(const QVariantMap ¶m
|
||||
schedules << pack<ChargingSchedule>(schedule);
|
||||
}
|
||||
returns.insert("chargingSchedules", schedules);
|
||||
returns.insert("degradedMode", m_smartChargingManager->degradedMode()); // [ETM] L2, cf. supra
|
||||
return createReply(returns);
|
||||
}
|
||||
|
||||
@ -623,6 +672,99 @@ JsonReply *NymeaEnergyJsonHandler::ClearLoadFault(const QVariantMap ¶ms)
|
||||
return createReply(ret);
|
||||
}
|
||||
|
||||
QVariantMap NymeaEnergyJsonHandler::loadTelemetrySchema()
|
||||
{
|
||||
// Verrou : le TEMPS RESTANT, pas des watts. lockMinPowerW/lockMaxPowerW sont un artefact du
|
||||
// mécanisme d'allocation, illisible pour un utilisateur. Ceci n'enfreint pas ECS-306 :
|
||||
// cette exigence interdit qu'un index de PALIER franchisse la frontière d'ALLOCATION ; une
|
||||
// valeur d'AFFICHAGE sortante est un autre chemin.
|
||||
QVariantMap lockItem;
|
||||
lockItem.insert("kind", enumValueName(String)); // "minOn" | "minOff" | "minStateHold"
|
||||
lockItem.insert("remainingS", enumValueName(Int));
|
||||
|
||||
// UNION DISCRIMINÉE par mécanisme, comme LoadConfig : « kind » discrimine, chaque variante
|
||||
// porte SA charge utile. LM-302 s'applique tel quel — TOUTES les clés internes sont
|
||||
// optionnelles, sinon la charge utile d'un mécanisme contraindrait celle des autres.
|
||||
QVariantMap mechItem;
|
||||
mechItem.insert("kind", enumValueName(String)); // "relay" | "variable" | "sgReady"
|
||||
mechItem.insert("o:stageW", enumValueName(Double)); // relay
|
||||
mechItem.insert("o:maxStageW", enumValueName(Double)); // relay
|
||||
mechItem.insert("o:setpointW", enumValueName(Double)); // variable
|
||||
mechItem.insert("o:percent", enumValueName(Double)); // variable
|
||||
mechItem.insert("o:maxPowerW", enumValueName(Double)); // variable
|
||||
mechItem.insert("o:state", enumValueName(Int)); // sgReady
|
||||
mechItem.insert("o:estimatedPowerW", enumValueName(Double)); // sgReady
|
||||
mechItem.insert("o:chargingEnabled", enumValueName(Bool)); // evcharger (réservé 3g)
|
||||
mechItem.insert("o:pluggedIn", enumValueName(Bool)); // evcharger (réservé 3g)
|
||||
mechItem.insert("o:currentA", enumValueName(Double)); // evcharger (réservé 3g)
|
||||
mechItem.insert("o:phaseCount", enumValueName(Uint)); // evcharger (réservé 3g)
|
||||
|
||||
// Motif : un CODE et ses paramètres. « params » est typé Variant parce que ses clés
|
||||
// DÉPENDENT du code — les figer au schéma reviendrait à publier N objets au lieu d'un, et à
|
||||
// casser le contrat à chaque motif ajouté. Le catalogue des codes et de leurs paramètres
|
||||
// fait autorité dans INTERFACE.md et dans etm/types/decisionreason.h.
|
||||
QVariantMap decisionItem;
|
||||
decisionItem.insert("code", enumValueName(String));
|
||||
decisionItem.insert("params", enumValueName(Variant));
|
||||
|
||||
QVariantMap loadItem;
|
||||
loadItem.insert("loadId", enumValueName(String));
|
||||
// ALLOCATION du waterfall (ce qui a décrémenté le budget), pas la mesure : la mesure vit
|
||||
// dans la charge utile de mécanisme. La somme des allocatedW retombe sur budget.allocatedW.
|
||||
loadItem.insert("allocatedW", enumValueName(Double));
|
||||
loadItem.insert("available", enumValueName(Bool));
|
||||
loadItem.insert("o:faultCode", enumValueName(String)); // absent = aucun défaut
|
||||
loadItem.insert("o:lock", lockItem); // absent = aucun verrou actif
|
||||
loadItem.insert("o:mechanism", mechItem);
|
||||
loadItem.insert("decision", decisionItem);
|
||||
|
||||
QVariantMap budgetItem;
|
||||
budgetItem.insert("surplusW", enumValueName(Double));
|
||||
budgetItem.insert("evReservedW", enumValueName(Double));
|
||||
budgetItem.insert("allocatedW", enumValueName(Double));
|
||||
// Sans « recreditedW » l'identité surplus − alloué == restant serait fausse dès qu'une
|
||||
// charge consomme déjà, et le client chercherait l'écart. Cf. PlanBudget.
|
||||
budgetItem.insert("recreditedW", enumValueName(Double));
|
||||
budgetItem.insert("remainingW", enumValueName(Double));
|
||||
|
||||
QVariantMap schema;
|
||||
// Omis, jamais nul (règle maison, cf. GetEnergyRatios) :
|
||||
// - timestamp absent = aucun cycle d'arbitrage depuis le démarrage. En forger un se
|
||||
// lirait « cycle exécuté », soit exactement le mensonge que ce champ existe pour
|
||||
// empêcher. Présent, c'est la FIN du dernier cycle — la preuve de fraîcheur.
|
||||
// - budget absent = mode dégradé L2 : la planification est SUSPENDUE, il n'existe
|
||||
// aucun budget pour ce cycle. Publier des zéros se lirait « rien à allouer ».
|
||||
schema.insert("o:timestamp", enumValueName(String)); // ISO 8601 UTC
|
||||
schema.insert("degradedMode", enumValueName(Bool));
|
||||
schema.insert("o:budget", budgetItem);
|
||||
schema.insert("loads", QVariantList() << loadItem);
|
||||
return schema;
|
||||
}
|
||||
|
||||
QVariantMap NymeaEnergyJsonHandler::currentLoadTelemetry() const
|
||||
{
|
||||
QVariantMap t;
|
||||
#ifdef ETM_ARBITRATOR
|
||||
if (EnergyArbitrator *arb = dynamic_cast<EnergyArbitrator *>(m_smartChargingManager))
|
||||
t = arb->loadTelemetry();
|
||||
#endif
|
||||
// Carte minimale tant qu'aucun cycle n'a eu lieu : les clés REQUISES du schéma sont posées,
|
||||
// « timestamp » ne l'est pas. Le client lit donc « pas encore de cycle », et non un cycle
|
||||
// fictif à budget nul.
|
||||
if (!t.contains(QStringLiteral("degradedMode")))
|
||||
t.insert(QStringLiteral("degradedMode"),
|
||||
m_smartChargingManager && m_smartChargingManager->degradedMode());
|
||||
if (!t.contains(QStringLiteral("loads")))
|
||||
t.insert(QStringLiteral("loads"), QVariantList());
|
||||
return t;
|
||||
}
|
||||
|
||||
JsonReply *NymeaEnergyJsonHandler::GetLoadTelemetry(const QVariantMap ¶ms)
|
||||
{
|
||||
Q_UNUSED(params)
|
||||
return createReply(currentLoadTelemetry());
|
||||
}
|
||||
|
||||
JsonReply *NymeaEnergyJsonHandler::GetEnergyRatios(const QVariantMap ¶ms)
|
||||
{
|
||||
Q_UNUSED(params)
|
||||
|
||||
@ -84,6 +84,18 @@ public:
|
||||
*/
|
||||
Q_INVOKABLE JsonReply *ClearLoadFault(const QVariantMap ¶ms);
|
||||
|
||||
/*!
|
||||
* \brief [ETM] Instantané de l'état runtime de l'arbitrage (allocation, budget, verrous,
|
||||
* mécanismes, motifs).
|
||||
* \param params Aucun.
|
||||
* \return La MÊME charge utile que la notification \c LoadTelemetryChanged — pas un
|
||||
* recalcul : l'objet rendu est celui qui a été publié.
|
||||
* \note Répond à la règle « tout élément runtime a un \c Get* ET un \c *Changed ». Un
|
||||
* client connecté après un changement doit pouvoir lire l'état, sans quoi la
|
||||
* notification seule le laisse dans un silence ambigu (famille ECS-411).
|
||||
*/
|
||||
Q_INVOKABLE JsonReply *GetLoadTelemetry(const QVariantMap ¶ms);
|
||||
|
||||
signals:
|
||||
void PhasePowerLimitChanged(const QVariantMap ¶ms);
|
||||
void AcquisitionToleranceChanged(const QVariantMap ¶ms);
|
||||
@ -97,6 +109,8 @@ signals:
|
||||
void BatteryLevelConsiderationChanged(const QVariantMap ¶ms);
|
||||
void LoadConfigChanged(const QVariantMap ¶ms);
|
||||
void EnergyRatiosChanged(const QVariantMap ¶ms);
|
||||
//! [ETM] État runtime d'arbitrage — sur changement significatif ET au battement périodique.
|
||||
void LoadTelemetryChanged(const QVariantMap ¶ms);
|
||||
|
||||
private slots:
|
||||
void onPowerBalanceChanged();
|
||||
@ -112,6 +126,18 @@ private:
|
||||
bool m_lastRatiosValid = false; // un calcul a-t-il déjà été émis ?
|
||||
|
||||
QVariantMap currentEnergyRatios();
|
||||
/*!
|
||||
* \brief Schéma de la charge utile de télémétrie — UNE déclaration, deux usages.
|
||||
* \return Le map de schéma, posé à l'identique dans les \c returns de
|
||||
* \c GetLoadTelemetry et dans les \c params de \c LoadTelemetryChanged.
|
||||
* \note C'est la règle « snapshot ≡ notification » rendue impossible à enfreindre :
|
||||
* deux déclarations divergeraient, et c'est l'appel de reprise à froid qui casserait —
|
||||
* celui qu'on ne teste pas tous les jours.
|
||||
*/
|
||||
static QVariantMap loadTelemetrySchema();
|
||||
//! \return Charge utile courante ; carte MINIMALE (sans \c timestamp) si aucun cycle
|
||||
//! d'arbitrage n'a encore eu lieu — cf. \c EnergyArbitrator::loadTelemetry().
|
||||
QVariantMap currentLoadTelemetry() const;
|
||||
void sendSpotMarketConfigurationChangedNotification();
|
||||
|
||||
};
|
||||
|
||||
@ -40,12 +40,16 @@ using namespace nymeaserver;
|
||||
// (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/adapters/evadapter.h"
|
||||
#include "../../../energyplugin/evcharger.h"
|
||||
#include "../../../energyplugin/etm/types/loadconfig.h"
|
||||
#include "../../../energyplugin/etm/config/loadconfigstore.h"
|
||||
#include "../../../energyplugin/etm/ratios/energyratioscalculator.h"
|
||||
#include "../../../energyplugin/etm/scheduler/rulebasedscheduler.h"
|
||||
#include "../../../energyplugin/etm/types/decisionreason.h"
|
||||
#endif
|
||||
|
||||
#include <functional>
|
||||
#include <QDir>
|
||||
#include <QFile>
|
||||
#include <QFileInfo>
|
||||
@ -680,7 +684,9 @@ void Simulation::testEcsRelayTopologies()
|
||||
auto sp = [&](double w) {
|
||||
LoadAction a;
|
||||
a.kind = LoadAction::Setpoint; a.funding = LoadAction::Surplus;
|
||||
a.powerW = w; a.reason = QStringLiteral("test topo");
|
||||
// Le motif est un CODE : ce test ne porte pas sur le texte, seulement sur la
|
||||
// non-vacuité exigée par la règle 7 (une action au code vide est rejetée).
|
||||
a.powerW = w; a.reason = { DecisionCode::SurplusSetpoint };
|
||||
return a;
|
||||
};
|
||||
auto freshSetup = [&](ThingManager *&tm, QObject *&owner) {
|
||||
@ -912,7 +918,7 @@ void Simulation::testEcsBudgetUnderLock()
|
||||
|
||||
auto sp = [&](double w) {
|
||||
LoadAction a; a.kind = LoadAction::Setpoint; a.funding = LoadAction::Surplus;
|
||||
a.powerW = w; a.reason = QStringLiteral("amorçage test"); return a;
|
||||
a.powerW = w; a.reason = { DecisionCode::SurplusSetpoint }; return a;
|
||||
};
|
||||
|
||||
// Amorçage : la charge 1 monte à 2000 W et arme son verrou minOn (300 s).
|
||||
@ -1194,7 +1200,7 @@ void Simulation::testEcsColdStartLockExpires()
|
||||
|
||||
auto sp = [&](double w, const QDateTime &at) {
|
||||
LoadAction a; a.kind = LoadAction::Setpoint; a.funding = LoadAction::Surplus;
|
||||
a.powerW = w; a.reason = QStringLiteral("test démarrage à froid");
|
||||
a.powerW = w; a.reason = { DecisionCode::SurplusSetpoint };
|
||||
return r->applyAction(a, at);
|
||||
};
|
||||
|
||||
@ -1240,7 +1246,10 @@ void Simulation::testEcsColdStartLockExpires()
|
||||
const Plan plan = sched.getPlan(ctx);
|
||||
for (const LoadAction &a : plan.timeSlots.first().actions)
|
||||
if (a.loadId == routeur->descriptor().id)
|
||||
return a.reason;
|
||||
// La ligne du JOURNAL, RENDUE depuis le code — c'est elle que les assertions
|
||||
// ci-dessous inspectent. Le test porte donc toujours sur le TEXTE PUBLIÉ, et il
|
||||
// prouve en même temps que la refonte {code, params} ne l'a pas déplacé.
|
||||
return renderFr(a.reason, routeur->descriptor().label);
|
||||
return QString();
|
||||
};
|
||||
|
||||
@ -1266,7 +1275,7 @@ void Simulation::testEcsColdStartLockExpires()
|
||||
RelayRouter *r4 = new RelayRouter(tm, "chaud", "Charge chaude",
|
||||
QList<LoadConfigRelay>({ {rA.toString(), 1000} }), 600, 0, 1, LoadNeeds(), arb);
|
||||
LoadAction montee; montee.kind = LoadAction::Setpoint; montee.funding = LoadAction::Surplus;
|
||||
montee.powerW = 1000; montee.reason = QStringLiteral("amorçage minOn");
|
||||
montee.powerW = 1000; montee.reason = { DecisionCode::SurplusSetpoint };
|
||||
r4->applyAction(montee, t0);
|
||||
QCOMPARE(qRound(r4->currentSetpointW()), 1000);
|
||||
const QString mOn = motifPour(r4, -400, t0.addSecs(10)); // import 400 W → budget 600 < 1000
|
||||
@ -1294,7 +1303,7 @@ void Simulation::testEcsColdStartLockExpires()
|
||||
amorce.kind = LoadAction::Setpoint;
|
||||
amorce.funding = LoadAction::Surplus;
|
||||
amorce.powerW = 0;
|
||||
amorce.reason = QStringLiteral("amorçage");
|
||||
amorce.reason = { DecisionCode::SurplusSetpoint };
|
||||
r2->applyAction(amorce, t0);
|
||||
|
||||
LoadContext c1 = r2->toLoadContext(t0.addSecs(minOff + 1));
|
||||
@ -1325,7 +1334,7 @@ void Simulation::testEcsPartialFailure()
|
||||
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 échec d'écriture"); return a;
|
||||
a.powerW = w; a.reason = { DecisionCode::SurplusSetpoint }; return a;
|
||||
};
|
||||
// ECS-410 est ASYNCHRONE par construction (AGENTS règle 5 : jamais d'attente dans
|
||||
// update()). Les acquittements arrivent par signal : sans faire tourner la boucle
|
||||
@ -1522,7 +1531,7 @@ void Simulation::testSgReadyPartialFailure()
|
||||
const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0));
|
||||
auto st = [&](int s) {
|
||||
LoadAction a; a.kind = LoadAction::State; a.funding = LoadAction::Surplus;
|
||||
a.state = s; a.reason = QStringLiteral("test échec SG-Ready"); return a;
|
||||
a.state = s; a.reason = { DecisionCode::SgRecommended }; return a;
|
||||
};
|
||||
auto laisserRetomber = [&]() { QTest::qWait(300); };
|
||||
|
||||
@ -3783,6 +3792,696 @@ void Simulation::run()
|
||||
QFile::copy(outputDir.path() + QDir::separator() + simulationImageName, resultsDir.path() + QDir::separator() + simulationImageName);
|
||||
}
|
||||
|
||||
void Simulation::testDecisionReasonRendering()
|
||||
{
|
||||
#ifndef ETM_ARBITRATOR
|
||||
QSKIP("testDecisionReasonRendering nécessite ETM_ARBITRATOR.");
|
||||
#else
|
||||
// [Lot B §1 / critère 6] « Journal INCHANGÉ ».
|
||||
//
|
||||
// Depuis la refonte, LoadAction::reason porte un CODE et des PARAMÈTRES ; la ligne
|
||||
// française est RENDUE par renderFr(). Le risque de la manœuvre est là et nulle part
|
||||
// ailleurs : une phrase reformulée « au passage ». Les motifs d'ECS-309 et ECS-309-b sont
|
||||
// le fruit de corrections coûteuses au banc — les réécrire enverrait à nouveau
|
||||
// diagnostiquer le mauvais problème, avec l'autorité d'une explication.
|
||||
//
|
||||
// Ce tableau est donc la copie MOT POUR MOT des QStringLiteral d'avant la refonte. Il se
|
||||
// relit contre `git show HEAD:energyplugin/etm/scheduler/rulebasedscheduler.cpp`. Un test
|
||||
// « motif non vide » n'aurait rien vu ici — c'est précisément la leçon d'ECS-309.
|
||||
struct Cas { DecisionReason reason; QString label; QString attendu; };
|
||||
const QList<Cas> table = {
|
||||
// --- EV (proxy amont) : aucune charge nommée, donc aucun libellé ---
|
||||
{ { DecisionCode::EvSurplus }, QString(),
|
||||
QStringLiteral("Surplus PV disponible — recharge solaire") },
|
||||
{ { DecisionCode::EvSpotMarket }, QString(),
|
||||
QStringLiteral("Tarif aWATTar favorable — recharge heure creuse") },
|
||||
{ { DecisionCode::EvDeadline }, QString(),
|
||||
QStringLiteral("Deadline VE approchante — recharge prioritaire") },
|
||||
{ { DecisionCode::EvEcoMin }, QString(),
|
||||
QStringLiteral("Aucun surplus — courant minimum maintenu (mode EcoMin)") },
|
||||
{ { DecisionCode::EvIdle }, QString(),
|
||||
QStringLiteral("Aucun surplus disponible — recharge suspendue") },
|
||||
|
||||
// --- Charges pilotées en watts ---
|
||||
{ { DecisionCode::LoadUnavailable, {{"frozenW", 1500}} }, QStringLiteral("Chauffe-eau"),
|
||||
QStringLiteral("Chauffe-eau INDISPONIBLE — aucune commande émise ; 1500 W toujours "
|
||||
"comptés au budget (levée par ClearLoadFault)") },
|
||||
{ { DecisionCode::LockMinOn, {{"appliedW", 2000}, {"budgetW", 600}} },
|
||||
QStringLiteral("ECS prioritaire"),
|
||||
QStringLiteral("Verrou minOn — ECS prioritaire maintenue à 2000 W (puissance engagée, "
|
||||
"budget 600 W) ; le résidu en tient compte") },
|
||||
{ { DecisionCode::LockMinOff, {{"budgetW", 7706}, {"requestedW", 1000}} },
|
||||
QStringLiteral("Charge à froid"),
|
||||
QStringLiteral("Verrou minOff — Charge à froid ne peut pas s'enclencher avant la fin "
|
||||
"de la fenêtre (budget 7706 W disponible, palier 1000 W refusé)") },
|
||||
{ { DecisionCode::LockMinOffCapped,
|
||||
{{"appliedW", 1000}, {"budgetW", 4000}, {"requestedW", 2500}} },
|
||||
QStringLiteral("ECS relais"),
|
||||
QStringLiteral("Verrou minOff — ECS relais plafonnée à 1000 W avant la fin de la "
|
||||
"fenêtre (budget 4000 W disponible, palier 2500 W refusé)") },
|
||||
{ { DecisionCode::SurplusSetpoint,
|
||||
{{"budgetW", 3000}, {"setpointW", 2400}, {"stepped", true}} },
|
||||
QStringLiteral("Chauffe-eau"),
|
||||
QStringLiteral("Surplus PV 3000 W — Chauffe-eau consigne 2400 W (palier)") },
|
||||
{ { DecisionCode::SurplusSetpoint,
|
||||
{{"budgetW", 3000}, {"setpointW", 3000}, {"stepped", false}} },
|
||||
QStringLiteral("Routeur PV"),
|
||||
QStringLiteral("Surplus PV 3000 W — Routeur PV consigne 3000 W") },
|
||||
{ { DecisionCode::SurplusInsufficient, {{"budgetW", -389}} },
|
||||
QStringLiteral("Charge à froid 3"),
|
||||
QStringLiteral("Surplus insuffisant (-389 W) — Charge à froid 3 à 0 W") },
|
||||
|
||||
// --- SG-Ready ---
|
||||
{ { DecisionCode::LockMinStateHold, {{"state", 3}} }, QStringLiteral("PAC test"),
|
||||
QStringLiteral("Verrou minStateHold — PAC test maintenue état 3 (court-cycling PAC)") },
|
||||
{ { DecisionCode::SgForced, {{"budgetW", 4000}, {"estimatedW", 3000}} },
|
||||
QStringLiteral("PAC test"),
|
||||
QStringLiteral("Surplus abondant 4000 W — PAC test forcée (état 4, ~3000 W)") },
|
||||
{ { DecisionCode::SgRecommended, {{"budgetW", 2000}, {"estimatedW", 1500}} },
|
||||
QStringLiteral("PAC test"),
|
||||
QStringLiteral("Surplus PV 2000 W — PAC test recommandée (état 3, ~1500 W)") },
|
||||
{ { DecisionCode::SgNormal, {{"budgetW", 1000}} }, QStringLiteral("PAC test"),
|
||||
QStringLiteral("Surplus insuffisant (1000 W) — PAC test en normal (état 2, non pilotée)") },
|
||||
|
||||
// --- Sécurité et cycle de vie ---
|
||||
{ { DecisionCode::DegradedL2 }, QString(),
|
||||
QStringLiteral("Compteur muet depuis >90 s — consigne de repli (L2 watchdog)") },
|
||||
{ { DecisionCode::SafeStateRelay }, QString(),
|
||||
QStringLiteral("Charge désactivée — mise en état sûr avant retrait (ECS-413)") },
|
||||
{ { DecisionCode::SafeStateSetpoint }, QString(),
|
||||
QStringLiteral("Charge désactivée — mise en état sûr (0 W) avant retrait (ECS-413)") },
|
||||
{ { DecisionCode::SafeStateSgReady }, QString(),
|
||||
QStringLiteral("Charge désactivée — mise en état sûr (état 2) avant retrait (ECS-413)") },
|
||||
};
|
||||
|
||||
QSet<QString> codesCouverts;
|
||||
for (const Cas &c : table) {
|
||||
const QString rendu = renderFr(c.reason, c.label);
|
||||
QVERIFY2(rendu == c.attendu,
|
||||
qUtf8Printable(QStringLiteral("code %1\n rendu : %2\n attendu : %3")
|
||||
.arg(c.reason.code, rendu, c.attendu)));
|
||||
codesCouverts.insert(c.reason.code);
|
||||
}
|
||||
|
||||
// Le catalogue est FERMÉ : tout code ajouté sans ligne de tableau doit faire échouer ce
|
||||
// test, sinon la garantie « le journal est rendu depuis le code » se perd au premier oubli.
|
||||
const QStringList catalogue = {
|
||||
DecisionCode::EvSurplus, DecisionCode::EvSpotMarket, DecisionCode::EvDeadline,
|
||||
DecisionCode::EvEcoMin, DecisionCode::EvIdle, DecisionCode::LoadUnavailable,
|
||||
DecisionCode::LockMinOn, DecisionCode::LockMinOff, DecisionCode::LockMinOffCapped,
|
||||
DecisionCode::SurplusSetpoint, DecisionCode::SurplusInsufficient,
|
||||
DecisionCode::LockMinStateHold, DecisionCode::SgForced, DecisionCode::SgRecommended,
|
||||
DecisionCode::SgNormal, DecisionCode::DegradedL2, DecisionCode::SafeStateRelay,
|
||||
DecisionCode::SafeStateSetpoint, DecisionCode::SafeStateSgReady };
|
||||
for (const QString &code : catalogue)
|
||||
QVERIFY2(codesCouverts.contains(code),
|
||||
qUtf8Printable(QStringLiteral("code %1 sans ligne de rendu attendue").arg(code)));
|
||||
|
||||
// Repli d'un code HORS catalogue : jamais une chaîne vide. Un rendu vide ferait passer un
|
||||
// motif présent-mais-inconnu pour un motif absent — le silence ambigu de la règle 7-c.
|
||||
const QString inconnu = renderFr(DecisionReason("CODE_QUI_NEXISTE_PAS", {{"x", 1}}),
|
||||
QStringLiteral("Charge"));
|
||||
QVERIFY(!inconnu.isEmpty());
|
||||
QVERIFY2(inconnu.contains("CODE_QUI_NEXISTE_PAS"), qUtf8Printable(inconnu));
|
||||
QVERIFY(!renderFr(DecisionReason()).isEmpty()); // motif absent : dit, pas tu
|
||||
#endif
|
||||
}
|
||||
|
||||
void Simulation::testLoadTelemetryRpc()
|
||||
{
|
||||
#ifndef ETM_ARBITRATOR
|
||||
QSKIP("testLoadTelemetryRpc nécessite ETM_ARBITRATOR.");
|
||||
#else
|
||||
// [Lot B §2] La frontière de télémétrie, de bout en bout : c'est la couche que l'app
|
||||
// consomme. Le constat qui a motivé ce lot était que l'état runtime de l'arbitre ne
|
||||
// franchissait AUCUNE frontière RPC — 90 s d'écoute, arbitre actif, zéro notification.
|
||||
//
|
||||
// Couvre les critères d'acceptation 1 à 5 : notification non nulle et cohérente,
|
||||
// snapshot ≡ notification, reprise à froid du mode dégradé, arbitre figé détectable,
|
||||
// zéro langue naturelle dans la charge utile.
|
||||
const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-telemetry.json";
|
||||
QFile::remove(cfgPath);
|
||||
qputenv("NYMEA_ENERGY_LOAD_CONFIG", cfgPath.toUtf8());
|
||||
|
||||
cleanupTestCase();
|
||||
m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite";
|
||||
initTestCase();
|
||||
|
||||
EnergyArbitrator *arb = dynamic_cast<EnergyArbitrator *>(m_experiencePlugin->smartChargingManager());
|
||||
QVERIFY(arb);
|
||||
ThingManager *tm = NymeaCore::instance()->thingManager();
|
||||
|
||||
// Le harnais de simulation n'abonne PAS le client aux notifications (il charge son
|
||||
// experience mock APRÈS initTestCase, donc EnergyTestBase saute l'abonnement). Or ce test
|
||||
// porte précisément sur les notifications : sans cet appel, il « passerait » en observant
|
||||
// le silence qu'il est censé faire échouer.
|
||||
enableNotifications({"NymeaEnergy"});
|
||||
|
||||
QUuid meterId = addMeter();
|
||||
m_experiencePlugin->energyManager()->setRootMeter(meterId);
|
||||
Thing *meter = tm->findConfiguredThing(meterId);
|
||||
QVERIFY(meter);
|
||||
meter->setStateValue("connected", true);
|
||||
|
||||
// ---- 0. AVANT tout cycle : « pas encore d'arbitrage » se DIT, il ne se devine pas ------
|
||||
// timestamp ABSENT (omis, jamais nul) : forger un instant se lirait « cycle exécuté, rien à
|
||||
// allouer », soit exactement le mensonge que ce champ existe pour empêcher.
|
||||
QVariant resp = injectAndWait("NymeaEnergy.GetLoadTelemetry");
|
||||
QVariantMap vide = resp.toMap().value("params").toMap();
|
||||
QVERIFY2(!vide.contains("timestamp"), "timestamp forgé avant le premier cycle");
|
||||
QVERIFY(vide.contains("degradedMode"));
|
||||
QCOMPARE(vide.value("degradedMode").toBool(), false);
|
||||
QVERIFY(vide.value("loads").toList().isEmpty());
|
||||
|
||||
// ---- Trois mécanismes, un seul budget : l'union discriminée doit sortir ENTIÈRE --------
|
||||
QUuid rA = addPowerSwitch(1000, 26661); // relay-router (1 relais, minOn 300 s)
|
||||
QUuid k1 = addPowerSwitch(0, 26662); // sg-ready K1
|
||||
QUuid k2 = addPowerSwitch(0, 26663); // sg-ready K2
|
||||
QUuid varId = addEtmVariableLoad(27040); // etmvariableload dynamic
|
||||
Thing *relayA = tm->findConfiguredThing(rA);
|
||||
Thing *varThing = tm->findConfiguredThing(varId);
|
||||
QVERIFY(relayA && varThing);
|
||||
QVERIFY(tm->findConfiguredThing(k1) && tm->findConfiguredThing(k2));
|
||||
varThing->setStateValue("currentPowerW", 0);
|
||||
|
||||
LoadConfigStore *store = new LoadConfigStore(arb);
|
||||
arb->setLoadConfigStore(store);
|
||||
|
||||
LoadConfigs cfgs;
|
||||
cfgs.append(LoadConfig::fromMap(QVariantMap{
|
||||
{"id", varId.toString()}, {"label", "Routeur PV"}, {"adapter", "etmvariableload"},
|
||||
{"mode", "dynamic"}, {"maxPowerW", 3000}, {"priority", 1}, {"enabled", true},
|
||||
{"domain", "ecs"}}));
|
||||
cfgs.append(LoadConfig::fromMap(QVariantMap{
|
||||
{"id", "pac-telemetrie"}, {"label", "PAC"}, {"adapter", "sg-ready"}, {"mode", "fixed"},
|
||||
{"priority", 2}, {"enabled", true}, {"domain", "hvac"},
|
||||
{"sgReady", QVariantMap{
|
||||
{"minStateHoldS", 300},
|
||||
{"states", QVariantList()
|
||||
<< QVariantMap{{"state", 1}, {"relays", QVariantList() << k1.toString()},
|
||||
{"estimatedPowerW", 0.0}}
|
||||
<< QVariantMap{{"state", 2}, {"relays", QVariantList()},
|
||||
{"estimatedPowerW", 0.0}}
|
||||
<< QVariantMap{{"state", 3}, {"relays", QVariantList() << k2.toString()},
|
||||
{"estimatedPowerW", 800.0}}
|
||||
<< QVariantMap{{"state", 4},
|
||||
{"relays", QVariantList() << k1.toString() << k2.toString()},
|
||||
{"estimatedPowerW", 1600.0}}}}}}));
|
||||
cfgs.append(LoadConfig::fromMap(QVariantMap{
|
||||
{"id", "ecs-relais-telemetrie"}, {"label", "ECS relais"}, {"adapter", "relay-router"},
|
||||
{"mode", "fixed"}, {"priority", 3}, {"enabled", true}, {"domain", "ecs"},
|
||||
{"relays", QVariantList() << QVariantMap{{"thingId", rA.toString()}, {"powerW", 1000}}},
|
||||
{"minOnS", 300}, {"minOffS", 0}}));
|
||||
QString err;
|
||||
QVERIFY2(store->setConfigs(cfgs, &err), err.toUtf8());
|
||||
|
||||
// ---- 1. Le CONSTAT rejoué : un cycle d'arbitrage produit-il une notification ? ---------
|
||||
QSignalSpy notificationSpy(m_mockTcpServer, &MockTcpServer::outgoingData);
|
||||
const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0));
|
||||
meter->setStateValue("currentPower", -6000); // 6000 W exportés
|
||||
arb->simulationCallUpdate(t0);
|
||||
QCoreApplication::processEvents();
|
||||
|
||||
QVariantList notifs = checkNotifications(notificationSpy, "NymeaEnergy.LoadTelemetryChanged");
|
||||
QVERIFY2(!notifs.isEmpty(), "aucune notification LoadTelemetryChanged — c'est le constat "
|
||||
"d'origine, arbitre actif et frontière RPC muette");
|
||||
QVariantMap notif = notifs.last().toMap().value("params").toMap();
|
||||
|
||||
// Cohérence avec la cascade : Routeur PV (rang 1) prend 3000 (clamp maxPowerW), reliquat
|
||||
// 3000 → PAC (rang 2) forcée état 4 (~1600), reliquat 1400 → ECS relais (rang 3) 1000.
|
||||
QCOMPARE(notif.value("degradedMode").toBool(), false);
|
||||
QVERIFY(notif.contains("timestamp"));
|
||||
QCOMPARE(notif.value("timestamp").toString(), t0.toUTC().toString(Qt::ISODate));
|
||||
|
||||
const QVariantMap budget = notif.value("budget").toMap();
|
||||
QCOMPARE(qRound(budget.value("surplusW").toDouble()), 6000);
|
||||
QCOMPARE(qRound(budget.value("evReservedW").toDouble()), 0);
|
||||
QCOMPARE(qRound(budget.value("allocatedW").toDouble()), 5600);
|
||||
QCOMPARE(qRound(budget.value("remainingW").toDouble()), 400);
|
||||
QCOMPARE(qRound(budget.value("recreditedW").toDouble()), 0); // 1er cycle : rien ne coulait
|
||||
// L'invariant publié : surplus + recrédit − alloué == restant. Le recrédit y figure parce
|
||||
// que sans lui l'identité serait FAUSSE dès qu'une charge consomme déjà — c'est-à-dire dans
|
||||
// le cas normal — et le client chercherait un écart inexplicable.
|
||||
QCOMPARE(qRound(budget.value("surplusW").toDouble() + budget.value("recreditedW").toDouble()
|
||||
- budget.value("allocatedW").toDouble()),
|
||||
qRound(budget.value("remainingW").toDouble()));
|
||||
|
||||
const QVariantList loads = notif.value("loads").toList();
|
||||
QCOMPARE(loads.size(), 3); // l'EV n'y est pas : décidé en amont
|
||||
QVariantMap parId;
|
||||
double sommeAllouee = 0;
|
||||
for (const QVariant &v : loads) {
|
||||
const QVariantMap e = v.toMap();
|
||||
parId.insert(e.value("loadId").toString(), e);
|
||||
sommeAllouee += e.value("allocatedW").toDouble();
|
||||
}
|
||||
// La somme des allocations RETOMBE sur budget.allocatedW : sans quoi un client qui
|
||||
// additionne les lignes obtiendrait un total en désaccord avec l'en-tête.
|
||||
QCOMPARE(qRound(sommeAllouee), qRound(budget.value("allocatedW").toDouble()));
|
||||
|
||||
const QVariantMap eVar = parId.value(varId.toString()).toMap();
|
||||
const QVariantMap ePac = parId.value("pac-telemetrie").toMap();
|
||||
const QVariantMap eRelay = parId.value("ecs-relais-telemetrie").toMap();
|
||||
QVERIFY(!eVar.isEmpty() && !ePac.isEmpty() && !eRelay.isEmpty());
|
||||
|
||||
// Union discriminée de mécanisme : chaque variante sort avec SA charge utile, et seulement
|
||||
// la sienne (LM-300/302 appliqués au sens sortant).
|
||||
QCOMPARE(eVar.value("mechanism").toMap().value("kind").toString(), QString("variable"));
|
||||
QCOMPARE(qRound(eVar.value("mechanism").toMap().value("setpointW").toDouble()), 3000);
|
||||
QCOMPARE(qRound(eVar.value("mechanism").toMap().value("percent").toDouble()), 100);
|
||||
QVERIFY(!eVar.value("mechanism").toMap().contains("state")); // charge utile d'un AUTRE mécanisme
|
||||
QVERIFY(!eVar.value("mechanism").toMap().contains("stageW"));
|
||||
|
||||
QCOMPARE(ePac.value("mechanism").toMap().value("kind").toString(), QString("sgReady"));
|
||||
QCOMPARE(ePac.value("mechanism").toMap().value("state").toInt(), 4);
|
||||
QVERIFY(!ePac.value("mechanism").toMap().contains("stageW"));
|
||||
|
||||
QCOMPARE(eRelay.value("mechanism").toMap().value("kind").toString(), QString("relay"));
|
||||
QCOMPARE(qRound(eRelay.value("mechanism").toMap().value("stageW").toDouble()), 1000);
|
||||
QVERIFY(!eRelay.value("mechanism").toMap().contains("percent"));
|
||||
|
||||
// Motifs : des CODES, avec leurs paramètres numériques. Jamais une phrase.
|
||||
QCOMPARE(eVar.value("decision").toMap().value("code").toString(),
|
||||
QString(DecisionCode::SurplusSetpoint));
|
||||
QCOMPARE(eVar.value("decision").toMap().value("params").toMap().value("stepped").toBool(), false);
|
||||
QCOMPARE(ePac.value("decision").toMap().value("code").toString(), QString(DecisionCode::SgForced));
|
||||
QCOMPARE(eRelay.value("decision").toMap().value("code").toString(),
|
||||
QString(DecisionCode::SurplusSetpoint));
|
||||
QCOMPARE(eRelay.value("decision").toMap().value("params").toMap().value("stepped").toBool(), true);
|
||||
|
||||
// Verrous en SECONDES restantes, pas en watts. Les deux charges viennent de commuter à t0,
|
||||
// donc leur fenêtre est pleine. Le routeur PV n'a AUCUN verrou côté moteur : la clé est
|
||||
// absente, ce qui est la vérité — et non un verrou à zéro, qui se lirait « fenêtre expirée ».
|
||||
QCOMPARE(eRelay.value("lock").toMap().value("kind").toString(), QString("minOn"));
|
||||
QCOMPARE(eRelay.value("lock").toMap().value("remainingS").toInt(), 300);
|
||||
QCOMPARE(ePac.value("lock").toMap().value("kind").toString(), QString("minStateHold"));
|
||||
QCOMPARE(ePac.value("lock").toMap().value("remainingS").toInt(), 300);
|
||||
QVERIFY(!eVar.contains("lock"));
|
||||
|
||||
// Disponibilité, et absence de code de défaut sur une installation saine.
|
||||
for (const QVariant &v : loads) {
|
||||
QCOMPARE(v.toMap().value("available").toBool(), true);
|
||||
QVERIFY(!v.toMap().contains("faultCode"));
|
||||
}
|
||||
|
||||
// ---- 2. Snapshot ≡ notification --------------------------------------------------------
|
||||
// Le Get ne recalcule pas : il rend l'objet publié. Un recalcul donnerait des verrous
|
||||
// rafraîchis sous un timestamp périmé — un état qui n'a jamais existé.
|
||||
resp = injectAndWait("NymeaEnergy.GetLoadTelemetry");
|
||||
QCOMPARE(resp.toMap().value("params").toMap(), notif);
|
||||
|
||||
// ---- 5. ZÉRO langue naturelle dans la charge utile -------------------------------------
|
||||
// Contrôle MÉCANIQUE : toute chaîne émise est ASCII et sans espace. Une phrase française en
|
||||
// porterait forcément (accents et/ou espaces). Le libellé de la charge, lui, n'entre pas
|
||||
// dans la charge utile : il est substitué au RENDU, côté journal.
|
||||
std::function<void(const QVariant &, const QString &)> verifierCodes =
|
||||
[&](const QVariant &v, const QString &chemin) {
|
||||
if (v.userType() == QMetaType::QVariantMap) {
|
||||
const QVariantMap m = v.toMap();
|
||||
for (auto it = m.constBegin(); it != m.constEnd(); ++it)
|
||||
verifierCodes(it.value(), chemin + '.' + it.key());
|
||||
} else if (v.userType() == QMetaType::QVariantList) {
|
||||
const QVariantList l = v.toList();
|
||||
for (int i = 0; i < l.size(); ++i)
|
||||
verifierCodes(l.at(i), QStringLiteral("%1[%2]").arg(chemin).arg(i));
|
||||
} else if (v.userType() == QMetaType::QString) {
|
||||
const QString s = v.toString();
|
||||
for (const QChar &c : s)
|
||||
QVERIFY2(c.unicode() >= 32 && c.unicode() < 127,
|
||||
qUtf8Printable(QStringLiteral("%1 : caractère non ASCII dans « %2 »")
|
||||
.arg(chemin, s)));
|
||||
QVERIFY2(!s.contains(' '),
|
||||
qUtf8Printable(QStringLiteral("%1 : espace dans « %2 » — une valeur "
|
||||
"émise doit être un CODE, pas une phrase")
|
||||
.arg(chemin, s)));
|
||||
}
|
||||
};
|
||||
verifierCodes(notif, QStringLiteral("telemetry"));
|
||||
|
||||
// ---- 4. Arbitre FIGÉ détectable --------------------------------------------------------
|
||||
// Aucun cycle nouveau : le battement republie l'instantané tel quel. Le client reçoit donc
|
||||
// un état, et son timestamp N'AVANCE PAS — c'est la preuve de non-fraîcheur. Sans
|
||||
// battement, « installation stable » et « moteur arrêté » sont le même silence.
|
||||
notificationSpy.clear();
|
||||
arb->publishTelemetryHeartbeat();
|
||||
QCoreApplication::processEvents();
|
||||
notifs = checkNotifications(notificationSpy, "NymeaEnergy.LoadTelemetryChanged");
|
||||
QVERIFY2(!notifs.isEmpty(), "aucun battement de cœur");
|
||||
const QVariantMap battement = notifs.last().toMap().value("params").toMap();
|
||||
QCOMPARE(battement.value("timestamp").toString(), notif.value("timestamp").toString());
|
||||
|
||||
// ---- Le SEUIL : une oscillation de quelques watts ne réveille personne ------------------
|
||||
// Le routeur est déjà à son plafond ; un surplus voisin ne change ni allocation ni code de
|
||||
// motif. Sans seuil, chaque cycle notifierait pour rien sur une cible RPi Zero 2W.
|
||||
notificationSpy.clear();
|
||||
meter->setStateValue("currentPower", -6010); // +10 W, sous le seuil
|
||||
arb->simulationCallUpdate(t0.addSecs(60));
|
||||
QCoreApplication::processEvents();
|
||||
QVERIFY2(checkNotifications(notificationSpy, "NymeaEnergy.LoadTelemetryChanged").isEmpty(),
|
||||
"une variation de 10 W a notifié — le seuil ne mord pas");
|
||||
// …mais le Get, lui, a bien avancé : l'état INTERNE est à jour même sans notification.
|
||||
resp = injectAndWait("NymeaEnergy.GetLoadTelemetry");
|
||||
const QVariantMap c2 = resp.toMap().value("params").toMap();
|
||||
QCOMPARE(c2.value("timestamp").toString(), t0.addSecs(60).toUTC().toString(Qt::ISODate));
|
||||
// Deuxième cycle : les charges consomment, donc le RECRÉDIT n'est plus nul — et c'est
|
||||
// exactement le cas où l'identité serait fausse sans lui. L'allocation TOTALE, elle, n'a
|
||||
// pas bougé : c'est pour cela que le seuil ne mord pas.
|
||||
const QVariantMap b2 = c2.value("budget").toMap();
|
||||
QVERIFY2(qRound(b2.value("recreditedW").toDouble()) > 0, "recrédit nul alors que les charges consomment");
|
||||
QCOMPARE(qRound(b2.value("allocatedW").toDouble()), 5600);
|
||||
QCOMPARE(qRound(b2.value("surplusW").toDouble() + b2.value("recreditedW").toDouble()
|
||||
- b2.value("allocatedW").toDouble()),
|
||||
qRound(b2.value("remainingW").toDouble()));
|
||||
|
||||
// ---- 3. Reprise à froid du mode dégradé ------------------------------------------------
|
||||
// Un client qui se connecte APRÈS la bascule doit la lire, par GetLoadTelemetry ET par
|
||||
// GetChargingSchedules. C'est la famille ECS-411 : une notification seule laisse le
|
||||
// nouveau venu dans un silence ambigu.
|
||||
notificationSpy.clear();
|
||||
arb->recordMeterUpdate(t0.addSecs(60));
|
||||
arb->evaluateMeterFreshness(t0.addSecs(60 + 91));
|
||||
QCoreApplication::processEvents();
|
||||
QVERIFY(arb->degradedMode());
|
||||
|
||||
notifs = checkNotifications(notificationSpy, "NymeaEnergy.LoadTelemetryChanged");
|
||||
QVERIFY2(!notifs.isEmpty(), "bascule en mode dégradé sans notification de télémétrie");
|
||||
const QVariantMap dgr = notifs.last().toMap().value("params").toMap();
|
||||
QCOMPARE(dgr.value("degradedMode").toBool(), true);
|
||||
// budget ABSENT : la planification est suspendue, il n'y a pas de budget pour ce cycle.
|
||||
// Publier des zéros se lirait « arbitrage exécuté, rien à allouer » — l'inverse du vrai.
|
||||
QVERIFY2(!dgr.contains("budget"), "budget publié en mode dégradé (planification suspendue)");
|
||||
QVERIFY(!dgr.value("loads").toList().isEmpty());
|
||||
for (const QVariant &v : dgr.value("loads").toList()) {
|
||||
QCOMPARE(v.toMap().value("decision").toMap().value("code").toString(),
|
||||
QString(DecisionCode::DegradedL2));
|
||||
QCOMPARE(qRound(v.toMap().value("allocatedW").toDouble()), 0);
|
||||
}
|
||||
verifierCodes(dgr, QStringLiteral("telemetry-degrade"));
|
||||
|
||||
// Le nouveau venu lit l'état par les DEUX portes.
|
||||
resp = injectAndWait("NymeaEnergy.GetLoadTelemetry");
|
||||
QCOMPARE(resp.toMap().value("params").toMap().value("degradedMode").toBool(), true);
|
||||
resp = injectAndWait("NymeaEnergy.GetChargingSchedules");
|
||||
QVERIFY2(resp.toMap().value("params").toMap().contains("degradedMode"),
|
||||
"GetChargingSchedules ne rend toujours pas degradedMode (poussé en notification "
|
||||
"seule : le défaut que ce lot corrige)");
|
||||
QCOMPARE(resp.toMap().value("params").toMap().value("degradedMode").toBool(), true);
|
||||
|
||||
// Sortie du mode dégradé : symétrique, et publiée TOUT DE SUITE. La planification ne
|
||||
// reprend qu'au cycle suivant ; sans cette republication, la télémétrie annoncerait encore
|
||||
// degradedMode=true pendant une minute après la levée du repli.
|
||||
notificationSpy.clear();
|
||||
arb->recordMeterUpdate(t0.addSecs(300));
|
||||
QCoreApplication::processEvents();
|
||||
QVERIFY(!arb->degradedMode());
|
||||
notifs = checkNotifications(notificationSpy, "NymeaEnergy.LoadTelemetryChanged");
|
||||
QVERIFY2(!notifs.isEmpty(), "sortie du mode dégradé sans notification");
|
||||
QCOMPARE(notifs.last().toMap().value("params").toMap().value("degradedMode").toBool(), false);
|
||||
resp = injectAndWait("NymeaEnergy.GetChargingSchedules");
|
||||
QCOMPARE(resp.toMap().value("params").toMap().value("degradedMode").toBool(), false);
|
||||
|
||||
qunsetenv("NYMEA_ENERGY_LOAD_CONFIG");
|
||||
QFile::remove(cfgPath);
|
||||
#endif
|
||||
}
|
||||
|
||||
void Simulation::testLoadConfigDomain()
|
||||
{
|
||||
#ifndef ETM_ARBITRATOR
|
||||
QSKIP("testLoadConfigDomain nécessite ETM_ARBITRATOR.");
|
||||
#else
|
||||
// [Lot B §3] o:domain — PURE MÉTADONNÉE d'intention. Trois exigences, plus une quatrième
|
||||
// qui n'est pas dans la liste mais qui est la raison d'être du champ : l'arbitrage ne
|
||||
// change pas. Un champ « sans effet » se prouve, il ne se déclare pas.
|
||||
const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-domain.json";
|
||||
QFile::remove(cfgPath);
|
||||
qputenv("NYMEA_ENERGY_LOAD_CONFIG", cfgPath.toUtf8());
|
||||
|
||||
cleanupTestCase();
|
||||
m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite";
|
||||
initTestCase();
|
||||
|
||||
EnergyArbitrator *arb = dynamic_cast<EnergyArbitrator *>(m_experiencePlugin->smartChargingManager());
|
||||
QVERIFY(arb);
|
||||
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 ecsId = addEtmVariableLoad(27050);
|
||||
Thing *ecsThing = tm->findConfiguredThing(ecsId);
|
||||
QVERIFY(ecsThing);
|
||||
ecsThing->setStateValue("currentPowerW", 0);
|
||||
|
||||
// 1. Charge AVEC domaine + charge SANS domaine : les deux acceptées.
|
||||
QVariantList configs;
|
||||
configs << QVariantMap{
|
||||
{"id", ecsId.toString()}, {"label", "Chauffe-eau"}, {"adapter", "etmvariableload"},
|
||||
{"mode", "fixed"}, {"powerLevels", QVariantList() << 0 << 2400}, {"maxPowerW", 2400},
|
||||
{"priority", 1}, {"enabled", true}, {"domain", "ecs"}};
|
||||
configs << QVariantMap{ // aucune clé « domain » : non classée
|
||||
{"id", "{cafe0001-0000-0000-0000-000000000001}"}, {"label", "Sans domaine"},
|
||||
{"adapter", "etmvariableload"}, {"mode", "dynamic"}, {"maxPowerW", 1000},
|
||||
{"priority", 2}, {"enabled", true}};
|
||||
QVariant resp = injectAndWait("NymeaEnergy.SetLoadConfig", {{"loadConfigs", configs}});
|
||||
QCOMPARE(resp.toMap().value("params").toMap().value("energyError").toString(),
|
||||
QString("EnergyErrorNoError"));
|
||||
|
||||
// 2. ALLER-RETOUR lire → réécrire sans perte. C'est le geste ordinaire de l'app, et c'est
|
||||
// le mur qu'a touché LM-302 : GetLoadConfig sérialise via le méta-objet, donc TOUTES les
|
||||
// propriétés déclarées. Tout mécanisme ou champ ajouté embarque ce test.
|
||||
resp = injectAndWait("NymeaEnergy.GetLoadConfig");
|
||||
const QVariantList lu = resp.toMap().value("params").toMap().value("loadConfigs").toList();
|
||||
QCOMPARE(lu.size(), 2);
|
||||
QVariantMap avec, sans;
|
||||
for (const QVariant &v : lu) {
|
||||
if (v.toMap().value("id").toString() == ecsId.toString()) avec = v.toMap();
|
||||
else sans = v.toMap();
|
||||
}
|
||||
QCOMPARE(avec.value("domain").toString(), QString("ecs"));
|
||||
// Forme EXACTE d'une charge non classée en sortie RPC : la clé est PRÉSENTE et VIDE.
|
||||
// GetLoadConfig sérialise via le méta-objet, donc toutes les propriétés déclarées sortent,
|
||||
// y compris vides — comme « sgReady: {} » ou « powerLevels: [] » ici même. C'est le
|
||||
// corollaire LM-302, et c'est ce qui rend l'aller-retour Get → Set possible. L'assertion
|
||||
// est exacte plutôt que permissive : une tolérance masquerait un changement de forme, or
|
||||
// c'est la forme que l'app consomme. Vérifié identique au banc le 2026-08-25.
|
||||
QVERIFY2(sans.contains("domain"), "la propriété domain doit sortir même vide (méta-objet)");
|
||||
QCOMPARE(sans.value("domain").toString(), QString());
|
||||
QVERIFY(sans.value("sgReady").toMap().isEmpty()); // même forme, pour comparaison
|
||||
|
||||
resp = injectAndWait("NymeaEnergy.SetLoadConfig", {{"loadConfigs", lu}});
|
||||
QCOMPARE(resp.toMap().value("params").toMap().value("energyError").toString(),
|
||||
QString("EnergyErrorNoError"));
|
||||
resp = injectAndWait("NymeaEnergy.GetLoadConfig");
|
||||
for (const QVariant &v : resp.toMap().value("params").toMap().value("loadConfigs").toList())
|
||||
if (v.toMap().value("id").toString() == ecsId.toString())
|
||||
QCOMPARE(v.toMap().value("domain").toString(), QString("ecs")); // survit au tour
|
||||
|
||||
// 3. Valeur HORS énumération : rejet EN BLOC. Une valeur libre acceptée aujourd'hui
|
||||
// deviendrait une valeur à supporter le jour où la couche domaine en fera son
|
||||
// discriminant.
|
||||
QVariantList mauvais = configs;
|
||||
QVariantMap m0 = mauvais.first().toMap();
|
||||
m0["domain"] = "chauffe-eau"; // libellé, pas un code de l'énumération
|
||||
mauvais[0] = m0;
|
||||
resp = injectAndWait("NymeaEnergy.SetLoadConfig", {{"loadConfigs", mauvais}});
|
||||
QCOMPARE(resp.toMap().value("params").toMap().value("energyError").toString(),
|
||||
QString("EnergyErrorInvalidParameter"));
|
||||
// Rien n'a été écrasé : la config valide tient toujours.
|
||||
resp = injectAndWait("NymeaEnergy.GetLoadConfig");
|
||||
QCOMPARE(resp.toMap().value("params").toMap().value("loadConfigs").toList().size(), 2);
|
||||
|
||||
// Toute la liste de l'énumération est acceptée — sinon « fermée » voudrait dire « arbitraire ».
|
||||
for (const QString &d : LoadConfig::knownDomains()) {
|
||||
QVariantList une;
|
||||
une << QVariantMap{
|
||||
{"id", ecsId.toString()}, {"label", "Chauffe-eau"}, {"adapter", "etmvariableload"},
|
||||
{"mode", "fixed"}, {"powerLevels", QVariantList() << 0 << 2400}, {"maxPowerW", 2400},
|
||||
{"priority", 1}, {"enabled", true}, {"domain", d}};
|
||||
resp = injectAndWait("NymeaEnergy.SetLoadConfig", {{"loadConfigs", une}});
|
||||
QCOMPARE(resp.toMap().value("params").toMap().value("energyError").toString(),
|
||||
QString("EnergyErrorNoError"));
|
||||
}
|
||||
|
||||
// 4. AUCUN effet sur l'arbitrage : la charge est servie exactement pareil, domaine ou non.
|
||||
// Le champ enregistre une intention d'installateur ; il ne pilote rien.
|
||||
meter->setStateValue("currentPower", -3000);
|
||||
arb->simulationCallUpdate(utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0)));
|
||||
QCoreApplication::processEvents();
|
||||
QCOMPARE(qRound(ecsThing->stateValue("powerSetpoint").toDouble()), 2400);
|
||||
|
||||
qunsetenv("NYMEA_ENERGY_LOAD_CONFIG");
|
||||
QFile::remove(cfgPath);
|
||||
#endif
|
||||
}
|
||||
|
||||
void Simulation::testRuntimeViewIsSubstantial()
|
||||
{
|
||||
#ifndef ETM_ARBITRATOR
|
||||
QSKIP("testRuntimeViewIsSubstantial nécessite ETM_ARBITRATOR.");
|
||||
#else
|
||||
// [Lot B §2] La pure virtuelle `ILoadAdapter::runtimeView()` force chaque adaptateur à
|
||||
// RÉPONDRE — elle ne l'oblige pas à répondre quelque chose. Une implémentation qui rendrait
|
||||
// `LoadRuntimeView{}` compilerait tout aussi bien, passerait le dispatch, et sortirait de la
|
||||
// télémétrie SANS BRUIT : le client verrait une charge sans mécanisme et sans verrou,
|
||||
// indistinguable d'une charge libre. C'est exactement le silence ambigu que le garde-fou
|
||||
// prétend empêcher, et le compilateur ne le voit pas.
|
||||
//
|
||||
// Ce test est le second verrou : pour CHAQUE adaptateur, la vue doit être SUBSTANTIELLE
|
||||
// (mécanisme nommé, charge utile non vide) ET porter les valeurs RUNTIME réelles — pas des
|
||||
// valeurs par défaut qui coïncideraient par hasard. On amène donc chaque adaptateur dans un
|
||||
// état NON trivial avant de lire.
|
||||
cleanupTestCase();
|
||||
m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite";
|
||||
initTestCase();
|
||||
|
||||
EnergyArbitrator *arb = dynamic_cast<EnergyArbitrator *>(m_experiencePlugin->smartChargingManager());
|
||||
QVERIFY(arb);
|
||||
ThingManager *tm = NymeaCore::instance()->thingManager();
|
||||
|
||||
QUuid meterId = addMeter();
|
||||
m_experiencePlugin->energyManager()->setRootMeter(meterId);
|
||||
Thing *meter = tm->findConfiguredThing(meterId);
|
||||
QVERIFY(meter);
|
||||
meter->setStateValue("connected", true);
|
||||
|
||||
const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0));
|
||||
|
||||
// ---- 1. RelayRouter : palier appliqué en WATTS + verrou minOn en SECONDES --------------
|
||||
QUuid rA = addPowerSwitch(1000, 26661);
|
||||
QUuid rB = addPowerSwitch(1500, 26662);
|
||||
QVERIFY(tm->findConfiguredThing(rA) && tm->findConfiguredThing(rB));
|
||||
RelayRouter *routeur = new RelayRouter(tm, "rv-relais", "Routeur de relais",
|
||||
QList<LoadConfigRelay>({ {rA.toString(), 1000}, {rB.toString(), 1500} }),
|
||||
300, 0, 1, LoadNeeds(), arb);
|
||||
|
||||
LoadAction monte;
|
||||
monte.kind = LoadAction::Setpoint; monte.funding = LoadAction::Surplus;
|
||||
monte.powerW = 2500; monte.reason = { DecisionCode::SurplusSetpoint };
|
||||
routeur->applyAction(monte, t0);
|
||||
QCOMPARE(qRound(routeur->currentSetpointW()), 2500); // état NON trivial : palier haut
|
||||
|
||||
LoadRuntimeView vRelais = routeur->runtimeView(t0.addSecs(45));
|
||||
QCOMPARE(vRelais.mechanismKind, QString("relay"));
|
||||
// La charge utile porte le palier RÉELLEMENT appliqué, pas 0 ni le plafond.
|
||||
QCOMPARE(qRound(vRelais.mechanism.value("stageW").toDouble()), 2500);
|
||||
QCOMPARE(qRound(vRelais.mechanism.value("maxStageW").toDouble()), 2500);
|
||||
// Verrou : minOn armé à t0, lu 45 s plus tard → 255 s restantes. Une vue vide donnerait 0,
|
||||
// qui se lirait « fenêtre expirée » — l'inverse de la vérité.
|
||||
QCOMPARE(vRelais.lockKind, QString("minOn"));
|
||||
QCOMPARE(vRelais.lockRemainingS, 255);
|
||||
QVERIFY(vRelais.faultCode.isEmpty()); // installation saine
|
||||
// …et le verrou EXPIRE : sans cela, un « 255 » constant passerait le test ci-dessus.
|
||||
QVERIFY(routeur->runtimeView(t0.addSecs(301)).lockKind.isEmpty());
|
||||
|
||||
// ---- 2. EtmVariableLoadAdapter : consigne, pourcentage dérivé, aucun verrou -----------
|
||||
QUuid varId = addEtmVariableLoad(27060);
|
||||
Thing *varThing = tm->findConfiguredThing(varId);
|
||||
QVERIFY(varThing);
|
||||
varThing->setStateValue("currentPowerW", 0);
|
||||
EtmVariableLoadAdapter *continu = new EtmVariableLoadAdapter(
|
||||
tm, varId.toString(), "Routeur PV", QList<int>(), 4000, 2, LoadNeeds(), arb);
|
||||
|
||||
LoadAction consigne;
|
||||
consigne.kind = LoadAction::Setpoint; consigne.funding = LoadAction::Surplus;
|
||||
consigne.powerW = 3000; consigne.reason = { DecisionCode::SurplusSetpoint };
|
||||
continu->applyAction(consigne, t0);
|
||||
QCOMPARE(qRound(continu->currentSetpointW()), 3000);
|
||||
|
||||
LoadRuntimeView vContinu = continu->runtimeView(t0);
|
||||
QCOMPARE(vContinu.mechanismKind, QString("variable"));
|
||||
QCOMPARE(qRound(vContinu.mechanism.value("setpointW").toDouble()), 3000);
|
||||
QCOMPARE(qRound(vContinu.mechanism.value("maxPowerW").toDouble()), 4000);
|
||||
// percent est DÉRIVÉ (3000/4000) : une valeur choisie pour qu'aucun défaut plausible —
|
||||
// 0, 100, ou la consigne elle-même — ne puisse passer par accident.
|
||||
QCOMPARE(vContinu.mechanism.value("percent").toDouble(), 75.0);
|
||||
// Aucune fenêtre de verrou côté moteur : l'anti-rebond vit dans le thing. L'ABSENCE est ici
|
||||
// la vérité, et elle est assertée — pas seulement tolérée.
|
||||
QVERIFY(vContinu.lockKind.isEmpty());
|
||||
QVERIFY(vContinu.faultCode.isEmpty());
|
||||
|
||||
// Le code de défaut n'est pas du code mort : un thing absent de la configuration le lève.
|
||||
EtmVariableLoadAdapter *fantome = new EtmVariableLoadAdapter(
|
||||
tm, "{deadbeef-0000-0000-0000-000000000042}", "Charge fantôme",
|
||||
QList<int>(), 1000, 3, LoadNeeds(), arb);
|
||||
QCOMPARE(fantome->runtimeView(t0).faultCode, QString("THING_MISSING"));
|
||||
|
||||
// ---- 3. SgReadyAdapter : état normé, estimation, verrou minStateHold ------------------
|
||||
QUuid k1 = addPowerSwitch(0, 26663);
|
||||
QUuid k2 = addPowerSwitch(0, 26664);
|
||||
QVERIFY(tm->findConfiguredThing(k1) && tm->findConfiguredThing(k2));
|
||||
SgReadyAdapter *pac = new SgReadyAdapter(
|
||||
tm, "rv-pac", "PAC",
|
||||
QHash<int, QList<QString>>({ {1, {k1.toString()}}, {2, {}},
|
||||
{3, {k2.toString()}}, {4, {k1.toString(), k2.toString()}} }),
|
||||
QHash<int, double>({ {1, 0.0}, {2, 0.0}, {3, 1500.0}, {4, 2600.0} }),
|
||||
900, 4, arb);
|
||||
|
||||
LoadAction etat;
|
||||
etat.kind = LoadAction::State; etat.funding = LoadAction::Surplus;
|
||||
etat.state = 3; etat.reason = { DecisionCode::SgRecommended };
|
||||
pac->applyAction(etat, t0);
|
||||
QCOMPARE(pac->currentState(), 3); // état NON trivial (le défaut est 2)
|
||||
|
||||
LoadRuntimeView vPac = pac->runtimeView(t0.addSecs(120));
|
||||
QCOMPARE(vPac.mechanismKind, QString("sgReady"));
|
||||
QCOMPARE(vPac.mechanism.value("state").toInt(), 3);
|
||||
QCOMPARE(qRound(vPac.mechanism.value("estimatedPowerW").toDouble()), 1500);
|
||||
QCOMPARE(vPac.lockKind, QString("minStateHold"));
|
||||
QCOMPARE(vPac.lockRemainingS, 780); // 900 − 120
|
||||
QVERIFY(vPac.faultCode.isEmpty());
|
||||
QVERIFY(pac->runtimeView(t0.addSecs(901)).lockKind.isEmpty()); // le verrou expire
|
||||
|
||||
// ECS-110 — une PAC dont l'encodage ne permet pas l'état 2 n'est JAMAIS pilotable. Le taire
|
||||
// la ferait passer pour une PAC au repos ; le code le dit.
|
||||
SgReadyAdapter *bancale = new SgReadyAdapter(
|
||||
tm, "rv-pac-bancale", "PAC sans état 2",
|
||||
QHash<int, QList<QString>>({ {3, {k2.toString()}} }),
|
||||
QHash<int, double>({ {3, 1500.0} }), 300, 5, arb);
|
||||
QCOMPARE(bancale->runtimeView(t0).faultCode, QString("UNUSABLE_ENCODING"));
|
||||
|
||||
// ---- 4. EvAdapter : hors charge utile en beta, mais IMPLÉMENTÉ ------------------------
|
||||
// Déclaré et écrit dès maintenant : en 3g, quand le VE rejoindra le waterfall unifié, il
|
||||
// n'y aura rien à écrire ici. Une vue vide se découvrirait ce jour-là, en production.
|
||||
// « ABC » et pas le défaut « All » : le mock n'accepte que A,B,C,AB,BC,AC,ABC.
|
||||
QUuid chargerId = addCharger(QStringLiteral("ABC"));
|
||||
QVERIFY(!chargerId.isNull());
|
||||
arb->simulationCallUpdate(t0); // syncAdapters() crée les EvAdapter
|
||||
QCoreApplication::processEvents();
|
||||
EvCharger *borne = arb->registeredEvChargers().value(ThingId(chargerId.toString()));
|
||||
QVERIFY2(borne, "la borne EV n'est pas enregistrée auprès de l'arbitre");
|
||||
EvAdapter *ev = new EvAdapter(borne, arb);
|
||||
|
||||
LoadRuntimeView vEv = ev->runtimeView(t0);
|
||||
QCOMPARE(vEv.mechanismKind, QString("evcharger"));
|
||||
for (const char *cle : {"chargingEnabled", "pluggedIn", "currentA", "phaseCount"})
|
||||
QVERIFY2(vEv.mechanism.contains(QLatin1String(cle)),
|
||||
qUtf8Printable(QStringLiteral("clé « %1 » absente de la vue evcharger").arg(cle)));
|
||||
|
||||
// ---- 5. Le verrou générique : AUCUNE vue vide, pour AUCUN adaptateur ------------------
|
||||
// C'est l'assertion qui échouerait si un adaptateur futur se contentait de « return {}; ».
|
||||
// Elle porte sur la LISTE des adaptateurs, pas sur un cas particulier : ajouter un
|
||||
// mécanisme sans l'ajouter ici laisse le trou ouvert, donc la liste est nommée en dur.
|
||||
struct Cas { const char *nom; ILoadAdapter *a; };
|
||||
const QList<Cas> tous = { {"relay-router", routeur}, {"etmvariableload", continu},
|
||||
{"sg-ready", pac}, {"evcharger", ev} };
|
||||
for (const Cas &c : tous) {
|
||||
const LoadRuntimeView v = c.a->runtimeView(t0);
|
||||
QVERIFY2(!v.mechanismKind.isEmpty(),
|
||||
qUtf8Printable(QStringLiteral("%1 : mechanismKind VIDE — vue non implémentée").arg(c.nom)));
|
||||
QVERIFY2(!v.mechanism.isEmpty(),
|
||||
qUtf8Printable(QStringLiteral("%1 : charge utile de mécanisme VIDE").arg(c.nom)));
|
||||
// Une charge utile qui ne porterait que « kind » serait une vue vide déguisée : le
|
||||
// client n'y lirait rien de l'état réel. On exige au moins une grandeur.
|
||||
QVERIFY2(v.mechanism.count() >= 2,
|
||||
qUtf8Printable(QStringLiteral("%1 : charge utile réduite à un discriminant").arg(c.nom)));
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
void Simulation::printStates(Thing *thing)
|
||||
{
|
||||
qCDebug(dcSimulation()) << "Thing states for" << thing->name();
|
||||
|
||||
@ -117,6 +117,26 @@ private slots:
|
||||
// les identifiants en double, et la tolérance d'une charge désactivée.
|
||||
void testThingOwnershipIsExclusive();
|
||||
|
||||
// [Lot B §1] Rendu français des motifs : chaque code du catalogue produit MOT POUR MOT la
|
||||
// phrase d'avant la refonte {code, params}. Le journal se prouve par comparaison, pas par
|
||||
// relecture — et une phrase reformulée au passage ferait échouer ce test, pas un humain.
|
||||
void testDecisionReasonRendering();
|
||||
|
||||
// [Lot B §2] Frontière de télémétrie : GetLoadTelemetry ≡ LoadTelemetryChanged, verrous en
|
||||
// secondes, union discriminée de mécanisme, reprise à froid du mode dégradé, battement de
|
||||
// cœur, et ZÉRO langue naturelle dans la charge utile.
|
||||
void testLoadTelemetryRpc();
|
||||
|
||||
// [Lot B §3] o:domain : aller-retour lire→réécrire sans perte, valeur hors énumération
|
||||
// rejetée, charge sans domaine acceptée — et aucun effet sur l'arbitrage.
|
||||
void testLoadConfigDomain();
|
||||
|
||||
// [Lot B §2] La pure virtuelle force à RÉPONDRE, pas à répondre quelque chose : une vue
|
||||
// vide compilerait tout aussi bien et sortirait de la télémétrie sans bruit. Ce test exige
|
||||
// une vue SUBSTANTIELLE — mécanisme nommé, charge utile portant les valeurs runtime
|
||||
// réelles, verrou en secondes qui expire — pour les QUATRE adaptateurs.
|
||||
void testRuntimeViewIsSubstantial();
|
||||
|
||||
void printStates(Thing *thing);
|
||||
void updateChargerMeter(Thing *thing);
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user