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:
parent
6f19863620
commit
2792e07265
34
CLAUDE.md
34
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** :
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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}" : ""}');
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user