fix(télémétrie): funding a quitté la charge en +etm30 — le lire dans levels[]

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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GKM8FDNHZWogAsjjfB8pSw
This commit is contained in:
Patrick Schurig 2026-08-30 08:16:29 +02:00
parent 6f19863620
commit 2792e07265
5 changed files with 311 additions and 14 deletions

View File

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

View File

@ -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<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,
);
/// 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<String, dynamic> 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<LoadLevel> 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<String, dynamic>.from(l))
]
: const [],
lock: lock is Map ? LoadLock.fromJson(Map<String, dynamic>.from(lock)) : null,
mechanism: mech is Map
? LoadMechanism.fromJson(Map<String, dynamic>.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 = <String>{};
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) {

View File

@ -140,7 +140,7 @@ class _BugReportScreenState extends State<BugReportScreen> {
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}" : ""}');

View File

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

View File

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