From a14adcfcc3ba826d0160a4a0b2c34db6df8b22a8 Mon Sep 17 00:00:00 2001 From: Patrick Schurig Date: Sun, 6 Sep 2026 12:41:10 +0200 Subject: [PATCH] fix(bilan): un cumul ABSENT n'est pas un cumul nul MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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) Claude-Session: https://claude.ai/code/session_01GKM8FDNHZWogAsjjfB8pSw --- .../dashboard/widgets/energy_flow_card.dart | 12 ++++++- lib/models/energy_data.dart | 13 ++++++++ lib/services/nymea_service.dart | 28 ++++++++++++++++ test/daily_balance_test.dart | 32 +++++++++++++++++++ 4 files changed, 84 insertions(+), 1 deletion(-) diff --git a/lib/features/dashboard/widgets/energy_flow_card.dart b/lib/features/dashboard/widgets/energy_flow_card.dart index 1b3b248..d14b738 100644 --- a/lib/features/dashboard/widgets/energy_flow_card.dart +++ b/lib/features/dashboard/widgets/energy_flow_card.dart @@ -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 » diff --git a/lib/models/energy_data.dart b/lib/models/energy_data.dart index c8b2caa..90c6a30 100644 --- a/lib/models/energy_data.dart +++ b/lib/models/energy_data.dart @@ -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, diff --git a/lib/services/nymea_service.dart b/lib/services/nymea_service.dart index 4c834ea..e884365 100644 --- a/lib/services/nymea_service.dart +++ b/lib/services/nymea_service.dart @@ -1001,7 +1001,19 @@ class NymeaService extends ChangeNotifier { return logs.map((e) { final m = e as Map; 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 = [ diff --git a/test/daily_balance_test.dart b/test/daily_balance_test.dart index 7dfb452..1452801 100644 --- a/test/daily_balance_test.dart +++ b/test/daily_balance_test.dart @@ -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 _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); + }); + }); +}