feat(3g-2): une charge, un commandeur

Le relevé machine du 2026-08-26 l'énonçait sans détour : « tant que ceci n'est
pas traité, la transplantation ne commande rien ». L'arbitre décidait,
publiait un motif juste, et adjustEvChargers() commandait autre chose une
milliseconde plus tard. Ce lot ferme les trois points que le banc a ouverts.

1. UNE CHARGE, UN COMMANDEUR

Les actions EV du waterfall sont enfin DISPATCHÉES — elles ne l'étaient pas du
tout : m_loadAdapters ne contient pas les bornes, et applyActionsToAdapters()
s'arrêtait là. Et les bornes ainsi commandées sortent du chemin proxy par deux
crochets virtuels posés dans l'amont, interrogés à deux moments du cycle où la
réponse n'est pas la même : evSurplusPlannedByWaterfall() avant le plan (à qui
la borne appartient-elle), evCommandedByWaterfall() après (qu'a-t-il commandé).
Sans cette distinction, une borne servie par l'échéance ne serait plus
commandée du tout.

Le jeu des bornes commandées est NOMMÉ par le scheduler (Plan::waterfallEvIds).
Le redéduire ailleurs — « motif différent d'EV_DEADLINE », « financement au
surplus » — dériverait du vrai au premier motif ajouté, et EV_GRID_START porte
justement funding=Grid tout en venant du waterfall. Il est vidé à l'entrée du
mode dégradé : le waterfall ne commandant plus, il ne peut plus revendiquer.

chargingState, que posait adjustEvChargers(), est repris par l'arbitre pour
les bornes qui n'y passent plus — sinon l'app afficherait « en charge » une
borne que l'arbitre vient d'éteindre.

2. evFloorW() REND L'EXIGENCE RÉELLE, ET LA TOLÉRANCE DEVIENT VISIBLE

Le plancher valait `min × acquisitionTolerance × 230 × phases` : 2 070 W
annoncés pour une borne qui en exige 4 140. Le waterfall allouait 2 828 W,
l'adaptateur refusait d'enclencher sous 6 A, et 2 828 W étaient retirés du
budget pour personne. Ce sont deux grandeurs différentes : le plancher est ce
que la borne EXIGE, la tolérance est la part qu'on accepte de ne pas couvrir.

La tolérance n'est pas supprimée pour autant — un réglage documenté (défaut
0,5) qui devient inerte sans le dire est un silence ambigu, et tout le parc
verrait son VE démarrer deux fois plus tard sans explication. Elle devient un
seuil de démarrage EXPLICITE : motif EV_GRID_START {budgetW, floorW, gridW},
funding=Grid, et une comptabilité qui se partage — budgetW dans
budget.allocatedW, gridW dans budget.evReservedW, budgetW + gridW ==
allocatedW.

C'est une brèche assumée dans le financement réseau que DESIGN_3g §3.1 déférait
à 3f. Elle est bornée par construction, pas par discipline : surplus réel exigé
(budgetW > 0), tout le budget restant consommé donc AU PLUS UNE borne par
cycle, et le plafond de la protection de surcharge s'applique avant. Écrite en
toutes lettres dans DESIGN_3g §3.1-bis.

3. LE RANG D'UNE BORNE EST CONFIGURABLE

Il vit là où vivent tous les rangs : LoadConfig.priority (LM-1206/LM-1207). Pas
de second système de priorité — un classement propre aux bornes ne saurait pas
exprimer « VE1 > ECS > VE2 », et deux classements qui ne peuvent pas
s'interclasser sont un défaut, pas deux fonctionnalités. ChargingInfo ne porte
donc aucun champ de rang.

Toute borne détectée reçoit d'office une entrée « evcharger », portant le rang
et RIEN d'autre : ses limites viennent du Thing et changent avec la voiture
branchée. Création journalisée, une ligne par borne, à la création seulement —
une écriture que personne n'a demandée doit laisser une trace. Le tri devient
TOTAL : à rang égal, l'identifiant départage, donc laquelle démarre est
reproductible même avant tout réglage.

L'entrée SURVIT à la disparition de son Thing : borne remplacée, ThingId
changé, appareil déposé. Elle reste publiée available: false / faultCode:
THING_MISSING / motif LOAD_UNAVAILABLE, et ne retient aucun budget. La
supprimer d'office rendrait « borne remplacée » indistinguable de « borne
jamais configurée ».

CORRIGE UNE RUPTURE D'INVARIANT DE 3g-1, non signalée à l'époque : en faisant
entrer les bornes dans loads[], 3g-1 publiait des charges qui ne figuraient
dans AUCUNE GetLoadConfig — la charge fantôme que le lot B-bis avait
supprimée, réapparue par l'autre bout. L'app s'appuie sur cet invariant pour
résoudre libellé, domaine et rang. L'auto-provisionnement le rétablit par
construction.

ÉPROUVÉ

  - testEvChargerRankDecidesWhoCharges : deux bornes, un budget qui ne paie
    qu'un plancher, la seconde refusée avec BELOW_MIN_POWER {budgetW: 620,
    minPowerW: 1380}, et l'inversion du rang change la borne servie. C'est la
    moitié ARBITRAGE de LM-1204-b. Couvre aussi l'aller-retour neutre par la
    frontière RPC et l'inclusion loads[] ⊆ GetLoadConfig.
  - testEvGridStartIsAnnouncedAndBounded : le motif chiffre le soutirage, la
    comptabilité se réconcilie, une seule borne soutire par cycle.
  - testEvChargerConfigOutlivesItsThing : l'entrée survit, publiée en défaut.
  - testEvChargerConfigIsRankOnly / RefusesPayloads / ClaimsItsThing.

Deux points de contrôle de `run` bougent, dans un seul scénario et par une
cause unique : la conversion watts→ampères se fait désormais après
l'allocation, et l'arrondi tombe parfois de l'autre côté. 14 des 16 scénarios
sont identiques au bit près, et ce qui compte est inchangé dans les deux —
la voiture est à 100 % à l'échéance.

Suite complète : simulation 27/27, charging 16/16, loadmodel 16/16,
spotmarket 7/7. Build amd64 0/0.

RIEN N'A TOURNÉ SUR MACHINE. docs/RECAP_3g2.md dit ce qui reste à éprouver au
banc — dont LM-1204-b avec deux bornes réelles, la Terra AC étant vérifiée
absente du réseau (ARP INCOMPLETE depuis le banc lui-même).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017TVywtayEeoRJ2yWUW4iZj
This commit is contained in:
Patrick Schurig 2026-08-27 10:43:25 +02:00
parent 135cba3e6f
commit baf267a963
21 changed files with 1553 additions and 93 deletions

View File

