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:
parent
cd8dd02989
commit
58bf57e67f
@ -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"
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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é';
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)}';
|
||||
}
|
||||
|
||||
@ -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
235
test/eco_levels_test.dart
Normal 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);
|
||||
});
|
||||
});
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user