feat(télémétrie): la réconciliation repose sur counts — l'exception EV_GRID_START tombe

`+etm32` publie `counts` par niveau : où chaque watt est compté, et combien.
Vérifié à la sonde sur `.75` avant de toucher au code — `counts` sur tous les
niveaux, destination publiée même à zéro, et les DEUX identités qui tiennent.

- `LoadLevel.counts`, `countsTotalW`, `countsCoherent`, `partSurplusW` ;
- `surplusShareW` LIT `counts["budget.allocatedW"]` au lieu d'interpréter ;
- `soutirageShareW` + `LoadTelemetry.soutirageIdentityHolds` : la seconde
  identité, qui n'existait pas ce matin faute de registre pour les watts achetés ;
- `LoadTelemetry.drawCommittedW` — absent = box antérieure à `+etm31`, jamais
  « rien acheté » ;
- `destinationsInconnues` : une clé née après cette app est NOMMÉE, jamais
  comptée zéro, et l'identité par niveau tient sans connaître le sens des clés.

L'exception au motif survit exactement là où la box ne sait pas dire le partage
autrement — formes sans `counts`. Ce n'est pas une version qui l'a retirée, c'est
une charge utile qui l'a rendue inutile ; sur une installation déjà déployée, la
retirer casserait la réconciliation.

Le rapport de bogue porte désormais les deux identités, les destinations
inconnues et les niveaux incohérents (Σ counts != targetW) — une incohérence de
la box, pas un arrondi.

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-08-30 10:51:10 +02:00
parent 47866ddee3
commit a1ffd13107
4 changed files with 394 additions and 99 deletions

102
CLAUDE.md
View File

@ -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**,

View File

@ -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<String> 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<String, double>? counts;
factory LoadLevel.fromJson(Map<String, dynamic> 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<String, dynamic> 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 <String, double>{}).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<String> 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<LoadTelemetryEntry> 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<String, dynamic> m) {
@ -693,6 +810,8 @@ class LoadTelemetry {
LoadTelemetryEntry.fromJson(Map<String, dynamic>.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<String> 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<String> get destinationsInconnues => {
for (final e in loads) ...e.destinationsInconnues,
};
LoadTelemetryEntry? entryFor(String loadId) {
for (final e in loads) {
if (e.loadId == loadId) return e;

View File

@ -127,6 +127,17 @@ class _BugReportScreenState extends State<BugReportScreen> {
// 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)",

View File

@ -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<String, dynamic> 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);
});
});
}