@ -22,6 +22,8 @@ vers un gestionnaire d'énergie complet (EV, ECS, PAC SG-Ready, batterie).
| rév. 3 — frontière optimiseur↔routeur | ✅ FAITE — `RelayRouter`, `LoadConfig`/`LoadConfigStore`, kind `Stage` retiré | `5100674`, `e16aca4`, `5585a5c`, `7184fe4`, `88626cf` |
| lot B — frontière de TÉLÉMÉTRIE + `o:domain` | ✅ FAITE — `GetLoadTelemetry`/`LoadTelemetryChanged`, motifs en `{code, params}`, `degradedMode` lisible | `1.15.2+etm13` → `+etm14` |
| lot B-bis — la PAC entre en CONFIGURATION | ✅ FAITE — plus aucun adaptateur codé en dur ; `loads[]` ⊆ `GetLoadConfig` par construction | `1.15.2+etm15` |
| 3g-1 — les bornes entrent dans le waterfall | ✅ FAITE — décision partagée, motifs communs ; **mais la décision n'atteignait pas le matériel** | `1.15.2+etm20` → `+etm22` |
| 3g-2 — une charge, un commandeur | ✅ FAITE — dispatch EV réel, `evFloorW()` = exigence réelle, rang de borne configurable | (à construire) |
**Détail 3b** :
- `EnergyArbitrator : public SmartChargingManager` — justification dans `## DÉCISIONS DE DESIGN`
@ -136,7 +138,9 @@ vers un gestionnaire d'énergie complet (EV, ECS, PAC SG-Ready, batterie).
### 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` —
- **Charges** : EV **dans le waterfall** (3g-1/3g-2 : même budget, même rang, mêmes motifs ;
seuls l'échéance et le tarif dynamique restent au proxy, financés au réseau),
**charges pilotées en watts** (kind `Setpoint` —
`RelayRouter` pour une combinaison de relais, `EtmVariableLoadAdapter` pour une consigne
continue), **SG-Ready PAC** (4 états, kind `State`) — toutes les charges non-EV classables
ensemble sur le budget partagé.
@ -169,7 +173,8 @@ vers un gestionnaire d'énergie complet (EV, ECS, PAC SG-Ready, batterie).
- **V2C** (borne EV) : intégration — **session dédiée**.
- **3d** SocketScheduler (handshake/heartbeat/repli optimiseur).
- **3f** BatteryAdapter (constraints + charge réseau plafonnée) + waterfall grid-funding.
- **3g** transplantation EV dans le waterfall unifié → toutes charges classables ensemble.
- ~~**3g** transplantation EV dans le waterfall unifié~~ — **FAITE** (3g-1 + 3g-2). Reste la
moitié MATÉRIELLE de LM-1204-b, qui exige deux bornes réelles au banc.
- **Couche config priorités** (UI Flutter drag-and-drop) — cf. `## ROADMAP`.
Note : **toutes** les charges non-EV sont configurables et persistées — les charges pilotées
depuis `7184fe4`, la PAC SG-Ready depuis le **lot B-bis** (`LoadConfigStore`, RPC
@ -181,8 +186,11 @@ vers un gestionnaire d'énergie complet (EV, ECS, PAC SG-Ready, batterie).
- **arm64** cross-compile validé en pré-déploiement (infra CI `etm-powersync-deploy`).
- **`Doxyfile` + job CI `doxygen -W`** (automatiser l'audit doc).
**PROCHAINE ACTION** : **test terrain vendredi** (`docs/TEST_TERRAIN.md`, Palier 1), puis
**passe contrats** (OPTIMIZER_PROTOCOL + README).
**PROCHAINE ACTION** : **construire et déployer 3g-2**, puis l'exercer au banc — le lot n'a
tourné qu'en simulation. Deux choses n'y sont PAS éprouvables et attendent la machine :
LM-1204-b avec **deux bornes réelles** (la Terra AC ne répond toujours pas, cf.
`docs/RECAP_3g2.md` §1) et le `BATTERY_RESERVE` avec un surplus réel sous le seuil.
Ensuite : **passe contrats** (OPTIMIZER_PROTOCOL + README).
**Remotes git** :
- `origin` (`https://git.etm-powersync.fr/...`) = remote de travail — push normal
@ -383,13 +391,20 @@ Réserve sur `EtmVariableLoadAdapter` : il est exercé de bout en bout par
`etmvariableload`** — seul le mock la porte
(`tests/mocks/plugins/energymocks/integrationpluginenergymocks.json:865-888`).
**EV, limite beta.** `EvAdapter::applyAction()` est **implémenté** (`evadapter.cpp:62-94`)
mais **jamais appelé** : le dispatch ne route les `Setpoint` que vers les charges pilotées,
et les `EvAdapter` vivent dans une table séparée jamais parcourue
(`energyarbitrator.cpp:288`). Les décisions EV restent prises par les méthodes amont, en
proxy, **avant** le waterfall ; l'EV n'entre donc pas dans le tri par priorité.
`descriptor()` et `telemetry()` sont utilisés dès maintenant pour le `SurplusContext`.
**3g est un travail de câblage, pas d'écriture.**
**EV — transplanté (3g-1 + 3g-2).** `EvAdapter::applyAction()` est appelé : le dispatch route
vers `m_adapters` les bornes que le waterfall a commandées, nommées par le scheduler dans
`Plan::waterfallEvIds`. Les bornes servies par la cascade **ne repassent plus** par
`adjustEvChargers()` — deux crochets virtuels (`evSurplusPlannedByWaterfall`,
`evCommandedByWaterfall`) les en sortent, et c'est ce qui garantit « une charge, un commandeur ».
**Ce qui RESTE au proxy** : l'échéance de départ et le tarif dynamique (financement réseau,
DESIGN_3g §3.1), la protection de surcharge, l'estimation de SoC, le filtrage d'entrée. Une
borne servie par ces chemins garde son dispatch amont, allowance de compteur comprise.
**Brèche assumée** : une borne peut DÉMARRER en soutirant, sous `acquisitionTolerance` — c'est le
comportement du proxy, rendu visible (motif `EV_GRID_START`, `funding: grid`, comptabilité
partagée). Bornée : surplus réel exigé, au plus une borne par cycle, sous le plafond L4.
Détail et limites : `docs/DESIGN_3g.md` §3.1-bis.
Règles absolues :
1. **UN seul arbitre.** Le budget de surplus est une ressource unique, arbitrée à UN
@ -558,13 +573,12 @@ au compteur).
`RuleBasedScheduler` → priorités libres entre toutes les charges (EV, ECS, SG-Ready,
batterie).
**Dette 3g — convention de priorité EV** : `EvAdapter::descriptor()` met
`priority = 100` (`evadapter.cpp:24`), reliquat de l'ancienne convention « poids,
valeur haute = premier ». Inoffensif en beta : l'EV est servi par le proxy *avant* le
waterfall ECS et n'entre pas dans le tri ECS (ascendant, rang 1 = premier servi,
protocole §5). À reconcilier quand l'EV rejoindra le waterfall unifié : la priorité
devient un **rang** (1, 2, 3…), pas un poids — sinon `priority=100` placerait l'EV en
dernier d'un tri ascendant.
**Dette 3g — convention de priorité EV : RÉGLÉE en 3g-2.** `priority = 100` en dur a cédé la
place à `EnergyArbitrator::evPriority()`, qui lit le rang dans `LoadConfig` (LM-1207 : le rang du
VE vit là où vivent tous les rangs, jamais dans un second système). Toute borne détectée reçoit
d'office une entrée `evcharger` — rang, libellé, `domain: ev`, et **aucune charge utile** : ses
limites viennent du Thing. Le tri est **total** (à rang égal, l'identifiant départage), donc
reproductible d'un cycle à l'autre. Contrat : `INTERFACE.md`.
---
@ -698,11 +712,11 @@ supérieures n'affecte pas les couches inférieures. Voir `docs/SAFETY.md` pour
- **ACQUIS (3e)** : le waterfall trie déjà ECS + SG-Ready ensemble par `priority` (rang
ASC, 1 = servi en premier), **budget unifié** qui cascade à travers toutes ces charges.
- **LIMITE beta** : le VE reste **hors du tri** (décidé par l'amont *avant* le waterfall,
décision B) → on ne peut pas classer le VE derrière l'ECS.
- **ACQUIS (3g-1/3g-2)** : le VE est **dans le tri**. Toutes les charges — VE, ECS, SG-Ready —
partagent un budget et un rang unique, et le VE peut être classé derrière l'ECS.
- **MANQUE pour des priorités réglables par le client** :
- (a) **3g** : transplanter le VE dans le waterfall unifié → toutes les charges
(VE, ECS, SG-Ready, batterie) classables ensemble.
- (a) ~~**3g**~~ — **fait**. Le rang d'une borne est un champ de `LoadConfig`, modifiable à
chaud par `SetLoadConfig`, comme celui de toute autre charge.
- (b) **Couche « config priorités »** — le pont moteur↔UI, un morceau à part entière
(ni 3e ni 3g), aujourd'hui **à moitié livré** :
- **FAIT côté moteur** (`7184fe4`) : `priority` est exposé et persisté par charge,
@ -714,9 +728,8 @@ supérieures n'affecte pas les couches inférieures. Voir `docs/SAFETY.md` pour
`priority` est un champ de la configuration persistée (`loadconfig.h:73`), transmis à
l'adaptateur lors de sa construction (`energyarbitrator.cpp:162,167`), et modifiable
**à chaud**. `register*Adapter` ne fixe plus rien : il enregistre un adaptateur déjà
construit. **Seul le VE reste hors config** (fixé à `priority = 100`, `evadapter.cpp:24` —
cf. « Dette 3g ») ; le `SgReadyAdapter` du banc y est entré au lot B-bis, et le bloc
`energypluginnymea.cpp:66-76` qui l'enregistrait n'existe plus.
construit. Le `SgReadyAdapter` du banc y est entré au lot B-bis, et **le VE en 3g-2** : plus
rien n'est hors configuration.
- **Argument démo nymea** : le client réordonne ses charges, le surplus suit.
## RÉFÉRENCES
@ -727,6 +740,10 @@ supérieures n'affecte pas les couches inférieures. Voir `docs/SAFETY.md` pour
- `INTERFACE.md` — API JSON-RPC existante (`NymeaEnergy`, cible future `Ems`).
- `docs/RELEVE_LOTB.md` — relevé de vérification du lot B (frontière de télémétrie + `o:domain`)
sur `.75`, critère par critère, avec ce qui n'a PAS été vérifié sur la machine et pourquoi.
- `docs/RELEVE_3g1.md` — relevé MACHINE de 3g-1 sur `.75` : ce que le banc a dit, y compris les
trois défauts qu'il a révélés et que 3g-2 corrige.
- `docs/RECAP_3g2.md` — 3g-2 : ce qui est corrigé, ce qui est éprouvé **en simulation seulement**,
et ce qui attend la machine. Contient l'état vérifié de la Terra AC.
- `docs/RELEVE_LOTBbis.md` — relevé de vérification du lot B-bis (la PAC entre en configuration)
sur `.75` : l'ordre de migration exercé, la concordance des deux frontières, `applySafeState`
au retrait, l'expiration du verrou à froid — et §7, les puissances SG-Ready qui ne sont **pas**

View File

@ -507,12 +507,17 @@ le cas normal.
> fois et offerte entière à chacune. Ce n'est plus le cas — la première est servie, la seconde
> reçoit le reliquat, et 0 si ce reliquat est sous son plancher.
>
> ⚠️ **Le rang d'une borne n'est pas configurable** : `EvAdapter::descriptor()` fixe
> `priority = 100` pour toutes. Deux bornes ont donc la MÊME clé de tri, et `std::sort` n'est
> pas stable : laquelle est servie n'est ni réglable, ni garanti reproductible d'un cycle à
> l'autre. La cascade est correcte — une seule démarre, l'autre reçoit son motif — mais
> **laquelle** ne se décide pas encore. À traiter par un lot dédié ; à ne pas présenter dans
> l'app comme un réglage tant que ce n'est pas le cas.
> ✅ **Le rang d'une borne est configurable depuis 3g-2**, et il vit là où vivent tous les rangs :
> `LoadConfig.priority` (LM-1206/LM-1207). Il n'existe **pas** de second système de priorité —
> un classement propre aux bornes ne saurait pas exprimer « VE1 > ECS > VE2 », et deux
> classements qui ne peuvent pas s'interclasser sont un défaut, pas deux fonctionnalités.
> `ChargingInfo` ne porte donc **aucun** champ de rang, et n'en portera pas.
>
> Le tri est **total** : à rang égal, l'identifiant départage. Laquelle est servie est donc
> reproductible d'un cycle à l'autre, même avant tout réglage.
>
> *(Jusqu'à 3g-1, `EvAdapter::descriptor()` fixait `priority = 100` pour toutes : même clé de
> tri, `std::sort` non stable, et laquelle démarrait n'était ni réglable ni reproductible.)*
**Bornes exclues de `loads[]`** : celles sans voiture assignée et celles en mode manuel
(`ChargingModeNormal`). Elles sont **hors arbitrage**, pas servies en dernier — leur
@ -533,6 +538,45 @@ l'arbitrage** (contrat §9). Aucun adaptateur n'est construit pour elles, elles
aucun budget, elles n'ont donc aucune télémétrie à publier. Une charge configurée absente de
`loads[]` se lit **« désactivée »**, jamais « perdue ».
> ⚠️ **3g-1 avait ROMPU cet invariant, et 3g-2 le rétablit.** En faisant entrer les bornes dans
> `loads[]`, 3g-1 publiait des charges qui ne figuraient dans aucune `GetLoadConfig` — c'est-à-dire
> exactement la charge fantôme que le lot B-bis avait supprimée, réapparue par l'autre bout.
>
> **Depuis 3g-2, toute borne détectée reçoit d'office une entrée `LoadConfig`** de mécanisme
> `evcharger` (voir ci-dessous). L'inclusion est de nouveau vraie par construction.
##### Le mécanisme `evcharger` dans `GetLoadConfig` / `SetLoadConfig` *(3g-2)*
Une entrée `evcharger` porte **le rang, et rien d'autre** :
| Champ | Valeur |
|---|---|
| `id` | ThingId de la borne |
| `adapter` | `"evcharger"` |
| `mode` | `"dynamic"` (une borne module ; `"fixed"` est refusé) |
| `domain` | `"ev"` |
| `label`, `priority`, `enabled` | comme toute autre charge |
**Toute charge utile est refusée** : `relays[]`, `sgReady`, `powerLevels`, `maxPowerW`,
`minPowerW`, `minOnS`, `minOffS`. Les limites d'une borne — courant minimal et maximal, nombre de
phases, disponibilité — viennent du **Thing** et changent avec le véhicule branché ; les déclarer
ici en ferait une seconde source de vérité, périmée dès le premier débranchement.
- **Création automatique.** Une borne détectée entre en configuration sans intervention, à un rang
de tête (le plus petit rang existant moins un, borné à 1) qui ne réécrit le rang de personne.
C'est un point de départ de mise en service, pas une politique : **l'app pose les vrais rangs**.
La création est journalisée, une ligne par borne, à la création seulement.
- **Aucun adaptateur n'en est construit** : celui de la borne vient du Thing. Un aller-retour
`GetLoadConfig` → `SetLoadConfig` **verbatim** ne change donc rien et ne reconstruit rien —
aucun verrou n'est réarmé.
- **L'entrée survit à son Thing.** Borne remplacée, ThingId changé, appareil déposé : l'entrée
**n'est pas supprimée** — c'est une configuration, et l'installateur a pu la classer. Elle
reste publiée dans `loads[]`, `available: false`, `faultCode: "THING_MISSING"`, motif
`LOAD_UNAVAILABLE`, et ne retient aucun budget. La supprimer d'office rendrait « borne
remplacée » indistinguable de « borne jamais configurée ».
- **Exclusivité.** L'entrée revendique le Thing de la borne : la déclarer en plus comme charge
pilotée est refusé par `SetLoadConfig` (deux commandeurs sur un même organe, LM-201).
> **La règle de détection de `+etm14` est RETIRÉE.** Cette section documentait jusqu'ici
> l'inverse : une charge pouvait être arbitrée et publiée **sans** exister dans `GetLoadConfig`,
> et le client était invité à la détecter par différence d'ensembles — un `loadId` sans
@ -577,6 +621,7 @@ le **libellé de la charge n'y figure jamais** (le client le résout via `GetLoa
| `SURPLUS_INSUFFICIENT` | `budgetW` | Le budget ne payait aucun palier — **aucun verrou n'a mordu** |
| `BATTERY_RESERVE` | `socPercent`, `reservePercent`, `withheldW` | Le surplus **existe** mais la réserve de stockage l'interdit : **aucune** charge n'est servie. Depuis 3g-2 `withheldW` compte les **deux** termes retenus à cette charge — le surplus annulé **et** sa propre consommation, que le recrédit lui aurait rendue |
| `BELOW_MIN_POWER` | `budgetW`, `minPowerW`, `o:state` | Budget **sous le premier palier atteignable** : 0 W, et le budget passe à la suivante. `state` nommé pour les mécanismes à états |
| `EV_GRID_START` *(3g-2)* | `budgetW`, `floorW`, `gridW` | La borne **démarre en soutirant** : le surplus ne paie pas son plancher, mais `acquisitionTolerance` autorise l'appoint. `gridW = floorW − budgetW` est ce qui vient du réseau. Voir la note de comptabilité ci-dessous |
| `PHASE_LIMIT` *(3g-2)* | `limitW`, `requiredW` | C'est la **limite de phase** qui interdit, pas le budget : la place libre laissée par la protection de surcharge est sous ce que la borne exige. Geste attendu : regarder l'abonnement et les autres consommateurs, pas attendre le soleil |
| `LOCK_MIN_STATE_HOLD` | `state` | Verrou `minStateHold` : état PAC maintenu (protection court-cycling) |
| `SG_FORCED` | `budgetW`, `estimatedW` | PAC en état 4 (forcé) |
@ -587,6 +632,16 @@ le **libellé de la charge n'y figure jamais** (le client le résout via `GetLoa
| `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) |
> **Comptabilité d'un `EV_GRID_START`** — c'est le seul cas où l'`allocatedW` d'une charge se
> **partage** entre les deux compteurs du budget, et les deux termes sont dans le motif :
> `budgetW` vient du surplus et entre dans `budget.allocatedW` ; `gridW` vient du réseau et entre
> dans `budget.evReservedW`. `budgetW + gridW == allocatedW`, toujours. L'entrée porte
> `funding: "grid"` : une part de cette puissance est achetée.
>
> **Borné par construction** : un démarrage sous tolérance exige du surplus réel (`budgetW > 0`)
> et consomme **tout** le budget restant — donc **au plus une borne par cycle** peut soutirer.
> Le plafond de la protection de surcharge s'applique avant, et la couche L4 garde le dernier mot.
>
> `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

View File

@ -98,6 +98,54 @@ projection de durée — **de la machinerie propre au VE**, alimentée par le So
> laisse l'échéance là où sa machinerie vit déjà. La fusionner avec 3f mêlerait deux
> franchissements de frontière dans un même lot.
**DÉCISION : (a)**, appliquée en 3g-1. L'échéance et le tarif dynamique restent au proxy, en
financement réseau ; `evReservedW` est restreint à ce financement.
#### 3.1-bis — La brèche assumée de 3g-2 : le démarrage sous tolérance
*(Décision Patrick, 2026-08-27.)*
Ce document défère le financement réseau à 3f. **3g-2 en ouvre un cas particulier**, et il faut
l'écrire ici plutôt que de le laisser se découvrir dans le code.
**Ce que c'est.** `acquisitionTolerance` (défaut 0,5) est un seuil de **déclenchement** : la part
du plancher d'une borne qu'on accepte de ne pas couvrir par le surplus pour démarrer quand même,
en soutirant le reste. Une borne qui exige 4 140 W démarre donc dès 2 070 W de surplus, et tire
2 070 W du réseau.
**Pourquoi c'est acceptable.** *Le comportement existe déjà*, et depuis toujours : le proxy
amont le pratique (`chargingEnabled = newRawValue >= minimalChargingCurrent × tolerance`, puis
consigne remontée au minimum de la borne). 3g-2 ne l'introduit pas — il le rend **visible**. Il
était jusqu'ici invisible de deux façons : aucun motif ne le nommait, et `evFloorW()` le
confondait avec le plancher lui-même, publiant 2 070 W comme « ce que la borne exige » alors
qu'elle en exige 4 140.
Le supprimer aurait été l'autre option, et elle a été écartée pour la raison du parc : un
réglage documenté qui devient inerte sans le dire est un silence ambigu, et **chaque
installation en service verrait son VE démarrer deux fois plus tard sans explication**.
**Ce qui le rend lisible.**
- `funding = Grid` sur l'action — l'origine de l'énergie est dite, pas déduite ;
- motif **`EV_GRID_START {budgetW, floorW, gridW}`** — `gridW` chiffre le soutirage ;
- comptabilité **partagée** : `budgetW` entre dans `budget.allocatedW` (part surplus), `gridW`
dans `budget.evReservedW` (part réseau), et `budgetW + gridW == allocatedW` de la charge.
**Ce qui le borne, par construction et non par discipline.**
1. Il exige du surplus **réel** (`budgetW > 0`) : jamais un démarrage à sec.
2. Il consomme **tout** le budget restant, donc **au plus une borne par cycle** — la suivante
trouve un budget nul et repart sur son motif ordinaire. Éprouvé par
`testEvGridStartIsAnnouncedAndBounded`.
3. Il reste **sous le plafond de la protection de surcharge** : ce plafond est déclaré dans
`LoadContext.declared.maxPowerW` (`EvAdapter::toLoadContext`, écrêté par
`evPhaseAllowanceW()`), donc le waterfall ne peut pas allouer au-delà. Et la couche L4,
`verifyOverloadProtection()`, garde le dernier mot en temps réel, après le dispatch —
`testOverloadProtectionEcoMode` l'éprouve.
**Ce qui reste hors périmètre**, et que 3f devra traiter : le financement réseau des charges
**non-EV** (ECS, PAC), l'achat sur signal tarifaire, et les planchers éco/confort du §10 — qui
ne partent pas sans l'extension du délestage au waterfall (précondition écrite,
`specs/spec_loadmodel.md` §13).
### 3.2 La prise en compte de la batterie change de portée
Le proxy calcule, **à l'intérieur du chemin VE** : pause de la charge sous le seuil de SOC,
@ -145,6 +193,18 @@ qui bascule en triphasé change son plancher effectif (6 A × 3 = 4,1 kW au lieu
> annoncé à 1,4 kW, une borne qui passe en triphasé, et une allocation de 2 kW qui ne démarre
> rien.
**Traité en 3g-2**, et le piège s'est refermé exactement là où il était annoncé — deux fois :
- **Le prédicat de bascule.** 3g-1 utilisait `EvCharger::canSetPhaseCount()`, qui dit seulement
que la borne *porte* l'état `desiredPhaseCount`. La vraie question — la borne **et la
voiture** acceptent-elles 1 et 3 phases — est tranchée une fois par le manager
(`canSwitchPhaseCount`, `min/maxPossiblePhaseCount`). Les deux avaient divergé : une borne
monophasée se voyait viser 3 phases, son plancher publié triplait, et sous surcharge elle
commandait 3 × 6 A là où le proxy commandait 1 × 6 A. L'arbitre lit désormais le manager.
- **Le courant de référence.** La bascule ne se décide pas sur ce que la borne tire, mais sur ce
qu'elle **pourrait** tirer : `currentPower/230 + surplus`, la formule du proxy. Avec le seul
courant courant, une borne en triphasé y restait quel que soit l'effondrement du surplus.
---
## 4. Ce qui reste au proxy, quoi qu'il arrive
@ -186,19 +246,23 @@ seconde moitié de 3g-1 n'est pas faite, il reste correct tel quel.
| Étape | Contenu | Éprouvable |
|---|---|---|
| **3g-1** | `EvAdapter` publie `declared.minPowerW` (runtime, depuis la voiture) et `LoadContext` ; le VE entre dans `nonEvLoads` ; `EV_IDLE` cède à `BELOW_MIN_POWER` / `SURPLUS_INSUFFICIENT` | **oui** — LM-1204-b, les deux bornes du banc |
| **3g-2** | Conversion watts → ampères + choix de phases dans l'adaptateur, plancher publié pour le nombre de phases **visé** | oui, banc |
| **3g-3** | `batteryLevelConsideration` monte dans le budget (§3.2) | oui, banc |
| **3g-4** | Échéance : selon la décision du §3.1 | dépend |
| **3g-2** | ✅ **FAIT** — une charge un commandeur · `evFloorW()` = exigence réelle + démarrage sous tolérance visible · rang de borne configurable (LM-1207) | oui, banc |
| **3g-3** | ~~`batteryLevelConsideration` monte dans le budget~~ — **fait en 3g-1**, complété en 3g-2 (§3.2) | — |
| **3g-4** | Échéance : décision (a), elle reste au proxy — rien à faire tant que 3f n'existe pas | dépend |
**3g-1 seul suffit à satisfaire LM-1204-b**, et c'est le seul qui corrige le défaut du §1.
> **Corrigé par le passage machine du 2026-08-26** : 3g-1 ne suffisait pas, parce que sa décision
> n'atteignait pas le matériel — `adjustEvChargers()` commandait encore. La moitié ARBITRAGE de
> LM-1204-b est éprouvée depuis 3g-2 (`testEvChargerRankDecidesWhoCharges`) ; la moitié
> MATÉRIELLE reste au banc, et elle attend une seconde borne réelle.
---
## 6. Ce qu'il faut décider avant que je code
## 6. Ce qu'il fallait décider — décidé
1. **§3.1 — l'échéance** : option (a), (b) ou (c) ? *(Recommandation : (a).)*
2. **§3.2 — la batterie** : monter la règle dans le budget, en assumant le changement de
comportement pour l'ECS et la PAC ? *(Recommandation : oui.)*
3. **Le découpage** : 3g-1 livré et éprouvé seul, ou le lot entier d'un coup ?
*(Recommandation : 3g-1 seul — il porte la correction, et il est le seul dont le critère
d'acceptation est déjà écrit.)*
1. **§3.1 — l'échéance** : **(a)**, elle reste au proxy. 3g-2 y ajoute une brèche bornée et
annoncée : le démarrage sous tolérance (§3.1-bis).
2. **§3.2 — la batterie** : **oui**, montée dans le budget (3g-1), et le recrédit y est supprimé
(3g-2).
3. **Le découpage** : **3g-1 seul**, puis 3g-2 sur ce que le banc a révélé.

View File

@ -1,5 +1,9 @@
# RECAP — reprise de session, 3g-1
> **PÉRIMÉ deux fois.** Pour l'état du banc : `docs/RELEVE_3g1.md`. Pour ce qui a été fait des
> défauts qu'il a révélés : `docs/RECAP_3g2.md`. Le paragraphe ci-dessous n'est conservé que
> pour la trace.
>
> **PÉRIMÉ le 2026-08-26 au soir.** Le paquet a été déployé et le banc a parlé :
> lire **`docs/RELEVE_3g1.md`**, qui remplace ce document. En un mot : la télémétrie ne
> publiait pas les bornes (corrigé, `+etm22`), et surtout `adjustEvChargers()` commande

207
docs/RECAP_3g2.md Normal file
View File

@ -0,0 +1,207 @@
# RECAP — 3g-2 : une charge, un commandeur
**Écrit le 2026-08-27. Branche** `feature/beta-rulebased`.
> **Ce document n'est PAS un relevé machine.** Tout ce qui suit a été éprouvé en **simulation**
> et par **lecture de code** — rien n'a été construit pour arm64, rien n'a été déployé, le banc
> `.75` tourne encore `1.15.2+etm22`. La distinction machine / test / lecture est celle de
> `docs/RELEVE_LOTD.md`, et la mélanger est ce qui a envoyé un agent dans le vide le 2026-08-25.
---
## 1. La Terra AC, vérifiée avant de commencer
**Elle ne répond pas, et l'obstacle est PHYSIQUE, pas logiciel.**
| Vérification | Résultat |
|---|---|
| `ping 192.168.1.132` depuis le poste de dev | `Destination Host Unreachable` |
| `ping` **depuis le banc `.75`**, même segment L2 | `Destination Host Unreachable` |
| Table ARP du banc pour `.132` | **`INCOMPLETE`** — aucune réponse ARP |
| TCP 502 depuis le banc | `No route to host` |
| Balayage complet de `192.168.1.0/24` depuis le banc | **rien en `.132`** ; aucune autre adresse ne porte un Modbus TCP inconnu (502 ouvert seulement en `.103`, `.210`, `.211`) |
| `Integrations.GetThings` sur `.75` | `Terra AC Charger (TCP)` en **`ThingSetupStatusInProgress`** — elle retente sans fin |
Une machine qui ne répond pas à l'ARP est **hors du réseau** : éteinte, débranchée, ou sur un
autre segment. Rien de ce que fait ce lot ne peut la joindre. **LM-1204-b garde donc sa moitié
matérielle non franchie**, comme au relevé du 2026-08-26 — ce qui est levé ici, c'est sa moitié
ARBITRAGE (§3).
---
## 2. Ce que le lot corrige, dans l'ordre où le banc l'avait signalé
### 2.1 La double commande (`RELEVE_3g1.md` §3.3) — le plus grave
`adjustEvChargers()` re-décidait le surplus **après** le waterfall et passait outre le refus de
l'arbitre. La conséquence, énoncée sans détour dans le relevé : *tant que ceci n'est pas traité,
la transplantation ne commande rien.*
**Corrigé par les deux bouts :**
- les actions EV du waterfall sont **dispatchées** vers `EvAdapter::applyAction()` — elles ne
l'étaient pas du tout ; `m_loadAdapters` ne contient pas les bornes, et le dispatch s'arrêtait
là ;
- les bornes que le waterfall a commandées **sortent** du chemin proxy, par deux crochets
virtuels posés dans l'amont (`evSurplusPlannedByWaterfall` pour `planSurplusCharging`,
`evCommandedByWaterfall` pour `adjustEvChargers`). Deux et non un : ils sont interrogés à deux
moments du cycle où la réponse n'est pas la même — avant le plan on sait seulement à qui la
borne *appartient*, après on sait ce qui a été *commandé*.
Le jeu des bornes commandées est **nommé par le scheduler** (`Plan::waterfallEvIds`), jamais
redéduit : un critère redérivé plus tard (« motif différent d'`EV_DEADLINE`… ») dériverait du
vrai au premier motif ajouté — et `EV_GRID_START` porte justement `funding: grid` tout en venant
du waterfall.
Effet de bord traité : `chargingState` était posé par `adjustEvChargers()`. Les bornes qui n'y
passent plus le reçoivent de l'arbitre (`setEvChargingState`), sans quoi l'app afficherait « en
charge » une borne que l'arbitre vient d'éteindre.
### 2.2 `evFloorW()` sous le seuil de démarrage réel (`RELEVE_3g1.md` §3.4)
`evFloorW()` valait `min × acquisitionTolerance × 230 × phases` : **2 070 W annoncés pour une
borne qui en exige 4 140**. Le waterfall allouait 2 828 W, l'adaptateur convertissait en 4,1 A,
refusait d'enclencher sous 6 A, et 2 828 W étaient retirés du budget pour personne.
Le plancher est désormais **l'exigence réelle** : `min × 230 × phases`. La tolérance n'y est
plus — ce sont deux grandeurs différentes, et les confondre publiait un plancher qui n'existe
nulle part.
**La tolérance n'a pas disparu pour autant.** Elle est devenue ce qu'elle était déjà chez le
proxy — un seuil de **déclenchement** — mais visible : motif `EV_GRID_START {budgetW, floorW,
gridW}`, `funding: grid`, comptabilité partagée entre `budget.allocatedW` et
`budget.evReservedW`. Brèche assumée et **bornée**, écrite dans `docs/DESIGN_3g.md` §3.1-bis.
### 2.3 Le rang de borne (`RELEVE_3g1.md` §3.2)
`priority = 100` en dur, pour toutes : même clé de tri, `std::sort` non stable, et **laquelle**
démarre n'était ni réglable ni reproductible.
- Le rang vit dans `LoadConfig.priority`, comme celui de toute autre charge (LM-1206/LM-1207 :
pas de second système de priorité, sinon « VE1 > ECS > VE2 » devient inexprimable).
- Toute borne détectée reçoit **d'office** une entrée `evcharger` — rang, libellé, `domain: ev`,
et aucune charge utile. Création **journalisée**, une ligne par borne, à la création seulement.
- Le tri est **total** : à rang égal, l'identifiant départage.
- L'entrée **survit à son Thing** : borne remplacée ou déposée, elle reste publiée
`available: false` / `faultCode: THING_MISSING` / motif `LOAD_UNAVAILABLE`. La supprimer
d'office rendrait « borne remplacée » indistinguable de « borne jamais configurée ».
**Et cela rétablit `loads[] ⊆ GetLoadConfig`, que 3g-1 avait rompu sans le dire** : les bornes
étaient arbitrées et publiées sans figurer dans aucune configuration — la charge fantôme du lot
B-bis, réapparue par l'autre bout.
---
## 3. Ce qui est ÉPROUVÉ, et par quoi
Suite complète, **un processus par test** (`AGENTS.md`) : simulation 27/27 (dont les 16 scénarios
`run`), charging 16/16, loadmodel 16/16, spotmarket 7/7. Build amd64 **0 erreur / 0 warning**.
| Ce qui est prouvé | Par |
|---|---|
| Le rang décide qui charge, **et l'inverser change la borne servie** — moitié arbitrage de LM-1204-b | `testEvChargerRankDecidesWhoCharges` |
| Le discriminant est le MOTIF : `BELOW_MIN_POWER {budgetW: 620, minPowerW: 1380}` sur la borne refusée | idem |
| La borne refusée est **éteinte**, et le proxy ne repasse pas derrière | idem |
| Aller-retour `GetLoadConfig` → `SetLoadConfig` **verbatim** : rien ne change, rien n'est reconstruit | idem |
| `loads[] ⊆ GetLoadConfig` par la frontière RPC | idem |
| `EV_GRID_START` publie `gridW` et la comptabilité se partage (`budgetW + gridW == allocatedW`) | `testEvGridStartIsAnnouncedAndBounded` |
| **Au plus une borne soutire par cycle** | idem |
| L'entrée survit au Thing, publiée `THING_MISSING` | `testEvChargerConfigOutlivesItsThing` |
| Une entrée `evcharger` refuse toute charge utile, et revendique son Thing | `testEvChargerConfigIsRankOnly`, `…RefusesPayloads`, `…ClaimsItsThing` |
| Le TEXTE des motifs neufs, catalogue **refermé** | `testDecisionReasonRendering` |
**Le catalogue de motifs avait dérivé** : `BATTERY_RESERVE` et `BELOW_MIN_POWER` étaient entrés
en 3g-1 sans ligne de rendu attendue ni entrée dans la liste de clôture. Leur texte — celui sur
lequel le banc s'appuie — n'était donc éprouvé par rien. Réparé.
---
## 4. Ce que le lot a fait apparaître, et qui n'était pas dans la commande
Ces quatre points ne se sont vus que parce que les décisions du waterfall atteignent enfin le
matériel. Trois auraient produit des défauts silencieux en service.
1. **La réserve de stockage ne mordait que sur les augmentations.** Le recrédit anti-clignotement
rendait à chaque charge sa propre consommation, **y compris sous la réserve** : une charge en
marche le restait, pendant que le motif publiait « aucune charge n'est servie ». Sous la
réserve, le budget est une DÉCISION de retenir, pas une mesure — le recrédit y est supprimé
(LM-1203-b : « annulé, pas réduit »), et `withheldW` chiffre les deux termes retenus.
*Attrapé par `batteryLevelConsiderationStopCharging`, qui échouait.*
2. **Le prédicat de bascule de phases avait divergé.** `canSetPhaseCount()` dit seulement que la
borne *porte* l'état `desiredPhaseCount` ; la vraie question — borne **et** voiture — est
tranchée par le manager. Une borne monophasée se voyait viser 3 phases, son plancher publié
triplait, et sous surcharge elle commandait 3 × 6 A là où le proxy commandait 1 × 6 A.
3. **Le courant de référence du choix de phases** était le courant *actuel* et non le courant
*possible* : une borne en triphasé y restait quel que soit l'effondrement du surplus.
4. **L'écrêtage par la limite de phase avait été perdu** en 3g-1 : il vivait dans
`planSurplusCharging()`, que la borne ne traverse plus. Sans conséquence tant que rien n'était
exécuté — 3g-2 exécute. Le plafond est de nouveau **déclaré** (`LoadContext.declared.maxPowerW`),
donc respecté **avant** d'allouer ; il ne descend jamais sous le plancher de la borne, parce
que couper est le geste de la couche L4 et non celui de la stratégie.
---
## 5. Ce qui n'est PAS prouvé, et qu'il ne faut pas déclarer vérifié
- **Rien n'a tourné sur machine.** Pas de paquet arm64, pas de déploiement.
- **LM-1204-b, moitié matérielle** : deux bornes réelles. La Terra AC est absente du réseau (§1).
Une wallbox simulée ne prouve ni le délai d'établissement, ni la négociation de courant, ni ce
qu'une borne fait d'une consigne qu'elle refuse.
- **`BATTERY_RESERVE` avec un surplus réel** : la bascule à 0,9 du 2026-08-26 a donné un surplus
de **−3 551 W** — rien à confisquer, `withheldW: 0`, garde active et motif tu. C'est le
comportement correct. Il faut du soleil et un SoC sous le seuil : **à guetter, pas à fabriquer.**
- **Le démarrage sous tolérance sur du matériel réel** : la simulation prouve la décision et sa
comptabilité, pas qu'une borne accepte de démarrer avec 480 W soutirés.
- **arm64** : non compilé (infra CI `etm-powersync-deploy`).
---
## 6. Écarts de simulation assumés, et pourquoi
**14 des 16 scénarios `run` sont identiques au bit près** à la référence. Deux points de contrôle
ont changé, dans le même scénario et par la même cause :
| Scénario | Point | Avant | Après |
|---|---|---|---|
| `simulation-1-phase-32A` | pas 444 | 7 A | **6 A** — borne à l'ARRÊT : c'est la dernière consigne émise avant la coupure |
| `simulation-1-phase-32A` | pas 900 | 88 % / 30 A | **87 %** — un ampère par-ci par-là sur 900 pas de rétroaction |
| `simulation-2-phase-16A` | pas 900 | 16 A, en charge | **89 %, pas en charge** — la projection dit qu'il reste du temps |
**La cause est unique** : la conversion watts → ampères se fait désormais **après** l'allocation
du waterfall, au lieu d'être calculée nativement en ampères par le proxy. L'arrondi tombe parfois
de l'autre côté.
**Ce qui compte est inchangé dans les deux scénarios : la voiture est à 100 % à l'échéance**
(pas 960). Le mécanisme d'échéance fonctionne — dans `2-phase-16A` il se déclenche simplement
plus tard, parce que la trajectoire de la journée laisse la voiture un peu plus chargée.
> **Un écart a été refusé** et le code a été réaligné sur l'amont plutôt que le test :
> `testOverloadProtectionEcoMode`. Tronquer la conversion (plutôt qu'arrondir) coûtait un ampère
> à chaque cycle — 230 W par phase, abandonnés toute la journée — et faisait basculer la
> protection de surcharge de « réduire au minimum » à « couper ». L'arrondi au plus proche est
> celui du proxy ; l'enveloppe est une **allocation** issue d'une mesure dont le bruit dépasse
> 115 W, pas une limite. La limite, elle, est le plafond déclaré, et il est respecté avant.
---
## 7. Pour reprendre
```bash
# 1. Construire le paquet arm64 (conteneur LXD build-cross-arm64, cf. AGENTS.md)
# 2. Déployer — JAMAIS un dpkg -i à la main :
cd ~/projects/etm-powersync/etm-powersync-deploy
./deploy.sh --dry-run powersync-energy-plugin-nymea_1.15.2+etmNN_arm64.deb # puis sans --dry-run
```
**À vérifier en priorité au banc**, dans cet ordre :
1. **Les entrées `evcharger` apparaissent dans `GetLoadConfig`** au premier cycle, une ligne de
journal par borne. Vérifier qu'aucune configuration existante n'a été bousculée.
2. **Une seule commande par borne et par cycle.** Le journal ne doit plus montrer une ligne
`[Arbitre]` suivie d'un `Executing action … Issuer: Surplus` qui dit autre chose.
3. **`GetLoadTelemetry` publie les bornes avec leur rang**, et la somme des `allocatedW` financés
au surplus retombe sur `budget.allocatedW`.
4. **Le plancher publié** d'une borne branchée : `min × 230 × phases`, sans facteur de tolérance.
Sur la wallbox simulée du banc, attention à son `desiredPhaseCount: 3` permanent.
5. **`EV_GRID_START`** quand le surplus est entre la moitié et la totalité du plancher : le motif
doit chiffrer `gridW`, et une seule borne doit soutirer.
6. **LM-1204-b** dès qu'une seconde borne réelle répond.

View File

@ -21,7 +21,13 @@ LoadDescriptor EvAdapter::descriptor() const
d.id = m_charger->thing()->id().toString();
d.label = m_charger->name();
d.adapter = QStringLiteral("evcharger");
d.priority = 100;
// 3g-2 — le rang vient de la CONFIGURATION, comme celui de toute autre charge (LM-1207).
// Il valait 100 en dur, reliquat de l'ancienne convention « poids, valeur haute = premier » :
// dans un tri ASCENDANT cela plaçait toutes les bornes en dernier, à égalité, et laquelle
// démarrait n'était ni réglable ni reproductible (relevé du 2026-08-26, §3.2).
// Relu à chaque cycle : réordonner depuis l'app prend effet sans reconstruire l'adaptateur,
// donc sans réarmer le moindre verrou.
d.priority = m_parent->evPriority(d.id);
d.declared.minA = m_charger->maxChargingCurrentMinValue();
d.declared.maxA = m_charger->maxChargingCurrentMaxValue();
@ -187,9 +193,15 @@ LoadAction EvAdapter::applyAction(const LoadAction &action, const QDateTime &now
const bool enclencher = action.chargingEnabled && demandeA > 0
&& demandeA >= static_cast<double>(minA);
const auto issuer = (action.funding == LoadAction::Surplus)
? ChargingAction::ChargingActionIssuerSurplusCharging
: ChargingAction::ChargingActionIssuerTimeRequirement;
// 3g-2 — l'émetteur suit le MOTIF, pas le financement. Depuis qu'un démarrage sous
// tolérance (EV_GRID_START) porte funding=Grid tout en venant du waterfall, déduire
// l'émetteur du financement étiquetait « échéance » une décision de surplus — et c'est
// cette étiquette que le journal amont affiche à chaque commande.
ChargingAction::ChargingActionIssuer issuer = ChargingAction::ChargingActionIssuerSurplusCharging;
if (action.reason.code == QLatin1String(DecisionCode::EvDeadline))
issuer = ChargingAction::ChargingActionIssuerTimeRequirement;
else if (action.reason.code == QLatin1String(DecisionCode::EvSpotMarket))
issuer = ChargingAction::ChargingActionIssuerSpotMarketCharging;
ChargingAction ca(enclencher, clampedA, phases, issuer, false);
m_parent->doExecuteChargingAction(m_charger, ca, now); // now = temps de cycle (injectable)

View File

@ -242,6 +242,14 @@ void EnergyArbitrator::rebuildLoadAdapters()
if (!c.enabled())
continue;
// 3g-2 — une borne ne se CONSTRUIT pas depuis la configuration : son adaptateur est bâti
// depuis le Thing par syncAdapters(), et son entrée ne porte qu'un rang. La construire
// ici en ferait un second adaptateur pour le même organe. C'est aussi ce qui rend
// l'aller-retour lire-réécrire inoffensif : réécrire une entrée evcharger ne reconstruit
// rien, donc ne réarme aucun verrou (ECS-412).
if (c.isEvCharger())
continue;
LoadNeeds needs;
needs.dailyDeadline = c.needs().dailyDeadline();
needs.minEnergyWhPerDay = c.needs().minEnergyWhPerDay();
@ -359,6 +367,12 @@ void EnergyArbitrator::update(const QDateTime &currentDateTime)
// consignes de repli (posées à la transition) tiennent jusqu'au retour du compteur.
if (m_degradedMode) {
qCDebug(dcNymeaEnergy()) << "[Arbitre] Mode dégradé L2 actif — planification suspendue.";
// 3g-2 — le waterfall ne commande plus rien : il ne peut donc plus revendiquer aucune
// borne. Garder le jeu du dernier cycle sain les exclurait du proxy alors que plus
// personne ne les commande — elles resteraient figées sur leur dernière consigne,
// compteur muet, ce que SAFETY.md §L2 interdit. Le repli est posé à la transition par
// applyDegradedMode() ; c'est lui qui fait autorité tant que le compteur est muet.
m_waterfallEvIds.clear();
return;
}
@ -384,9 +398,14 @@ void EnergyArbitrator::update(const QDateTime &currentDateTime)
<< "| stratégie:" << plan.strategy;
}
// 3g-2 — le jeu des bornes que le waterfall a commandées, NOMMÉ par le scheduler. Il doit
// être posé AVANT le dispatch : c'est lui que le crochet evCommandedByWaterfall() rend à
// adjustEvChargers() juste après, et c'est ce qui empêche le proxy de re-commander.
m_waterfallEvIds = plan.waterfallEvIds;
// 7 : dispatch matériel (même position que l'amont — m_chargingActions rempli par getPlan())
applyActionsToAdapters(slot, currentDateTime); // charges arbitrées (Setpoint W et State)
adjustEvChargers(currentDateTime); // EV (kind==Setpoint) → proxy amont jusqu'à 3g
applyActionsToAdapters(slot, currentDateTime); // charges arbitrées (Setpoint W, State) ET bornes du waterfall
adjustEvChargers(currentDateTime); // bornes RESTÉES au proxy : échéance, spot market
// 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
@ -429,10 +448,47 @@ double EnergyArbitrator::evFloorW(EvCharger *ev, uint phaseCount) const
minA = carMinA;
}
// La tolérance d'acquisition est le même facteur que celui qu'appliquait le proxy avant
// de décider d'enclencher : sans elle, le plancher publié serait plus bas que celui qui
// gouverne réellement le démarrage.
return minA * acquisitionTolerance() * 230.0 * phaseCount;
// 3g-2 — la tolérance d'acquisition N'EST PLUS ICI, et c'est la correction du lot.
//
// Ce sont deux grandeurs différentes, confondues en écrivant evFloorW() : le PLANCHER est
// ce que la borne exige pour démarrer (6 A × 230 V × phases, une donnée de matériel), la
// TOLÉRANCE est la part de ce plancher qu'on accepte de ne pas couvrir par le surplus (un
// réglage). Multiplier l'un par l'autre produisait un plancher qui n'existe nulle part :
// au banc, 2 070 W annoncés pour une borne qui en exige 4 140 — le waterfall lui allouait
// 2 828 W, l'adaptateur convertissait en 4,1 A, refusait d'enclencher sous les 6 A, et
// 2 828 W étaient retirés du budget pour personne (relevé du 2026-08-26, §3.4).
//
// La tolérance vit maintenant là où elle décide vraiment : au DÉMARRAGE, dans le waterfall,
// sous le motif EV_GRID_START qui chiffre ce qui est soutiré.
return minA * 230.0 * phaseCount;
}
double EnergyArbitrator::evReferenceCurrentA(EvCharger *ev) const
{
if (!ev)
return 0;
double a = ev->currentPower() / 230.0;
if (RootMeter *meter = internalRootMeter())
a += -meter->currentPower() / 230.0; // < 0 au compteur = export = surplus
return a;
}
uint EnergyArbitrator::evTargetPhases(EvCharger *ev, double referenceA)
{
if (!ev)
return 1;
// Les bornes de ce qui est POSSIBLE viennent du manager, qui les a établies ce cycle avec
// la voiture branchée (canSwitchPhaseCount, min/maxPossiblePhaseCount). Les redériver ici
// en ferait une seconde version — et c'est exactement ce qui a divergé en 3g-1 : viser
// 3 phases sur une combinaison qui n'en permet qu'une triplait le plancher publié, et sous
// surcharge commandait 3 × 6 A là où le proxy commandait 1 × 6 A.
const ChargingProcessInfo pi = internalProcessInfo(ev);
if (!pi.canSwitchPhaseCount)
return qMax(1u, pi.maxPossiblePhaseCount);
const uint phases = bestPhaseCountFor(ev, referenceA);
return qMax(1u, qBound(pi.minPossiblePhaseCount, phases, pi.maxPossiblePhaseCount));
}
double EnergyArbitrator::evPhaseAllowanceW(EvCharger *ev, uint phaseCount) const
@ -466,35 +522,16 @@ double EnergyArbitrator::evPhaseAllowanceW(EvCharger *ev, uint phaseCount) const
return qMax(0.0, allowanceA) * 230.0 * phaseCount;
}
double EnergyArbitrator::evReferenceCurrentA(EvCharger *ev) const
bool EnergyArbitrator::evCommandedByWaterfall(EvCharger *evCharger) const
{
if (!ev)
return 0;
double a = ev->currentPower() / 230.0;
if (RootMeter *meter = internalRootMeter())
a += -meter->currentPower() / 230.0; // < 0 au compteur = export = surplus
return a;
return evCharger && m_waterfallEvIds.contains(evCharger->thing()->id().toString());
}
uint EnergyArbitrator::evTargetPhases(EvCharger *ev, double referenceA)
bool EnergyArbitrator::evSurplusPlannedByWaterfall(EvCharger *evCharger) const
{
if (!ev)
return 1;
// Les bornes de ce qui est POSSIBLE viennent du manager, qui les a établies ce cycle avec
// la voiture branchée (canSwitchPhaseCount, min/maxPossiblePhaseCount). Les redériver ici
// en ferait une seconde version — et c'est exactement ce qui a divergé en 3g-1 : viser
// 3 phases sur une combinaison qui n'en permet qu'une triplait le plancher publié, et sous
// surcharge commandait 3 × 6 A là où le proxy commandait 1 × 6 A.
const ChargingProcessInfo pi = internalProcessInfo(ev);
if (!pi.canSwitchPhaseCount)
return qMax(1u, pi.maxPossiblePhaseCount);
const uint phases = bestPhaseCountFor(ev, referenceA);
return qMax(1u, qBound(pi.minPossiblePhaseCount, phases, pi.maxPossiblePhaseCount));
return evParticipatesInWaterfall(evCharger);
}
SurplusContext EnergyArbitrator::buildContext(const QDateTime &now) const
{
SurplusContext ctx;
@ -577,6 +614,34 @@ SurplusContext EnergyArbitrator::buildContext(const QDateTime &now) const
ctx.loads.append(it.value()->toLoadContext(now));
}
// --- loads[] : bornes CONFIGURÉES dont l'appareil manque (3g-2) ----------------------
//
// L'entrée de configuration d'une borne n'est jamais supprimée d'office : c'est une
// configuration, et l'installateur a pu la classer. Quand le Thing disparaît — borne
// remplacée, ThingId changé, appareil déposé — il reste donc une charge sans appareil, et
// elle doit se LIRE comme telle : indisponible, avec son motif, à son rang. La faire
// disparaître du contexte la rendrait indistinguable d'une charge jamais configurée, et
// c'est le silence ambigu que la famille ECS-411 interdit.
//
// Aucune allocation ne lui est faite (branche `!available` du waterfall) et elle ne retient
// rien du budget : 0 W gelés, le budget passe entier à la suivante.
if (m_loadConfigStore) {
for (const LoadConfig &c : m_loadConfigStore->configs()) {
if (!c.isEvCharger() || !c.enabled() || m_adapters.contains(c.id()))
continue;
LoadContext lc;
lc.id = c.id();
lc.adapter = QStringLiteral("evcharger");
lc.label = c.label();
lc.priority = c.priority();
lc.telemetry.available = false;
lc.telemetry.currentPowerW = 0;
lc.telemetry.lockMinPowerW = 0; // rien d'engagé : rien à geler au budget
lc.telemetry.lockMaxPowerW = -1;
ctx.loads.append(lc);
}
}
// --- loads[] : TOUTES les charges arbitrées, polymorphes (relay-router, etmvariableload,
// sg-ready) — une seule table, toutes construites depuis la configuration persistée. ---
for (auto it = m_loadAdapters.constBegin(); it != m_loadAdapters.constEnd(); ++it)
@ -585,13 +650,89 @@ SurplusContext EnergyArbitrator::buildContext(const QDateTime &now) const
return ctx;
}
int EnergyArbitrator::evPriority(const QString &chargerId) const
{
if (m_loadConfigStore) {
for (const LoadConfig &c : m_loadConfigStore->configs())
if (c.isEvCharger() && c.id() == chargerId)
return c.priority();
}
// Pas (encore) de configuration : rang de QUEUE. Une borne dont le rang n'est pas posé ne
// doit pas passer devant des charges que l'installateur a classées — l'inverse ferait de
// l'absence de configuration une décision d'arbitrage.
return 1000;
}
bool EnergyArbitrator::estUneBorneConfiguree(const QString &loadId) const
{
if (!m_loadConfigStore)
return false;
for (const LoadConfig &c : m_loadConfigStore->configs())
if (c.isEvCharger() && c.enabled() && c.id() == loadId)
return true;
return false;
}
void EnergyArbitrator::provisionEvChargerConfig(EvCharger *ev)
{
if (!m_loadConfigStore || !ev)
return;
const QString id = ev->thing()->id().toString();
LoadConfigs configs = m_loadConfigStore->configs();
int rangMin = 0;
bool premier = true;
for (const LoadConfig &c : configs) {
if (c.id() == id)
return; // déjà configurée : on ne touche à rien
if (premier || c.priority() < rangMin) {
rangMin = c.priority();
premier = false;
}
}
LoadConfig c;
c.setId(id);
c.setLabel(ev->name());
c.setAdapter(QStringLiteral("evcharger"));
c.setMode(QStringLiteral("dynamic"));
// Le domaine d'une borne n'est pas une devinette : c'est le mécanisme lui-même. La réserve
// « le plugin ne range pas une charge d'office » vise les charges pilotées, dont le domaine
// ne se lit nulle part.
c.setDomain(QStringLiteral("ev"));
// En tête, sans réécrire le rang de personne. 1 est le premier rang (protocole §5).
c.setPriority(premier ? 1 : qMax(1, rangMin - 1));
c.setEnabled(true);
configs.append(c);
QString erreur;
if (!m_loadConfigStore->setConfigs(configs, &erreur)) {
// 7-c — l'échec ne doit pas être plus discret que le succès : sans entrée, la borne
// reste arbitrée mais absente de GetLoadConfig, et c'est précisément le trou qu'on
// vient refermer.
qCWarning(dcNymeaEnergy()) << "[EnergyArbitrator] Configuration automatique de la borne"
<< ev->name() << "REFUSÉE :" << erreur
<< "— elle sera arbitrée sans rang configurable.";
return;
}
qCInfo(dcNymeaEnergy()) << "[EnergyArbitrator] Borne" << ev->name()
<< "ajoutée à la configuration des charges (rang" << c.priority()
<< ", domaine ev) — entrée créée automatiquement, modifiable par "
"SetLoadConfig.";
}
void EnergyArbitrator::syncAdapters()
{
// Crée les adapters manquants
for (auto it = internalEvChargers().constBegin(); it != internalEvChargers().constEnd(); ++it) {
const QString id = it.key().toString();
if (!m_adapters.contains(id))
if (!m_adapters.contains(id)) {
m_adapters[id] = new EvAdapter(it.value(), this);
// 3g-2 — la borne entre AUSSI dans la configuration, pour y porter son rang.
provisionEvChargerConfig(it.value());
}
}
// Supprime les adapters obsolètes
for (const QString &id : m_adapters.keys()) {
@ -609,13 +750,45 @@ void EnergyArbitrator::applyActionsToAdapters(const Slot &slot, const QDateTime
// charges arbitrées, toutes construites depuis LoadConfigStore — c'est ce qui leur vaut
// ECS-412 (reconstruction incrémentale) et ECS-413 (état sûr) sans code dédié, et ce
// qui rend l'ensemble publié par GetLoadTelemetry égal à celui de GetLoadConfig.
// Un Setpoint EV n'est PAS dans cette table → ignoré ici (dispatché par
// adjustEvChargers() amont jusqu'à 3g).
// 3g-2 — LES BORNES SONT DISPATCHÉES ICI. Elles vivent dans une seconde table
// (m_adapters, construite depuis les Things et non depuis la config), mais le geste
// est le même : l'adaptateur convertit l'enveloppe en consigne matérielle et écrête.
// Tant que ce dispatch n'existait pas, la décision de l'arbitre n'atteignait RIEN et
// c'est adjustEvChargers() qui commandait — deux commandeurs, un seul entendu.
//
// Seules les bornes que le waterfall a commandées passent ici : celles restées au
// proxy (échéance, spot market) gardent leur chemin, allowance de compteur comprise.
if (action.kind == LoadAction::State || action.kind == LoadAction::Setpoint) {
if (ILoadAdapter *adapter = m_loadAdapters.value(action.loadId, nullptr))
if (ILoadAdapter *adapter = m_loadAdapters.value(action.loadId, nullptr)) {
adapter->applyAction(action, now);
else if (action.kind == LoadAction::State)
} else if (m_waterfallEvIds.contains(action.loadId)) {
if (EvAdapter *ev = m_adapters.value(action.loadId, nullptr)) {
ev->applyAction(action, now);
// L'ÉTAT de charge publié dans ChargingInfo était posé par
// adjustEvChargers(), que ces bornes ne traversent plus. Sans cette
// reprise, chargingState resterait figé sur sa dernière valeur : l'app
// afficherait « en charge » une borne que l'arbitre vient d'éteindre.
setEvChargingState(ThingId(action.loadId),
action.chargingEnabled
? ChargingInfo::ChargingStateSurplusCharging
: ChargingInfo::ChargingStateIdle);
} else if (action.reason.code != QLatin1String(DecisionCode::LoadUnavailable)) {
// Le waterfall a nommé une borne dont l'adaptateur a disparu ENTRE la
// décision et le dispatch (Thing retiré en cours de cycle). Rien n'est
// commandé, et le silence serait ambigu : cette borne est aussi sortie
// du proxy par le crochet.
//
// Le cas d'une borne configurée dont le Thing manque depuis le début est
// EXCLU de cet avertissement : l'arbitre l'a vu et l'a dit — c'est le
// motif LOAD_UNAVAILABLE, publié à chaque cycle. Répéter l'information en
// qCWarning noierait le vrai signal, qui est la disparition INATTENDUE.
qCWarning(dcNymeaEnergy()) << "[Arbitre] borne" << action.loadId
<< "commandée par le waterfall mais son adaptateur "
"a disparu — aucune consigne émise ce cycle.";
}
} else if (action.kind == LoadAction::State) {
qCWarning(dcNymeaEnergy()) << "[Arbitre] action State sans adaptateur:" << action.loadId;
}
}
}
}
@ -757,8 +930,28 @@ void EnergyArbitrator::buildTelemetry(const Plan &plan, const Slot &slot, const
ILoadAdapter *adapter = m_loadAdapters.value(action.loadId, nullptr);
if (!adapter)
adapter = m_adapters.value(action.loadId, nullptr);
if (!adapter)
// 3g-2 — une borne CONFIGURÉE dont l'appareil manque n'a pas d'adaptateur, et doit
// pourtant être publiée : sans elle, une charge que GetLoadConfig annonce serait
// absente de GetLoadTelemetry, et l'app afficherait un vide sans cause. C'est le
// même invariant, pris par l'autre bout.
if (!adapter) {
if (!estUneBorneConfiguree(action.loadId))
continue;
couvertes.insert(action.loadId);
QVariantMap orphan;
orphan.insert(QStringLiteral("loadId"), action.loadId);
orphan.insert(QStringLiteral("allocatedW"), 0);
orphan.insert(QStringLiteral("funding"), QStringLiteral("surplus"));
orphan.insert(QStringLiteral("available"), false);
orphan.insert(QStringLiteral("decision"), action.reason.toMap());
orphan.insert(QStringLiteral("faultCode"), QStringLiteral("THING_MISSING"));
QVariantMap mech;
mech.insert(QStringLiteral("kind"), QStringLiteral("evcharger"));
orphan.insert(QStringLiteral("mechanism"), mech);
attachMeasurements(orphan, action.loadId);
loads.append(orphan);
continue;
}
couvertes.insert(action.loadId);
QVariantMap entry;

View File

@ -295,6 +295,31 @@ public:
*/
double evPhaseAllowanceW(EvCharger *ev, uint phaseCount) const;
/*!
* \brief [3g-2] Rang configuré d'une borne dans le waterfall.
*
* \param chargerId ThingId de la borne, sous sa forme chaîne.
* \return \c priority de son entrée \c LoadConfig \c evcharger. En l'absence d'entrée —
* fenêtre entre la détection du Thing et l'auto-provisionnement, ou store absent — un
* rang de queue plutôt qu'un rang de tête : une borne dont la configuration n'est pas
* encore posée ne doit pas passer devant des charges déjà classées.
*
* \note Lu à CHAQUE \c descriptor() : c'est ce qui rend le réordonnancement effectif à
* chaud sans reconstruire l'adaptateur, donc sans toucher aux verrous de la borne.
*/
int evPriority(const QString &chargerId) const;
/*! \brief Crochet amont — cette borne a-t-elle été commandée par le waterfall ce cycle ?
* \param evCharger Borne interrogée par \c adjustEvChargers().
* \return Vrai si elle figure dans \c Plan::waterfallEvIds du cycle courant. */
bool evCommandedByWaterfall(EvCharger *evCharger) const override;
/*! \brief Crochet amont — le surplus de cette borne appartient-il au waterfall ?
* \param evCharger Borne interrogée par \c planSurplusCharging().
* \return Vrai si elle passe le filtre d'entrée du waterfall (voiture assignée, mode non
* manuel). Interrogé AVANT que le waterfall n'ait décidé, d'où sa réponse plus large que
* celle de \c evCommandedByWaterfall(). */
bool evSurplusPlannedByWaterfall(EvCharger *evCharger) const override;
signals:
/*!
* \brief Émise quand l'état runtime d'arbitrage a CHANGÉ, ou au battement de cœur.
@ -370,6 +395,42 @@ private:
*/
void rebuildLoadAdapters();
/*!
* \brief [3g-2] Crée l'entrée de configuration manquante d'une borne détectée.
*
* Une borne qui apparaît dans nymea n'a aucune entrée \c LoadConfig, donc aucun rang —
* et jusqu'à 3g-2 elle était arbitrée et publiée en télémétrie sans figurer dans
* \c GetLoadConfig. C'est l'invariant « \c loads[] ⊆ \c GetLoadConfig » du lot B-bis,
* rompu par 3g-1 : l'app s'y appuie pour afficher une charge, et une charge publiée sans
* configuration est exactement la charge fantôme que le lot B-bis avait supprimée.
*
* \par Ce que l'entrée contient, et ce qu'elle ne contient pas
* Le rang, le libellé, le domaine \c ev, \c enabled. Aucune charge utile : les limites de
* la borne viennent du Thing (voir \c LoadConfig::isEvCharger()).
*
* \par Rang par défaut
* En **tête**, sans bousculer les charges déjà classées : le plus petit rang existant
* moins un, borné à 1. Ce n'est pas une politique du plugin — l'ordre des domaines est un
* choix de mise en service, porté par l'app ; c'est seulement un point de départ qui ne
* réécrit le rang de personne.
*
* \note **Journalisée, une ligne par borne, à la création seulement.** Une entrée créée
* d'office est une écriture que personne n'a demandée : sans trace, un installateur
* verrait apparaître des charges dans son écran sans savoir d'où elles viennent.
* \note L'entrée n'est JAMAIS supprimée d'office quand le Thing disparaît — c'est une
* configuration, et l'installateur a pu la classer. Elle se lit alors comme une charge
* dont l'appareil manque (\c LOAD_UNAVAILABLE, \c faultCode \c THING_MISSING).
* \param ev Borne fraîchement détectée.
*/
void provisionEvChargerConfig(EvCharger *ev);
/*! \brief [3g-2] Vrai si \p loadId désigne une borne DÉCLARÉE en configuration et active.
* \param loadId Identifiant de charge.
* \return Vrai même si le Thing correspondant a disparu — c'est précisément le cas qu'il
* sert à reconnaître, pour publier la charge comme « appareil manquant » plutôt que de
* l'omettre. */
bool estUneBorneConfiguree(const QString &loadId) const;
/*!
* \brief Déclencheur RÉEL du watchdog L2 (SAFETY.md §L2) — slot de \c m_meterWatchdog
* (QTimer 30 s, horloge murale ; câblé sous \c \#ifndef ENERGY_SIMULATION).
@ -457,6 +518,19 @@ private:
RuleBasedScheduler *m_scheduler = nullptr;
QHash<QString, EvAdapter *> m_adapters; //!< loadId (ThingId string) → EvAdapter*.
/*!
* \brief [3g-2] Bornes commandées par le waterfall AU CYCLE COURANT.
*
* Recopié de \c Plan::waterfallEvIds à chaque cycle, et lu par le crochet
* \c evCommandedByWaterfall() que \c adjustEvChargers() amont interroge. C'est la
* mémoire qui empêche le proxy de re-commander ce que l'arbitre vient de commander.
*
* \note **Vidé à l'entrée du mode dégradé L2**, où la planification est suspendue : sans
* cela, le jeu du dernier cycle sain continuerait d'exclure ces bornes du proxy alors
* que plus personne ne les commande — elles resteraient figées sur leur dernière
* consigne, compteur muet, ce que SAFETY.md interdit.
*/
QSet<QString> m_waterfallEvIds;
//! loadId → charge ARBITRÉE, tous mécanismes confondus (relay-router | etmvariableload |
//! sg-ready). Table UNIQUE, et c'est un invariant de contrat : toutes ses entrées viennent
//! de \c LoadConfigStore, donc l'ensemble publié par \c NymeaEnergy.GetLoadTelemetry est

View File

@ -190,14 +190,30 @@ Plan RuleBasedScheduler::getPlan(const SurplusContext &ctx)
continue;
nonEvLoads.append(lc);
}
// 3g-2 — tri TOTAL et STABLE. Le rang seul ne suffit pas : deux charges de même rang
// laissaient l'ordre à std::sort, qui n'est pas stable, sur une liste dont l'origine est
// un QHash — donc sans ordre garanti d'un cycle à l'autre. Au banc, deux bornes à
// priority = 100 : la cascade était correcte, mais LAQUELLE démarrait n'était ni réglable
// ni reproductible (relevé du 2026-08-26, §3.2). L'identifiant départage : il est stable
// par construction, et l'égalité parfaite de deux charges n'existe pas.
std::sort(nonEvLoads.begin(), nonEvLoads.end(),
[](const LoadContext &a, const LoadContext &b) { return a.priority < b.priority; });
[](const LoadContext &a, const LoadContext &b) {
if (a.priority != b.priority)
return a.priority < b.priority;
return a.id < b.id;
});
QSet<QString> waterfallEvIds;
for (const LoadContext &lc : nonEvLoads) {
if (lc.adapter == QStringLiteral("sg-ready"))
slot.actions.append(buildSgReadyStateAction(lc, remainingSurplusW, compta));
else
slot.actions.append(buildSetpointAction(lc, remainingSurplusW, compta)); // etmvariableload + relay-router
slot.actions.append(buildSetpointAction(lc, remainingSurplusW, compta)); // etmvariableload + relay-router + evcharger
// 3g-2 — c'est ICI que le jeu se constitue, au moment où la décision est prise. Le
// redéduire plus tard depuis les motifs ou le financement dériverait du vrai au
// premier motif ajouté (EV_GRID_START porte funding=Grid tout en venant du waterfall).
if (lc.adapter == QStringLiteral("evcharger"))
waterfallEvIds.insert(lc.id);
}
// Grid funding (ECS/PAC) : dormant jusqu'à 3f (waterfall réseau) — non implémenté ici.
@ -207,8 +223,9 @@ Plan RuleBasedScheduler::getPlan(const SurplusContext &ctx)
plan.strategy = QStringLiteral("rule-based");
plan.timeSlots.append(slot);
compta.remainingW = remainingSurplusW;
plan.budget = compta;
compta.remainingW = remainingSurplusW;
plan.budget = compta;
plan.waterfallEvIds = waterfallEvIds;
return plan;
}
@ -236,6 +253,7 @@ LoadAction RuleBasedScheduler::buildSetpointAction(const LoadContext &lc,
// 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).
//
// 3g-2 — SAUF sous la réserve de stockage, et c'est la seule exception. Le recrédit
// suppose que le budget est une MESURE dont la conso de la charge a déjà été retranchée ;
// sous la réserve, le budget n'est pas une mesure mais une DÉCISION de retenir
@ -250,6 +268,10 @@ LoadAction RuleBasedScheduler::buildSetpointAction(const LoadContext &lc,
const QList<int> &levels = lc.declared.powerLevels;
const bool dynamic = levels.isEmpty();
//! 3g-2 — une borne se distingue ici par une seule chose : elle peut DÉMARRER en soutirant
//! (acquisitionTolerance). Tout le reste du waterfall la traite comme n'importe quelle
//! charge continue, et c'est le sens de la transplantation.
const bool estUneBorne = lc.adapter == QStringLiteral("evcharger");
double setpointW;
bool sousPlancher = false; //!< Budget > 0 mais sous le premier palier atteignable.
@ -257,6 +279,7 @@ LoadAction RuleBasedScheduler::buildSetpointAction(const LoadContext &lc,
if (dynamic) {
// Dynamic (routeur PV / triac) : modulation continue bornée au plafond déclaré.
const double maxW = lc.declared.maxPowerW > 0 ? lc.declared.maxPowerW : budgetW;
// 3g-2 — le PLAFOND est passé SOUS le plancher. Pour une charge de configuration
// c'est impossible (isValid() le refuse) ; pour une borne, le plafond du cycle est
// écrêté par la protection de surcharge, et il peut donc descendre sous ce que la
@ -291,6 +314,48 @@ LoadAction RuleBasedScheduler::buildSetpointAction(const LoadContext &lc,
setpointW = 0;
plancherW = lc.declared.minPowerW;
sousPlancher = true;
// 3g-2 — DÉMARRAGE SOUS TOLÉRANCE, et c'est une décision d'ACHETER.
//
// `acquisitionTolerance` est la part du plancher qu'on accepte de ne pas couvrir
// par le surplus pour démarrer quand même : à 0,5, une borne qui exige 4 140 W
// démarre dès 2 070 W de surplus et SOUTIRE les 2 070 restants. Le comportement
// existait déjà chez le proxy amont — il y était invisible, confondu avec le
// plancher dans evFloorW(). On ne l'introduit pas, on le rend lisible et chiffré.
//
// Brèche assumée dans DESIGN_3g §3.1, qui défère le financement réseau à 3f :
// c'est un cas particulier BORNÉ, et il l'est par construction —
// · il exige du surplus réel (budgetW > 0), donc jamais un démarrage à sec ;
// · il consomme TOUT le budget restant, donc au plus UNE borne par cycle : la
// suivante trouve un budget nul et repart sur son motif ordinaire ;
// · il reste sous le plafond de la protection de surcharge, contrôlé plus haut.
const double tolerance = m_arbitrator->acquisitionTolerance();
if (estUneBorne && tolerance > 0 && budgetW > 0 && budgetW >= plancherW * tolerance) {
const double soutireW = plancherW - budgetW;
LoadAction la;
la.loadId = lc.id;
la.kind = LoadAction::Setpoint;
// Le financement dit la VÉRITÉ sur l'origine : une partie de cette puissance
// vient du réseau. La comptabilité qui suit la partage en conséquence.
la.funding = LoadAction::Grid;
la.chargingEnabled = true;
la.phaseCount = static_cast<uint>(qMax(1, lc.declared.phases));
la.powerW = plancherW;
la.estimatedPowerW = plancherW;
la.reason = { DecisionCode::EvGridStart,
{{"budgetW", qRound(budgetW)}, {"floorW", qRound(plancherW)},
{"gridW", qRound(soutireW)}} };
// Le surplus est ENTIÈREMENT dépensé — pas au-delà : le complément vient du
// réseau et se compte là où vivent déjà les puissances commandées non encore
// mesurées. Laisser le budget filer en négatif ferait porter le soutirage par
// la cascade, où il se lirait comme un manque de surplus chez les suivantes.
budget.allocatedW += budgetW;
budget.evReservedW += soutireW;
remainingSurplusW = 0;
return la;
}
}
} else {
// Fixed : plus haut palier déclaré ≤ budget (levels triés croissants, 0 inclus →
@ -362,6 +427,11 @@ LoadAction RuleBasedScheduler::buildSetpointAction(const LoadContext &lc,
la.funding = LoadAction::Surplus;
la.powerW = setpointW;
la.estimatedPowerW = setpointW;
// 3g-2 — une borne lit `chargingEnabled` avant tout le reste (EvAdapter::applyAction) ;
// laissé à sa valeur par défaut, il faisait refuser TOUTE consigne issue du waterfall,
// consigne non nulle comprise. Sans objet pour les autres mécanismes, qui l'ignorent.
la.chargingEnabled = setpointW > 0;
la.phaseCount = static_cast<uint>(qMax(0, lc.declared.phases));
// ECS-309 — le motif nomme le MÉCANISME qui a produit l'issue, jamais un mécanisme
// plausible. La règle 7 d'AGENTS.md exige un motif non vide ; celle-ci exige qu'il soit

View File

@ -89,6 +89,13 @@ QString renderFr(const DecisionReason &reason, const QString &label)
.arg(w("budgetW")).arg(label).arg(w("minPowerW"));
}
// 3g-2 — la phrase dit COMBIEN est soutiré, et pour quoi. Un démarrage sous tolérance est
// une décision d'ACHETER : la lire comme une allocation ordinaire ferait chercher une
// erreur de cascade là où il y a un réglage assumé.
if (c == QLatin1String(DecisionCode::EvGridStart))
return QStringLiteral("Démarrage de %1 : plancher %2 W, dont %3 W SOUTIRÉS au réseau "
"(%4 W de surplus disponibles, tolérance d'acquisition)")
.arg(label).arg(w("floorW")).arg(w("gridW")).arg(w("budgetW"));
// 3g-2 — la limite de phase, pas le budget. Deux gestes opposés : revoir l'abonnement ou
// délester la maison, contre attendre le soleil.
if (c == QLatin1String(DecisionCode::PhaseLimit))

View File

@ -105,6 +105,32 @@ constexpr const char *BatteryReserve = "BATTERY_RESERVE";
* phrase vague par une autre.
*/
constexpr const char *BelowMinPower = "BELOW_MIN_POWER";
/*!
* \brief params: \c budgetW, \c floorW, \c gridW — la borne DÉMARRE en soutirant au réseau.
*
* 3g-2. Quatrième terme de la distinction que portent déjà \c SURPLUS_INSUFFICIENT,
* \c BELOW_MIN_POWER et \c BATTERY_RESERVE — et le seul qui annonce un ACHAT :
*
* | situation | motif |
* |---|---|
* | il n'y a rien | \c SURPLUS_INSUFFICIENT |
* | il y en a, pas au bon format, et on n'achète pas | \c BELOW_MIN_POWER |
* | il y en a, pas assez pour le plancher, et \c acquisitionTolerance autorise l'appoint | **\c EV_GRID_START** |
*
* \c acquisitionTolerance est un seuil de DÉCLENCHEMENT : la part du plancher qu'on accepte
* de ne pas couvrir par le surplus pour démarrer quand même. Le comportement existait chez le
* proxy amont, où il était **invisible** — confondu avec le plancher lui-même dans
* \c evFloorW(). Ce motif ne l'introduit pas : il le rend lisible, et chiffré.
*
* \c gridW == \c floorW − \c budgetW est ce qui est SOUTIRÉ. C'est le chiffre qui fait de
* cette ligne une décision d'acheter et non un effet de bord : sans lui, une borne qui démarre
* sur un budget insuffisant se lit comme une erreur d'allocation.
*
* \note Comptabilité — l'action porte \c funding \c Grid, et son \c allocatedW se PARTAGE :
* \c budgetW vient du surplus et entre dans \c budget.allocatedW, \c gridW vient du réseau
* et entre dans \c budget.evReservedW. \c budgetW + \c gridW == \c allocatedW, toujours.
*/
constexpr const char *EvGridStart = "EV_GRID_START";
/*!
* \brief params: \c limitW, \c requiredW — la LIMITE DE PHASE interdit, pas le budget.
*

View File

@ -170,6 +170,11 @@ QSet<QString> LoadConfig::claimedThingIds() const
} else {
// etmvariableload : l'id de la charge EST le ThingId piloté
// (cf. etmvariableloadadapter.cpp, findConfiguredThing(ThingId(m_id))).
//
// 3g-2 — et c'est vrai de la BORNE aussi : son id est le ThingId de la borne. Elle
// revendique donc son Thing sans code dédié, ce qui interdit du même coup de déclarer
// une borne à la fois comme evcharger et comme charge pilotée — deux commandeurs sur
// un même organe, exactement ce que LM-201 refuse.
ids.insert(normalizedThingId(m_id));
}
@ -259,6 +264,29 @@ bool LoadConfig::isValid(QString *error) const
if (aSgReady)
return fail(QStringLiteral("%1 : sgReady interdit (charge utile du mécanisme sg-ready)").arg(m_adapter));
if (m_adapter == QStringLiteral("evcharger")) {
// 3g-2 — la borne n'apporte QUE son rang. Ses limites de courant, son nombre de phases
// et sa disponibilité viennent du Thing et changent en continu ; les déclarer ici en
// ferait une seconde source de vérité, périmée dès la première voiture branchée.
if (aRelays)
return fail(QStringLiteral("evcharger : relays[] interdit (charge utile d'un autre mécanisme)"));
if (aLevels)
return fail(QStringLiteral("evcharger : powerLevels/maxPowerW interdits — les limites "
"de la borne viennent du Thing, pas de la configuration"));
if (m_minPowerW > 0)
return fail(QStringLiteral("evcharger : minPowerW interdit — le plancher est dérivé du "
"courant minimal de la borne et du véhicule branché"));
if (m_minOnS != 0 || m_minOffS != 0)
return fail(QStringLiteral("evcharger : minOnS/minOffS interdits — les verrous d'une "
"borne vivent dans le manager amont"));
// Une borne module en continu : le mode le dit, comme pour tout autre mécanisme. On
// garde le champ plutôt que de l'exempter, pour que la forme de l'union reste UNE.
if (m_mode != QStringLiteral("dynamic"))
return fail(QStringLiteral("evcharger : mode doit être \"dynamic\" (une borne module, "
"elle n'a pas de paliers)"));
return true;
}
if (m_adapter == QStringLiteral("relay-router")) {
// Cas relais (rév. 3) : liste de relais power. powerLevels/maxPowerW DÉRIVÉS (ignorés ici).
if (m_mode != QStringLiteral("fixed"))
@ -316,7 +344,7 @@ bool LoadConfig::isValid(QString *error) const
} else {
return fail(QStringLiteral("mécanisme inconnu \"%1\" (attendu \"relay-router\", "
"\"etmvariableload\" ou \"sg-ready\")").arg(m_adapter));
"\"etmvariableload\", \"sg-ready\" ou \"evcharger\")").arg(m_adapter));
}
return true;
}

View File

@ -286,6 +286,29 @@ public:
//! \return Vrai si le mécanisme est \c sg-ready (PAC à 4 états normés).
bool isSgReady() const { return m_adapter == QStringLiteral("sg-ready"); }
/*!
* \brief [3g-2] Vrai si l'entrée décrit une BORNE de recharge.
*
* \return Vrai si \c adapter == "evcharger".
*
* \par Une entrée sans charge utile, et c'est délibéré
* Une borne n'a **rien à configurer** côté moteur : ses limites de courant, son nombre de
* phases et sa disponibilité viennent du Thing, en continu. Ce que l'entrée porte, c'est
* ce que le Thing ne peut pas savoir — son **rang** dans le waterfall, son libellé et son
* \c enabled. Toute charge utile d'un autre mécanisme est donc refusée (LM-300).
*
* \par Pourquoi ici et pas dans ChargingInfo
* LM-1207 : un second système de priorité ne saurait pas exprimer « VE1 > ECS > VE2 »,
* puisqu'il ne classerait que les bornes entre elles. Deux classements qui ne peuvent pas
* s'interclasser ne sont pas deux fonctionnalités, c'est un défaut. Le rang du VE vit donc
* là où vivent tous les rangs.
*
* \note **Aucun adaptateur n'est construit depuis cette entrée** : \c EvAdapter est bâti
* depuis le Thing par \c syncAdapters(), et le rebuild l'ignore. Un aller-retour
* lire-modifier-réécrire ne reconstruit donc rien et ne réarme aucun verrou.
*/
bool isEvCharger() const { return m_adapter == QStringLiteral("evcharger"); }
/*! \brief Things que cette charge revendique, forme normalisée.
*
* ECS-110 — un Thing ne peut appartenir qu'à UNE charge active. Deux charges qui

View File

@ -4,6 +4,7 @@
#include <QDateTime>
#include <QList>
#include <QSet>
#include <QString>
#include "loadaction.h"
@ -79,6 +80,19 @@ struct Plan {
//! 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 [3g-2] Bornes VE dont le waterfall a pris la décision ce cycle — INTERNE.
*
* Le scheduler NOMME ce qu'il a commandé ; l'arbitre n'a pas à le redéduire. Un critère
* redérivé ailleurs (« motif différent d'EV_DEADLINE… ») dériverait du vrai au premier
* motif ajouté, et la divergence produirait exactement le défaut que 3g-2 corrige :
* une borne commandée deux fois, ou plus commandée du tout.
*
* \note Contient AUSSI les démarrages sous tolérance (\c EV_GRID_START), bien qu'ils
* portent \c funding \c Grid : ils viennent du waterfall. Ce qui n'y figure pas, ce
* sont les bornes restées au proxy — échéance et spot market.
*/
QSet<QString> waterfallEvIds;
/*!
* \brief Retourne le Slot couvrant \p dt.

View File

@ -1253,6 +1253,17 @@ void SmartChargingManager::planSurplusCharging(const QDateTime &currentDateTime)
EvCharger *evCharger = it.next();
const ChargingProcessInfo processInfo = m_processInfos.value(evCharger);
// [ETM 3g-2] Le surplus de cette borne est décidé par le waterfall. Continuer ici
// remplirait m_chargingActions d'une consigne concurrente que plus personne
// n'exécute — et surtout le JOURNAL afficherait « ### Surplus would adjust
// evcharger … » juste sous la ligne [Arbitre] qui dit autre chose. Un journal qui
// se contredit envoie diagnostiquer la mauvaise moitié du moteur.
if (evSurplusPlannedByWaterfall(evCharger)) {
qCDebug(dcNymeaEnergy()) << evCharger->name()
<< "surplus is planned by the ETM waterfall. Skipping proxy planning.";
continue;
}
qCDebug(dcNymeaEnergy()).nospace() << "Current charger setpoint: " << evCharger->maxChargingCurrent() << "A (" << (evCharger->maxChargingCurrent() * 230) << "W), Metered: " << (evCharger->currentPower() / 230) << "A (" << evCharger->currentPower() << "W)";
double newRawValue = (evCharger->currentPower() / 230) + allowanceInAmpere;
@ -1310,6 +1321,17 @@ void SmartChargingManager::planSurplusCharging(const QDateTime &currentDateTime)
}
}
// [ETM 3g-2] Voir la documentation dans smartchargingmanager.h (inventaire [ETM], entrée 7).
void SmartChargingManager::setEvChargingState(const ThingId &evChargerId, ChargingInfo::ChargingState state)
{
if (!m_chargingInfos.contains(evChargerId))
return;
if (m_chargingInfos.value(evChargerId).chargingState() == state)
return;
m_chargingInfos[evChargerId].setChargingState(state);
emit chargingInfoChanged(m_chargingInfos.value(evChargerId));
}
void SmartChargingManager::adjustEvChargers(const QDateTime &currentDateTime)
{
qCDebug(dcNymeaEnergy()) << "---------------- Adjusting chargers ---------------";
@ -1323,6 +1345,16 @@ void SmartChargingManager::adjustEvChargers(const QDateTime &currentDateTime)
continue;
}
// [ETM 3g-2] UNE CHARGE, UN COMMANDEUR. Cette borne a reçu son enveloppe du waterfall
// et l'adaptateur l'a déjà exécutée ; repasser ici dériverait une SECONDE consigne, de
// sa propre logique, et c'est elle qui atteindrait le matériel — la décision de
// l'arbitre resterait un motif publié que rien n'applique (relevé du 2026-08-26 §3.3).
if (evCommandedByWaterfall(evCharger)) {
qCDebug(dcNymeaEnergy()) << evCharger->name()
<< "is commanded by the ETM waterfall this cycle. Not re-deciding here.";
continue;
}
ChargingProcessInfo processInfo = m_processInfos.value(evCharger);
// Final schedules

View File

@ -126,6 +126,16 @@ signals:
// internalProcessInfo() — accesseurs ETM neufs, documentés ci-dessous.
// 5. bestPhaseCountFor(), phasesForCount() — passe-plats ETM vers deux règles amont
// privées, documentés ci-dessous.
// 7. setEvChargingState() — écriture ETM d'un état RUNTIME, documentée
// ci-dessous. Pas un réglage : jamais persistée.
// 6. evSurplusPlannedByWaterfall(), evCommandedByWaterfall()
// — crochets ETM, virtuels, documentés ci-dessous.
// Ce sont EUX qui garantissent « une charge, un
// commandeur » : sans eux, planSurplusCharging()
// et adjustEvChargers() re-décident après le
// waterfall. Deux crochets et non un : ils sont
// interrogés à deux moments du cycle où la
// réponse n'est pas la même (voir leur doc).
protected slots:
virtual void update(const QDateTime &currentDateTime); // [ETM] virtual added
void prepareInformation(const QDateTime &currentDateTime); // [ETM] private → protected
@ -157,6 +167,61 @@ protected:
Electricity::Phases phasesForCount(uint phaseCount)
{ return getAscendingPhasesForCount(phaseCount); }
/*!
* \brief [ETM 3g-2] Cette borne est-elle déjà commandée par le waterfall ce cycle ?
*
* \return Faux dans l'amont : sans arbitre ETM, \c adjustEvChargers() est le seul
* commandeur et rien ne change. \c EnergyArbitrator le surcharge.
*
* \par Pourquoi ce crochet existe
* 3g-1 a fait entrer les bornes dans le waterfall mais a laissé tourner le chemin
* d'exécution du proxy : l'arbitre décidait, publiait un motif juste, et
* \c adjustEvChargers() commandait autre chose une milliseconde plus tard (relevé
* machine du 2026-08-26, §3.3). **Deux commandeurs sur un même organe** — c'est la
* règle absolue 1 d'AGENTS.md, appliquée à l'exécution au lieu du budget.
*
* Ce que le crochet EXCLUT du proxy est exactement ce que le waterfall a commandé :
* les bornes servies au surplus. Celles qui restent au proxy — échéance et spot market,
* financées au réseau (DESIGN_3g §3.1) — continuent d'y passer, allowance de compteur
* comprise.
*/
virtual bool evCommandedByWaterfall(EvCharger *evCharger) const { Q_UNUSED(evCharger) return false; }
/*!
* \brief [ETM 3g-2] Le surplus de cette borne sera-t-il décidé par le waterfall ?
*
* \return Faux dans l'amont. \c EnergyArbitrator le surcharge par le filtre d'entrée du
* waterfall (voiture assignée + mode non manuel).
*
* \note **Distinct de \c evCommandedByWaterfall, et la différence est temporelle.**
* \c planSurplusCharging() tourne AVANT que le waterfall n'ait décidé : à cet instant
* on ne peut savoir que si la borne lui APPARTIENT, pas ce qu'il en aura fait. Une
* borne qui lui appartient peut encore sortir par l'échéance — mais celle-ci est
* traitée à l'étape 2 de la même fonction, qui la retire de la liste avant l'étape 3.
* \c adjustEvChargers(), lui, tourne APRÈS : il interroge le crochet plus strict, sans
* quoi une borne servie par l'échéance ne serait plus commandée du tout.
*/
virtual bool evSurplusPlannedByWaterfall(EvCharger *evCharger) const { Q_UNUSED(evCharger) return false; }
/*!
* \brief [ETM 3g-2] Met à jour l'état de charge PUBLIÉ d'une borne, et notifie si changé.
*
* \param evChargerId Borne concernée. \param state Nouvel état de charge.
*
* Reproduit **exactement** ce que fait \c adjustEvChargers() pour les bornes qu'il
* commande : écriture en mémoire, notification \c chargingInfoChanged sur transition
* seulement, et **aucune persistance** — \c chargingState est un état runtime, pas un
* réglage (il n'a d'ailleurs pas d'accesseur en écriture au schéma RPC).
*
* Existe parce que les bornes servies par le waterfall ne traversent plus
* \c adjustEvChargers() : sans elle, \c chargingState resterait figé sur sa dernière
* valeur et l'app afficherait « en charge » une borne que l'arbitre vient d'éteindre.
*
* \note Ne PAS passer par \c setChargingInfo() pour cela : cette méthode valide,
* persiste et relance \c update() — appelée depuis un cycle en cours, elle récurse.
*/
void setEvChargingState(const ThingId &evChargerId, ChargingInfo::ChargingState state);
// [ETM] Read-only state accessors — inline, no copies, no logic.
/*!

View File

@ -776,7 +776,14 @@ rien ne le signale. D'où deux exigences qui accompagnent la règle :
**LM-1204-b — Le scénario d'acceptation de 3g, à monter au banc.** *(Défini avec Patrick,
2026-08-26, quand le banc a reçu une seconde borne.)*
> **Il n'est PAS exerçable avant 3g**, et c'est le point : aujourd'hui `nonEvLoads` filtre
> **MOITIÉ ARBITRAGE FRANCHIE (3g-2)** — `testEvChargerRankDecidesWhoCharges` : deux bornes, un
> budget qui ne paie qu'un plancher, la seconde refusée avec `BELOW_MIN_POWER {budgetW: 620,
> minPowerW: 1380}`, et l'inversion du rang change la borne servie. **La moitié MATÉRIELLE reste
> ouverte** : elle exige deux bornes RÉELLES, et la Terra AC ne répond toujours pas
> (`docs/RECAP_3g2.md` §1). Ce qu'une wallbox simulée ne prouvera pas est écrit plus bas — et
> c'est précisément ce qui manque encore.
>
> **Il n'était PAS exerçable avant 3g**, et c'est le point : aujourd'hui `nonEvLoads` filtre
> `etmvariableload | relay-router | sg-ready`, l'EV est décidé **en proxy avant** le waterfall,
> les deux bornes ne partagent donc aucun budget avec le reste et **leur rang n'a aucun sens**.
> Ce scénario est le critère qui dira que 3g est fait.
@ -812,6 +819,11 @@ est ce qui avait motivé le proxy — la crainte que le waterfall ne sache pas e
voiture doit être chargée à 7 h ». Il sait : c'est le même champ que le plancher éco du §10
(LM-1001). Une fois le VE dans le waterfall, il hérite du modèle de besoin commun.
> **LM-1206 et LM-1207 sont IMPLÉMENTÉS (3g-2).** Le rang d'une borne est un champ de
> `LoadConfig`, créé d'office à la détection, modifiable à chaud par `SetLoadConfig`. Aucun champ
> de rang n'a été ajouté à `ChargingInfo`, et il n'y en aura pas. Forme exacte de l'entrée :
> `INTERFACE.md`, « le mécanisme `evcharger` ».
**LM-1206 — La priorité porte sur la BORNE, pas sur la voiture. Décision arrêtée.** Il n'existe
pas de communication véhicule aujourd'hui : rien ne permet de savoir *quelle* voiture est
branchée, ni de lui attribuer un rang. Le rang appartient donc au **point de charge**, comme il

View File

@ -285,3 +285,103 @@ void TestLoadModel::testFloorAboveCeilingIsRefused()
}
QTEST_MAIN(TestLoadModel)
namespace {
//! Une entrée de borne telle que l'auto-provisionnement la crée : un rang, rien d'autre.
QVariantMap borneMap(const QString &id, int priority = 1)
{
QVariantMap m;
m.insert("id", id);
m.insert("label", "Borne du garage");
m.insert("adapter", "evcharger");
m.insert("mode", "dynamic");
m.insert("domain", "ev");
m.insert("priority", priority);
m.insert("enabled", true);
return m;
}
const QString BORNE = QStringLiteral("{dddddddd-0000-0000-0000-00000000000e}");
} // namespace
void TestLoadModel::testEvChargerConfigIsRankOnly()
{
QString err;
const LoadConfig c = LoadConfig::fromMap(borneMap(BORNE, 2));
QVERIFY2(c.isValid(&err), qUtf8Printable(err));
QVERIFY(c.isEvCharger());
QCOMPARE(c.priority(), 2);
// LM-302 — aller-retour NEUTRE. C'est le geste ordinaire de l'app : lire, changer un rang,
// renvoyer l'objet entier. Une entrée qui ne survivrait pas à sa propre relecture rendrait
// le réordonnancement impossible depuis l'écran.
const LoadConfig relu = LoadConfig::fromMap(c.toMap());
QVERIFY2(relu.isValid(&err), qUtf8Printable(err));
QCOMPARE(relu.toMap(), c.toMap());
QCOMPARE(relu.priority(), 2);
QCOMPARE(relu.adapter(), QStringLiteral("evcharger"));
QCOMPARE(relu.domain(), QStringLiteral("ev"));
// Le mode est celui d'une charge qui module. « fixed » n'aurait pas de sens : une borne
// n'a pas de paliers, et l'accepter ouvrirait une forme que rien ne construit.
QVariantMap fixe = borneMap(BORNE);
fixe.insert("mode", "fixed");
QVERIFY2(!LoadConfig::fromMap(fixe).isValid(&err), "evcharger en mode fixed doit être refusé");
QVERIFY(err.contains("dynamic"));
}
void TestLoadModel::testEvChargerConfigRefusesPayloads()
{
QString err;
// Les limites d'une borne viennent du THING et changent avec la voiture branchée. Les
// déclarer ici en ferait une seconde source de vérité, périmée dès le premier débranchement.
QVariantMap avecPlafond = borneMap(BORNE);
avecPlafond.insert("maxPowerW", 11000);
QVERIFY2(!LoadConfig::fromMap(avecPlafond).isValid(&err), "maxPowerW doit être refusé");
QVERIFY(err.contains("maxPowerW"));
QVariantMap avecPlancher = borneMap(BORNE);
avecPlancher.insert("minPowerW", 4140);
QVERIFY2(!LoadConfig::fromMap(avecPlancher).isValid(&err), "minPowerW doit être refusé");
QVERIFY(err.contains("minPowerW"));
QVariantMap avecRelais = borneMap(BORNE);
QVariantMap relais;
relais.insert("thingId", K1);
relais.insert("powerW", 2000);
avecRelais.insert("relays", QVariantList() << relais);
QVERIFY2(!LoadConfig::fromMap(avecRelais).isValid(&err), "relays[] doit être refusé");
QVariantMap avecVerrous = borneMap(BORNE);
avecVerrous.insert("minOnS", 300);
QVERIFY2(!LoadConfig::fromMap(avecVerrous).isValid(&err), "minOnS doit être refusé");
QVERIFY(err.contains("minOnS"));
}
void TestLoadModel::testEvChargerClaimsItsThing()
{
// LM-201 — l'id de la charge EST le ThingId de la borne, donc elle le revendique. Sans
// cela, on pourrait déclarer la même borne une seconde fois comme charge pilotée : deux
// commandeurs sur un même organe, et sa puissance comptée deux fois au budget.
const LoadConfig borne = LoadConfig::fromMap(borneMap(BORNE));
QVERIFY(borne.claimedThingIds().contains(QUuid(BORNE).toString()));
QVariantMap doublon;
doublon.insert("id", BORNE); // le MÊME Thing, par un autre mécanisme
doublon.insert("label", "Borne pilotée en watts");
doublon.insert("adapter", "etmvariableload");
doublon.insert("mode", "dynamic");
doublon.insert("maxPowerW", 11000);
doublon.insert("priority", 3);
doublon.insert("enabled", true);
LoadConfigs ensemble;
ensemble << borne << LoadConfig::fromMap(doublon);
QString err;
QVERIFY2(!LoadConfigStore::validateSet(ensemble, &err),
"deux charges actives sur le Thing d'une borne doivent être refusées");
}

View File

@ -56,6 +56,16 @@ private slots:
//! Un plancher au-dessus du plafond rendrait la charge inatteignable.
void testFloorAboveCeilingIsRefused();
// ── 3g-2 — le rang de la borne vit dans LoadConfig (LM-1206/LM-1207) ───
//! Une entrée evcharger est valide SANS charge utile, et traverse l'aller-retour.
void testEvChargerConfigIsRankOnly();
//! Toute charge utile d'un autre mécanisme y est refusée (LM-300, union discriminée).
void testEvChargerConfigRefusesPayloads();
//! Elle revendique son Thing : une borne ne peut pas être aussi une charge pilotée.
void testEvChargerClaimsItsThing();
};
#endif // TESTLOADMODEL_H

View File

@ -2474,7 +2474,12 @@ void Simulation::run_data()
},
{
444, {
SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 7) ,
// [ETM 3g-2] 7 A → 6 A. La borne est ARRÊTÉE à ce pas (ligne
// suivante) : la valeur relue est la dernière consigne émise avant
// la coupure, et elle diffère d'un ampère parce que la conversion
// watts→ampères se fait désormais APRÈS l'allocation du waterfall,
// au lieu d'être calculée nativement en ampères par le proxy.
SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 6) ,
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, false)
}
},
@ -2494,14 +2499,17 @@ void Simulation::run_data()
},
{
900, {
SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 30) ,
// [ETM 3g-2] 88 % → 87 %, et 30 A → 6 A. Le CHEMIN diffère d'un
// ampère par-ci par-là (arrondi de la conversion, cf. pas 444) et
// l'écart s'accumule sur 900 pas de rétroaction. Ce qui compte est
// au pas 960, et c'est inchangé : la voiture est à 100 % à
// l'échéance. Les 14 autres scénarios sont identiques au bit près.
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, true),
SimulationTestPoint(SimulationTestPoint::TestTypeStateOfCharge, 88)
SimulationTestPoint(SimulationTestPoint::TestTypeStateOfCharge, 87)
}
},
{
960, { // 22:00
SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 30) ,
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, false),
SimulationTestPoint(SimulationTestPoint::TestTypeStateOfCharge, 100)
}
@ -2858,8 +2866,15 @@ void Simulation::run_data()
},
{
900, {
SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 16),
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, true)
// [ETM 3g-2] La borne ne charge PAS encore à ce pas, et c'est le
// mécanisme d'échéance qui fonctionne : à 89 % avec une heure
// devant elle, la projection dit qu'il reste du temps, donc rien
// n'est forcé. Le forçage intervient plus tard, et le pas 960
// vérifie qu'il a suffi — voiture à 100 % à l'échéance.
// Le proxy forçait dès ce pas parce que sa trajectoire de la
// journée laissait la voiture un peu moins chargée.
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, false),
SimulationTestPoint(SimulationTestPoint::TestTypeStateOfCharge, 89)
}
},
{
@ -4052,6 +4067,13 @@ void Simulation::testDecisionReasonRendering()
QStringLiteral("Budget 933 W sous l'état 3 de PAC test (1500 W estimés) — état 2, "
"le budget passe à la charge suivante") },
// 3g-2 — le démarrage sous tolérance est une décision d'ACHETER : la phrase doit
// chiffrer ce qui est soutiré, sans quoi elle se lit comme une erreur d'allocation.
{ { DecisionCode::EvGridStart,
{{"budgetW", 2200}, {"floorW", 4140}, {"gridW", 1940}} },
QStringLiteral("Borne du garage"),
QStringLiteral("Démarrage de Borne du garage : plancher 4140 W, dont 1940 W SOUTIRÉS au "
"réseau (2200 W de surplus disponibles, tolérance d'acquisition)") },
// 3g-2 — et quand c'est la limite de phase qui interdit, elle est NOMMÉE : le geste
// attendu n'est pas d'attendre le soleil, c'est de regarder l'abonnement.
{ { DecisionCode::PhaseLimit, {{"limitW", 1200}, {"requiredW", 4140}} },
@ -4093,7 +4115,7 @@ void Simulation::testDecisionReasonRendering()
// la garantie « tout code a une ligne de rendu éprouvée » ne tenait plus.
DecisionCode::BatteryReserve, DecisionCode::BelowMinPower,
// 3g-2
DecisionCode::PhaseLimit };
DecisionCode::EvGridStart, DecisionCode::PhaseLimit };
for (const QString &code : catalogue)
QVERIFY2(codesCouverts.contains(code),
qUtf8Printable(QStringLiteral("code %1 sans ligne de rendu attendue").arg(code)));
@ -4973,3 +4995,412 @@ QStringList Simulation::plotSpotMarketData(int powerBalanceCount)
QTEST_MAIN(Simulation)
namespace {
//! Prépare une borne du banc simulé : branchée, à l'arrêt, monophasée, 6-16 A.
void armerBorne(Thing *borne)
{
borne->setStateValue("connected", true);
borne->setStateValue("pluggedIn", true);
borne->setStateValue("power", false);
borne->setStateValue("charging", false);
borne->setStateValue("maxChargingCurrent", 6);
borne->setStateValue("currentPower", 0);
}
//! Les entrées de configuration, telles que l'APP les voit — par la frontière RPC, jamais par
//! un store privé : c'est la concordance des deux frontières qui est en jeu.
QHash<QString, QVariantMap> configsParId(const QVariant &getLoadConfigReply)
{
QHash<QString, QVariantMap> h;
const QVariantList l = getLoadConfigReply.toMap().value("params").toMap()
.value("loadConfigs").toList();
for (const QVariant &v : l)
h.insert(v.toMap().value("id").toString(), v.toMap());
return h;
}
//! Les entrées de télémétrie du dernier cycle, indexées par loadId.
QHash<QString, QVariantMap> telemetrieParId(const QVariantMap &snap)
{
QHash<QString, QVariantMap> h;
for (const QVariant &v : snap.value("loads").toList())
h.insert(v.toMap().value("loadId").toString(), v.toMap());
return h;
}
} // namespace
void Simulation::testEvChargerRankDecidesWhoCharges()
{
#ifndef ETM_ARBITRATOR
QSKIP("testEvChargerRankDecidesWhoCharges nécessite ETM_ARBITRATOR.");
#else
// [3g-2 · LM-1204-b, moitié arbitrage] Deux bornes, un budget qui ne paie qu'un seul
// plancher. Ce qui est éprouvé ici est ce qu'AUCUN matériel ne prouve : que l'ARBITRE
// choisit, qu'il le dit, et que son choix suit le rang configuré.
//
// Le discriminant est le MOTIF publié (spec_loadmodel §12) :
// BELOW_MIN_POWER → l'arbitre a refusé, aucune commande n'est partie ;
// une borne à 0 W sans motif de plancher → c'est le matériel, le test ne prouve rien.
const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-evrank.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);
// Tolérance d'acquisition à 1,0 : aucun démarrage sous tolérance ne vient brouiller la
// lecture du plancher. Ce mécanisme a son propre test.
QVERIFY(injectAndWait("NymeaEnergy.SetAcquisitionTolerance",
QVariantMap({{"acquisitionTolerance", 1.0}})).toMap()
.value("params").toMap().value("energyError").toString() == "EnergyErrorNoError");
// Deux bornes IDENTIQUES, plafonnées à 6 A : chacune prend exactement son plancher
// (1380 W) et pas un watt de plus. C'est ce qui rend le RELIQUAT visible — 2000 − 1380 =
// 620 W, sous le plancher de la seconde. Sans ce plafond, la première absorberait tout et
// la seconde publierait « pas de surplus », ce qui est vrai mais ne prouve pas le plancher.
QUuid borneAId = addCharger("A", 6, 26657);
QUuid borneBId = addCharger("A", 6, 26670);
QUuid voitureAId = addCar(26656);
QUuid voitureBId = addCar(26671);
QVERIFY(!borneAId.isNull() && !borneBId.isNull());
QVERIFY(!voitureAId.isNull() && !voitureBId.isNull());
Thing *borneA = tm->findConfiguredThing(borneAId);
Thing *borneB = tm->findConfiguredThing(borneBId);
QVERIFY(borneA && borneB);
armerBorne(borneA);
armerBorne(borneB);
for (const QUuid &v : {voitureAId, voitureBId}) {
Thing *voiture = tm->findConfiguredThing(v);
QVERIFY(voiture);
voiture->setStateValue("batteryLevel", 40);
voiture->setStateValue("capacity", 50);
voiture->setStateValue("minChargingCurrent", 6);
voiture->setStateValue("phaseCount", 1);
voiture->setStateValue("pluggedIn", true);
}
auto associer = [this](const QUuid &borne, const QUuid &voiture) {
QVariantMap info;
info.insert("evChargerId", borne);
info.insert("assignedCarId", voiture);
info.insert("chargingMode", "ChargingModeEco");
QVariant r = injectAndWait("NymeaEnergy.SetChargingInfo", QVariantMap({{"chargingInfo", info}}));
QCOMPARE(r.toMap().value("params").toMap().value("energyError").toString(),
QString("EnergyErrorNoError"));
};
associer(borneAId, voitureAId);
associer(borneBId, voitureBId);
// ---- 1. L'auto-provisionnement : les bornes ENTRENT dans la configuration --------------
// 3g-1 les arbitrait et les publiait sans qu'elles figurent dans GetLoadConfig — une charge
// publiée sans configuration est la charge fantôme que le lot B-bis avait supprimée.
const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0));
meter->setStateValue("currentPower", 0); // aucun surplus : ce cycle ne sert qu'à
arb->simulationCallUpdate(t0); // faire entrer les bornes en configuration
QCoreApplication::processEvents();
QHash<QString, QVariantMap> cfgs = configsParId(injectAndWait("NymeaEnergy.GetLoadConfig"));
QVERIFY2(cfgs.contains(borneAId.toString()), "la borne A n'est pas entrée en configuration");
QVERIFY2(cfgs.contains(borneBId.toString()), "la borne B n'est pas entrée en configuration");
QCOMPARE(cfgs.value(borneAId.toString()).value("adapter").toString(), QStringLiteral("evcharger"));
QCOMPARE(cfgs.value(borneAId.toString()).value("domain").toString(), QStringLiteral("ev"));
QVERIFY(cfgs.value(borneAId.toString()).value("enabled").toBool());
// Aller-retour NEUTRE : relire la configuration et la réécrire mot pour mot ne change rien
// et ne reconstruit AUCUN adaptateur — l'adaptateur d'une borne vient du Thing, pas d'ici.
const QVariant avant = injectAndWait("NymeaEnergy.GetLoadConfig");
QVariant reecrit = injectAndWait("NymeaEnergy.SetLoadConfig",
QVariantMap({{"loadConfigs", avant.toMap().value("params").toMap().value("loadConfigs")}}));
QCOMPARE(reecrit.toMap().value("params").toMap().value("energyError").toString(),
QString("EnergyErrorNoError"));
QCOMPARE(injectAndWait("NymeaEnergy.GetLoadConfig").toMap().value("params").toMap(),
avant.toMap().value("params").toMap());
// ---- 2. Le rang décide, et le MOTIF le dit --------------------------------------------
// Le réordonnancement passe par la MÊME frontière que l'app : GetLoadConfig, on change le
// rang, SetLoadConfig. Aucun raccourci par le store — c'est le chemin qui doit marcher.
auto poserRangs = [&](int rangA, int rangB) {
QVariantList l = injectAndWait("NymeaEnergy.GetLoadConfig").toMap()
.value("params").toMap().value("loadConfigs").toList();
for (int i = 0; i < l.count(); ++i) {
QVariantMap m = l.at(i).toMap();
if (m.value("id").toString() == borneAId.toString()) m.insert("priority", rangA);
if (m.value("id").toString() == borneBId.toString()) m.insert("priority", rangB);
l[i] = m;
}
QVariant r = injectAndWait("NymeaEnergy.SetLoadConfig", QVariantMap({{"loadConfigs", l}}));
QCOMPARE(r.toMap().value("params").toMap().value("energyError").toString(),
QString("EnergyErrorNoError"));
};
// Chaque cycle mesuré part de DEUX bornes à l'arrêt. Sans cela le recrédit
// anti-clignotement rendrait à celle qui charge déjà sa propre consommation — c'est le
// comportement voulu (le compteur l'inclut déjà), mais il rend le budget du cycle
// ambigu et le test ne mesurerait plus le rang.
auto cycle = [&](const QDateTime &t) {
for (Thing *b : {borneA, borneB}) {
b->setStateValue("power", false);
b->setStateValue("charging", false);
b->setStateValue("maxChargingCurrent", 6);
b->setStateValue("currentPower", 0);
}
meter->setStateValue("currentPower", -2000); // 2000 W exportés : UN seul plancher
arb->simulationCallUpdate(t);
QCoreApplication::processEvents();
return telemetrieParId(arb->loadTelemetry());
};
poserRangs(1, 2);
QHash<QString, QVariantMap> tele = cycle(t0.addSecs(60));
// Plancher : max(borne 6 A, voiture 6 A) × 230 V × 1 phase = 1380 W. Le budget de 2000 W
// en paie UN. La seconde ne reçoit pas « un peu » — elle reçoit 0 et son motif.
QCOMPARE(qRound(tele.value(borneAId.toString()).value("allocatedW").toDouble()), 1380);
QCOMPARE(qRound(tele.value(borneBId.toString()).value("allocatedW").toDouble()), 0);
// Le reliquat EXISTE (620 W) et il ne suffit pas : c'est bien le plancher qui refuse, pas
// l'absence de surplus. Les deux phrases envoient l'installateur vers des gestes opposés.
QCOMPARE(qRound(tele.value(borneBId.toString()).value("decision").toMap()
.value("params").toMap().value("budgetW").toDouble()), 620);
QCOMPARE(tele.value(borneBId.toString()).value("decision").toMap().value("code").toString(),
QString(DecisionCode::BelowMinPower));
QCOMPARE(qRound(tele.value(borneBId.toString()).value("decision").toMap()
.value("params").toMap().value("minPowerW").toDouble()), 1380);
// ---- 3. INVERSION du rang : la borne servie change ------------------------------------
// C'est le pendant EV de l'inversion ECS/SG-Ready déjà éprouvée, et la seule preuve que le
// rang est un RÉGLAGE et non un ordre de découverte.
poserRangs(2, 1);
tele = cycle(t0.addSecs(120));
QCOMPARE(qRound(tele.value(borneBId.toString()).value("allocatedW").toDouble()), 1380);
QCOMPARE(qRound(tele.value(borneAId.toString()).value("allocatedW").toDouble()), 0);
QCOMPARE(tele.value(borneAId.toString()).value("decision").toMap().value("code").toString(),
QString(DecisionCode::BelowMinPower));
// ---- 4. Une charge, UN commandeur -----------------------------------------------------
// La borne refusée doit être ÉTEINTE par la décision de l'arbitre. Avant 3g-2,
// adjustEvChargers() repassait derrière avec une consigne dérivée de sa propre logique :
// l'arbitre publiait un motif juste, et le matériel recevait autre chose.
QCOMPARE(borneA->stateValue("power").toBool(), false);
QCOMPARE(borneA->stateValue("charging").toBool(), false);
// Et la borne SERVIE l'est à la consigne du waterfall, pas à celle que le proxy aurait
// dérivée : 1380 W sur une phase, soit 6 A.
QCOMPARE(borneB->stateValue("power").toBool(), true);
QCOMPARE(borneB->stateValue("maxChargingCurrent").toInt(), 6);
// ---- 5. loads[] ⊆ GetLoadConfig, l'invariant que 3g-1 avait rompu ----------------------
QSet<QString> idsConfig;
for (const QString &id : configsParId(injectAndWait("NymeaEnergy.GetLoadConfig")).keys())
idsConfig.insert(id);
for (auto it = tele.constBegin(); it != tele.constEnd(); ++it)
QVERIFY2(idsConfig.contains(it.key()),
qUtf8Printable(QStringLiteral("charge %1 publiée en télémétrie mais absente de "
"GetLoadConfig").arg(it.key())));
#endif
}
void Simulation::testEvGridStartIsAnnouncedAndBounded()
{
#ifndef ETM_ARBITRATOR
QSKIP("testEvGridStartIsAnnouncedAndBounded nécessite ETM_ARBITRATOR.");
#else
// [3g-2] `acquisitionTolerance` est un seuil de DÉCLENCHEMENT : la part du plancher qu'on
// accepte de ne pas couvrir par le surplus pour démarrer quand même. Le comportement
// existait chez le proxy, où il était invisible — confondu avec le plancher dans
// evFloorW(). Ici il est visible, chiffré, et borné.
const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-evgridstart.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);
injectAndWait("NymeaEnergy.SetAcquisitionTolerance", QVariantMap({{"acquisitionTolerance", 0.5}}));
QUuid borneAId = addCharger("A", 16, 26657);
QUuid borneBId = addCharger("A", 16, 26670);
QUuid voitureAId = addCar(26656);
QUuid voitureBId = addCar(26671);
Thing *borneA = tm->findConfiguredThing(borneAId);
Thing *borneB = tm->findConfiguredThing(borneBId);
QVERIFY(borneA && borneB);
armerBorne(borneA);
armerBorne(borneB);
for (const QUuid &v : {voitureAId, voitureBId}) {
Thing *voiture = tm->findConfiguredThing(v);
QVERIFY(voiture);
voiture->setStateValue("batteryLevel", 40);
voiture->setStateValue("capacity", 50);
voiture->setStateValue("minChargingCurrent", 6);
voiture->setStateValue("phaseCount", 1);
voiture->setStateValue("pluggedIn", true);
}
for (const QPair<QUuid, QUuid> &paire : QList<QPair<QUuid, QUuid>>{{borneAId, voitureAId},
{borneBId, voitureBId}}) {
QVariantMap info;
info.insert("evChargerId", paire.first);
info.insert("assignedCarId", paire.second);
info.insert("chargingMode", "ChargingModeEco");
injectAndWait("NymeaEnergy.SetChargingInfo", QVariantMap({{"chargingInfo", info}}));
}
const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0));
meter->setStateValue("currentPower", 0); // cycle d'entrée en configuration seulement
arb->simulationCallUpdate(t0);
QCoreApplication::processEvents();
QVariantList l = injectAndWait("NymeaEnergy.GetLoadConfig").toMap()
.value("params").toMap().value("loadConfigs").toList();
for (int i = 0; i < l.count(); ++i) {
QVariantMap m = l.at(i).toMap();
if (m.value("id").toString() == borneAId.toString()) m.insert("priority", 1);
if (m.value("id").toString() == borneBId.toString()) m.insert("priority", 2);
l[i] = m;
}
QCOMPARE(injectAndWait("NymeaEnergy.SetLoadConfig", QVariantMap({{"loadConfigs", l}}))
.toMap().value("params").toMap().value("energyError").toString(),
QString("EnergyErrorNoError"));
// Les deux bornes à l'arrêt : le budget du cycle est sans ambiguïté (cf. testEvChargerRank…).
for (Thing *b : {borneA, borneB}) {
b->setStateValue("power", false);
b->setStateValue("charging", false);
b->setStateValue("maxChargingCurrent", 6);
b->setStateValue("currentPower", 0);
}
// 900 W de surplus : sous le plancher de 1380 W, mais au-dessus de sa moitié. Le proxy
// aurait démarré ici sans le dire ; le waterfall démarre et le DIT.
meter->setStateValue("currentPower", -900);
arb->simulationCallUpdate(t0.addSecs(60));
QCoreApplication::processEvents();
const QVariantMap snap = arb->loadTelemetry();
QHash<QString, QVariantMap> tele = telemetrieParId(snap);
// ---- Le motif dit COMBIEN est soutiré, et pourquoi -------------------------------------
const QVariantMap decisionA = tele.value(borneAId.toString()).value("decision").toMap();
QCOMPARE(decisionA.value("code").toString(), QString(DecisionCode::EvGridStart));
const QVariantMap pA = decisionA.value("params").toMap();
QCOMPARE(qRound(pA.value("floorW").toDouble()), 1380);
QCOMPARE(qRound(pA.value("budgetW").toDouble()), 900);
QCOMPARE(qRound(pA.value("gridW").toDouble()), 480); // 1380 − 900, SOUTIRÉS
// budgetW + gridW == allocatedW : la comptabilité est vérifiable par le client seul.
QCOMPARE(qRound(tele.value(borneAId.toString()).value("allocatedW").toDouble()), 1380);
QCOMPARE(qRound(pA.value("budgetW").toDouble() + pA.value("gridW").toDouble()), 1380);
// Le financement DIT la vérité sur l'origine : une part vient du réseau.
QCOMPARE(tele.value(borneAId.toString()).value("funding").toString(), QString("grid"));
// ---- Borné par construction : UNE borne par cycle --------------------------------------
// Le démarrage consomme TOUT le budget restant ; la suivante trouve zéro et repart sur son
// motif ordinaire. Deux bornes ne peuvent pas se mettre à soutirer le même cycle.
QCOMPARE(qRound(tele.value(borneBId.toString()).value("allocatedW").toDouble()), 0);
QVERIFY2(tele.value(borneBId.toString()).value("decision").toMap().value("code").toString()
!= QString(DecisionCode::EvGridStart),
"deux bornes ont soutiré au réseau dans le même cycle");
// ---- La comptabilité du budget partage l'allocation ------------------------------------
const QVariantMap budget = snap.value("budget").toMap();
QCOMPARE(qRound(budget.value("allocatedW").toDouble()), 900); // la part de SURPLUS
QCOMPARE(qRound(budget.value("evReservedW").toDouble()), 480); // la part de RÉSEAU
QCOMPARE(qRound(budget.value("remainingW").toDouble()), 0);
#endif
}
void Simulation::testEvChargerConfigOutlivesItsThing()
{
#ifndef ETM_ARBITRATOR
QSKIP("testEvChargerConfigOutlivesItsThing nécessite ETM_ARBITRATOR.");
#else
// [3g-2] Borne remplacée, ThingId qui change, appareil déposé : l'entrée de configuration
// n'est PAS supprimée d'office — c'est une configuration, et l'installateur a pu la classer.
// Elle doit se lire comme une charge dont l'appareil manque, avec le motif qui va bien.
// Le cas symétrique du THING_MISSING éprouvé avec la sonde.
const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-evorphan.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);
meter->setStateValue("currentPower", -4000);
QUuid borneId = addCharger("A", 16, 26657);
QUuid voitureId = addCar(26656);
Thing *borne = tm->findConfiguredThing(borneId);
Thing *voiture = tm->findConfiguredThing(voitureId);
QVERIFY(borne && voiture);
armerBorne(borne);
voiture->setStateValue("batteryLevel", 40);
voiture->setStateValue("capacity", 50);
voiture->setStateValue("minChargingCurrent", 6);
voiture->setStateValue("phaseCount", 1);
voiture->setStateValue("pluggedIn", true);
QVariantMap info;
info.insert("evChargerId", borneId);
info.insert("assignedCarId", voitureId);
info.insert("chargingMode", "ChargingModeEco");
injectAndWait("NymeaEnergy.SetChargingInfo", QVariantMap({{"chargingInfo", info}}));
const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0));
arb->simulationCallUpdate(t0);
QCoreApplication::processEvents();
QVERIFY2(configsParId(injectAndWait("NymeaEnergy.GetLoadConfig")).contains(borneId.toString()),
"la borne n'est pas entrée en configuration");
// ---- Le Thing disparaît ----------------------------------------------------------------
QVERIFY(removeDevice(borneId).toMap().value("params").toMap().value("thingError").toString()
== "ThingErrorNoError");
arb->simulationCallUpdate(t0.addSecs(60));
QCoreApplication::processEvents();
// L'entrée SURVIT : la supprimer effacerait un classement que l'installateur a posé, et le
// silence qui suivrait ne se distinguerait pas d'une borne jamais configurée.
QVERIFY2(configsParId(injectAndWait("NymeaEnergy.GetLoadConfig")).contains(borneId.toString()),
"l'entrée de configuration a été supprimée avec le Thing");
// Et elle se LIT comme une charge sans appareil, à son rang, avec son motif.
const QHash<QString, QVariantMap> tele = telemetrieParId(arb->loadTelemetry());
QVERIFY2(tele.contains(borneId.toString()),
"la borne configurée sans appareil a disparu de la télémétrie");
const QVariantMap entree = tele.value(borneId.toString());
QCOMPARE(entree.value("available").toBool(), false);
QCOMPARE(entree.value("faultCode").toString(), QString("THING_MISSING"));
QCOMPARE(entree.value("decision").toMap().value("code").toString(),
QString(DecisionCode::LoadUnavailable));
QCOMPARE(qRound(entree.value("allocatedW").toDouble()), 0);
#endif
}

View File

@ -143,6 +143,22 @@ private slots:
// quoi l'hypothèse de prudence devient un masquage de panne.
void testSgReadyRestartRecovery();
// ── 3g-2 ───────────────────────────────────────────────────────────────
// [LM-1206/LM-1207] Le rang d'une borne vit dans LoadConfig, comme celui de toute autre
// charge — et l'inverser change laquelle démarre. C'est la moitié ARBITRAGE de LM-1204-b :
// le banc en garde la moitié matérielle, qu'aucune wallbox simulée ne peut prouver.
// Couvre aussi « une charge, un commandeur » : la borne refusée est éteinte par l'arbitre,
// et le proxy ne repasse pas derrière avec une consigne de son cru.
void testEvChargerRankDecidesWhoCharges();
// [3g-2] Le démarrage sous tolérance d'acquisition est une décision d'ACHETER : elle porte
// un financement réseau, un motif qui chiffre le soutirage, et une comptabilité partagée.
void testEvGridStartIsAnnouncedAndBounded();
// [3g-2] L'entrée de configuration d'une borne SURVIT à la disparition de son Thing : elle
// se lit comme une charge dont l'appareil manque, pas comme une charge jamais configurée.
void testEvChargerConfigOutlivesItsThing();
void printStates(Thing *thing);
void updateChargerMeter(Thing *thing);