fix(bilan): un cumul ABSENT n'est pas un cumul nul

`double d(String k) => (m[k] as num?)?.toDouble() ?? 0.0` : un champ manquant
devenait 0,0 au décodage, deux bornes à zéro donnaient une différence à zéro, et
l'écran affichait « 0,0 kWh · Vers réseau » comme une mesure. C'est le zéro forgé
que nous traquons depuis une semaine, cette fois de NOTRE côté.

Le cas est réel et n'attend pas sa cause : un compteur racine parfaitement en
place, la puissance instantanée qui arrive, et les cumuls qui n'arrivent pas.

`PowerBalanceEntry.totalsPresent` porte la distinction, et `fetchDailyBalance`
refuse de présenter comme mesure un bilan bâti là-dessus — au même titre qu'un
compteur corrompu. « Rien échangé aujourd'hui » et « la box ne publie pas ses
cumuls » sont deux états, et le second n'a pas le droit de porter le premier.
Le repli à 0 reste, pour ne pas propager des nullables dans tout l'écran Énergie,
mais il est désormais ACCOMPAGNÉ du fait qu'il est un repli.

Et `abs()` sur la production devient un choix DOCUMENTÉ plutôt qu'un correctif
tacite : la production n'est pas signée, c'est le compteur qui l'est, donc la
valeur absolue masquerait un compteur câblé à l'envers dont le signe serait la
seule trace. Le correctif appartient à la source. On la garde en connaissance de
cause — un signe faux se voit tous les jours, un câblage inversé ne se voit
jamais — et on ne revient pas en arrière sans savoir ce que la box publiera.

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-09-06 12:41:10 +02:00
parent a6dd319ef2
commit a14adcfcc3
4 changed files with 84 additions and 1 deletions

View File

@ -350,7 +350,17 @@ class _NodeLayerState extends State<_NodeLayer>
icon: Icons.wb_sunny_rounded,
color: EtmTokens.solarOf(context),
label: 'Solaire',
// ⚠️ `currentPowerProduction` est publié NÉGATIF par nymea (-8 980 W =
// ⚠️ **`abs()` est un choix, et il a un coût connu.** La production n'est
// pas une grandeur signée : c'est le COMPTEUR qui l'est. Prendre la valeur
// absolue redresse donc un signe de source côté affichage — et, ce faisant,
// rendrait invisible un compteur câblé à l'envers, dont le signe serait la
// seule trace. Le correctif appartient à la source ; il est demandé au
// moteur. D'ici là on garde `abs()` en connaissance de cause : entre un
// signe faux à l'écran et une anomalie de câblage masquée, le premier se
// voit tous les jours, la seconde ne se voit jamais — et surtout, revenir
// en arrière sans savoir ce que la box publiera ne serait pas un choix.
//
// `currentPowerProduction` est publié NÉGATIF par nymea (-8 980 W =
// 8,98 kW produits), et le service garde les signes bruts par politique.
// L'AFFICHAGE, lui, doit dire ce qu'un client comprend : une production est
// une quantité positive. Vu sur appareil le 2026-09-05, « -8.9 kW Solaire »

View File

