feat(§10): la carte de charge porte ses deux niveaux — une commande, deux lignes

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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GKM8FDNHZWogAsjjfB8pSw
This commit is contained in:
Patrick Schurig 2026-09-05 07:54:22 +02:00
parent cd8dd02989
commit 58bf57e67f
7 changed files with 758 additions and 2 deletions

View File

@ -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"

View File

@ -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:

View File

@ -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é';

View File

@ -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<dynamic>? measuredGaps;
const EcoProgress({
required this.regime,
required this.targetWh,
this.deliveredWh,
this.purchasedWh = 0,
this.measuredSince,
this.measuredGaps,
});
factory EcoProgress.fromJson(Map<String, dynamic> 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<String, dynamic> 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<String, dynamic>.from(m['decision'] as Map))
: null,
progress: m['progress'] is Map
? EcoProgress.fromJson(Map<String, dynamic>.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)

View File

@ -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)}';
}

View File

@ -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<Widget> _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);
}

235
test/eco_levels_test.dart Normal file
View File

@ -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<String, dynamic> _payload({
Map<String, dynamic>? progressEco,
Map<String, dynamic>? 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<String, dynamic> 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<String, dynamic> 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);
});
});
}