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