@ -185,8 +185,21 @@ class PowerBalanceEntry {
/// Soutiré au réseau, cumulé (kWh).
final double totalAcquisitionKwh;
/// **Les quatre cumuls étaient-ils PRÉSENTS dans la charge utile ?**
///
/// ⚠️ Sans ce drapeau, un champ absent devient `0.0` au décodage — et un bilan bâti sur
/// deux bornes à zéro s'affiche « 0,0 kWh · Vers réseau » comme s'il était mesuré. C'est
/// le zéro forgé, du côté de l'écran cette fois : « rien échangé » et « la box ne le
/// publie pas » sont deux états, et le second n'a pas le droit de porter le premier.
///
/// Le cas est réel : un compteur racine bien en place et des cumuls qui n'arrivent pas —
/// une intégration incomplète côté box. La distinction doit tenir **quelle que soit la
/// raison** de l'absence, donc sans attendre que cette raison soit corrigée.
final bool totalsPresent;
const PowerBalanceEntry({
required this.timestamp,
this.totalsPresent = false,
this.productionW = 0,
this.consumptionW = 0,
this.acquisitionW = 0,

View File

@ -1001,7 +1001,19 @@ class NymeaService extends ChangeNotifier {
return logs.map<PowerBalanceEntry>((e) {
final m = e as Map<String, dynamic>;
double d(String k) => (m[k] as num?)?.toDouble() ?? 0.0;
// Un cumul ABSENT n'est pas un cumul nul. Le repli à 0 reste, pour ne pas
// propager des nullables dans tout l'écran Énergie — mais il est désormais
// ACCOMPAGNÉ du fait qu'il est un repli, et `fetchDailyBalance` refuse de
// présenter comme mesure un bilan bâti là-dessus.
const cumuls = [
'totalProduction',
'totalConsumption',
'totalReturn',
'totalAcquisition'
];
final presents = cumuls.every((k) => m[k] is num);
return PowerBalanceEntry(
totalsPresent: presents,
timestamp: DateTime.fromMillisecondsSinceEpoch(
(m['timestamp'] as num).toInt() * 1000),
// Signes nymea BRUTS — ne corriger aucun signe (§7.1). L'autoconso
@ -1100,6 +1112,22 @@ class NymeaService extends ChangeNotifier {
final a = tries.first;
final b = tries.last;
// ── CUMULS ABSENTS : on refuse, et pour la même raison qu'un compteur corrompu ──
//
// Un champ manquant est décodé à 0 (repli assumé, cf. `PowerBalanceEntry`). Deux
// bornes à zéro donnent une différence à zéro, et l'écran afficherait « 0,0 kWh »
// comme une mesure. « Rien échangé aujourd'hui » et « la box ne publie pas ses
// cumuls » sont deux états, et le second n'a pas le droit de porter le premier.
//
// Le cas se produit avec un compteur racine parfaitement en place : la donnée
// instantanée arrive, les cumuls non. La distinction tient donc ici, sans rien
// attendre de la cause.
if (!a.totalsPresent || !b.totalsPresent) {
_log('[Bilan] cumuls absents de la charge utile — non mesurable, '
'et surtout pas « 0 kWh »', force: true);
return null;
}
// Compteurs corrompus : on refuse AVANT de soustraire. Deux valeurs absurdes peuvent
// avoir une différence d'apparence saine.
final bornes = [

View File

@ -19,6 +19,7 @@ import 'dart:convert';
import 'dart:io';
import 'package:etm_powersync_app/models/daily_balance.dart';
import 'package:etm_powersync_app/models/energy_data.dart';
import 'package:flutter_test/flutter_test.dart';
Map<String, dynamic> _fixture() => jsonDecode(
@ -46,6 +47,7 @@ DailyBalance _depuisFixture() {
}
void main() {
_cumulsAbsents();
group('bilan du jour — sur le relevé réel de .75', () {
test('les deltas sont ceux calculés hors de l\'app', () {
final b = _depuisFixture();
@ -149,3 +151,33 @@ void main() {
});
});
}
/// **Un cumul ABSENT n'est pas un cumul nul** — la distinction tient côté écran, quelle que
/// soit la raison de l'absence.
///
/// Le cas est réel : un compteur racine parfaitement en place, la puissance instantanée qui
/// arrive, et les cumuls qui n'arrivent pas. Décodés à 0, ils produisent un bilan de zéros
/// que l'écran présenterait comme une mesure — « 0,0 kWh · Vers réseau » sur une journée
/// dont on ne sait rien.
void _cumulsAbsents() {
group('cumuls absents : « non mesurable », jamais « 0 kWh »', () {
test('le drapeau distingue le repli du vrai zéro', () {
// Décodage d'une entrée SANS les quatre cumuls : les valeurs retombent à 0…
final sansCumuls = PowerBalanceEntry(timestamp: DateTime(2026, 9, 6));
expect(sansCumuls.totalsPresent, isFalse,
reason: '…mais l\'écran doit pouvoir savoir que c\'était un repli');
});
test('une entrée complète se déclare présente', () {
final avec = PowerBalanceEntry(
timestamp: DateTime(2026, 9, 6),
totalsPresent: true,
totalProductionKwh: 59.89,
totalConsumptionKwh: 57.31,
totalAcquisitionKwh: 26.31,
totalReturnKwh: 28.89,
);
expect(avec.totalsPresent, isTrue);
});
});
}