diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 35c606a..d61ddfd 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -139,15 +139,6 @@ "@telemetryAllocated": { "placeholders": { "watts": { "type": "int", "format": "decimalPattern" } } }, - "telemetryEvArbiterIntent": "L'arbitre a décidé {watts} W", - "@telemetryEvArbiterIntent": { - "description": "Remplace « Alloué X W » pour une BORNE. Tant que le lot plugin 3g-2 n'est pas livré, adjustEvChargers() re-décide après le waterfall : l'allocation publiée est une intention, pas un ordre. Le verbe doit donc nommer QUI a décidé, pas ce que la borne fait.", - "placeholders": { "watts": { "type": "int", "format": "decimalPattern" } } - }, - "telemetryEvIntentDisclaimer": "Décision de l'arbitre — l'état réel de la borne est ci-dessus.", - "@telemetryEvIntentDisclaimer": { - "description": "Ligne qui suit le motif d'une borne. Sans elle, un écran qui affiche « budget sous le plancher » à côté d'une borne qui charge à 6 A a l'air faux — et c'est le moteur qui se contredit, pas l'écran. Tombe avec 3g-2." - }, "telemetryEvCharging": "Borne : en charge, {current} A sur {phases} phase(s)", "@telemetryEvCharging": { "description": "ÉTAT RÉEL, lu dans mechanism.chargingEnabled / currentA / phaseCount — qui viennent du Thing, pas de l'arbitre.", @@ -316,6 +307,23 @@ "min": { "type": "int", "format": "decimalPattern" } } }, + "decisionEvGridStart": "Démarrage soutiré : {grid} W achetés au réseau pour compléter les {budget} W de surplus et atteindre le plancher de {floor} W.", + "@decisionEvGridStart": { + "description": "EV_GRID_START. La borne démarre alors que le surplus ne paie pas son plancher : acquisitionTolerance autorise l'appoint. gridW = floorW − budgetW est CE QUI EST ACHETÉ — c'est le chiffre qui fait de cette ligne une décision et non un effet de bord, donc il vient en tête. Motif préparé mais JAMAIS vu sur machine au 2026-08-27 : ne rien bâtir autour.", + "placeholders": { + "grid": { "type": "int", "format": "decimalPattern" }, + "budget": { "type": "int", "format": "decimalPattern" }, + "floor": { "type": "int", "format": "decimalPattern" } + } + }, + "decisionPhaseLimit": "Limite de phase atteinte : {limit} W disponibles pour {required} W exigés — ce n'est pas le surplus qui manque.", + "@decisionPhaseLimit": { + "description": "PHASE_LIMIT. Deux causes, deux gestes : la limite de phase se règle en délestant la maison ou en revoyant l'abonnement, le manque de surplus s'attend. Confondre les deux envoie chercher du soleil là où il faut lire un compteur — d'où la seconde moitié de la phrase.", + "placeholders": { + "limit": { "type": "int", "format": "decimalPattern" }, + "required": { "type": "int", "format": "decimalPattern" } + } + }, "decisionBatteryReserveWithheld": "{withheld} W de surplus retenus par la réserve batterie ({soc} % sous le seuil de {seuil} %) — il y a du surplus, mais une règle interdit de le distribuer.", "@decisionBatteryReserveWithheld": { "description": "Variante NOMINALE : le plugin publie toujours les trois paramètres ensemble, et sa garde interne exige withheldW > 0 — un motif à 0 W dirait « une règle vous bloque » là où il n'y a simplement pas de surplus. Le nombre de watts retenus vient en TÊTE parce que c'est lui qui rend la cause lisible : « 3 200 W retenus » se comprend, « batterie à 47 % » se subit.", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 6cc5681..0c5f9fc 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -445,18 +445,6 @@ abstract class L10n { /// **'Alloué {watts} W'** String telemetryAllocated(int watts); - /// Remplace « Alloué X W » pour une BORNE. Tant que le lot plugin 3g-2 n'est pas livré, adjustEvChargers() re-décide après le waterfall : l'allocation publiée est une intention, pas un ordre. Le verbe doit donc nommer QUI a décidé, pas ce que la borne fait. - /// - /// In fr, this message translates to: - /// **'L\'arbitre a décidé {watts} W'** - String telemetryEvArbiterIntent(int watts); - - /// Ligne qui suit le motif d'une borne. Sans elle, un écran qui affiche « budget sous le plancher » à côté d'une borne qui charge à 6 A a l'air faux — et c'est le moteur qui se contredit, pas l'écran. Tombe avec 3g-2. - /// - /// In fr, this message translates to: - /// **'Décision de l\'arbitre — l\'état réel de la borne est ci-dessus.'** - String get telemetryEvIntentDisclaimer; - /// ÉTAT RÉEL, lu dans mechanism.chargingEnabled / currentA / phaseCount — qui viennent du Thing, pas de l'arbitre. /// /// In fr, this message translates to: @@ -661,6 +649,18 @@ abstract class L10n { /// **'Budget de {budget} W sous l\'état {state} ({min} W estimés) — maintien en état 2, le budget passe à la charge suivante.'** String decisionBelowMinPowerState(int budget, int state, int min); + /// EV_GRID_START. La borne démarre alors que le surplus ne paie pas son plancher : acquisitionTolerance autorise l'appoint. gridW = floorW − budgetW est CE QUI EST ACHETÉ — c'est le chiffre qui fait de cette ligne une décision et non un effet de bord, donc il vient en tête. Motif préparé mais JAMAIS vu sur machine au 2026-08-27 : ne rien bâtir autour. + /// + /// In fr, this message translates to: + /// **'Démarrage soutiré : {grid} W achetés au réseau pour compléter les {budget} W de surplus et atteindre le plancher de {floor} W.'** + String decisionEvGridStart(int grid, int budget, int floor); + + /// PHASE_LIMIT. Deux causes, deux gestes : la limite de phase se règle en délestant la maison ou en revoyant l'abonnement, le manque de surplus s'attend. Confondre les deux envoie chercher du soleil là où il faut lire un compteur — d'où la seconde moitié de la phrase. + /// + /// In fr, this message translates to: + /// **'Limite de phase atteinte : {limit} W disponibles pour {required} W exigés — ce n\'est pas le surplus qui manque.'** + String decisionPhaseLimit(int limit, int required); + /// Variante NOMINALE : le plugin publie toujours les trois paramètres ensemble, et sa garde interne exige withheldW > 0 — un motif à 0 W dirait « une règle vous bloque » là où il n'y a simplement pas de surplus. Le nombre de watts retenus vient en TÊTE parce que c'est lui qui rend la cause lisible : « 3 200 W retenus » se comprend, « batterie à 47 % » se subit. /// /// In fr, this message translates to: diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index 605583d..f10c073 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -241,19 +241,6 @@ class L10nFr extends L10n { return 'Alloué $wattsString W'; } - @override - String telemetryEvArbiterIntent(int watts) { - final intl.NumberFormat wattsNumberFormat = - intl.NumberFormat.decimalPattern(localeName); - final String wattsString = wattsNumberFormat.format(watts); - - return 'L\'arbitre a décidé $wattsString W'; - } - - @override - String get telemetryEvIntentDisclaimer => - 'Décision de l\'arbitre — l\'état réel de la borne est ci-dessus.'; - @override String telemetryEvCharging(double current, int phases) { final intl.NumberFormat currentNumberFormat = @@ -512,6 +499,34 @@ class L10nFr extends L10n { return 'Budget de $budgetString W sous l\'état $state ($minString W estimés) — maintien en état 2, le budget passe à la charge suivante.'; } + @override + String decisionEvGridStart(int grid, int budget, int floor) { + final intl.NumberFormat gridNumberFormat = intl.NumberFormat.decimalPattern( + localeName, + ); + final String gridString = gridNumberFormat.format(grid); + final intl.NumberFormat budgetNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String budgetString = budgetNumberFormat.format(budget); + final intl.NumberFormat floorNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String floorString = floorNumberFormat.format(floor); + + return 'Démarrage soutiré : $gridString W achetés au réseau pour compléter les $budgetString W de surplus et atteindre le plancher de $floorString W.'; + } + + @override + String decisionPhaseLimit(int limit, int required) { + final intl.NumberFormat limitNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String limitString = limitNumberFormat.format(limit); + final intl.NumberFormat requiredNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String requiredString = requiredNumberFormat.format(required); + + return 'Limite de phase atteinte : $limitString W disponibles pour $requiredString W exigés — ce n\'est pas le surplus qui manque.'; + } + @override String decisionBatteryReserveWithheld(int withheld, int soc, int seuil) { final intl.NumberFormat withheldNumberFormat = diff --git a/lib/models/load_telemetry.dart b/lib/models/load_telemetry.dart index 6c5e109..7e81119 100644 --- a/lib/models/load_telemetry.dart +++ b/lib/models/load_telemetry.dart @@ -165,15 +165,11 @@ class LoadMechanism { // ── evcharger (3g) — l'ÉTAT RÉEL de la borne ──────────────────────────────── // - // Ces quatre champs viennent du Thing, pas de l'arbitre. C'est la distinction qui - // compte tant que 3g-2 n'est pas livré : `adjustEvChargers()` re-décide le surplus - // APRÈS le waterfall et passe outre le refus de l'arbitre. Relevé sur `.75`, deux - // lignes du même cycle — l'arbitre publie `BELOW_MIN_POWER` et « 0 W », le proxy - // commande `ON` à 6 A une milliseconde plus tard. - // - // Donc : pour une borne, [LoadTelemetryEntry.allocatedW] et son motif sont une - // INTENTION, ces champs-ci sont ce que la borne fait. Les deux peuvent se contredire, - // et c'est l'écran qui aurait l'air faux. Cf. [LoadTelemetryEntry.allocationIsCommand]. + // Ces quatre champs viennent du Thing ; `allocatedW` vient de l'arbitre. Depuis `+etm23` + // l'arbitre est le seul à commander la borne (le double commandeur de `+etm22` est + // supprimé), mais **commander n'est pas obtenir** : un plafond matériel, un véhicule qui + // refuse, un câble débranché font diverger les deux. C'est ici que l'écart se lit, pas + // dans l'allocation. bool? get chargingEnabled => raw['chargingEnabled'] as bool?; bool? get pluggedIn => raw['pluggedIn'] as bool?; double? get currentA => _dn(raw['currentA']); @@ -300,23 +296,52 @@ class LoadTelemetryEntry { /// laisse l'identité vérifiable sur une box ancienne au lieu de la faire échouer. bool get isSurplusFunded => funding != 'grid'; - /// **Faux pour une borne : l'allocation publiée est une INTENTION, pas un ordre.** + /// Part de [allocatedW] qui a été **payée par le surplus**, donc comptée dans + /// `budget.allocatedW`. /// - /// `adjustEvChargers()` re-décide le surplus après le waterfall et passe outre le refus - /// de l'arbitre — relevé sur `.75`, l'arbitre publie « 0 W / `BELOW_MIN_POWER` » et le - /// proxy commande `ON` à 6 A dans le même cycle. Pour une borne, [allocatedW] et - /// [decision] sont donc **la décision de l'arbitre**, et l'état réel se lit dans - /// `mechanism.chargingEnabled` / `mechanism.currentA`, qui viennent du Thing. + /// Presque toujours tout ou rien. **Une seule décision partage une allocation entre les + /// deux compteurs du budget** : `EV_GRID_START` — la borne démarre alors que le surplus + /// ne paie pas son plancher, et `acquisitionTolerance` autorise l'appoint. Le motif + /// porte `budgetW` (venu du surplus) et `gridW` (acheté au réseau), et le plugin + /// garantit `budgetW + gridW == allocatedW`. /// - /// Écrire « la borne ne charge pas parce que le budget est sous le plancher » à partir - /// du seul motif est faux aujourd'hui, et c'est l'écran qui aurait l'air faux. + /// Compter cette ligne comme entièrement « réseau » creuserait un trou de la taille de + /// `budgetW` dans la réconciliation ; la compter entièrement « surplus » attribuerait au + /// soleil une puissance achetée. Ni l'un ni l'autre ne se voit à l'œil : c'est une + /// réconciliation qui échoue sans dire pourquoi. /// - /// La réserve **ne vaut pas** pour l'ECS, la PAC et les autres charges pilotées : là, - /// l'allocation publiée **est** la commande. + /// Si le motif est là mais `budgetW` absent, on retombe sur zéro plutôt que sur une + /// part devinée — un chiffre faux est pire qu'un chiffre manquant dans un contrôle + /// d'identité. + double get surplusShareW { + if (isSurplusFunded) return allocatedW; + if (decision.code == 'EV_GRID_START') return decision.numParam('budgetW') ?? 0.0; + return 0.0; + } + + /// **[allocatedW] est ce qui a été COMMANDÉ. Ce n'est pas ce que la charge tire.** /// - /// Elle tombe avec le lot plugin 3g-2 (« l'arbitre commande seul »). Le jour où il est - /// déployé, ce getter rend `true` partout et les branches qui le lisent disparaissent. - bool get allocationIsCommand => mechanism?.isEvCharger != true; + /// La distinction a changé de nature avec le lot plugin 3g-2, et il faut la reprendre + /// exactement, sans l'élargir. + /// + /// Jusqu'à `+etm22`, une borne avait **deux commandeurs** : l'arbitre décidait et + /// publiait, puis `adjustEvChargers()` re-décidait derrière lui et commandait autre + /// chose. Ce que la télémétrie publiait n'atteignait pas le matériel. `+etm23` ferme + /// ça — une charge, un commandeur ; mesuré sur machine, 9 commandes émises à la borne, + /// 9 par l'arbitre, 0 par le proxy. + /// + /// **Ce qui devient vrai, c'est que personne d'autre ne commande. Pas que la charge + /// fait ce qu'on lui dit.** Un plafond matériel, un véhicule qui refuse, un câble + /// débranché feront toujours diverger la commande et la réalité — pour une borne comme + /// pour un chauffe-eau dont la résistance a lâché. + /// + /// L'écart commande/réalité ne se lit donc **jamais** dans [allocatedW] : il se lit dans + /// [measuredW] quand un compteur est rattaché, et dans la charge utile [mechanism] — + /// pour une borne, `chargingEnabled`, `currentA`, `phaseCount`, `pluggedIn`. + /// + /// Ce getter n'existe plus : il portait la réserve « pour une borne, ce n'est même pas + /// une commande », qui est levée. Sa disparition est délibérée — le garder à `true` + /// partout inviterait à lire « commande » comme « réalité ». } /// Instantané complet de l'arbitrage. @@ -341,8 +366,10 @@ class LoadTelemetry { /// /// - une borne peut être là au titre du surplus **ou** au titre du réseau (échéance, /// tarif dynamique) — d'où [LoadTelemetryEntry.funding] ; - /// - pour une borne, l'allocation publiée n'est **pas** un ordre tant que 3g-2 n'est pas - /// livré — d'où [LoadTelemetryEntry.allocationIsCommand]. + /// - une borne **configurée mais absente d'ici** n'est pas perdue : elle est *hors + /// arbitrage en ce moment* — pas de véhicule branché (depuis `+etm24`), pas de voiture + /// assignée, ou mode manuel. L'inclusion `loads[] ⊆ GetLoadConfig` tient dans ce sens + /// seulement. /// /// Depuis le lot plugin B-bis, tout `loadId` publié ici a une entrée dans /// `GetLoadConfig` — l'inverse n'est pas vrai : une charge configurée absente d'ici est @@ -385,10 +412,14 @@ class LoadTelemetry { /// C'est le seul terme comparable à `budget.allocatedW` : une borne servie au réseau /// est comptée dans `budget.evReservedW`, et l'inclure ici creuserait un écart que rien /// ne permettrait d'interpréter — défaut du moteur, ou borne au réseau ? + /// + /// Somme des [LoadTelemetryEntry.surplusShareW], et non des `allocatedW` filtrés : le + /// démarrage soutiré (`EV_GRID_START`) partage une même allocation entre les deux + /// compteurs du budget. double get surplusAllocatedW { var total = 0.0; for (final e in loads) { - if (e.isSurplusFunded) total += e.allocatedW; + total += e.surplusShareW; } return total; } diff --git a/lib/services/telemetry_text.dart b/lib/services/telemetry_text.dart index fbe76f2..b71c9ff 100644 --- a/lib/services/telemetry_text.dart +++ b/lib/services/telemetry_text.dart @@ -152,6 +152,35 @@ String decisionText(L10n t, LoadDecision d) { // Deux variantes, selon que le mécanisme se commande en watts ou en états. La présence // de `state` les départage — et si elle manque sur un mécanisme à états, le repli // rendra le code et ses paramètres plutôt qu'une phrase fausse. + // ── Démarrage soutiré (3g-2) ──────────────────────────────────────────── + // + // « Il y a du surplus, pas assez pour le plancher, et un réglage autorise à acheter + // la différence. » C'est une décision, pas un effet de bord — d'où `gridW` en tête de + // phrase : c'est le nombre de watts ACHETÉS, le seul qui distingue cette ligne d'un + // démarrage ordinaire. + // + // C'est aussi la seule ligne dont l'allocation se partage entre les deux compteurs du + // budget : `budgetW` au surplus, `gridW` au réseau. Cf. `surplusShareW`. + // + // **Jamais vu sur machine au 2026-08-27** — guetté six cycles au banc, le budget est + // passé de 385 W à 2 350 W sans s'arrêter dans la fenêtre de tolérance. La clé est + // prête ; rien n'est bâti autour. + case 'EV_GRID_START': + final grid = _w(d, 'gridW'); + final budget = _w(d, 'budgetW'); + final floor = _w(d, 'floorW'); + if (grid == null || budget == null || floor == null) break; + return t.decisionEvGridStart(grid, budget, floor); + + // C'est la LIMITE DE PHASE qui interdit, pas le budget. Deux causes, deux gestes chez + // le client : l'une se règle en délestant ou en revoyant l'abonnement, l'autre + // s'attend. Les confondre envoie chercher du soleil là où il faut lire un compteur. + case 'PHASE_LIMIT': + final limit = _w(d, 'limitW'); + final requis = _w(d, 'requiredW'); + if (limit == null || requis == null) break; + return t.decisionPhaseLimit(limit, requis); + case 'BELOW_MIN_POWER': final budget = _w(d, 'budgetW'); final min = _w(d, 'minPowerW'); diff --git a/lib/widgets/load_cards.dart b/lib/widgets/load_cards.dart index b044d1a..4ebb78a 100644 --- a/lib/widgets/load_cards.dart +++ b/lib/widgets/load_cards.dart @@ -134,12 +134,16 @@ class LoadLiveBlock extends StatelessWidget { final mech = live.mechanism; final lock = live.lock; - // Une BORNE ne se lit pas comme les autres charges tant que 3g-2 n'est pas livré : - // `adjustEvChargers()` re-décide après le waterfall et peut commander l'inverse de ce - // que l'arbitre a publié. L'allocation et son motif restent affichés — ils disent ce - // que le moteur a VOULU, ce qui est une information — mais l'état réel du matériel - // passe DEVANT, et rien ne présente le motif comme la cause de ce que fait la borne. - final borne = !live.allocationIsCommand; + // Depuis `+etm23`, l'arbitre est le SEUL à commander une borne : l'allocation + // publiée est bien un ordre, et la mise en garde qui la présentait comme une simple + // intention est retirée. + // + // Ce qui reste vrai, et qui ne doit pas se perdre dans la levée de la réserve : + // commander n'est pas obtenir. Un plafond matériel, un véhicule qui refuse, un câble + // débranché font diverger l'ordre et la réalité. La borne garde donc sa ligne d'état + // matériel — pas parce qu'un autre commandeur la contredirait, mais parce que c'est + // le seul endroit où cet écart-là se lit. + final borne = mech?.isEvCharger == true; // La ligne de mécanisme redirait le courant déjà donné par l'état réel d'une borne. final mechText = @@ -152,12 +156,11 @@ class LoadLiveBlock extends StatelessWidget { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - // ── L'ÉTAT RÉEL de la borne, AVANT la décision de l'arbitre ───────── + // ── Ce que la borne FAIT, à côté de ce qui lui a été commandé ─────── // - // `chargingEnabled` / `currentA` viennent du Thing ; l'allocation et son motif - // viennent de l'arbitre. Les deux peuvent se contredire aujourd'hui, et l'ordre - // d'affichage est ce qui empêche l'écran d'avoir l'air faux : on montre d'abord - // ce que le matériel fait, ensuite ce que le moteur a décidé. + // `chargingEnabled` / `currentA` / `pluggedIn` viennent du Thing. L'allocation + // vient de l'arbitre, qui est désormais seul à commander — mais l'ordre et son + // effet restent deux choses, et c'est ici que l'écart se voit. if (borne) ...[ Row(children: [ Icon(Icons.ev_station_rounded, size: 14, color: tone), @@ -176,10 +179,7 @@ class LoadLiveBlock extends StatelessWidget { size: 14, color: stale ? const Color(0xFFB0B7BF) : const Color(0xFF2E7D32)), const SizedBox(width: 4), - Text( - borne - ? t.telemetryEvArbiterIntent(live.allocatedW.round()) - : t.telemetryAllocated(live.allocatedW.round()), + Text(t.telemetryAllocated(live.allocatedW.round()), style: TextStyle( fontSize: 12, fontWeight: FontWeight.w600, color: tone)), if (mechText != null) ...[ @@ -219,15 +219,6 @@ class LoadLiveBlock extends StatelessWidget { const SizedBox(height: 2), Text(decisionText(t, live.decision), style: TextStyle(fontSize: 11, color: tone)), - // Le motif d'une borne est la décision de l'arbitre, pas la cause de ce que la - // borne fait. Le dire explicitement coûte une ligne ; le taire fait passer une - // contradiction du moteur pour un défaut de l'écran. - if (borne) ...[ - const SizedBox(height: 2), - Text(t.telemetryEvIntentDisclaimer, - style: TextStyle( - fontSize: 11, fontStyle: FontStyle.italic, color: tone)), - ], // Financement — une charge servie au RÉSEAU n'est pas comptée au budget de // surplus. Sans cette mention, l'utilisateur voit une charge servie alors que le // surplus est nul, et cherche un défaut. @@ -545,10 +536,6 @@ String _etatBorne(L10n t, LoadMechanism? mech) { String _ecartMesure(LoadTelemetryEntry live) { final m = live.measuredW; if (m == null) return ''; - // Sur une borne, l'allocation n'est pas une consigne : « sous la consigne » y - // affirmerait un écart entre une commande et son effet là où il n'y a peut-être - // aucune commande. Muet tant que 3g-2 n'a pas rendu l'arbitre seul commandeur. - if (!live.allocationIsCommand) return ''; final a = live.allocatedW; if (a <= 0) return m > 20 ? '· alloué 0 W' : ''; final ecart = (m - a).abs(); diff --git a/test/load_telemetry_test.dart b/test/load_telemetry_test.dart index 5d63e8d..4a408ab 100644 --- a/test/load_telemetry_test.dart +++ b/test/load_telemetry_test.dart @@ -364,6 +364,8 @@ void main() { 'reservePercent': 90, 'withheldW': 3200, }, + 'EV_GRID_START': {'budgetW': 2070, 'floorW': 4140, 'gridW': 2070}, + 'PHASE_LIMIT': {'limitW': 3680, 'requiredW': 4140}, 'DEGRADED_L2': {}, 'SAFE_STATE_RELAY': {}, 'SAFE_STATE_SETPOINT': {}, @@ -478,40 +480,119 @@ void main() { expect(degrade.surplusIdentityHolds, isNull); }); - test('pour une BORNE, l\'allocation n\'est pas une commande — pour l\'ECS, si', () { - final tel = LoadTelemetry.fromJson(payload()); - // La réserve est ciblée : elle ne doit PAS déborder sur les charges pilotées, où - // l'allocation publiée est bien l'ordre émis. - expect(tel.entryFor('chauffe-eau')!.allocationIsCommand, isTrue); - expect(tel.entryFor('borne-echeance')!.allocationIsCommand, isFalse); - }); - - test('l\'état réel de la borne vient du Thing, pas du motif d\'arbitrage', () { - // Le cas relevé sur `.75` : l'arbitre refuse (« 0 W », BELOW_MIN_POWER) et le proxy - // commande ON à 6 A dans le même cycle. Les deux se contredisent, et c'est - // `mechanism` qui dit ce que la borne FAIT. + test('commander n\'est pas obtenir — l\'écart se lit dans mechanism, pas dans ' + 'allocatedW', () { + // 3g-2 a supprimé le second commandeur : l'allocation d'une borne EST un ordre. + // Ce qui reste, et que la levée de la réserve ne doit pas emporter : un plafond + // matériel, un véhicule qui refuse ou un câble débranché font toujours diverger + // l'ordre et la réalité. Ici, commandée à 4 140 W, la borne ne tire rien parce + // qu'aucun véhicule n'est branché. final borne = LoadTelemetryEntry.fromJson(const { 'loadId': 'borne-surplus', - 'allocatedW': 0.0, + 'allocatedW': 4140.0, 'available': true, 'funding': 'surplus', 'mechanism': { 'kind': 'evcharger', - 'chargingEnabled': true, - 'currentA': 6.0, - 'phaseCount': 1, - 'pluggedIn': true, + 'chargingEnabled': false, + 'currentA': 0.0, + 'phaseCount': 3, + 'pluggedIn': false, }, 'decision': { - 'code': 'BELOW_MIN_POWER', - 'params': {'budgetW': 1096, 'minPowerW': 4140}, + 'code': 'SURPLUS_SETPOINT', + 'params': {'budgetW': 4200, 'setpointW': 4140, 'stepped': false}, }, }); - expect(borne.allocatedW, 0); - expect(borne.mechanism!.chargingEnabled, isTrue); - expect(borne.mechanism!.currentA, 6); - expect(borne.allocationIsCommand, isFalse, - reason: 'écrire « la borne ne charge pas » ici serait faux'); + expect(borne.allocatedW, 4140, reason: 'ce qui a été COMMANDÉ'); + expect(borne.mechanism!.chargingEnabled, isFalse, reason: 'ce qui SE PASSE'); + expect(borne.mechanism!.pluggedIn, isFalse, reason: 'et pourquoi'); + }); + }); + + // ─────────────────────────────────────────────────────────────────────────── + group('3g-2 — démarrage soutiré, et la comptabilité qui se partage', () { + test('EV_GRID_START nomme les watts ACHETÉS, pas seulement le manque', () { + final phrase = decisionText( + t, + const LoadDecision( + code: 'EV_GRID_START', + params: {'budgetW': 2070, 'floorW': 4140, 'gridW': 2070}, + ), + ); + expect(phrase, isNot(contains('EV_GRID_START'))); + expect(phrase, contains('réseau'), + reason: 'sans le mot, la ligne se lit comme un démarrage ordinaire'); + }); + + test('PHASE_LIMIT ne se lit pas « il manque du soleil »', () { + final phrase = decisionText( + t, + const LoadDecision( + code: 'PHASE_LIMIT', params: {'limitW': 3680, 'requiredW': 4140}), + ); + expect(phrase, isNot(contains('PHASE_LIMIT'))); + expect(phrase.toLowerCase(), contains('phase')); + // Deux causes, deux gestes : la phrase doit écarter explicitement le surplus. + expect(phrase.toLowerCase(), contains('surplus')); + }); + + test('un démarrage soutiré PARTAGE son allocation entre les deux compteurs', () { + // C'est le seul cas où une même allocatedW alimente budget.allocatedW ET + // budget.evReservedW. La compter en bloc d'un côté ou de l'autre casse la + // réconciliation sans rien dire. + final tel = LoadTelemetry.fromJson(const { + 'degradedMode': false, + 'timestamp': '2026-08-27T12:00:00Z', + 'budget': { + 'surplusW': 2500.0, + 'evReservedW': 2070.0, + 'recreditedW': 0.0, + 'allocatedW': 2070.0, + 'remainingW': 430.0, + }, + 'loads': [ + { + 'loadId': 'borne-appoint', + 'allocatedW': 4140.0, + 'available': true, + 'funding': 'grid', + 'mechanism': {'kind': 'evcharger', 'chargingEnabled': true}, + 'decision': { + 'code': 'EV_GRID_START', + 'params': {'budgetW': 2070, 'floorW': 4140, 'gridW': 2070}, + }, + }, + ], + }); + final e = tel.loads.single; + // budgetW + gridW == allocatedW, garanti par le plugin. + expect(e.decision.numParam('budgetW')! + e.decision.numParam('gridW')!, + e.allocatedW); + expect(e.surplusShareW, 2070, reason: 'seule la part surplus se réconcilie'); + expect(tel.surplusIdentityHolds, isTrue); + }); + + test('sans budgetW, la part surplus retombe à zéro plutôt que sur une devinette', () { + final e = LoadTelemetryEntry.fromJson(const { + 'loadId': 'borne', + 'allocatedW': 4140.0, + 'available': true, + 'funding': 'grid', + 'decision': {'code': 'EV_GRID_START', 'params': {}}, + }); + expect(e.surplusShareW, 0); + }); + + test('une borne servie au réseau SANS partage ne compte pas au surplus', () { + final e = LoadTelemetryEntry.fromJson(const { + 'loadId': 'borne-echeance', + 'allocatedW': 3680.0, + 'available': true, + 'funding': 'grid', + 'decision': {'code': 'EV_DEADLINE', 'params': {}}, + }); + expect(e.surplusShareW, 0); }); }); }