From 58bf57e67f72c533fc52d917b56fabcf7fa199c6 Mon Sep 17 00:00:00 2001 From: Patrick Schurig Date: Sat, 5 Sep 2026 07:54:22 +0200 Subject: [PATCH] =?UTF-8?q?feat(=C2=A710):=20la=20carte=20de=20charge=20po?= =?UTF-8?q?rte=20ses=20deux=20niveaux=20=E2=80=94=20une=20commande,=20deux?= =?UTF-8?q?=20lignes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Premier lot d'écran, sur des champs tous posés (+etm55) et tous vus à la sonde. L'allocation reste le parent ; les niveaux en sont la décomposition. Une charge reçoit UNE commande par cycle quelles que soient les passes (LM-1003) : deux lignes décrivent une seule décision. - `EcoProgress` — regime, targetWh, deliveredWh (absent sous `unmeasurable`, jamais 0), purchasedWh (publié dans TOUS les régimes : c'est la commande du moteur, donc un fait connu sans mesure), measuredSince, measuredGaps ; - `LoadLevel` gagne `decision` et `progress` : chaque passe porte son motif. Lire la racine seule afficherait « rien fait » sur une charge qui achète — relevé du 2026-09-01 05:47, `SURPLUS_INSUFFICIENT` en racine et 767 W achetés au niveau éco ; - `ecoDecisionText` / `ecoProgressText` : les phrases des trois motifs éco et de leurs quatre causes. Aucun nom de paramètre n'est deviné — `cause`, `missingWh`, les quatre valeurs et `measuredGaps` sont vérifiés dans le binaire posé. Seule la FORME de `measuredGaps` reste non observée, donc on n'en lit que la présence ; - garde R6 à l'écran : `ECO_FLOOR_MET` sous `unmeasurable` est DÉNONCÉ comme une incohérence de la box, pas rendu. Servie oui, tenue jamais. Deux interdits écrits dans le code, pas seulement dans la maquette : sous `unmeasurable` on n'annonce pas que l'achat va s'intensifier (le taux est constant depuis +etm55), et `atMaximum: false` n'y veut pas dire « on n'est pas au plafond » mais « il n'y a aucun étalement, donc rien qui puisse cesser ». Un booléen à false se lit spontanément comme rassurant. Les charges utiles des tests viennent de `.75` — le cas éco-servi/confort-en- attente du 2026-09-01, le cas mesuré du 2026-09-04 — pas d'une lecture de contrat. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01GKM8FDNHZWogAsjjfB8pSw --- lib/l10n/app_fr.arb | 68 +++++++++ lib/l10n/app_localizations.dart | 114 ++++++++++++++ lib/l10n/app_localizations_fr.dart | 81 ++++++++++ lib/models/load_telemetry.dart | 94 ++++++++++++ lib/services/telemetry_text.dart | 90 +++++++++++ lib/widgets/load_cards.dart | 78 +++++++++- test/eco_levels_test.dart | 235 +++++++++++++++++++++++++++++ 7 files changed, 758 insertions(+), 2 deletions(-) create mode 100644 test/eco_levels_test.dart diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index dbe84cb..f8d8ce7 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -160,6 +160,74 @@ "@telemetryFundedByGrid": { "description": "funding == « grid » : l'allocation est hors de budget.allocatedW, et sans registre où la retrouver (evReservedW est une correction du budget, pas une destination — constaté 2026-08-30). Sans cette mention, l'utilisateur voit une charge servie alors que le surplus est nul et croit à un défaut." }, + "niveauEco": "ÉCO", + "@niveauEco": { + "description": "Étiquette du niveau plancher dans la décomposition d'une commande. Majuscules courtes : la carte en porte deux l'une sous l'autre." + }, + "niveauConfort": "CONFORT", + "decisionEcoFloorGrid": "Plancher servi — {gridW} W achetés au réseau", + "@decisionEcoFloorGrid": { + "description": "ECO_FLOOR_GRID. L'achat est le fait actionnable : il coûte, contrairement au surplus.", + "placeholders": { "gridW": { "type": "int" } } + }, + "decisionEcoFloorMet": "Plancher atteint — {livre} sur {cible} livrés", + "@decisionEcoFloorMet": { + "description": "ECO_FLOOR_MET. Le SEUL état qui ferme l'obligation, et il est interdit sous le régime unmeasurable (R6) : on n'annonce pas tenu ce qu'on ne mesure pas.", + "placeholders": { "livre": { "type": "String" }, "cible": { "type": "String" } } + }, + "decisionEcoFloorMissed": "Obligation abandonnée à la bascule — {raison}", + "@decisionEcoFloorMissed": { + "description": "ECO_FLOOR_MISSED. La cause décide de la phrase ET du geste. Pas de cause « surplus insuffisant » : l'éco achète au réseau, donc le surplus ne peut jamais l'empêcher.", + "placeholders": { "raison": { "type": "String" } } + }, + "ecoCauseDrawCap": "le plafond de soutirage l'a refusée", + "ecoCauseLoadCapacity": "la charge ne pouvait pas absorber davantage", + "ecoCauseUnmeasurable": "avancement non vérifiable — ni tenue, ni chiffrable", + "@ecoCauseUnmeasurable": { + "description": "Sous unmeasurable, ECO_FLOOR_MISSED sort TOUS LES JOURS par construction, R6 interdisant ECO_FLOOR_MET. Ne jamais écrire « non tenue » : on ne sait pas." + }, + "ecoCauseUnavailable": "la charge était indisponible", + "ecoManque": " · il manquait {wh}", + "@ecoManque": { + "description": "missingWh, ABSENT sous unmeasurable — on ne sait pas ce qui a été livré, donc pas ce qui manquait ; un chiffre inventerait la grandeur dont l'absence est le problème.", + "placeholders": { "wh": { "type": "String" } } + }, + "ecoAvancementMesure": "{livre} sur {cible} livrés", + "@ecoAvancementMesure": { + "placeholders": { "livre": { "type": "String" }, "cible": { "type": "String" } } + }, + "ecoAvancementNonVerifiable": "reste {wh} à livrer — avancement non vérifiable", + "@ecoAvancementNonVerifiable": { + "description": "Sous unmeasurable, remainingWh == targetWh : rien n'est soustrait. « 4,0 sur 4,0 » veut dire « rien de mesurable », jamais « rien livré ».", + "placeholders": { "wh": { "type": "String" } } + }, + "ecoMesureDepuis": "mesuré depuis {heure}", + "@ecoMesureDepuis": { + "placeholders": { "heure": { "type": "String" } } + }, + "ecoMesureIntermittente": "mesuré par intermittence depuis {heure}", + "@ecoMesureIntermittente": { + "description": "measuredGaps présent. La date reste le PREMIER instant : redater à chaque retour effacerait l'histoire, se taire nierait le battement.", + "placeholders": { "heure": { "type": "String" } } + }, + "ecoTauxConstant": "taux constant (cible ÷ jour) — sans mesure, rien ne le corrige", + "@ecoTauxConstant": { + "description": "LM-1013-b-v. NE JAMAIS écrire que l'achat va s'intensifier sous unmeasurable : depuis +etm55 il n'y a plus d'escalade. Et atMaximum:false n'y veut PAS dire « on n'est pas au plafond » — il veut dire qu'il n'y a AUCUN étalement, donc rien qui puisse cesser. Un booléen à false se lit spontanément comme rassurant ; c'est la nuance qu'un lecteur pressé inversera." + }, + "ecoAuMaximum": "au maximum de la charge", + "ecoAchete": "{wh} achetés pour cette obligation", + "@ecoAchete": { + "description": "purchasedWh. Publié dans TOUS les régimes : c'est la commande du moteur, donc un fait connu sans mesure. Ne coïncide avec le livré que quand tout va bien — c'est sa divergence qui informe.", + "placeholders": { "wh": { "type": "String" } } + }, + "ecoIncoherenceTenue": "Incohérence de la box : plancher annoncé atteint alors que l'avancement n'est pas mesurable", + "@ecoIncoherenceTenue": { + "description": "R6 est armée des deux côtés : ECO_FLOOR_MET ne peut pas sortir sous le régime unmeasurable. Si la combinaison arrive quand même, l'écran la DÉNONCE au lieu de la rendre — servie oui, tenue jamais." + }, + "confortEnAttente": "En attente — {motif}", + "@confortEnAttente": { + "placeholders": { "motif": { "type": "String" } } + }, "telemetryNotArbitrated": "Non arbitrée — absente du dernier cycle publié", "@telemetryNotArbitrated": { "description": "Charge présente dans GetLoadConfig mais absente de loads[] : elle est désactivée, jamais perdue" diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index fe2aa5d..7ee9c5b 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -475,6 +475,120 @@ abstract class L10n { /// **'Financée au réseau (échéance ou tarif dynamique) — hors budget de surplus'** String get telemetryFundedByGrid; + /// Étiquette du niveau plancher dans la décomposition d'une commande. Majuscules courtes : la carte en porte deux l'une sous l'autre. + /// + /// In fr, this message translates to: + /// **'ÉCO'** + String get niveauEco; + + /// No description provided for @niveauConfort. + /// + /// In fr, this message translates to: + /// **'CONFORT'** + String get niveauConfort; + + /// ECO_FLOOR_GRID. L'achat est le fait actionnable : il coûte, contrairement au surplus. + /// + /// In fr, this message translates to: + /// **'Plancher servi — {gridW} W achetés au réseau'** + String decisionEcoFloorGrid(int gridW); + + /// ECO_FLOOR_MET. Le SEUL état qui ferme l'obligation, et il est interdit sous le régime unmeasurable (R6) : on n'annonce pas tenu ce qu'on ne mesure pas. + /// + /// In fr, this message translates to: + /// **'Plancher atteint — {livre} sur {cible} livrés'** + String decisionEcoFloorMet(String livre, String cible); + + /// ECO_FLOOR_MISSED. La cause décide de la phrase ET du geste. Pas de cause « surplus insuffisant » : l'éco achète au réseau, donc le surplus ne peut jamais l'empêcher. + /// + /// In fr, this message translates to: + /// **'Obligation abandonnée à la bascule — {raison}'** + String decisionEcoFloorMissed(String raison); + + /// No description provided for @ecoCauseDrawCap. + /// + /// In fr, this message translates to: + /// **'le plafond de soutirage l\'a refusée'** + String get ecoCauseDrawCap; + + /// No description provided for @ecoCauseLoadCapacity. + /// + /// In fr, this message translates to: + /// **'la charge ne pouvait pas absorber davantage'** + String get ecoCauseLoadCapacity; + + /// Sous unmeasurable, ECO_FLOOR_MISSED sort TOUS LES JOURS par construction, R6 interdisant ECO_FLOOR_MET. Ne jamais écrire « non tenue » : on ne sait pas. + /// + /// In fr, this message translates to: + /// **'avancement non vérifiable — ni tenue, ni chiffrable'** + String get ecoCauseUnmeasurable; + + /// No description provided for @ecoCauseUnavailable. + /// + /// In fr, this message translates to: + /// **'la charge était indisponible'** + String get ecoCauseUnavailable; + + /// missingWh, ABSENT sous unmeasurable — on ne sait pas ce qui a été livré, donc pas ce qui manquait ; un chiffre inventerait la grandeur dont l'absence est le problème. + /// + /// In fr, this message translates to: + /// **' · il manquait {wh}'** + String ecoManque(String wh); + + /// No description provided for @ecoAvancementMesure. + /// + /// In fr, this message translates to: + /// **'{livre} sur {cible} livrés'** + String ecoAvancementMesure(String livre, String cible); + + /// Sous unmeasurable, remainingWh == targetWh : rien n'est soustrait. « 4,0 sur 4,0 » veut dire « rien de mesurable », jamais « rien livré ». + /// + /// In fr, this message translates to: + /// **'reste {wh} à livrer — avancement non vérifiable'** + String ecoAvancementNonVerifiable(String wh); + + /// No description provided for @ecoMesureDepuis. + /// + /// In fr, this message translates to: + /// **'mesuré depuis {heure}'** + String ecoMesureDepuis(String heure); + + /// measuredGaps présent. La date reste le PREMIER instant : redater à chaque retour effacerait l'histoire, se taire nierait le battement. + /// + /// In fr, this message translates to: + /// **'mesuré par intermittence depuis {heure}'** + String ecoMesureIntermittente(String heure); + + /// LM-1013-b-v. NE JAMAIS écrire que l'achat va s'intensifier sous unmeasurable : depuis +etm55 il n'y a plus d'escalade. Et atMaximum:false n'y veut PAS dire « on n'est pas au plafond » — il veut dire qu'il n'y a AUCUN étalement, donc rien qui puisse cesser. Un booléen à false se lit spontanément comme rassurant ; c'est la nuance qu'un lecteur pressé inversera. + /// + /// In fr, this message translates to: + /// **'taux constant (cible ÷ jour) — sans mesure, rien ne le corrige'** + String get ecoTauxConstant; + + /// No description provided for @ecoAuMaximum. + /// + /// In fr, this message translates to: + /// **'au maximum de la charge'** + String get ecoAuMaximum; + + /// purchasedWh. Publié dans TOUS les régimes : c'est la commande du moteur, donc un fait connu sans mesure. Ne coïncide avec le livré que quand tout va bien — c'est sa divergence qui informe. + /// + /// In fr, this message translates to: + /// **'{wh} achetés pour cette obligation'** + String ecoAchete(String wh); + + /// R6 est armée des deux côtés : ECO_FLOOR_MET ne peut pas sortir sous le régime unmeasurable. Si la combinaison arrive quand même, l'écran la DÉNONCE au lieu de la rendre — servie oui, tenue jamais. + /// + /// In fr, this message translates to: + /// **'Incohérence de la box : plancher annoncé atteint alors que l\'avancement n\'est pas mesurable'** + String get ecoIncoherenceTenue; + + /// No description provided for @confortEnAttente. + /// + /// In fr, this message translates to: + /// **'En attente — {motif}'** + String confortEnAttente(String motif); + /// Charge présente dans GetLoadConfig mais absente de loads[] : elle est désactivée, jamais perdue /// /// In fr, this message translates to: diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index e31e113..26a74f0 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -263,6 +263,87 @@ class L10nFr extends L10n { String get telemetryFundedByGrid => 'Financée au réseau (échéance ou tarif dynamique) — hors budget de surplus'; + @override + String get niveauEco => 'ÉCO'; + + @override + String get niveauConfort => 'CONFORT'; + + @override + String decisionEcoFloorGrid(int gridW) { + return 'Plancher servi — $gridW W achetés au réseau'; + } + + @override + String decisionEcoFloorMet(String livre, String cible) { + return 'Plancher atteint — $livre sur $cible livrés'; + } + + @override + String decisionEcoFloorMissed(String raison) { + return 'Obligation abandonnée à la bascule — $raison'; + } + + @override + String get ecoCauseDrawCap => 'le plafond de soutirage l\'a refusée'; + + @override + String get ecoCauseLoadCapacity => + 'la charge ne pouvait pas absorber davantage'; + + @override + String get ecoCauseUnmeasurable => + 'avancement non vérifiable — ni tenue, ni chiffrable'; + + @override + String get ecoCauseUnavailable => 'la charge était indisponible'; + + @override + String ecoManque(String wh) { + return ' · il manquait $wh'; + } + + @override + String ecoAvancementMesure(String livre, String cible) { + return '$livre sur $cible livrés'; + } + + @override + String ecoAvancementNonVerifiable(String wh) { + return 'reste $wh à livrer — avancement non vérifiable'; + } + + @override + String ecoMesureDepuis(String heure) { + return 'mesuré depuis $heure'; + } + + @override + String ecoMesureIntermittente(String heure) { + return 'mesuré par intermittence depuis $heure'; + } + + @override + String get ecoTauxConstant => + 'taux constant (cible ÷ jour) — sans mesure, rien ne le corrige'; + + @override + String get ecoAuMaximum => 'au maximum de la charge'; + + @override + String ecoAchete(String wh) { + return '$wh achetés pour cette obligation'; + } + + @override + String get ecoIncoherenceTenue => + 'Incohérence de la box : plancher annoncé atteint alors que l\'avancement n\'est pas mesurable'; + + @override + String confortEnAttente(String motif) { + return 'En attente — $motif'; + } + @override String get telemetryNotArbitrated => 'Non arbitrée — absente du dernier cycle publié'; diff --git a/lib/models/load_telemetry.dart b/lib/models/load_telemetry.dart index dc16150..c562c28 100644 --- a/lib/models/load_telemetry.dart +++ b/lib/models/load_telemetry.dart @@ -365,6 +365,78 @@ class LoadDecision { bool? boolParam(String key) => params[key] as bool?; } +/// **L'avancement d'une obligation éco** — le bloc `progress` d'un niveau, depuis `+etm52`. +/// +/// Trois régimes qui coexistent dans le même cycle sur des charges différentes, d'où un champ +/// publié plutôt qu'un motif (LM-1009 C2). +class EcoProgress { + /// `"measured"` · `"estimated"` · `"unmeasurable"`. + final String regime; + + /// L'obligation déclarée, en Wh. + final double targetWh; + + /// Énergie livrée depuis l'ouverture. **Absent sous `unmeasurable`** — jamais zéro : une + /// dégradation ne doit pas se lire comme un avancement nul. + final double? deliveredWh; + + /// **Ce que le moteur a ACHETÉ pour cette obligation**, publié dans **tous** les régimes. + /// + /// C'est sa propre commande, donc un fait qu'il connaît sans mesure — à la différence de + /// [deliveredWh], qui est une observation qu'il ne peut pas toujours faire. C'est pourquoi + /// la paire n'est pas redondante : elle ne coïncide que quand tout va bien, et c'est sa + /// **divergence** qui informe. Relevé le 2026-09-04 sur charge réelle : 236 Wh achetés pour + /// 750 Wh livrés, et l'acheté recoupe `gridW × temps` à 0,3 %. + final double purchasedWh; + + /// Depuis quand on mesure. Après une bascule `unmeasurable → measured` en cours de période, + /// [deliveredWh] ne compte que depuis cette date **pendant que [targetWh] reste la cible + /// entière** : la paire cesse d'être une fraction, et c'est cette date qui l'explique. Le + /// moteur a refusé de rebaser la cible — on perdrait l'obligation réelle. + final DateTime? measuredSince; + + /// Les trous de mesure de la période, publiés **seulement s'il y en a**. + /// + /// ⚠️ **Forme non observée** : `strings` sur le binaire posé confirme la clé, pas son + /// contenu (`from`/`to` n'y sont pas). On n'en lit donc que la **présence** — « mesuré par + /// intermittence » — et rien de son détail tant qu'une sonde ne l'a pas vu sortir. + final List? measuredGaps; + + const EcoProgress({ + required this.regime, + required this.targetWh, + this.deliveredWh, + this.purchasedWh = 0, + this.measuredSince, + this.measuredGaps, + }); + + factory EcoProgress.fromJson(Map m) { + final since = m['measuredSince']; + final gaps = m['measuredGaps']; + return EcoProgress( + regime: m['regime'] as String? ?? '', + targetWh: _d(m['targetWh']), + deliveredWh: _dn(m['deliveredWh']), + purchasedWh: _d(m['purchasedWh']), + measuredSince: + (since is String && since.isNotEmpty) ? DateTime.tryParse(since) : null, + measuredGaps: gaps is List && gaps.isNotEmpty ? gaps : null, + ); + } + + bool get mesure => regime == 'measured'; + bool get nonMesurable => regime == 'unmeasurable'; + bool get estime => regime == 'estimated'; + + /// La mesure a connu des trous. Vrai seulement si la box l'a dit. + bool get parIntermittence => measuredGaps != null; + + /// **Une obligation non mesurable ne peut jamais se dire TENUE** (R6, armée des deux + /// côtés). Si `ECO_FLOOR_MET` sortait sous ce régime, ce serait une incohérence de la box. + bool get peutEtreTenue => !nonMesurable; +} + /// Le compteur du **surplus** : `budget.allocatedW`. Clé de `LoadLevel.counts`. const String kCompteurSurplus = 'budget.allocatedW'; @@ -442,8 +514,13 @@ class LoadLevel { required this.targetW, this.funding, this.counts, + this.decision, + this.progress, }); + /// Le plancher — l'obligation qu'on sert « quoi qu'il arrive », au réseau s'il le faut. + bool get estEco => level == 'eco'; + factory LoadLevel.fromJson(Map m) { final c = m['counts']; return LoadLevel( @@ -458,6 +535,12 @@ class LoadLevel { if (e.value is num) e.key.toString(): (e.value as num).toDouble(), } : null, + decision: m['decision'] is Map + ? LoadDecision.fromJson(Map.from(m['decision'] as Map)) + : null, + progress: m['progress'] is Map + ? EcoProgress.fromJson(Map.from(m['progress'] as Map)) + : null, ); } @@ -478,6 +561,17 @@ class LoadLevel { bool? get countsCoherent => counts == null ? null : (countsTotalW - targetW).abs() < 1.0; + /// Le motif **de cette passe** — chaque niveau porte le sien depuis `+etm43`. + /// + /// À ne pas confondre avec `LoadTelemetryEntry.decision`, qui n'en reflète qu'**un** et dit + /// lequel par sa clé `level`. Le 2026-09-01 à 05:47, le motif racine valait + /// `SURPLUS_INSUFFICIENT` pendant que ce niveau-ci achetait 767 W : la vue racine n'est pas + /// incomplète, elle est **fausse** si on la lit seule. + final LoadDecision? decision; + + /// L'avancement de l'obligation — **niveau `eco` seulement**. Voir [EcoProgress]. + final EcoProgress? progress; + /// Part de ce niveau comptée dans `budget.allocatedW`, **lue et non déduite**. double get partSurplusW => counts != null ? (counts![kCompteurSurplus] ?? 0.0) diff --git a/lib/services/telemetry_text.dart b/lib/services/telemetry_text.dart index b71c9ff..6a984db 100644 --- a/lib/services/telemetry_text.dart +++ b/lib/services/telemetry_text.dart @@ -270,3 +270,93 @@ String? mechanismText(L10n t, LoadMechanism m) { int? _w(LoadDecision d, String key) => d.numParam(key)?.round(); int? _i(LoadDecision d, String key) => d.numParam(key)?.toInt(); + +// ═══════════════════════════════════════════════════════════════════════════════ +// NIVEAUX ÉCO / CONFORT — §10 +// ═══════════════════════════════════════════════════════════════════════════════ + +/// Wh → « 2,1 kWh », ou « 850 Wh » sous le kilo. +/// +/// Une obligation se déclare en kWh et se sert en centaines de watts : afficher 2 100 Wh +/// ferait compter les zéros à chaque lecture. +String energieCourte(double wh) { + if (wh.abs() < 1000) return '${wh.round()} Wh'; + return '${(wh / 1000).toStringAsFixed(1).replaceAll('.', ',')} kWh'; +} + +/// La phrase d'un **niveau éco**. Rendue depuis le motif du NIVEAU, jamais celui de la charge. +/// +/// Le 2026-09-01 à 05:47, le motif racine valait `SURPLUS_INSUFFICIENT` pendant que le niveau +/// éco achetait 767 W au réseau : lire la racine ici afficherait « rien fait » sur une charge +/// en train d'acheter. +String ecoDecisionText(L10n t, LoadDecision d) { + switch (d.code) { + case 'ECO_FLOOR_GRID': + final grid = _w(d, 'gridW'); + if (grid == null) break; + return t.decisionEcoFloorGrid(grid); + + // Le seul état qui FERME l'obligation — et R6 l'interdit sous `unmeasurable`, des deux + // côtés de la frontière. S'il sortait là, ce serait une incohérence de la box, pas un + // avancement : c'est pourquoi l'appelant vérifie le régime avant de croire cette ligne. + case 'ECO_FLOOR_MET': + final livre = d.numParam('deliveredWh'); + final cible = d.numParam('targetWh'); + if (livre == null || cible == null) break; + return t.decisionEcoFloorMet(energieCourte(livre), energieCourte(cible)); + + // Quatre causes, quatre gestes. Pas de « surplus insuffisant » : l'éco achète au réseau, + // donc le surplus ne peut jamais être ce qui l'empêche. + case 'ECO_FLOOR_MISSED': + final raison = switch (d.params['cause']) { + 'drawCap' => t.ecoCauseDrawCap, + 'loadCapacity' => t.ecoCauseLoadCapacity, + 'unmeasurable' => t.ecoCauseUnmeasurable, + 'unavailable' => t.ecoCauseUnavailable, + _ => null, + }; + if (raison == null) break; + // `missingWh` est ABSENT sous `unmeasurable`, et c'est voulu : on ne sait pas ce qui a + // été livré, donc pas ce qui manquait. Ne rien ajouter alors. + final manque = d.numParam('missingWh'); + return t.decisionEcoFloorMissed(raison) + + (manque == null ? '' : t.ecoManque(energieCourte(manque))); + } + return t.decisionUnknown(d.code.isEmpty ? '—' : d.code, formatParams(d.params)); +} + +/// La ligne d'avancement d'un niveau éco — ce qui a été livré, ou pourquoi on ne le sait pas. +/// +/// ⚠️ **Sous `unmeasurable`, deux choses ne doivent PAS être dites**, et elles sont au +/// contrat depuis `+etm55` : +/// +/// 1. **que l'achat va s'intensifier** — il n'y a plus d'escalade, le taux est constant +/// (cible ÷ jour, LM-1013-b-v) ; +/// 2. **que `atMaximum: false` veut dire « on n'est pas au plafond »** — il veut dire qu'il +/// n'y a **aucun étalement**, donc rien qui puisse cesser. Un booléen à `false` se lit +/// spontanément comme rassurant : c'est la nuance qu'un lecteur pressé inversera, et c'est +/// pour ça qu'aucune phrase d'ici ne le rend tel quel. +String? ecoProgressText(L10n t, LoadLevel niveau) { + final p = niveau.progress; + if (p == null) return null; + final d = niveau.decision; + + if (p.nonMesurable) { + // `remainingWh == targetWh` sous ce régime : rien n'est soustrait. « 4,0 sur 4,0 » se lit + // « rien de MESURABLE », jamais « rien livré » — d'où une phrase et non une fraction. + final reste = d?.numParam('remainingWh') ?? p.targetWh; + return '${t.ecoAvancementNonVerifiable(energieCourte(reste))} · ${t.ecoTauxConstant}'; + } + + final livre = p.deliveredWh; + if (livre == null) return null; + final base = t.ecoAvancementMesure(energieCourte(livre), energieCourte(p.targetWh)); + final depuis = p.measuredSince; + if (depuis == null) return base; + final h = '${depuis.hour.toString().padLeft(2, '0')}:' + '${depuis.minute.toString().padLeft(2, '0')}'; + // La date reste le PREMIER instant mesuré ; l'intermittence se dit à part. Après une + // bascule en cours de période, `deliveredWh` ne compte que depuis elle pendant que la cible + // reste entière — la paire n'est donc plus une fraction, et c'est cette date qui l'explique. + return '$base · ${p.parIntermittence ? t.ecoMesureIntermittente(h) : t.ecoMesureDepuis(h)}'; +} diff --git a/lib/widgets/load_cards.dart b/lib/widgets/load_cards.dart index 06a790b..544171e 100644 --- a/lib/widgets/load_cards.dart +++ b/lib/widgets/load_cards.dart @@ -233,8 +233,7 @@ class LoadLiveBlock extends StatelessWidget { ], ], const SizedBox(height: 2), - Text(decisionText(t, live.decision), - style: TextStyle(fontSize: 11, color: tone)), + ..._lignesNiveaux(t, live, 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. @@ -725,3 +724,78 @@ String _ecartMesure(LoadTelemetryEntry live) { if (ecart < 100 || ecart / a < 0.15) return '· conforme'; return m < a ? '· sous la consigne' : '· au-dessus de la consigne'; } + +/// **Les niveaux d'une charge — la décomposition d'UNE commande, pas deux consignes.** +/// +/// La ligne d'allocation reste le parent ; ces lignes disent d'où vient ce qui a été décidé. +/// Une charge reçoit **une seule commande par cycle** quelles que soient les passes +/// (LM-1003) : si l'écran montre deux lignes, elles décrivent une seule décision. +/// +/// Repli sur le motif de la charge quand aucun niveau ne porte le sien — box antérieure à +/// `+etm43`. `levels[]` vide ne veut pas dire « aucune passe ». +List _lignesNiveaux(L10n t, LoadTelemetryEntry live, Color tone) { + final avecMotif = [for (final n in live.levels) if (n.decision != null) n]; + if (avecMotif.isEmpty) { + return [ + Text(decisionText(t, live.decision), + style: TextStyle(fontSize: 11, color: tone)), + ]; + } + return [ + for (final n in avecMotif) + Padding( + padding: const EdgeInsets.only(top: 3), + child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Container( + width: 54, + padding: const EdgeInsets.symmetric(vertical: 1), + decoration: BoxDecoration( + color: (n.estEco ? const Color(0xFFF9A825) : const Color(0xFF2E7D32)) + .withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(4), + ), + child: Text(n.estEco ? t.niveauEco : t.niveauConfort, + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 9, + fontWeight: FontWeight.w700, + color: n.estEco + ? const Color(0xFFF9A825) + : const Color(0xFF2E7D32))), + ), + const SizedBox(width: 6), + Expanded( + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text(_phraseNiveau(t, n), + style: TextStyle(fontSize: 11, color: tone)), + if (ecoProgressText(t, n) case final avancement?) + Text(avancement, + style: TextStyle( + fontSize: 10.5, height: 1.3, color: const Color(0xFF6B7280))), + // L'ACHETÉ, à côté du livré et jamais à sa place : l'un est la commande du + // moteur, l'autre une observation. Ils ne coïncident que quand tout va bien, + // et c'est leur divergence qui informe — d'où l'affichage des deux. + if ((n.progress?.purchasedWh ?? 0) > 0) + Text(t.ecoAchete(energieCourte(n.progress!.purchasedWh)), + style: TextStyle( + fontSize: 10.5, height: 1.3, color: const Color(0xFF6B7280))), + ]), + ), + ]), + ), + ]; +} + +/// La phrase d'un niveau — et la garde R6, qui **dénonce** au lieu de rendre. +/// +/// `ECO_FLOOR_MET` sous `unmeasurable` est interdit des deux côtés de la frontière. Si la +/// combinaison arrivait, la rendre telle quelle annoncerait « tenue » une obligation dont +/// personne ne peut savoir si elle l'est : servie oui, tenue jamais. +String _phraseNiveau(L10n t, LoadLevel n) { + final d = n.decision!; + if (!n.estEco) return decisionText(t, d); + if (d.code == 'ECO_FLOOR_MET' && (n.progress?.nonMesurable ?? false)) { + return t.ecoIncoherenceTenue; + } + return ecoDecisionText(t, d); +} diff --git a/test/eco_levels_test.dart b/test/eco_levels_test.dart new file mode 100644 index 0000000..fcbefb3 --- /dev/null +++ b/test/eco_levels_test.dart @@ -0,0 +1,235 @@ +/// **La carte à deux niveaux** — la décomposition d'UNE commande, éprouvée sur ce qu'elle +/// affiche. +/// +/// Les charges utiles viennent de `.75`, pas d'une lecture de contrat : le cas +/// « éco servi / confort en attente » est celui du 2026-09-01 à 05:47, où le surplus est +/// passé à −922 W pendant que le plancher achetait 767 W au réseau. Le cas mesuré est celui +/// du 2026-09-04, huit cycles de décroissance. +/// +/// Ce qu'ils figent, et qui a coûté une semaine à établir : +/// +/// - le motif RACINE ne suffit pas — il valait `SURPLUS_INSUFFICIENT` pendant que le niveau +/// éco achetait. Une vue fausse, pas incomplète ; +/// - `deliveredWh` absent sous `unmeasurable` n'est pas zéro, et `remainingWh == targetWh` +/// s'y lit « rien de mesurable », jamais « rien livré » ; +/// - `ECO_FLOOR_MET` sous `unmeasurable` est une incohérence de la box (R6) : on la DÉNONCE +/// au lieu de la rendre ; +/// - l'acheté s'affiche à côté du livré, jamais à sa place. +library; + +import 'package:etm_powersync_app/l10n/app_localizations.dart'; +import 'package:etm_powersync_app/models/load_telemetry.dart'; +import 'package:etm_powersync_app/providers/load_telemetry_provider.dart'; +import 'package:etm_powersync_app/widgets/load_cards.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// Relevé `.75` du 2026-09-01 05:47 — surplus négatif, plancher acheté au réseau. +Map _payload({ + Map? progressEco, + Map? decisionEco, +}) => + { + 'degradedMode': false, + 'timestamp': '2026-09-01T05:47:00Z', + 'budget': { + 'surplusW': -922.0, 'evReservedW': 0.0, 'recreditedW': 6500.0, + 'allocatedW': 0.0, 'remainingW': -922.0, + }, + 'draw': {'committedW': 766.81}, + 'loads': [ + { + 'loadId': 'x', + 'allocatedW': 766.81, + 'available': true, + 'measurement': {'source': 'none'}, + // Le motif RACINE : ce qu'un client d'avant `levels[]` lirait, et il est faux. + 'decision': { + 'code': 'SURPLUS_INSUFFICIENT', + 'level': 'comfort', + 'params': {'budgetW': -922}, + }, + 'levels': [ + { + 'level': 'eco', + 'funding': 'grid', + 'targetW': 766.81, + 'counts': {'draw.committedW': 766.81}, + 'progress': progressEco ?? + {'regime': 'unmeasurable', 'targetWh': 4000.0, 'purchasedWh': 43.12}, + 'decision': decisionEco ?? + { + 'code': 'ECO_FLOOR_GRID', + 'params': { + 'gridW': 767, 'remainingWh': 4000, 'targetWh': 4000, + 'remainingS': 18779, 'regime': 'unmeasurable', 'atMaximum': false, + }, + }, + }, + { + 'level': 'comfort', + 'funding': 'surplus', + 'targetW': 0.0, + 'counts': {'budget.allocatedW': 0.0}, + 'decision': { + 'code': 'SURPLUS_INSUFFICIENT', + 'params': {'budgetW': -922}, + }, + }, + ], + }, + ], + }; + +Widget _carte(Map payload) { + final tel = LoadTelemetryProvider()..adoptForTest(payload); + return MaterialApp( + locale: const Locale('fr'), + localizationsDelegates: L10n.localizationsDelegates, + supportedLocales: L10n.supportedLocales, + home: Scaffold( + body: LoadLiveBlock(live: tel.entryFor('x')!, stale: false, telemetry: tel), + ), + ); +} + +void main() { + group('une commande, deux lignes', () { + testWidgets('les deux niveaux s\'affichent, et l\'éco dit l\'ACHAT', + (tester) async { + await tester.pumpWidget(_carte(_payload())); + + expect(find.text('ÉCO'), findsOneWidget); + expect(find.text('CONFORT'), findsOneWidget); + expect(find.textContaining('767 W achetés au réseau'), findsOneWidget); + // Le confort ne dit pas « rien » : il dit qu'il n'y avait rien à prendre. + expect(find.textContaining('Aucun surplus'), findsOneWidget); + }); + + testWidgets('le motif RACINE ne s\'affiche pas seul — il serait faux ici', + (tester) async { + await tester.pumpWidget(_carte(_payload())); + // `SURPLUS_INSUFFICIENT` est bien rendu, mais SOUS l'étiquette CONFORT, à côté d'un + // niveau éco qui achète. Ce qui est interdit, c'est qu'il soit la seule phrase. + expect(find.textContaining('achetés au réseau'), findsOneWidget); + }); + + testWidgets('sous `unmeasurable` : ni fraction, ni promesse d\'escalade', + (tester) async { + await tester.pumpWidget(_carte(_payload())); + + // « reste 4,0 kWh » et NON « 0 sur 4,0 » : rien n'est soustrait, donc la fraction + // n'aurait aucun sens — et « 0 » se lirait « rien livré ». + expect(find.textContaining('reste 4,0 kWh à livrer'), findsOneWidget); + expect(find.textContaining('non vérifiable'), findsOneWidget); + // Le taux est CONSTANT depuis +etm55 : ne jamais annoncer une intensification. + expect(find.textContaining('taux constant'), findsOneWidget); + expect(find.textContaining('accélère'), findsNothing); + expect(find.textContaining('non tenue'), findsNothing); + }); + + testWidgets('l\'ACHETÉ s\'affiche à côté du livré, jamais à sa place', + (tester) async { + await tester.pumpWidget(_carte(_payload())); + expect(find.textContaining('43 Wh achetés pour cette obligation'), findsOneWidget); + }); + }); + + group('régime mesuré — relevé du 2026-09-04', () { + Map mesure() => _payload( + progressEco: { + 'regime': 'measured', + 'targetWh': 3000.0, + 'deliveredWh': 750.0, + 'purchasedWh': 236.5, + 'measuredSince': '2026-09-04T03:42:00Z', + }, + decisionEco: { + 'code': 'ECO_FLOOR_GRID', + 'params': { + 'gridW': 443, 'remainingWh': 2250, 'targetWh': 3000, + 'remainingS': 18299, 'regime': 'measured', 'atMaximum': false, + }, + }, + ); + + testWidgets('la fraction s\'affiche, et la date de mesure avec', (tester) async { + await tester.pumpWidget(_carte(mesure())); + expect(find.textContaining('750 Wh sur 3,0 kWh livrés'), findsOneWidget); + // Après une bascule en cours de période, `deliveredWh` ne compte que depuis cette + // date pendant que la cible reste entière : sans elle, la paire mentirait. + expect(find.textContaining('mesuré depuis 03:42'), findsOneWidget); + expect(find.textContaining('non vérifiable'), findsNothing); + }); + + testWidgets('l\'intermittence se dit, et la date ne bouge pas', (tester) async { + final p = mesure(); + (p['loads'] as List)[0]['levels'][0]['progress']['measuredGaps'] = [ + {'x': 1} + ]; + await tester.pumpWidget(_carte(p)); + expect(find.textContaining('mesuré par intermittence depuis 03:42'), findsOneWidget); + }); + }); + + group('R6 — servie oui, tenue jamais', () { + testWidgets('`ECO_FLOOR_MET` sous `unmeasurable` est DÉNONCÉ, pas rendu', + (tester) async { + await tester.pumpWidget(_carte(_payload( + decisionEco: { + 'code': 'ECO_FLOOR_MET', + 'params': {'deliveredWh': 4000, 'targetWh': 4000}, + }, + ))); + expect(find.textContaining('Incohérence de la box'), findsOneWidget); + expect(find.textContaining('atteint —'), findsNothing); + }); + + testWidgets('sous `measured`, il se rend normalement', (tester) async { + await tester.pumpWidget(_carte(_payload( + progressEco: { + 'regime': 'measured', 'targetWh': 4000.0, 'deliveredWh': 4000.0, + 'purchasedWh': 1200.0, + }, + decisionEco: { + 'code': 'ECO_FLOOR_MET', + 'params': {'deliveredWh': 4000, 'targetWh': 4000}, + }, + ))); + expect(find.textContaining('Plancher atteint'), findsOneWidget); + expect(find.textContaining('Incohérence'), findsNothing); + }); + }); + + group('ECO_FLOOR_MISSED — la cause décide de la phrase', () { + testWidgets('sous `unmeasurable` : ni « non tenue », ni chiffre du manque', + (tester) async { + await tester.pumpWidget(_carte(_payload( + decisionEco: { + 'code': 'ECO_FLOOR_MISSED', + 'params': {'cause': 'unmeasurable'}, + }, + ))); + // Deux lignes portent « non vérifiable » — le motif et l'avancement — et c'est + // cohérent : l'obligation est abandonnée POUR cette raison. On vise donc la clause + // qui n'appartient qu'au motif. + expect(find.textContaining('ni tenue, ni chiffrable'), findsOneWidget); + expect(find.textContaining('il manquait'), findsNothing); + }); + + testWidgets('le plafond de soutirage chiffre son manque, lui', (tester) async { + await tester.pumpWidget(_carte(_payload( + progressEco: { + 'regime': 'measured', 'targetWh': 4000.0, 'deliveredWh': 1500.0, + 'purchasedWh': 900.0, + }, + decisionEco: { + 'code': 'ECO_FLOOR_MISSED', + 'params': {'cause': 'drawCap', 'missingWh': 2500}, + }, + ))); + expect(find.textContaining('plafond de soutirage'), findsOneWidget); + expect(find.textContaining('il manquait 2,5 kWh'), findsOneWidget); + }); + }); +}