Patrick Schurig baf267a963 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
2026-08-27 10:43:25 +02:00

208 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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.