diff --git a/CLAUDE.md b/CLAUDE.md index 31f045f..d37b809 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -249,65 +249,67 @@ batteryGreen boostRed pvGreen minPvBlue accentTeal ## Télémétrie d'arbitrage — les pièges de lecture -**1. `funding` a QUITTÉ la charge en `+etm30` — il vit dans `levels[]`.** Une charge peut -figurer dans `loads[]` au titre du surplus **ou** du réseau (échéance, tarif dynamique) ; -au §10 elle pourra l'être **des deux à la fois** — plancher éco au réseau, confort au -surplus, même cycle — et c'est pourquoi le champ a dû descendre d'un cran **avant** le §10. +**1. `funding` dit l'ORIGINE, `counts` dit le REGISTRE — et aucun ne se dérive de l'autre.** +Depuis `+etm30` le financement a quitté la charge pour le **niveau** ; depuis `+etm32` chaque +niveau porte en plus **où ses watts sont comptés** : ```jsonc -"levels": [ { "level": "comfort", "funding": "surplus", "targetW": 3500.0 } ] +"levels": [{ "level": "comfort", "targetW": 1380, "funding": "grid", + "counts": { "budget.allocatedW": 900, "draw.committedW": 480 } }] ``` +- **`funding`** — « ces watts sont achetés » : une intention, voisine du motif. Sur un niveau + partagé il vaut `"grid"`, sous la règle **« grid l'emporte dès qu'un watt est acheté »** — + déterministe, indépendante des proportions. Il dit **s'il faut en parler**. +- **`counts`** — « ils sont comptés ici » : une comptabilité, avec **`Σ counts == targetW` + toujours**. Il dit **combien, et où**. + > 🔴 **Un écran qui lit `entry.funding` ne trouve plus rien, et sans erreur.** Passer par -> `LoadTelemetryEntry.resolvedFunding` / `isGridFunded`, qui résolvent les deux formes. -> `levels[]` vide = box antérieure à `+etm30`, pas « aucune passe ». +> `LoadTelemetryEntry.resolvedFunding` / `isGridFunded`. `levels[]` vide = box antérieure à +> `+etm30`, pas « aucune passe » ; `counts` absent = box antérieure à `+etm32`. +> +> **Les clés de `counts` sont des identifiants OPAQUES, jamais des chemins à parcourir.** Une +> clé inconnue de l'app ne se compte pas zéro : `Σ counts == targetW` reste vérifiable sans +> connaître son sens, et `destinationsInconnues` la nomme pour que l'écran dise « destination +> inconnue de cette version ». -L'identité vérifiable devient : *somme des `targetW` des niveaux financés au surplus == -`budget.allocatedW`*. Vérifiée à la sonde sur `.75` le 2026-08-30 (`+etm30`) : -3 500 + 3 000 == 6 500, puis 1 500 == 1 500 au cycle suivant. Omis en mode dégradé — sans -plan, pas de financement. +**Deux identités, et la seconde n'existait pas avant `+etm32`** (les deux vérifiées à la sonde +sur `.75` le 2026-08-30 en `1.15.2+etm32`) : -> 🔴 **Et il n'y a d'identité que de ce côté-là.** Une charge financée au réseau ne vit -> **nulle part** : `budget.evReservedW` n'est pas un registre d'allocation mais un **terme de -> correction** du budget — « **commandé, pas encore mesuré** », la latence entre l'ordre et -> sa lecture au compteur. Sur `+etm30` il mêle encore à cela la part réseau d'`EV_GRID_START`, -> et une borne au réseau qui tire déjà ce qu'on lui a commandé y contribue **0** alors que son -> `targetW` vaut 7 000 W. **Ne réconcilier aucun total dessus, jamais** — la nature du champ -> l'interdit, pas son état de livraison. -> -> Le libellé d'écran est **« Commandé, pas encore mesuré »** (2026-08-30). « Réservé au -> véhicule » affirmait deux faussetés : que c'est réservé, et que ça regarde le véhicule -> plutôt que la latence de sa mesure. -> -> Les watts achetés ont enfin un registre : **`draw.committedW`**, posé en `+etm31` et -> **vérifié à la sonde le 2026-08-30** (deux cycles). Publié dans la **même trame que -> `budget`**, avec **une seule clé** — `authorisedW`, `remainingW`, `binding` et -> `perPhaseBound` sont **absents, pas à zéro** : le plafond de soutirage n'existe pas encore, -> et un `authorisedW: 0` afficherait un plafond fictif avec sa source. -> -> ```jsonc -> "budget": { … }, "draw": { "committedW": 0 } -> ``` -> -> **`draw` est publié même quand rien n'est acheté** (`committedW: 0`, mesuré). Donc `draw` -> **absent** vaut « box antérieure à `+etm31` », **jamais** « rien acheté ce cycle » : zéro est -> une valeur quand la grandeur existe, l'absence est une valeur quand elle n'existe pas. -> -> ⚠️ **L'exception `EV_GRID_START` n'est PAS levée**, faute d'avoir pu l'observer. Le motif est -> **inobservable sur le banc** au 2026-08-30 : les deux bornes sont déclarées dans -> `GetLoadConfig` (V2C Trydan, Simulated wallbox) mais **absentes de `loads[]`** — hors -> arbitrage, aucun véhicule branché. La branche de `surplusShareW` tient tant qu'une sonde n'a -> pas vu un `EV_GRID_START` réel et ses deux moitiés tomber dans `budget.allocatedW` et -> `draw.committedW`. +| Identité | Terme sommé | Contre | +|---|---|---| +| surplus | `Σ counts["budget.allocatedW"]` | `budget.allocatedW` | +| soutirage | `Σ counts["draw.committedW"]` | `draw.committedW` | -Le niveau publié est `"comfort"` et ce **n'est pas un rebaptisage** : c'est bien la passe -qui, au §10, servira le confort depuis le surplus. Un niveau à `targetW: 0` est une passe -**parcourue** (motif à l'appui), jamais une absence. +**`EV_GRID_START` n'est plus une exception.** Le motif partage toujours une allocation — c'est +un fait — mais ses deux moitiés tombent dans des compteurs de **même nature** et se lisent en +clair : `params.budgetW` 900 → `counts["budget.allocatedW"]` 900 → `budget.allocatedW` 900 ; +`params.gridW` 480 → `counts["draw.committedW"]` 480 → `draw.committedW` 480. L'entorse à +connaître a disparu. **Elle survit dans le code uniquement là où la box ne sait pas dire le +partage autrement** (formes sans `counts`) : la retirer là casserait la réconciliation des +installations déjà déployées. -⚠️ **`EV_GRID_START` viole cette identité, et l'exception est ÉCRITE au contrat** : sa part -de surplus est `decision.params.budgetW`, pas `targetW`. Elle le restera tant que R3 n'est -pas livrée — **à lire comme l'exception documentée qu'elle est, jamais comme une divergence -à diagnostiquer**. Cf. `LoadTelemetry.surplusAllocatedW` et `LoadTelemetryEntry.surplusShareW`. +> 🔴 **`budget.evReservedW` n'est PAS un registre d'allocation.** C'est un **terme de +> correction** du budget — « **commandé, pas encore mesuré** », la latence entre l'ordre et sa +> lecture au compteur, et c'est le libellé d'écran (2026-08-30). « Réservé au véhicule » +> affirmait deux faussetés : que c'est réservé, et que ça regarde le véhicule plutôt que la +> latence de sa mesure. Depuis `+etm32` il ne reçoit plus aucune tranche d'allocation +> (`evReservedW: 0` pendant qu'un `EV_GRID_START` partage entre les deux vrais compteurs). +> **Ne réconcilier aucun total dessus, jamais** — la nature du champ l'interdit. + +**`draw.committedW`** (`+etm31`) est publié dans la **même trame que `budget`**, et **seul** : +`authorisedW`, `remainingW`, `binding`, `perPhaseBound` sont **absents, pas à zéro** — le +plafond de soutirage du §10 n'existe pas encore, et un `authorisedW: 0` afficherait un plafond +fictif avec sa source. + +```jsonc +"budget": { … }, "draw": { "committedW": 0 } +``` + +> **Publié même quand rien n'est acheté**, et **`counts` publié même à zéro avec sa +> destination** (`{"budget.allocatedW": 0}` sur une charge non servie — le cas le plus +> fréquent). Donc l'**absence** vaut « box antérieure », jamais « rien ce cycle » : **zéro est +> une valeur quand la grandeur existe, l'absence en est une quand elle n'existe pas.** **2. `allocatedW` est ce qui a été DÉCIDÉ — ni le commandé, ni ce que la charge tire.** Corrigé le 2026-08-29 par le moteur : `buildTelemetry()` publie le **plan**, diff --git a/lib/models/load_telemetry.dart b/lib/models/load_telemetry.dart index b61d72c..dc16150 100644 --- a/lib/models/load_telemetry.dart +++ b/lib/models/load_telemetry.dart @@ -365,6 +365,20 @@ class LoadDecision { bool? boolParam(String key) => params[key] as bool?; } +/// Le compteur du **surplus** : `budget.allocatedW`. Clé de `LoadLevel.counts`. +const String kCompteurSurplus = 'budget.allocatedW'; + +/// Le compteur des **watts achetés** : `draw.committedW`, né en `+etm31`. +const String kCompteurSoutirage = 'draw.committedW'; + +/// Les destinations que **cette version** sait attribuer. +/// +/// `budget.evReservedW` n'y figure pas, et c'est délibéré : ce n'est **pas un registre +/// d'allocation** mais un terme de correction du budget (« commandé, pas encore mesuré »). +/// Depuis `+etm32` il ne reçoit plus aucune tranche — `evReservedW: 0` pendant qu'un +/// `EV_GRID_START` partage entre les deux vrais compteurs. +const Set kDestinationsConnues = {kCompteurSurplus, kCompteurSoutirage}; + /// **Une passe du waterfall, pour une charge** — publié dans `levels[]` depuis `+etm30`. /// /// ## Ce que ce type acte, et ce qu'il n'anticipe pas @@ -397,21 +411,77 @@ class LoadLevel { /// L'arrondi réellement appliqué se lit dans [LoadTelemetryEntry.mechanism]. final double targetW; - /// `"surplus"` ou `"grid"` — **qui paie cette passe**. + /// **L'ORIGINE** — `"surplus"` ou `"grid"` : « d'où viennent ces watts ». + /// + /// Voisin du motif, et **ne se dérive pas** de [counts]. Sur un niveau dont l'allocation + /// est partagée il vaut `"grid"` : la règle publiée est *grid l'emporte dès qu'un watt + /// est acheté* — déterministe, indépendante des proportions, alignée sur le fait + /// actionnable. Il dit **s'il faut en parler** ; [counts] dit combien. final String? funding; - const LoadLevel({required this.level, required this.targetW, this.funding}); + /// **LE REGISTRE** — où ces watts sont comptés, et combien dans chacun. Depuis `+etm32`. + /// + /// `{"budget.allocatedW": 900, "draw.committedW": 480}` : les clés nomment un compteur du + /// cycle, et **`Σ counts == targetW` toujours**. C'est ce qui a supprimé l'exception + /// `EV_GRID_START` — un motif qui partage une allocation entre deux compteurs n'est plus + /// une entorse, mais une ligne à deux clés. + /// + /// ⚠️ **Les clés sont des identifiants OPAQUES, jamais des chemins à parcourir.** Une clé + /// inconnue de cette version n'est pas perdue : [countsTotalW] et [countsCoherent] restent + /// vrais sans connaître son sens, et [LoadTelemetryEntry.destinationsInconnues] la nomme + /// pour que l'écran dise « destination inconnue de cette version » — **jamais zéro**. + /// + /// `null` = box antérieure à `+etm32`. **Publié même à zéro, avec sa destination** + /// (`{"budget.allocatedW": 0}` sur une charge qui n'a rien reçu, mesuré sur `.75` le + /// 2026-08-30) : un `{}` aurait satisfait l'identité en perdant la destination, sur le cas + /// le plus fréquent. + final Map? counts; - factory LoadLevel.fromJson(Map m) => LoadLevel( - level: m['level'] as String? ?? '', - targetW: _d(m['targetW']), - funding: (m['funding'] is String && (m['funding'] as String).isNotEmpty) - ? m['funding'] as String - : null, - ); + const LoadLevel({ + required this.level, + required this.targetW, + this.funding, + this.counts, + }); + + factory LoadLevel.fromJson(Map m) { + final c = m['counts']; + return LoadLevel( + level: m['level'] as String? ?? '', + targetW: _d(m['targetW']), + funding: (m['funding'] is String && (m['funding'] as String).isNotEmpty) + ? m['funding'] as String + : null, + counts: c is Map + ? { + for (final e in c.entries) + if (e.value is num) e.key.toString(): (e.value as num).toDouble(), + } + : null, + ); + } /// Financement omis = surplus : le seul qui existait avant que le champ n'existe. bool get surplusFinance => funding != 'grid'; + + /// Ce que [counts] totalise — comparable à [targetW] **sans connaître le sens des clés**. + double get countsTotalW { + var t = 0.0; + for (final v in (counts ?? const {}).values) { + t += v; + } + return t; + } + + /// L'identité publiée `Σ counts == targetW`. `null` quand [counts] est absent. + /// Tolérance 1 W : les valeurs voyagent en `Double`. + bool? get countsCoherent => + counts == null ? null : (countsTotalW - targetW).abs() < 1.0; + + /// Part de ce niveau comptée dans `budget.allocatedW`, **lue et non déduite**. + double get partSurplusW => counts != null + ? (counts![kCompteurSurplus] ?? 0.0) + : (surplusFinance ? targetW : 0.0); } /// État runtime d'**une** charge arbitrée par le waterfall. @@ -568,34 +638,47 @@ class LoadTelemetryEntry { /// Si le motif est là mais `budgetW` absent, on retombe sur zéro plutôt que sur une /// part devinée — un chiffre faux est pire qu'un chiffre manquant dans un contrôle /// d'identité. - /// ⚠️ **L'exception `EV_GRID_START` est toujours là, et son retrait attend une MESURE.** + /// ## L'exception `EV_GRID_START` a été RETIRÉE le 2026-08-30 /// - /// Sa contribution au surplus n'est pas `targetW` mais `decision.params.budgetW` : le - /// motif partage une allocation entre deux compteurs, donc il viole l'identité - /// `Σ targetW(surplus) == budget.allocatedW`. C'était le cas fondateur de R3. + /// Elle valait ceci : le motif partage une allocation entre deux compteurs, donc sa part + /// de surplus n'était pas son allocation mais `decision.params.budgetW`, et l'identité + /// `Σ(surplus) == budget.allocatedW` ne tenait qu'en traitant cette ligne à part. /// - /// `+etm31` est censé y mettre fin — la part surplus dans `budget.allocatedW`, la part - /// réseau dans le nouveau `draw.committedW`, deux compteurs de même nature. La sonde du - /// 2026-08-30 confirme `+etm31` posé et `draw.committedW` publié, **mais n'a pas pu voir - /// le motif** : les deux bornes du banc sont déclarées et absentes de `loads[]` (hors - /// arbitrage, rien de branché), donc `EV_GRID_START` y est structurellement impossible. + /// `+etm32` publie [LoadLevel.counts] et **il n'y a plus rien à interpréter** : la ligne + /// porte ses deux moitiés en clair. Trace de la suite de simulation du moteur — + /// `params.budgetW` 900 → `counts["budget.allocatedW"]` 900 → `budget.allocatedW` 900 ; + /// `params.gridW` 480 → `counts["draw.committedW"]` 480 → `draw.committedW` 480 ; + /// `Σ counts` 1 380 == `targetW` == `allocatedW`. Forme confirmée sur `.75` le 2026-08-30 + /// en `1.15.2+etm32` : `counts` sur tous les niveaux, destination publiée **même à zéro**, + /// et les deux identités vérifiées. /// - /// **Cette branche tient jusqu'à l'observation d'un `EV_GRID_START` réel.** Retirer sur - /// la foi de l'annonce ferait exactement ce que la mesure a déjà démenti une fois cette - /// semaine. + /// **Ce que ce getter fait désormais : il LIT.** Trois formes, et une seule interprète + /// encore quelque chose : /// - /// **Et la trace ne viendra pas du banc** : mesuré le 2026-08-30, le motif n'y est pas - /// provocable — aucune action n'expose la prise véhicule, et sans `assignedCarId` la borne - /// reste hors `loads[]` même branchée (`tools/rpc/README.md`). La preuve viendra de la - /// suite de simulation du moteur, qui assère le partage depuis `+etm31` ; le critère, lui, - /// ne bouge pas : `params.budgetW` dans `budget.allocatedW` **et** `params.gridW` dans - /// `draw.committedW`. + /// | Forme | Part de surplus | + /// |---|---| + /// | `counts` publié (`+etm32`) | `counts["budget.allocatedW"]`, **lu** | + /// | niveaux sans `counts` (`+etm30`/`+etm31`) | `Σ targetW` des niveaux au surplus, **exception au motif** | + /// | un financement par charge (avant `+etm30`) | `allocatedW`, **exception au motif** | + /// + /// **L'exception survit exactement là où la box ne sait pas dire le partage autrement**, et + /// c'est la bonne frontière : elle n'est pas retirée parce qu'une version l'a annoncé, mais + /// parce qu'une charge utile la rend inutile. Sur une installation déjà déployée, la + /// retirer casserait la réconciliation. double get surplusShareW { - // L'exception passe avant tout : elle vaut pour les deux formes. + // `counts` publié : la ligne porte ses parts en clair, il n'y a rien à interpréter. + if (levels.any((l) => l.counts != null)) { + var total = 0.0; + for (final l in levels) { + total += l.partSurplusW; + } + return total; + } + // Sans `counts`, le partage d'`EV_GRID_START` n'est dit que par le motif. if (decision.code == 'EV_GRID_START' && !isSurplusFunded) { return decision.numParam('budgetW') ?? 0.0; } - if (levels.isEmpty) return isSurplusFunded ? allocatedW : 0.0; // avant +etm30 + if (levels.isEmpty) return isSurplusFunded ? allocatedW : 0.0; var total = 0.0; for (final l in levels) { if (l.surplusFinance) total += l.targetW; @@ -603,6 +686,29 @@ class LoadTelemetryEntry { return total; } + /// Part de cette charge comptée dans `draw.committedW` — les **watts achetés**. + /// + /// `null` avant `+etm32` : sans [LoadLevel.counts] il n'existe aucun registre du + /// soutirage, et zéro s'y lirait « rien acheté » là où la vérité est « on ne sait pas ». + double? get soutirageShareW { + if (!levels.any((l) => l.counts != null)) return null; + var total = 0.0; + for (final l in levels) { + total += l.counts?[kCompteurSoutirage] ?? 0.0; + } + return total; + } + + /// Les destinations de [LoadLevel.counts] que cette version ne sait pas attribuer. + /// + /// Vide dans le cas normal. Non vide = la box compte dans un registre né après cette app : + /// l'identité `Σ counts == targetW` reste vérifiable, mais l'écran doit dire + /// **« destination inconnue de cette version »**, et surtout pas compter zéro. + Set get destinationsInconnues => { + for (final l in levels) + ...?l.counts?.keys.where((k) => !kDestinationsConnues.contains(k)), + }; + /// **[allocatedW] est ce qui a été DÉCIDÉ — ni le commandé, ni ce que la charge tire.** /// /// Corrigé le 2026-08-29 : `buildTelemetry()` publie le **plan**, @@ -668,12 +774,23 @@ class LoadTelemetry { /// **désactivée**, jamais perdue. final List loads; + /// `draw.committedW` — **les watts achetés du cycle**, né en `+etm31`. + /// + /// Publié dans la même trame que [budget], et **seul** : `authorisedW`, `remainingW`, + /// `binding` et `perPhaseBound` (le plafond de soutirage du §10) sont **absents, pas à + /// zéro** — un `authorisedW: 0` afficherait un plafond qui n'existe pas, avec sa source. + /// + /// `null` = **box antérieure à `+etm31`**, jamais « rien acheté ce cycle » : l'objet est + /// publié à zéro avec sa clé quand rien n'est acheté (mesuré sur `.75`, deux cycles). + final double? drawCommittedW; + const LoadTelemetry({ required this.raw, required this.degradedMode, required this.timestamp, required this.budget, required this.loads, + this.drawCommittedW, }); factory LoadTelemetry.fromJson(Map m) { @@ -693,6 +810,8 @@ class LoadTelemetry { LoadTelemetryEntry.fromJson(Map.from(l)), ] : const [], + drawCommittedW: + m['draw'] is Map ? _dn((m['draw'] as Map)['committedW']) : null, ); } @@ -712,9 +831,9 @@ class LoadTelemetry { /// démarrage soutiré (`EV_GRID_START`) partage une même allocation entre les deux /// compteurs du budget. /// - /// Depuis `+etm30` le terme sommé est le `targetW` des **niveaux** financés au surplus. - /// Vérifié sur `.75` le 2026-08-30, `1.15.2+etm30` : 3 500 + 3 000 == 6 500 == - /// `budget.allocatedW`. + /// Depuis `+etm32` le terme sommé est **lu** : `counts["budget.allocatedW"]` de chaque + /// niveau, sans exception ni interprétation. Vérifié sur `.75` le 2026-08-30 en + /// `1.15.2+etm32` — et en `+etm30` avec les `targetW`, 3 500 + 3 000 == 6 500. double get surplusAllocatedW { var total = 0.0; for (final e in loads) { @@ -736,6 +855,52 @@ class LoadTelemetry { return (surplusAllocatedW - b.allocatedW).abs() < 1.0; } + /// Somme des parts comptées dans `draw.committedW`. `null` avant `+etm32`. + double? get soutirageAllocatedW { + var vu = false; + var total = 0.0; + for (final e in loads) { + final p = e.soutirageShareW; + if (p != null) { + vu = true; + total += p; + } + } + return vu ? total : null; + } + + /// **La seconde identité — elle n'existait pas avant `+etm32`** : + /// *somme des parts comptées au soutirage == `draw.committedW`*. + /// + /// Jusqu'ici les watts achetés n'avaient **aucun registre** : `budget.evReservedW` est un + /// terme de correction, pas une destination, et rien ne permettait de réconcilier ce + /// côté-là. `draw.committedW` (`+etm31`) lui donne un compteur, [LoadLevel.counts] + /// (`+etm32`) lui donne ses parts, et le côté réseau devient vérifiable comme l'autre. + /// + /// `null` = un des deux termes manque (box antérieure) — jamais `false`, qui se lirait + /// « le moteur se contredit » là où il n'a simplement rien publié. + bool? get soutirageIdentityHolds { + final total = soutirageAllocatedW; + final draw = drawCommittedW; + if (total == null || draw == null) return null; + return (total - draw).abs() < 1.0; + } + + /// Les niveaux dont `Σ counts != targetW` — une **incohérence de la box**, pas un arrondi. + /// + /// L'identité est publiée par le moteur et vraie par construction ; si elle tombe, c'est + /// la charge utile qui se contredit, et l'écran doit le dire au lieu de sommer quand même. + List get niveauxIncoherents => [ + for (final e in loads) + for (final l in e.levels) + if (l.countsCoherent == false) '${e.loadId}/${l.level}', + ]; + + /// Union des destinations inconnues de cette version, toutes charges confondues. + Set get destinationsInconnues => { + for (final e in loads) ...e.destinationsInconnues, + }; + LoadTelemetryEntry? entryFor(String loadId) { for (final e in loads) { if (e.loadId == loadId) return e; diff --git a/lib/screens/settings/bug_report_screen.dart b/lib/screens/settings/bug_report_screen.dart index 32e907c..45b3a79 100644 --- a/lib/screens/settings/bug_report_screen.dart +++ b/lib/screens/settings/bug_report_screen.dart @@ -127,6 +127,17 @@ class _BugReportScreenState extends State { // souvent plus que la description du symptôme : un rapport qui les porte // fausses désigne le moteur, un rapport qui les porte vraies désigne l'écran. ..writeln('- identité du budget : ${bud.identityHolds ? "OK" : "FAUSSE"}') + ..writeln('- identité soutirage/achats : ' + '${switch (tel.soutirageIdentityHolds) { + null => "sans objet (box antérieure à +etm32)", + true => "OK", + false => "FAUSSE (somme ${tel.soutirageAllocatedW!.round()} W " + "contre ${tel.drawCommittedW!.round()} W)", + }}') + ..writeln('- destinations inconnues : ' + '${tel.destinationsInconnues.isEmpty ? "aucune" : tel.destinationsInconnues.join(", ")}') + ..writeln('- niveaux incohérents (Σ counts != targetW) : ' + '${tel.niveauxIncoherents.isEmpty ? "aucun" : tel.niveauxIncoherents.join(", ")}') ..writeln('- identité surplus/allocations : ' '${switch (tel.surplusIdentityHolds) { null => "sans objet (pas de budget)", diff --git a/test/load_telemetry_test.dart b/test/load_telemetry_test.dart index 722b1ed..48bd6d7 100644 --- a/test/load_telemetry_test.dart +++ b/test/load_telemetry_test.dart @@ -723,42 +723,159 @@ void main() { expect(e.surplusShareW, 1000); }); - test('`EV_GRID_START` viole l\'identité, et c\'est l\'exception ÉCRITE', () { - // Le motif partage une allocation entre les deux compteurs du budget : sa part de - // surplus est `budgetW`, pas `targetW`. L'exception est au contrat (INTERFACE.md) - // et le restera jusqu'à R3 — à lire comme telle, jamais comme une divergence. + test('sans `counts`, l\'exception au motif survit — box +etm30/+etm31', () { + // La box partage l'allocation mais ne sait pas le dire : seul le motif le porte. final tel = LoadTelemetry.fromJson({ 'degradedMode': false, 'timestamp': '2026-08-30T06:20:00Z', 'budget': { - 'surplusW': 1200.0, - 'evReservedW': 2480.0, - 'recreditedW': 0.0, - 'allocatedW': 1200.0, - 'remainingW': 0.0, + 'surplusW': 900.0, 'evReservedW': 480.0, 'recreditedW': 0.0, + 'allocatedW': 900.0, 'remainingW': 0.0, }, 'loads': [ { 'loadId': 'borne-v2c', - 'allocatedW': 3680.0, + 'allocatedW': 1380.0, 'available': true, 'levels': [ - {'level': 'comfort', 'funding': 'grid', 'targetW': 3680.0}, + {'level': 'comfort', 'funding': 'grid', 'targetW': 1380.0}, ], 'decision': { 'code': 'EV_GRID_START', - 'params': {'budgetW': 1200, 'gridW': 2480}, + 'params': {'budgetW': 900, 'floorW': 1380, 'gridW': 480}, }, }, ], }); final e = tel.entryFor('borne-v2c')!; - - // La part de surplus vient du motif, pas du niveau… - expect(e.surplusShareW, 1200); - expect(e.levels.single.targetW, 3680); - // …ce qui fait retomber l'identité, précisément parce que l'exception est traitée. + expect(e.surplusShareW, 900); // lu dans params, faute de mieux expect(tel.surplusIdentityHolds, isTrue); + // Et le côté réseau n'est réconciliable par rien : pas de registre. + expect(e.soutirageShareW, isNull); + expect(tel.soutirageIdentityHolds, isNull); + }); + }); + + // ─────────────────────────────────────────────────────────────────────────── + group('+etm32 — `counts` : le registre, et la fin de l\'exception (R3)', () { + // Trace de la suite de simulation du moteur (brief §10), forme confirmée sur `.75` + // le 2026-08-30 en `1.15.2+etm32`. + Map partage() => { + 'degradedMode': false, + 'timestamp': '2026-08-30T08:46:00Z', + 'budget': { + 'surplusW': 900.0, 'evReservedW': 0.0, 'recreditedW': 0.0, + 'allocatedW': 900.0, 'remainingW': 0.0, + }, + 'draw': {'committedW': 480.0}, + 'loads': [ + { + 'loadId': 'borne-v2c', + 'allocatedW': 1380.0, + 'available': true, + 'levels': [ + { + 'level': 'comfort', + 'funding': 'grid', + 'targetW': 1380.0, + 'counts': {'budget.allocatedW': 900.0, 'draw.committedW': 480.0}, + }, + ], + 'decision': { + 'code': 'EV_GRID_START', + 'params': {'budgetW': 900, 'floorW': 1380, 'gridW': 480}, + }, + }, + ], + }; + + test('les deux moitiés se LISENT, et les deux identités tiennent', () { + final tel = LoadTelemetry.fromJson(partage()); + final e = tel.entryFor('borne-v2c')!; + + // La part de surplus ne vient plus du motif : elle est lue dans le registre. + expect(e.surplusShareW, 900); + expect(e.soutirageShareW, 480); + expect(tel.surplusIdentityHolds, isTrue); + expect(tel.soutirageIdentityHolds, isTrue); // n'existait pas avant +etm32 + + // L'identité par niveau, vérifiable SANS connaître le sens des clés. + expect(e.levels.single.countsCoherent, isTrue); + expect(e.levels.single.countsTotalW, 1380); + expect(tel.niveauxIncoherents, isEmpty); + + // `funding` reste l'origine et ne se dérive pas du registre : « grid » l'emporte + // dès qu'un watt est acheté, même quand la majorité vient du surplus. + expect(e.resolvedFunding, 'grid'); + expect(e.isGridFunded, isTrue); + }); + + test('une destination inconnue est NOMMÉE, jamais comptée zéro', () { + final p = partage(); + (p['loads'] as List)[0]['levels'][0]['counts'] = { + 'budget.allocatedW': 900.0, + 'draw.futureCounterW': 480.0, // registre né après cette app + }; + final tel = LoadTelemetry.fromJson(p); + final e = tel.entryFor('borne-v2c')!; + + expect(e.destinationsInconnues, {'draw.futureCounterW'}); + // L'identité par niveau tient quand même : elle ne dépend pas du sens des clés. + expect(e.levels.single.countsCoherent, isTrue); + // …et les 480 W ne sont attribués à aucun compteur connu, surtout pas au surplus. + expect(e.surplusShareW, 900); + expect(e.soutirageShareW, 0); + }); + + test('`draw` publié à zéro avec sa clé — absent = box antérieure', () { + // Mesuré sur `.75` : une charge qui n'a rien reçu porte quand même sa destination. + final tel = LoadTelemetry.fromJson({ + 'degradedMode': false, + 'timestamp': '2026-08-30T08:46:00Z', + 'budget': { + 'surplusW': -3029.4, 'evReservedW': 0.0, 'recreditedW': 1200.0, + 'allocatedW': 1200.0, 'remainingW': -3029.4, + }, + 'draw': {'committedW': 0.0}, + 'loads': [ + { + 'loadId': 'chauffe-eau', + 'allocatedW': 0.0, + 'available': true, + 'levels': [ + { + 'level': 'comfort', 'funding': 'surplus', 'targetW': 0.0, + 'counts': {'budget.allocatedW': 0.0}, + }, + ], + 'decision': {'code': 'SURPLUS_INSUFFICIENT', 'params': {}}, + }, + { + 'loadId': 'pac-terrain', + 'allocatedW': 1200.0, + 'available': true, + 'levels': [ + { + 'level': 'comfort', 'funding': 'surplus', 'targetW': 1200.0, + 'counts': {'budget.allocatedW': 1200.0}, + }, + ], + 'decision': {'code': 'SG_NORMAL', 'params': {}}, + }, + ], + }); + expect(tel.drawCommittedW, 0); + expect(tel.soutirageIdentityHolds, isTrue); // 0 == 0, et c'est vérifié, pas supposé + expect(tel.surplusAllocatedW, 1200); + expect(tel.surplusIdentityHolds, isTrue); + expect(tel.entryFor('chauffe-eau')!.levels.single.counts, {'budget.allocatedW': 0.0}); + + // `draw` absent ne vaut pas zéro : c'est une box antérieure à `+etm31`. + final ancienne = LoadTelemetry.fromJson({ + 'degradedMode': false, 'timestamp': '2026-08-30T08:46:00Z', 'loads': [], + }); + expect(ancienne.drawCommittedW, isNull); + expect(ancienne.soutirageIdentityHolds, isNull); }); }); }