Patrick Schurig fd516daea5 docs(3g §5): Min+PV — le mode a un minimum garanti, qui n'a jamais tourné
Établi, aucun correctif posé.

1. ChargingModeEcoWithMinCurrent A un comportement de minimum garanti : branche 4
   d'adjustEvChargers(), 6 A inconditionnels, sans condition de budget ni
   autorisation de soutirage. Ce n'est pas un EcoCharging avec un seuil.

2. Mais ce chemin n'a JAMAIS tourné, deux fois pour deux raisons. Chez l'amont, le
   repli d'échéance réécrivait le mode en ChargingModeEco à chaque cycle : branche
   morte, réglage détruit en silence. Corrigé côté ETM. Puis depuis 3g-2, le
   crochet evCommandedByWaterfall() exclut du proxy toute borne que le waterfall
   commande — la branche redevient inatteignable, pour une raison nouvelle. Mort
   par réécriture, puis mort par exclusion, sans que rien ne le signale.

3. Et sur cette installation il n'aurait rien changé : plancher 4 140 W contre
   2 000 W de crête. La voiture aurait chargé entièrement au réseau, en
   permanence, sans qu'aucun mécanisme ne le compte.

4. EV_GRID_START ne peut pas porter un minimum permanent. Ses trois bornes
   dérivent toutes du surplus — budgetW > 0, seuil proportionnel, consommation de
   tout le budget — et un minimum permanent en est indépendant par définition. Les
   lever, c'est créer le second chemin. Et EV_GRID_START achète HORS du registre
   d'autorisation de soutirage, là où la passe éco achète sous DrawCap : deux
   mécanismes dont un seul est borné, et c'est le second qui ferait disjoncter.

5. La différence de nature demeure : l'obligation éco est une énergie avec
   échéance, Min+PV une puissance permanente. On peut approcher la seconde par la
   première, mais sous régime mesuré le plancher retombe à zéro dès la cible
   atteinte — ce n'est pas un minimum permanent.
2026-09-06 14:41:28 +02:00

348 lines
20 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.

