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
15 KiB
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) :
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.cppporte 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
TimeRequirementprioritaire, 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 = Gridsur l'action — l'origine de l'énergie est dite, pas déduite ;- motif
EV_GRID_START {budgetW, floorW, gridW}—gridWchiffre le soutirage ; - comptabilité partagée :
budgetWentre dansbudget.allocatedW(part surplus),gridWdansbudget.evReservedW(part réseau), etbudgetW + gridW == allocatedWde la charge.
Ce qui le borne, par construction et non par discipline.
- Il exige du surplus réel (
budgetW > 0) : 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. Éprouvé par
testEvGridStartIsAnnouncedAndBounded. - Il reste sous le plafond de la protection de surcharge : ce plafond est déclaré dans
LoadContext.declared.maxPowerW(EvAdapter::toLoadContext, écrêté parevPhaseAllowanceW()), 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 —testOverloadProtectionEcoModel'é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'étatdesiredPhaseCount. 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), financementGrid, 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 |
— |
| 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é
- §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).
- §3.2 — la batterie : oui, montée dans le budget (3g-1), et le recrédit y est supprimé (3g-2).
- Le découpage : 3g-1 seul, puis 3g-2 sur ce que le banc a révélé.