From 2792e07265839771579d7cd34600577c651ee066 Mon Sep 17 00:00:00 2001 From: Patrick Schurig Date: Sun, 30 Aug 2026 08:16:29 +0200 Subject: [PATCH] =?UTF-8?q?fix(t=C3=A9l=C3=A9m=C3=A9trie):=20`funding`=20a?= =?UTF-8?q?=20quitt=C3=A9=20la=20charge=20en=20+etm30=20=E2=80=94=20le=20l?= =?UTF-8?q?ire=20dans=20`levels[]`?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit R2 est livrée côté moteur (1.15.2+etm30 sur .75) : `funding` ne figure plus à la racine d'une entrée de `loads[]`, il vit dans `levels[]`, un par passe. La rupture est effective, pas seulement contractuelle — `entry.funding` est désormais `null` partout, donc la mention « servie au réseau » avait disparu de la carte et une charge achetée au réseau aurait été comptée comme surplus dans l'identité. Une panne muette : rien n'échoue, tout se lit de travers. - `LoadLevel` — une passe : `level`, `targetW` (du DÉCIDÉ, comme `allocatedW`), `funding` ; - `resolvedFunding` / `isGridFunded` résolvent les DEUX formes ; `levels[]` vide = box antérieure, pas « aucune passe » ; - `'mixed'` est NOMMÉ plutôt que replié : impossible aujourd'hui (une seule passe publiée), courant au §10, et l'affirmer d'un côté serait une demi-vérité ; - `surplusShareW` somme les `targetW` financés au surplus, l'exception écrite `EV_GRID_START` (part de surplus = `params.budgetW`) passant avant. Vérifié à la sonde contre `.75`, pas en test : 3 500 + 3 000 == 6 500 == `budget.allocatedW` au cycle 06:10, puis 1 500 == 1 500 à 06:14. Le niveau publié est « comfort », et c'est déjà le bon nom. Aucun écran à deux niveaux n'est commencé : le reste de la maquette attend R1 et R3 à R8, et le §10 n'est pas implémenté. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01GKM8FDNHZWogAsjjfB8pSw --- CLAUDE.md | 34 +++- lib/models/load_telemetry.dart | 121 +++++++++++++- lib/screens/settings/bug_report_screen.dart | 2 +- lib/widgets/load_cards.dart | 2 +- test/load_telemetry_test.dart | 166 ++++++++++++++++++++ 5 files changed, 311 insertions(+), 14 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index eff71d3..d4b5d3f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -121,7 +121,7 @@ Min+PV + échéance → ChargingModeEcoMinWithTargetTime + targetPercentage + | `GetLoadConfig` | Lire les charges déclarées | Community | | `SetLoadConfig(loadConfigs[])` | Écrire — **remplace TOUT l'ensemble**, refus en bloc | Community | | `GetLoadTelemetry` | État runtime de l'arbitrage (budget, allocation, verrous, motifs) | Community | -| — | Depuis `+etm22`, `loads[]` contient **aussi les bornes** (`mechanism.kind = "evcharger"`) et chaque entrée porte `o:funding` (`"surplus"` / `"grid"`) | — | +| — | Depuis `+etm22`, `loads[]` contient **aussi les bornes** (`mechanism.kind = "evcharger"`). Le financement, lui, a **déménagé en `+etm30`** : plus de `funding` sur la charge, un `levels[]` avec un `funding` par passe | — | | `ClearLoadFault(loadId)` | Lever le verrou de défaut — **l'ack ne prouve rien** | Community | **Recharge EV** @@ -249,12 +249,32 @@ batteryGreen boostRed pvGreen minPvBlue accentTeal ## Télémétrie d'arbitrage — les pièges de lecture -**1. `funding` — ne jamais sommer `allocatedW` sans filtrer.** Depuis `+etm22` une borne -peut figurer dans `loads[]` au titre du surplus **ou** du réseau (échéance, tarif -dynamique). L'identité vérifiable est : *somme des `allocatedW` financés au surplus == -`budget.allocatedW`* ; une borne financée au réseau vit dans `budget.evReservedW`. Sommer -sans filtrer donne un écart que rien ne permet d'interpréter. Omis en mode dégradé — sans -plan, pas de financement. Cf. `LoadTelemetry.surplusAllocatedW`. +**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. + +```jsonc +"levels": [ { "level": "comfort", "funding": "surplus", "targetW": 3500.0 } ] +``` + +> 🔴 **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 ». + +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. Une charge financée au réseau +vit dans `budget.evReservedW`. Omis en mode dégradé — sans plan, pas de financement. + +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` 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`. **2. `allocatedW` est ce qui a été COMMANDÉ — jamais ce que la charge tire.** La mise en garde « pour une borne, ce n'est même pas une commande » est **levée** : diff --git a/lib/models/load_telemetry.dart b/lib/models/load_telemetry.dart index 613dc98..5820cb0 100644 --- a/lib/models/load_telemetry.dart +++ b/lib/models/load_telemetry.dart @@ -365,6 +365,55 @@ class LoadDecision { bool? boolParam(String key) => params[key] as bool?; } +/// **Une passe du waterfall, pour une charge** — publié dans `levels[]` depuis `+etm30`. +/// +/// ## Ce que ce type acte, et ce qu'il n'anticipe pas +/// +/// `+etm30` déplace `funding` de la charge vers le niveau (R2 du brief du 2026-08-29). +/// C'est une **rupture de forme**, pas un ajout : une charge de `+etm30` ne porte plus +/// aucun `funding` à sa racine, et un écran qui l'y cherche ne trouve rien — sans erreur. +/// +/// Le déplacement était nécessaire **avant** le §10 : la même charge y sera financée **au +/// réseau pour son plancher éco et au surplus pour son confort dans le même cycle**. Un +/// financement par charge y deviendrait indécidable. +/// +/// Aujourd'hui une seule passe est publiée, `"comfort"`, et ce n'est pas un rebaptisage : +/// c'est bien celle qui, au §10, servira le confort depuis le surplus. La forme est donc +/// juste dès maintenant, sans que rien du §10 soit implémenté. +/// +/// **Ce type ne fait que lire.** Aucun écran à deux niveaux n'existe : la liste des +/// charges reste ordonnée par rang, une entrée par charge, et le reste attend R1 et +/// R3 à R8. +class LoadLevel { + /// `"eco"` (plancher, §10 — jamais publié à ce jour) ou `"comfort"` (le surplus). + final String level; + + /// Ce que la passe a **décidé** pour cette charge. + /// + /// C'est du décidé, comme [LoadTelemetryEntry.allocatedW] — `buildTelemetry()` publie + /// le plan, l'adaptateur arrondit après. D'où l'identité exacte + /// `Σ targetW == allocatedW`, et **non** un écart d'arrondi : le chercher là revient à + /// comparer deux valeurs égales par construction, donc à ne jamais rien montrer. + /// L'arrondi réellement appliqué se lit dans [LoadTelemetryEntry.mechanism]. + final double targetW; + + /// `"surplus"` ou `"grid"` — **qui paie cette passe**. + final String? funding; + + const LoadLevel({required this.level, required this.targetW, this.funding}); + + 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, + ); + + /// Financement omis = surplus : le seul qui existait avant que le champ n'existe. + bool get surplusFinance => funding != 'grid'; +} + /// État runtime d'**une** charge arbitrée par le waterfall. class LoadTelemetryEntry { final Map raw; @@ -380,7 +429,12 @@ class LoadTelemetryEntry { /// Absent quand la charge est saine. final String? faultCode; - /// **Qui paie** cette allocation : `"surplus"` ou `"grid"`. Nouveau en `+etm22`. + /// **Qui paie** cette allocation : `"surplus"` ou `"grid"`. `+etm22` → `+etm27`. + /// + /// ⚠️ **Forme abandonnée en `+etm30`** : le financement vit désormais dans [levels], + /// un par passe. Sur une box à jour ce champ est **toujours `null`** — ne pas le lire + /// directement, passer par [resolvedFunding] ou [isGridFunded], qui résolvent les deux + /// formes. Il n'est conservé que pour les box antérieures. /// /// Il devient nécessaire du seul fait que les bornes sont entrées dans `loads[]` : deux /// charges peuvent y figurer pour des raisons différentes. @@ -398,6 +452,12 @@ class LoadTelemetryEntry { /// plus — d'où le repli explicite de [isSurplusFunded]. final String? funding; + /// Les passes du waterfall qui ont parcouru cette charge — voir [LoadLevel]. + /// + /// **Vide = box antérieure à `+etm30`**, et non « aucune passe » : le repli se fait + /// alors sur [funding] et [allocatedW], la forme d'avant. + final List levels; + /// D'où vient la mesure, et ce qu'elle autorise à conclure. Voir [LoadMeasurement]. final LoadMeasurement measurement; @@ -413,6 +473,7 @@ class LoadTelemetryEntry { required this.faultCode, this.measurement = LoadMeasurement.aucune, this.funding, + this.levels = const [], required this.lock, required this.mechanism, required this.decision, @@ -434,6 +495,12 @@ class LoadTelemetryEntry { funding: (m['funding'] is String && (m['funding'] as String).isNotEmpty) ? m['funding'] as String : null, + levels: m['levels'] is List + ? [ + for (final l in m['levels'] as List) + if (l is Map) LoadLevel.fromJson(Map.from(l)) + ] + : const [], lock: lock is Map ? LoadLock.fromJson(Map.from(lock)) : null, mechanism: mech is Map ? LoadMechanism.fromJson(Map.from(mech)) @@ -451,7 +518,34 @@ class LoadTelemetryEntry { /// `funding` absent (mode dégradé, ou box antérieure à `+etm22`) est traité comme /// surplus : c'est le seul financement qui existait avant ce champ, et le compter /// laisse l'identité vérifiable sur une box ancienne au lieu de la faire échouer. - bool get isSurplusFunded => funding != 'grid'; + bool get isSurplusFunded => resolvedFunding != 'grid'; + + /// **Le financement, résolu quelle que soit la forme publiée.** + /// + /// `+etm30` le porte par niveau, avant il était sur la charge. `null` = omis (mode + /// dégradé : sans plan, pas de financement). + /// + /// Rend `'mixed'` si les niveaux ne s'accordent pas — **impossible aujourd'hui**, une + /// seule passe étant publiée, et rendu possible par le §10 (plancher au réseau, confort + /// au surplus, même cycle). Le nommer plutôt que le replier sur l'un des deux évite + /// qu'un écran affirme un financement unique là où il y en aura deux. + String? get resolvedFunding { + if (levels.isEmpty) return funding; + final vus = {}; + for (final l in levels) { + if (l.funding != null) vus.add(l.funding!); + } + if (vus.isEmpty) return null; + if (vus.length > 1) return 'mixed'; + return vus.first; + } + + /// Vrai quand **tout** ce qui est servi à cette charge est acheté au réseau — donc + /// compté dans `budget.evReservedW` et non dans `budget.allocatedW`. + /// + /// Volontairement faux sur `'mixed'` : la mention « servie au réseau » serait alors une + /// demi-vérité, et c'est un écran à deux niveaux qui la remplacera (R1, R3 à R8). + bool get isGridFunded => resolvedFunding == 'grid'; /// Part de [allocatedW] qui a été **payée par le surplus**, donc comptée dans /// `budget.allocatedW`. @@ -470,10 +564,23 @@ 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` survit à `+etm30`, et elle est ÉCRITE au contrat** + /// (`INTERFACE.md`). Sa contribution au surplus n'est pas `targetW` mais + /// `decision.params.budgetW` : le motif partage une allocation entre les deux compteurs + /// du budget, donc il viole l'identité `Σ targetW(surplus) == budget.allocatedW`. C'est + /// le cas fondateur de R3, **documenté et non résolu** — à traiter comme tel, jamais + /// comme une divergence à diagnostiquer, tant que R3 n'est pas livrée. double get surplusShareW { - if (isSurplusFunded) return allocatedW; - if (decision.code == 'EV_GRID_START') return decision.numParam('budgetW') ?? 0.0; - return 0.0; + // L'exception passe avant tout : elle vaut pour les deux formes. + if (decision.code == 'EV_GRID_START' && !isSurplusFunded) { + return decision.numParam('budgetW') ?? 0.0; + } + if (levels.isEmpty) return isSurplusFunded ? allocatedW : 0.0; // avant +etm30 + var total = 0.0; + for (final l in levels) { + if (l.surplusFinance) total += l.targetW; + } + return total; } /// **[allocatedW] est ce qui a été COMMANDÉ. Ce n'est pas ce que la charge tire.** @@ -574,6 +681,10 @@ class LoadTelemetry { /// Somme des [LoadTelemetryEntry.surplusShareW], et non des `allocatedW` filtrés : le /// 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`. double get surplusAllocatedW { var total = 0.0; for (final e in loads) { diff --git a/lib/screens/settings/bug_report_screen.dart b/lib/screens/settings/bug_report_screen.dart index feff17b..32e907c 100644 --- a/lib/screens/settings/bug_report_screen.dart +++ b/lib/screens/settings/bug_report_screen.dart @@ -140,7 +140,7 @@ class _BugReportScreenState extends State { b.writeln(' - ${l.loadId} · alloué ${l.allocatedW.round()} W' '${l.measurement.mesuree ? " · mesuré ${l.measurement.powerW!.round()} W" " (${l.measurement.source.name})" : ""}' - ' · ${l.funding ?? "financement omis"}' + ' · ${l.resolvedFunding ?? "financement omis"}' ' · motif ${l.decision.code}${formatParamsBrut(l.decision.params)}' '${l.inFault ? " · EN DÉFAUT ${l.faultCode ?? ""}" : ""}' '${m != null ? " · mécanisme ${m.kind}" : ""}'); diff --git a/lib/widgets/load_cards.dart b/lib/widgets/load_cards.dart index feebe28..06a790b 100644 --- a/lib/widgets/load_cards.dart +++ b/lib/widgets/load_cards.dart @@ -238,7 +238,7 @@ class LoadLiveBlock extends StatelessWidget { // 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. - if (live.funding == 'grid') ...[ + if (live.isGridFunded) ...[ const SizedBox(height: 2), Row(children: [ Icon(Icons.power_rounded, size: 13, color: tone), diff --git a/test/load_telemetry_test.dart b/test/load_telemetry_test.dart index 4a408ab..722b1ed 100644 --- a/test/load_telemetry_test.dart +++ b/test/load_telemetry_test.dart @@ -595,4 +595,170 @@ void main() { expect(e.surplusShareW, 0); }); }); + + // ─────────────────────────────────────────────────────────────────────────── + group('+etm30 — `funding` descend dans `levels[]` (R2)', () { + // Charge utile RELEVÉE sur `.75` le 2026-08-30, plugin `1.15.2+etm30`, cycle + // 06:10:00Z. Deux points la rendent utile telle quelle : aucune charge ne porte de + // `funding` à sa racine, et l'identité y est exerçable pour de vrai — + // 3 500 + 3 000 == 6 500 == `budget.allocatedW`. + Map payload() => { + 'degradedMode': false, + 'timestamp': '2026-08-30T06:10:00Z', + 'budget': { + 'surplusW': 1221.9, + 'evReservedW': 0.0, + 'recreditedW': 6500.0, + 'allocatedW': 6500.0, + 'remainingW': 1221.9, + }, + 'loads': [ + { + 'loadId': 'chauffe-eau', + 'allocatedW': 3500.0, + 'available': true, + 'levels': [ + {'level': 'comfort', 'funding': 'surplus', 'targetW': 3500.0}, + ], + 'measurement': {'source': 'meter', 'powerW': 1500.0}, + 'mechanism': {'kind': 'relay', 'stageW': 3500.0, 'maxStageW': 3500.0}, + 'decision': { + 'code': 'SURPLUS_SETPOINT', + 'params': {'budgetW': 4722, 'setpointW': 3500, 'stepped': true}, + }, + }, + { + 'loadId': 'pac-terrain', + 'allocatedW': 3000.0, + 'available': true, + 'levels': [ + {'level': 'comfort', 'funding': 'surplus', 'targetW': 3000.0}, + ], + 'measurement': {'source': 'meter', 'powerW': 800.0}, + 'mechanism': {'kind': 'sgReady', 'state': 4, 'estimatedPowerW': 3000.0}, + 'decision': { + 'code': 'SG_FORCED', + 'params': {'budgetW': 4222, 'estimatedW': 3000}, + }, + }, + ], + }; + + test('la racine ne porte plus rien, et le niveau porte tout', () { + final tel = LoadTelemetry.fromJson(payload()); + final e = tel.entryFor('chauffe-eau')!; + + // La rupture est effective : lue à la racine, la valeur a disparu. + expect(e.funding, isNull); + // Résolue, elle est là — c'est par là que doivent passer les écrans. + expect(e.resolvedFunding, 'surplus'); + expect(e.isGridFunded, isFalse); + + expect(e.levels.single.level, 'comfort'); + expect(e.levels.single.targetW, 3500); + }); + + test('l\'identité tient sur la charge utile réelle de `.75`', () { + final tel = LoadTelemetry.fromJson(payload()); + // Σ targetW des niveaux financés au surplus == budget.allocatedW, exactement. + expect(tel.surplusAllocatedW, 6500); + expect(tel.surplusIdentityHolds, isTrue); + + // Et elle ne se confond pas avec Σ allocatedW : ici les deux coïncident parce + // qu'aucune charge n'est servie au réseau. C'est un cas, pas la règle. + expect(tel.loads.fold(0, (a, e) => a + e.allocatedW), 6500); + }); + + test('un niveau à `targetW: 0` est une passe PARCOURUE, pas une absence', () { + // Relevé au cycle 06:14 : le chauffe-eau n'a rien reçu, et son niveau est publié + // quand même avec le motif qui dit pourquoi. + final e = LoadTelemetryEntry.fromJson(const { + 'loadId': 'chauffe-eau', + 'allocatedW': 0.0, + 'available': true, + 'levels': [ + {'level': 'comfort', 'funding': 'surplus', 'targetW': 0.0}, + ], + 'decision': {'code': 'SURPLUS_INSUFFICIENT', 'params': {}}, + }); + expect(e.levels, hasLength(1)); + expect(e.resolvedFunding, 'surplus'); + expect(e.surplusShareW, 0); + }); + + test('la forme antérieure continue de se lire', () { + // Box avant `+etm30` : pas de `levels`, un `funding` à la racine. + final e = LoadTelemetryEntry.fromJson(const { + 'loadId': 'borne-echeance', + 'allocatedW': 3680.0, + 'available': true, + 'funding': 'grid', + 'decision': {'code': 'EV_DEADLINE', 'params': {}}, + }); + expect(e.levels, isEmpty); + expect(e.resolvedFunding, 'grid'); + expect(e.isGridFunded, isTrue); + expect(e.surplusShareW, 0); + }); + + test('des niveaux discordants se NOMMENT `mixed`, ils ne se replient pas', () { + // Impossible aujourd'hui — une seule passe est publiée. Le §10 le rendra courant : + // plancher éco acheté au réseau, confort servi par le surplus, même cycle. Replier + // sur l'un des deux ferait affirmer à un écran un financement unique qui n'existe + // pas ; `isGridFunded` reste donc faux, et c'est un écran à deux niveaux qui + // traitera le cas. + final e = LoadTelemetryEntry.fromJson(const { + 'loadId': 'chauffe-eau', + 'allocatedW': 2400.0, + 'available': true, + 'levels': [ + {'level': 'eco', 'funding': 'grid', 'targetW': 1400.0}, + {'level': 'comfort', 'funding': 'surplus', 'targetW': 1000.0}, + ], + 'decision': {'code': 'SURPLUS_SETPOINT', 'params': {}}, + }); + expect(e.resolvedFunding, 'mixed'); + expect(e.isGridFunded, isFalse); + // Seul le niveau financé au surplus entre dans l'identité. + 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. + 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, + }, + 'loads': [ + { + 'loadId': 'borne-v2c', + 'allocatedW': 3680.0, + 'available': true, + 'levels': [ + {'level': 'comfort', 'funding': 'grid', 'targetW': 3680.0}, + ], + 'decision': { + 'code': 'EV_GRID_START', + 'params': {'budgetW': 1200, 'gridW': 2480}, + }, + }, + ], + }); + 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(tel.surplusIdentityHolds, isTrue); + }); + }); }