# DESIGN — 3g : la transplantation du VE dans le waterfall
**Statut : conception, à valider avant tout code** (`AGENTS.md`, workflow obligatoire).
**Entrée** : `specs/spec_loadmodel.md` §12, critère d'acceptation LM-1204-b.
**Écrit après lecture de** `smartchargingmanager.cpp` (1 900 lignes) et
`rulebasedscheduler.cpp`.
> **Ce document répond d'abord à la question posée** : la transplantation perd-elle quelque
> chose de ce que le proxy fait de juste ? **Oui, trois choses**, et elles sont nommées au §3.
> Deux d'entre elles demandent une décision qui n'est pas technique.
---
## 1. Le constat qui justifie 3g, et il est plus dur que prévu
Le scénario des deux bornes n'est pas une faiblesse théorique du proxy. **C'est un défaut
présent, et il se lit en huit lignes** (`planSurplusCharging`, ~1215) :
```cpp
double allowanceInWatt = -currentLoad;
double allowanceInAmpere = allowanceInWatt / 230;
while (it.hasNext()) {
EvCharger *evCharger = it.next();
double newRawValue = (evCharger->currentPower() / 230) + allowanceInAmpere;
…
if (chargingEnabled) it.remove();
}
```
**`allowanceInAmpere` est calculé UNE fois, avant la boucle, et n'est jamais décrémenté.**
Chaque borne reçoit donc **la totalité** du surplus disponible, comme si elle était seule.
Avec 4 kW et deux bornes, le proxy ne produit pas « 2 kW à chacune » — il produit **« 4 kW à
chacune »**. Ce qui rattrape la situation n'est pas l'arbitrage : c'est le compteur au cycle
suivant, puis la protection de surcharge. Le système converge par correction d'erreur, pas par
décision.
**Le `it.remove()` n'y change rien** : il sort la borne de la liste pour les étapes ultérieures,
il ne rend pas son allocation au budget des suivantes.
> **Conséquence pour le critère LM-1204-b** : le contre-exemple à ne pas produire — « 2 kW à
> chacune, rien ne charge » — n'est même pas le comportement actuel. Le comportement actuel est
> pire, et il est masqué par la boucle de rétroaction du compteur. Le test doit donc se lire
> ainsi : **avant 3g, les deux bornes démarrent puis sont rattrapées ; après 3g, une seule
> démarre, délibérément.**
---
## 2. Ce que la transplantation apporte sans rien ajouter
Ces points sont déjà résolus par le waterfall, et le VE en hérite gratuitement :
| Ce que le proxy fait à la main | Ce que le waterfall fait déjà |
|---|---|
| Allocation identique à toutes les bornes | **Cascade** : `remainingSurplusW` décrémenté à chaque charge |
| Rang des bornes inexistant | `priority`, trié une fois, un seul rang pour toutes les charges |
| Rendre à la borne sa propre consommation | Recrédit anti-clignotement (correction B), déjà en place |
| `EV_IDLE` pour tout refus | `BELOW_MIN_POWER` / `SURPLUS_INSUFFICIENT`, distinction du §12 |
**Le plancher du VE existe déjà** dans le proxy — `minimalChargingCurrent × acquisitionTolerance`
— et c'est exactement la notion du §12. Il devient `declared.minPowerW`.
⚠️ **Avec une nuance qui compte** : `minimalChargingCurrent` vaut
`max(charger.maxChargingCurrentMinValue, car.minChargingCurrent)` (ligne 605). Le plancher d'une
borne **dépend donc de la voiture branchée**. Ce n'est pas une valeur de configuration comme
pour l'ECS : c'est une valeur **runtime**, republiée à chaque `descriptor()`. Le §12 le permet
(le champ est dans `LoadDeclared`, pas seulement dans `LoadConfig`), mais l'écran ne doit pas la
présenter comme un réglage.
---
## 3. Ce qui NE se transporte PAS — les trois points à trancher
### 3.1 L'échéance : la machinerie n'a pas d'équivalent, et le waterfall ne sait pas acheter
Le proxy force la charge à fond quand `projectedEndTime > cible − 10 min`. Ce calcul repose sur
`remainingPercentageToCharge = targetPercentage − carBatteryLevel` (ligne 666) et sur une
projection de durée — **de la machinerie propre au VE**, alimentée par le SoC de la voiture.
`needs { dailyDeadline, minEnergyWhPerDay }` porte bien la **déclaration** d'une échéance. Mais :
- **le scheduler ne la lit pas** — `rulebasedscheduler.cpp` porte en toutes lettres
« *Grid funding (ECS/PAC) : dormant jusqu'à 3f (waterfall réseau) — non implémenté ici* » ;
- forcer à fond pour tenir une échéance, c'est **soutirer au réseau**, donc franchir la même
frontière que les planchers éco du §10.
> **Décision demandée.** Trois options, et elles n'ont pas le même coût :
>
> **(a)** 3g déplace l'allocation de surplus **seulement**, et l'échéance reste au proxy —
> qui continue de produire une action `TimeRequirement` prioritaire, court-circuitant le
> waterfall quand elle mord. Le proxy ne disparaît pas ; il maigrit.
> **(b)** 3g embarque 3f (waterfall réseau) et l'échéance devient un besoin comme un autre.
> Lot bien plus gros, et il recoupe le §10.
> **(c)** L'échéance est **suspendue** en beta, la borne restant en surplus pur. À écarter :
> c'est la fonction qui a motivé le proxy.
>
> **Ma recommandation : (a).** Elle isole 3g sur ce qu'il corrige réellement — la cascade — et
> 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,
tampon de 500 W quand la batterie charge sous le seuil, et réinjection de la puissance batterie
dans le surplus au-dessus.
Ce n'est pas une règle « VE » : c'est une règle de **budget**. Transplanté tel quel, deux issues,
et **aucune n'est neutre** :
- **la monter dans le budget** → l'ECS et la PAC se mettent, elles aussi, à respecter
`batteryLevelConsideration`. C'est probablement souhaitable — mais **c'est un changement de
comportement pour des charges qui ne l'ont jamais eu**, sur des installations en service ;
- **la laisser au VE** → il faut la réécrire dans l'adaptateur EV, et le budget publié en
télémétrie cesse de correspondre à ce que le VE a réellement vu.
> **Décision demandée.** Ma recommandation : **la monter dans le budget**, et le dire au
> changelog comme un changement de comportement assumé. Une batterie sous son seuil de réserve
> ne devrait pas plus alimenter un chauffe-eau qu'une voiture.
**DÉCISION : montée dans le budget**, appliquée en 3g-1.
**Complément 3g-2, trouvé en exécutant vraiment les décisions du waterfall.** La règle ne
mordait que sur les AUGMENTATIONS : le recrédit anti-clignotement rendait à chaque charge sa
propre consommation, y compris sous la réserve, si bien qu'une charge déjà en marche le restait.
Le motif publié disait pourtant « aucune charge n'est servie ». **Sous la réserve, le recrédit
est désormais supprimé** — le budget est une décision de retenir, pas une mesure, et recréditer
dedans rend exactement ce qu'on venait de confisquer (LM-1203-b : « annulé, pas réduit »).
Le motif `BATTERY_RESERVE` chiffre en conséquence les deux termes retenus : le surplus annulé
**et** la consommation propre de la charge. Éprouvé par `batteryLevelConsiderationStopCharging`,
qui échouait avant cette correction.
### 3.3 Le nombre de phases se décide en ampères, le waterfall parle en watts
`getBestPhaseCount()` choisit 1 ou 3 phases **à partir du courant brut**, puis divise. Le
waterfall, lui, alloue des **watts** — la conversion watts → ampères et le choix du nombre de
phases doivent donc vivre **dans l'adaptateur EV**, au moment d'exécuter.
Ce n'est pas une perte : c'est conforme à la règle 4 d'`AGENTS.md` (les bornes de l'adaptateur
écrêtent toute sortie de stratégie). **Mais l'ordre change** : aujourd'hui le nombre de phases
est choisi *avant* de connaître l'allocation finale ; demain il sera choisi *après*. Une borne
qui bascule en triphasé change son plancher effectif (6 A × 3 = 4,1 kW au lieu de 1,4 kW), donc
**le plancher publié doit correspondre au nombre de phases visé, pas au nombre courant**.
> C'est le piège le plus subtil du lot, et il produirait un défaut silencieux : un plancher
> 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
- **`planSpotMarketCharging`** — tarifs dynamiques (tier Predict AI), financement `Grid`, hors
surplus. Précédence déjà gérée par le scheduler.
- **`verifyOverloadProtection` / `Recovery`** — protection par phase. Elle ne déleste que les
bornes (§ du lot D) ; son extension au waterfall est une **précondition du §10**, pas de 3g.
- **`prepareInformation`**, l'estimation de SoC, `setupPluggedInHandlers`, `lockOnUnplug` — état
du processus de charge, sans équivalent chez les autres charges.
- **Le filtrage d'entrée** : borne sans voiture assignée, ou en `ChargingModeNormal`. En
waterfall, ces bornes doivent être **absentes du contexte**, pas présentes à rang 0 : une
borne en mode manuel n'est pas « servie en dernier », elle est **hors arbitrage**.
---
## 4-bis. Vérifié : `evReservedW` après la transplantation
`evReservedW` (correction A) existe parce que l'EV est décidé **avant** le waterfall : sa
consigne du cycle n'est pas encore visible au compteur, donc on la réserve. Une fois le VE dans
la cascade, sa réservation **devient** son allocation — la compter en plus serait un double
comptage.
**Audit du financement de chaque action EV** (`rulebasedscheduler.cpp`) :
| action | financement | après 3g-1 |
|---|---|---|
| spot market (l. 61) | `Grid` | reste au proxy → **doit rester dans `evReservedW`** |
| `buildTimeRequirementAction` (l. 202) | `Grid` | reste au proxy → **doit rester** |
| `buildMinCurrentAction` (l. 219) | `Surplus` | **disparaît** — remplacé par la cascade |
| `buildIdleAction` (l. 233) | `Surplus` | **disparaît** — remplacé par les motifs du §12 |
**Conclusion** : le filtre de `evReservedW` doit devenir `funding == LoadAction::Grid`. Il ne
s'agit pas de le supprimer mais de le **restreindre** à ce qui reste hors cascade. Tant que la
seconde moitié de 3g-1 n'est pas faite, il reste correct tel quel.
## 5. Découpage proposé
| É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** | ✅ **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 fallait décider — décidé
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é.
---
## §5 — MIN+PV : ce que le mode fait réellement, et pourquoi il n'a jamais tenu sa promesse
> Établi le 2026-09-06, à la demande de Patrick. **Question de modèle, aucun correctif posé.**
### 1. `ChargingModeEcoWithMinCurrent` A un comportement de minimum garanti
Ce n'est pas un `EcoCharging` avec un seuil. La branche 4 de
`SmartChargingManager::adjustEvChargers()` applique un minimum **inconditionnel** :
```cpp
// 4. Min current fallback (EcoWithMinCurrent / EcoMinWithTargetTime)
uint minCurrent = qMax(EcoMinChargingCurrent, evCharger->maxChargingCurrentMinValue());
ChargingAction minAction(true, minCurrent, processInfo.effectivePhaseCount, ...Surplus);
executeChargingAction(evCharger, minAction, currentDateTime);
```
Aucune condition de budget, aucune autorisation de soutirage : 6 A quoi qu'il arrive. C'est bien
« toujours la puissance minimale ».
### 2. Mais ce chemin n'a JAMAIS tourné — deux fois, pour deux raisons différentes
**Chez l'amont, le mode ne survivait pas à un cycle.** Le repli d'échéance réécrivait
`EcoWithMinCurrent` en `ChargingModeEco`, persistait et notifiait — **la branche 4 était du code
mort**, et le réglage était détruit en silence du point de vue de l'app. Corrigé côté ETM par la
garde `exigeUneEcheance` (`smartchargingmanager.cpp` l. 600-615).
**Depuis 3g-2, le crochet l'exclut.** `evCommandedByWaterfall()` retire du proxy exactement les
bornes que le waterfall commande — et le waterfall prend toute borne à voiture assignée en mode
non manuel. La branche 4 est donc de nouveau inatteignable, pour une raison nouvelle.
> **Le mode a donc traversé deux régimes sans jamais s'exécuter**, et rien ne l'a signalé : mort
> par réécriture, puis mort par exclusion. C'est le mode d'érosion « une règle qui expire sans
> être exécutée », appliqué à un réglage exposé au client.
### 3. Et sur cette installation, il n'aurait rien changé
Plancher d'une borne triphasée à 6 A : **4 140 W**. Production crête de l'installation :
**2 000 W**. Le plancher vaut **deux fois** la crête. Même avec un lien parfait et la branche 4
vivante, la voiture n'aurait jamais chargé au surplus — elle aurait chargé **entièrement au
réseau**, en permanence, sans qu'aucun mécanisme ne le compte.
### 4. `EV_GRID_START` ne peut PAS porter un minimum permanent
Ses trois bornes dérivent toutes du surplus, et un minimum permanent est par définition
indépendant du surplus. Les lever, c'est créer le second chemin d'achat.
| borne actuelle | ce qu'un minimum permanent en ferait |
|---|---|
| `budgetW > 0` — jamais de démarrage à sec | tomberait : un minimum doit tenir la nuit |
| `budgetW >= plancherW × tolerance` — seuil de **déclenchement**, proportionnel au surplus | tomberait : un minimum est une constante |
| consomme tout le budget → au plus **une borne par cycle** | tomberait : plus rien ne borne le nombre |
Et la différence de fond : `EV_GRID_START` est une **brèche assumée et bornée** dans le report du
financement réseau à 3f, explicitement gardée sous le plafond de surcharge — mais **pas** rangée
dans le registre d'autorisation de soutirage. La passe éco, elle, achète *sous* `DrawCap` /
`DrawMargin`, avec les trois sources de plafond et le motif qui dit combien est acheté.
**Un minimum permanent porté par `EV_GRID_START` serait donc un achat hors comptabilité** — deux
mécanismes d'achat dont un seul est borné par l'autorisation. Ils divergeraient, et c'est le
second qui ferait disjoncter.
### 5. La différence de nature qui reste à trancher
| | nature | exprimable par le §10 ? |
|---|---|---|
| obligation éco | **énergie** avec une échéance | oui, c'est son objet |
| Min+PV | **puissance** permanente | non |
La passe éco peut exprimer « 30 kWh avant 7 h », pas « 4 140 W à tout instant ». On peut
approcher la seconde par la première — `minEnergyWhPerDay = 4140 × 24 ≈ 99 kWh/jour` donne une
cadence constante de 4 140 W — mais c'est une valeur qu'aucun client ne saisirait, et sous régime
mesuré le plancher **retombe à zéro** dès la cible atteinte. Ce n'est donc pas un minimum
permanent : c'est une obligation qui y ressemble tant qu'elle n'est pas tenue.
**La question à poser au client est celle de Patrick** : veut-il « 4 140 W en permanence », ou
« 30 kWh avant 7 h » ? La seconde est plus proche du besoin réel, et elle est **déjà servie**.