diff --git a/docs/captures/2026-08-28_bilan-du-jour.png b/docs/captures/2026-08-28_bilan-du-jour.png new file mode 100644 index 0000000..a1c7cd2 Binary files /dev/null and b/docs/captures/2026-08-28_bilan-du-jour.png differ diff --git a/integration_test/daily_balance_on_device_test.dart b/integration_test/daily_balance_on_device_test.dart new file mode 100644 index 0000000..e137cbb --- /dev/null +++ b/integration_test/daily_balance_on_device_test.dart @@ -0,0 +1,178 @@ +/// Le **bilan du jour**, sur l'appareil, contre les vrais compteurs de `.75`. +/// +/// ## Pourquoi il faut le voir sur appareil +/// +/// `test/daily_balance_test.dart` prouve le calcul sur une fixture. Il ne prouve ni que +/// les bornes de minuit local sont les bonnes une fois dans le fuseau du téléphone, ni que +/// le décodage des clés de log tient bout à bout, ni que les tuiles se remplissent après +/// le premier rendu — le bilan arrive **après** la première image, il faut un aller-retour. +/// +/// ## LECTURE SEULE — le banc est en préparation +/// +/// La V2C arrive, `.75` doit rester dans l'état où la prépa l'a laissé. Ce test n'écrit +/// **rien** : ni `SetLoadConfig`, ni `SetChargingInfo`, ni réglage. Il lit des logs et +/// regarde un écran. +/// +/// Lancement : +/// ```sh +/// flutter test integration_test/daily_balance_on_device_test.dart -d dee659e2 +/// ``` +library; + +import 'package:etm_powersync_app/main.dart' as app; +import 'package:etm_powersync_app/models/daily_balance.dart'; +import 'package:etm_powersync_app/services/connection_manager.dart'; +import 'package:etm_powersync_app/screens/dashboard_screen.dart'; +import 'package:etm_powersync_app/services/nymea_service.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:provider/provider.dart'; + +const _hote = '192.168.1.75'; + +Future _attendre( + WidgetTester tester, + bool Function() cond, { + Duration limite = const Duration(seconds: 30), + String quoi = 'condition', +}) async { + final fin = DateTime.now().add(limite); + while (DateTime.now().isBefore(fin)) { + await tester.pump(const Duration(milliseconds: 250)); + if (cond()) return; + } + fail('délai dépassé en attendant : $quoi'); +} + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + testWidgets('le bilan du jour, sur appareil, contre .75', (tester) async { + final debordements = []; + final precedent = FlutterError.onError; + FlutterError.onError = (details) { + final t = details.exceptionAsString(); + if (t.contains('overflowed by')) { + final l = t.split('\n').first; + if (!debordements.contains(l)) debordements.add(l); + return; + } + precedent?.call(details); + }; + addTearDown(() => FlutterError.onError = precedent); + + app.main(); + await tester.pump(const Duration(seconds: 3)); + + final ctx = tester.element(find.byType(MaterialApp).first); + final svc = Provider.of(ctx, listen: false); + final cnx = Provider.of(ctx, listen: false); + if (!svc.isConnected) { + // ignore: unawaited_futures + cnx.connectNew(host: _hote); + } + await _attendre(tester, () => svc.isConnected, + limite: const Duration(seconds: 60), quoi: 'la connexion à $_hote'); + debugPrint('[appareil] connecté à $_hote'); + + // ── 1. Le bilan tel que le service le lit ──────────────────────────────── + final DailyBalance? b = await svc.fetchDailyBalance(); + expect(b, isNotNull, + reason: '.75 publie des logs depuis minuit — la journée doit être mesurable'); + + debugPrint('[appareil] fenêtre ${b!.from} → ${b.to} · ${b.sampleCount} échantillons'); + debugPrint('[appareil] prod ${b.productionKwh.toStringAsFixed(2)} · ' + 'conso ${b.consumptionKwh.toStringAsFixed(2)} · ' + 'import ${b.importKwh.toStringAsFixed(2)} · ' + 'export ${b.exportKwh.toStringAsFixed(2)} kWh'); + debugPrint('[appareil] autoconso ${b.selfConsumptionRate?.toStringAsFixed(1)} % · ' + 'autonomie ${b.autonomyRate?.toStringAsFixed(1)} % · ' + 'identité ${b.identityHolds ? "OK" : "FAUSSE"} · ' + 'partielle : ${b.startsLate}'); + + // ── 2. Les bornes sont bien celles du JOUR, dans le fuseau du téléphone ── + final maintenant = DateTime.now(); + final minuit = DateTime(maintenant.year, maintenant.month, maintenant.day); + expect(b.from.isBefore(minuit), isFalse, + reason: 'la fenêtre ne doit pas déborder sur la veille — minuit LOCAL'); + expect(b.to.isAfter(minuit), isTrue); + expect(b.sampleCount, greaterThanOrEqualTo(2)); + + // ── 3. L'ordre de grandeur tranche l'unité, bout à bout ───────────────── + // + // C'est le piège qui a fait afficher « 78 Wh » pour une journée de 78 kWh. Une maison + // ne produit ni des dixièmes de kWh ni des mégawattheures dans la journée. + expect(b.productionKwh, greaterThan(0.5), + reason: 'des Wh donneraient une valeur mille fois trop petite'); + expect(b.productionKwh, lessThan(500)); + + // ── 4. L'identité des compteurs, sur la vraie machine ─────────────────── + expect(b.identityHolds, isTrue, + reason: 'production + soutiré doit égaler consommation + injecté : ' + '${b.productionKwh + b.importKwh} vs ${b.consumptionKwh + b.exportKwh}'); + + // ── 5. Aller AU tableau de bord ───────────────────────────────────────── + // + // Se connecter ne navigue pas : l'app reste sur l'écran Installations, et chercher les + // tuiles là-bas ne prouve que leur absence. Le contexte du `MaterialApp` est au-dessus + // du Router — on prend celui d'un Scaffold affiché. + GoRouter.of(tester.element(find.byType(Scaffold).first)).go('/'); + await tester.pump(const Duration(seconds: 3)); + await _attendre( + tester, + () => find.byType(DashboardScreen).evaluate().isNotEmpty, + quoi: 'le montage du tableau de bord', + ); + debugPrint('[appareil] tableau de bord monté'); + + // ── 6. Les tuiles se remplissent APRÈS le premier rendu ───────────────── + // + // Le tableau de bord lit le bilan dans un post-frame callback : la première image + // porte « — ». Si l'anneau ne se réarmait pas, il resterait vide pour toujours. + await _attendre( + tester, + () => find.text('Autoconsommation').evaluate().isNotEmpty, + quoi: 'les tuiles du jour', + ); + expect(find.text('Aujourd\'hui'), findsOneWidget, + reason: 'la période est dite UNE fois, en tête des quatre tuiles'); + expect(find.text('Vers réseau'), findsOneWidget); + expect(find.text('Depuis réseau'), findsOneWidget); + expect(find.textContaining('cumulé').evaluate(), isEmpty, + reason: 'plus aucune tuile ne montre un compteur depuis l\'origine'); + + // Les libellés ne doivent pas être coupés au milieu d'un mot — « Autoconsomm… » est + // ce que la capture d'appareil a montré au premier passage, et aucun test unitaire ne + // pouvait le voir : ils ne rendent pas ces widgets à la largeur d'un vrai écran. + for (final t in tester.widgetList(find.byType(Text))) { + final d = t.data; + if (d != null && d.endsWith('…')) { + fail('libellé tronqué à l\'écran : « $d »'); + } + } + + // Laisser le temps au bilan d'arriver et à l'anneau de s'animer, puis regarder. + for (var i = 0; i < 60; i++) { + await tester.pump(const Duration(milliseconds: 250)); + } + expect(find.text('— %').evaluate(), isEmpty, + reason: 'les anneaux doivent porter une valeur une fois le bilan lu'); + + debugPrint('[appareil] ÉCRAN « tableau de bord · bilan du jour » — maintenu 15 s'); + for (var i = 0; i < 60; i++) { + await tester.pump(const Duration(milliseconds: 250)); + } + + if (debordements.isNotEmpty) { + debugPrint('[appareil] DÉBORDEMENTS (${debordements.length}) :'); + for (final d in debordements) { + debugPrint('[appareil] • $d'); + } + } else { + debugPrint('[appareil] aucun débordement de mise en page'); + } + expect(debordements, isEmpty); + }); +} diff --git a/lib/features/dashboard/widgets/kpi_row.dart b/lib/features/dashboard/widgets/kpi_row.dart index afc327b..1be9f6c 100644 --- a/lib/features/dashboard/widgets/kpi_row.dart +++ b/lib/features/dashboard/widgets/kpi_row.dart @@ -1,38 +1,78 @@ import 'package:flutter/material.dart'; +import '../../../models/daily_balance.dart'; import '../../../models/energy_data.dart'; import '../../../theme/etm_tokens.dart'; import '../painters/ring_painter.dart'; +/// Les quatre tuiles sous le flux énergétique — **le bilan du JOUR**. +/// +/// ## Ce qu'elles montraient, et pourquoi c'était incohérent +/// +/// Aucune n'était journalière, et elles mélangeaient deux natures de grandeur sans le +/// dire : *Autoconsommation* et *Autonomie* étaient des **pourcentages instantanés**, +/// *Vers réseau* et *Depuis réseau* des **kWh cumulés depuis l'origine** de l'installation. +/// Les deux dernières ont même porté le mot « aujourd'hui », qui était faux — +/// `totalReturn` n'est pas remis à zéro chaque nuit. +/// +/// L'instantané n'est pas perdu : c'est ce que montre le **flux énergétique juste +/// au-dessus**. Les tuiles disent la journée, la carte dit l'instant, et chacune ne dit +/// qu'une chose. +/// +/// ## Ce qu'elles font des trous +/// +/// [balance] à `null` = journée **non mesurable** : moins de deux échantillons, compteurs +/// corrompus, ou compteur en recul. Les tuiles affichent alors « — » et le disent. Elles +/// n'affichent **jamais zéro** : « rien produit » et « on ne sait pas » sont deux états +/// différents, et à 3 h du matin le premier est vrai sans que le second le soit. class KpiRow extends StatelessWidget { final EnergyData data; - const KpiRow({super.key, required this.data}); + + /// Bilan du jour, ou `null` tant qu'il n'est pas lu — ou s'il n'est pas mesurable. + final DailyBalance? balance; + + const KpiRow({super.key, required this.data, this.balance}); @override Widget build(BuildContext context) { - final autoconso = data.selfConsumptionRate.clamp(0.0, 100.0); - final autonomy = data.autonomyRate.clamp(0.0, 100.0); - // Deux compteurs DISTINCTS de la box, et non un seul et son opposé. - // - // Ces deux tuiles lisaient toutes les deux `dayGridInjectionWh` — l'export — la - // seconde en prenant son opposé. Or l'import a son propre compteur - // (`totalAcquisition`) : dès que l'export était positif, « Depuis réseau » affichait - // 0,0 kWh en permanence, quelle que soit l'électricité réellement achetée. - // - // `null` = la box publie une valeur inexploitable ; la tuile le dit au lieu - // d'imprimer un nombre absurde (constaté sur `.75` : 9,1e+33 kWh exportés, alors que - // `totalProduction` restait sain — les compteurs cumulés de l'experience-plugin - // énergie sont corrompus, pas ceux du Thing compteur). - final toGrid = data.gridExportKwhOrNull; - final fromGrid = data.gridImportKwhOrNull; + final b = balance; + // Identité fausse = compteur cassé ou remis à zéro dans la fenêtre. On garde les + // chiffres — ils restent la seule chose que la box ait dite — mais la carte le + // signale, plutôt que de présenter comme une mesure ce qui ne se recoupe pas. + final suspect = b != null && !b.identityHolds; return Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ + // Le mot « aujourd'hui » est dit UNE fois, au-dessus des quatre tuiles. + // + // Répété dans chaque libellé, il les faisait toutes déborder — constaté à la + // capture d'appareil : « Autoconsomm… », « Vers réseau · aujou… ». Un suffixe qui + // mange le nom de la grandeur coûte plus qu'il n'apporte, alors qu'un en-tête + // porte la même information pour les quatre. + Padding( + padding: const EdgeInsets.only(left: 2, bottom: 8), + child: Row(children: [ + Icon(Icons.today_rounded, size: 14, color: EtmTokens.mutedOf(context)), + const SizedBox(width: 6), + Text('Aujourd\'hui', + style: EtmTokens.sans( + size: 12, + weight: FontWeight.w600, + color: EtmTokens.mutedOf(context))), + if (b != null && b.startsLate) ...[ + const SizedBox(width: 6), + Text('· depuis ${_hhmm(b.from)}', + style: EtmTokens.sans( + size: 11, color: EtmTokens.mutedOf(context))), + ], + ]), + ), Row( children: [ Expanded( child: _RingTile( label: 'Autoconsommation', - value: autoconso, + value: b?.selfConsumptionRate, color: EtmTokens.solarOf(context), ), ), @@ -40,7 +80,7 @@ class KpiRow extends StatelessWidget { Expanded( child: _RingTile( label: 'Autonomie', - value: autonomy, + value: b?.autonomyRate, color: EtmTokens.ecoOf(context), ), ), @@ -51,10 +91,8 @@ class KpiRow extends StatelessWidget { children: [ Expanded( child: _ValueTile( - // « aujourd'hui » était faux : `totalReturn` est cumulé depuis - // l'origine et n'est pas remis à zéro chaque nuit. - label: 'Vers réseau · cumulé', - value: toGrid, + label: 'Vers réseau', + value: b?.exportKwh, icon: Icons.north_west_rounded, color: EtmTokens.ecoOf(context), ), @@ -62,24 +100,48 @@ class KpiRow extends StatelessWidget { const SizedBox(width: 10), Expanded( child: _ValueTile( - label: 'Depuis réseau · cumulé', - value: fromGrid, + label: 'Depuis réseau', + value: b?.importKwh, icon: Icons.south_east_rounded, color: EtmTokens.importColor, ), ), ], ), + if (b != null && b.startsLate) ...[ + const SizedBox(height: 8), + _Mention( + icon: Icons.schedule_rounded, + texte: 'Journée partielle : la box ne publie rien avant ' + '${_hhmm(b.from)}. Le bilan part de cette heure, pas de minuit.', + ), + ], + if (suspect) ...[ + const SizedBox(height: 8), + _Mention( + icon: Icons.rule_rounded, + alerte: true, + texte: 'Les compteurs ne se recoupent pas : production + soutiré devrait ' + 'égaler consommation + injecté. Un compteur a probablement été remis à ' + 'zéro dans la journée.', + ), + ], ], ); } + + static String _hhmm(DateTime t) => + '${t.hour.toString().padLeft(2, '0')}:${t.minute.toString().padLeft(2, '0')}'; } // ── Ring tile — horizontal (ring left, text right) ──────────────────────────── class _RingTile extends StatefulWidget { final String label; - final double value; + + /// `null` = **non mesurable**, et surtout pas 0 %. Un anneau vide et un anneau à zéro se + /// ressemblent trop pour qu'on laisse le second dire le premier. + final double? value; final Color color; const _RingTile({required this.label, required this.value, required this.color}); @@ -97,13 +159,32 @@ class _RingTileState extends State<_RingTile> void initState() { super.initState(); _ctrl = AnimationController(vsync: this, duration: const Duration(milliseconds: 1100)); - _ring = Tween(begin: 0, end: widget.value / 100) - .animate(CurvedAnimation(parent: _ctrl, curve: Curves.easeOutCubic)); - _count = IntTween(begin: 0, end: widget.value.round()) - .animate(CurvedAnimation(parent: _ctrl, curve: Curves.easeOutCubic)); + _armer(); _ctrl.forward(); } + void _armer() { + final v = widget.value ?? 0; + _ring = Tween(begin: 0, end: v / 100) + .animate(CurvedAnimation(parent: _ctrl, curve: Curves.easeOutCubic)); + _count = IntTween(begin: 0, end: v.round()) + .animate(CurvedAnimation(parent: _ctrl, curve: Curves.easeOutCubic)); + } + + // Le bilan arrive APRÈS la première image — il faut un aller-retour RPC. Sans ce + // rejeu, l'anneau resterait figé sur la valeur qu'il avait au montage, c'est-à-dire + // aucune, et le tableau de bord paraîtrait vide sur une installation qui produit. + @override + void didUpdateWidget(covariant _RingTile old) { + super.didUpdateWidget(old); + if (old.value != widget.value) { + _armer(); + _ctrl + ..reset() + ..forward(); + } + } + @override void dispose() { _ctrl.dispose(); @@ -142,24 +223,38 @@ class _RingTileState extends State<_RingTile> child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - AnimatedBuilder( - animation: _count, - builder: (context, _) => Text( - '${_count.value} %', - style: EtmTokens.mono( - size: 18, - weight: FontWeight.w600, - color: EtmTokens.inkOf(context), + if (widget.value == null) + Text('—', + style: EtmTokens.mono( + size: 18, + weight: FontWeight.w600, + color: EtmTokens.mutedOf(context), + )) + else + AnimatedBuilder( + animation: _count, + builder: (context, _) => Text( + '${_count.value} %', + style: EtmTokens.mono( + size: 18, + weight: FontWeight.w600, + color: EtmTokens.inkOf(context), + ), ), ), - ), const SizedBox(height: 3), Text( - widget.label, + widget.value == null + ? '${widget.label} · pas mesurable' + : widget.label, style: EtmTokens.sans( size: 11, color: EtmTokens.mutedOf(context), ), + // Deux lignes plutôt qu'un mot coupé : « Autoconsomm… » ne se lit pas, + // et la tuile a la hauteur pour le porter entier. + maxLines: 2, + overflow: TextOverflow.ellipsis, ), ], ), @@ -228,11 +323,12 @@ class _ValueTile extends StatelessWidget { ), const SizedBox(height: 3), Text( - value == null ? '$label · compteur box invalide' : label, + value == null ? '$label · pas mesurable' : label, style: EtmTokens.sans( size: 10.5, color: EtmTokens.mutedOf(context), ), + maxLines: 2, overflow: TextOverflow.ellipsis, ), ], @@ -243,3 +339,27 @@ class _ValueTile extends StatelessWidget { ); } } + +/// Ligne d'explication sous les tuiles. Elle n'apparaît que quand il y a quelque chose à +/// dire — une journée partielle, ou des compteurs qui ne se recoupent pas. +class _Mention extends StatelessWidget { + final IconData icon; + final String texte; + final bool alerte; + + const _Mention({required this.icon, required this.texte, this.alerte = false}); + + @override + Widget build(BuildContext context) { + final couleur = + alerte ? EtmTokens.importColor : EtmTokens.mutedOf(context); + return Row(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Icon(icon, size: 14, color: couleur), + const SizedBox(width: 7), + Expanded( + child: Text(texte, + style: EtmTokens.sans(size: 10.5, color: couleur)), + ), + ]); + } +} diff --git a/lib/models/daily_balance.dart b/lib/models/daily_balance.dart new file mode 100644 index 0000000..e5604e9 --- /dev/null +++ b/lib/models/daily_balance.dart @@ -0,0 +1,111 @@ +/// **Bilan du jour** — l'énergie réellement comptée entre minuit local et maintenant. +/// +/// ## Pourquoi ce type existe +/// +/// Le tableau de bord affichait quatre tuiles dont **aucune n'était journalière** : deux +/// pourcentages instantanés, et deux compteurs cumulés **depuis l'origine de +/// l'installation** — `totalReturn` et `totalAcquisition` ne sont pas remis à zéro chaque +/// nuit. Les deux dernières portaient même le mot « aujourd'hui », ce qui était faux. +/// +/// ## La méthode : une DIFFÉRENCE de compteurs, jamais une intégration +/// +/// Le bilan se calcule par `total(maintenant) − total(minuit)` sur les quatre compteurs +/// cumulés des entrées de log. C'est une **mesure**, prise sur les mêmes compteurs que +/// ceux du fournisseur. +/// +/// L'alternative — intégrer les puissances instantanées — est une **estimation** : elle +/// suppose que chaque échantillon représente son intervalle, ce qui est faux dès qu'une +/// charge démarre entre deux points. Vérifié sur `.75` le 2026-08-27 : sur 88 échantillons +/// de 15 minutes, l'intégration donne **77,70 kWh** là où la différence des compteurs donne +/// **78,31** — 0,8 % d'écart, systématiquement à la baisse. +/// +/// Les deux ne sont donc pas interchangeables, et **il n'y a pas de repli silencieux de +/// l'une vers l'autre** : quand la mesure n'est pas exploitable, ce type le dit. +/// +/// ## L'unité est le kWh, et le contraire a coûté un bug +/// +/// Les champs `total*` des entrées de log sont en **kWh**, malgré le suffixe `Wh` que +/// portait le modèle Dart. Vérifié par recoupement : l'intégration des puissances (en W) +/// rend 77 697 Wh quand la différence des compteurs rend 78,31 — le rapport est mille. +/// L'écran Énergie affichait donc « 78 Wh » pour une journée de 78 kWh. +library; + +/// Bilan d'une journée. Toutes les énergies en **kWh**. +class DailyBalance { + /// Début et fin réels de la fenêtre mesurée — **pas** les bornes demandées. + /// + /// Le premier échantillon peut arriver après minuit (service redémarré, box installée + /// dans la journée). Publier la borne demandée laisserait croire à une journée complète. + final DateTime from; + final DateTime to; + + /// Nombre d'échantillons dans la fenêtre. Deux au minimum : une différence a besoin de + /// deux points. + final int sampleCount; + + final double productionKwh; + final double consumptionKwh; + + /// Soutiré au réseau (`totalAcquisition`). + final double importKwh; + + /// Injecté vers le réseau (`totalReturn`). + final double exportKwh; + + const DailyBalance({ + required this.from, + required this.to, + required this.sampleCount, + required this.productionKwh, + required this.consumptionKwh, + required this.importKwh, + required this.exportKwh, + }); + + /// Énergie produite ET consommée sur place — ce qui n'est pas parti au réseau. + double get selfConsumptionKwh => productionKwh - exportKwh; + + /// Part de la production consommée sur place. `null` sans production : à 3 h du matin, + /// « 0 % d'autoconsommation » se lirait comme un défaut alors qu'il n'y a rien à + /// autoconsommer. + double? get selfConsumptionRate { + if (productionKwh <= 0) return null; + return (selfConsumptionKwh / productionKwh * 100).clamp(0, 100); + } + + /// Part de la consommation couverte sans le réseau. `null` sans consommation. + double? get autonomyRate { + if (consumptionKwh <= 0) return null; + return ((consumptionKwh - importKwh) / consumptionKwh * 100).clamp(0, 100); + } + + /// **L'identité que les compteurs doivent vérifier** : + /// `production + import == consommation + export`. + /// + /// Ce n'est pas décoratif. Elle est le seul contrôle disponible sur des compteurs qu'on + /// ne peut pas recouper autrement, et elle a tenu **exactement** sur `.75` (112,244 des + /// deux côtés). Fausse, elle désigne un compteur cassé ou une remise à zéro au milieu de + /// la fenêtre — et le bilan ne doit alors pas être présenté comme une mesure. + /// + /// Tolérance : 1 % de la production, plancher 0,1 kWh. Les valeurs voyagent en `Double` + /// et les compteurs ne s'incrémentent pas tous au même instant. + bool get identityHolds { + final ecart = (productionKwh + importKwh) - (consumptionKwh + exportKwh); + final tolerance = (productionKwh * 0.01).clamp(0.1, double.infinity); + return ecart.abs() <= tolerance; + } + + /// Durée réellement couverte. + Duration get span => to.difference(from); + + /// Vrai si la fenêtre commence **sensiblement après minuit** — le bilan est alors + /// partiel, et le dire vaut mieux que de le présenter comme la journée entière. + /// + /// Seuil à 20 minutes : un échantillonnage au quart d'heure ne place pas forcément un + /// point à 00:00 pile, et crier « partiel » pour un décalage d'échantillonnage rendrait + /// l'avertissement inutile à force d'être affiché. + bool get startsLate { + final minuit = DateTime(from.year, from.month, from.day); + return from.difference(minuit) > const Duration(minutes: 20); + } +} diff --git a/lib/models/energy_data.dart b/lib/models/energy_data.dart index 87aa4ae..cf9704e 100644 --- a/lib/models/energy_data.dart +++ b/lib/models/energy_data.dart @@ -147,10 +147,25 @@ class PowerBalanceEntry { final double consumptionW; // consommation maison (W) final double acquisitionW; // import réseau (W, positif) final double storageW; // batterie : +charge / -décharge (W) - final double totalProductionWh; // cumulé depuis l'origine (Wh) - final double totalConsumptionWh; - final double totalReturnWh; // injection réseau cumulée (Wh) - final double totalAcquisitionWh; + /// Compteurs cumulés depuis l'origine, en **kWh** — et non en Wh comme le nom de ces + /// champs l'a longtemps dit. + /// + /// Vérifié sur `.75` le 2026-08-27 par recoupement, parce qu'aucune documentation ne le + /// tranchait : sur une fenêtre de 21,75 h, l'intégration trapézoïdale des puissances + /// instantanées (qui sont bien en **W**) rend **77 697 Wh**, quand la différence de + /// `totalProduction` sur la même fenêtre rend **78,31**. Le rapport est mille. + /// + /// Le mauvais nom n'était pas cosmétique : l'écran Énergie affichait « 78 Wh » pour une + /// journée de 78 kWh, et personne ne pouvait le voir sans connaître l'ordre de grandeur + /// attendu. + final double totalProductionKwh; + final double totalConsumptionKwh; + + /// Injecté vers le réseau, cumulé (kWh). + final double totalReturnKwh; + + /// Soutiré au réseau, cumulé (kWh). + final double totalAcquisitionKwh; const PowerBalanceEntry({ required this.timestamp, @@ -158,10 +173,9 @@ class PowerBalanceEntry { this.consumptionW = 0, this.acquisitionW = 0, this.storageW = 0, - this.totalProductionWh = 0, - this.totalConsumptionWh = 0, - this.totalReturnWh = 0, - this.totalAcquisitionWh = 0, + this.totalProductionKwh = 0, + this.totalConsumptionKwh = 0, + this.totalReturnKwh = 0, + this.totalAcquisitionKwh = 0, }); - } \ No newline at end of file diff --git a/lib/screens/dashboard_screen.dart b/lib/screens/dashboard_screen.dart index ebb2417..d430e9f 100644 --- a/lib/screens/dashboard_screen.dart +++ b/lib/screens/dashboard_screen.dart @@ -1,5 +1,8 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; +import '../models/daily_balance.dart'; import '../main.dart' show DrawerMenuButton; import '../providers/app_settings_provider.dart'; import '../services/nymea_service.dart'; @@ -21,6 +24,40 @@ class _DashboardScreenState extends State { // n'atteint le dashboard qu'avec une session active (box réelle ou démo). // La démo est un choix explicite depuis l'écran Installations. + /// Bilan du jour. `null` tant qu'il n'est pas lu, et `null` aussi s'il n'est pas + /// mesurable — les tuiles font la même chose des deux : elles disent « — ». + /// + /// Il n'est **pas** dans `EnergyData` : celui-ci porte l'instantané poussé par les + /// notifications, alors que le bilan demande un aller-retour sur les logs et ne change + /// qu'au quart d'heure. Les mêler ferait relire 88 échantillons à chaque trame de + /// puissance. + DailyBalance? _bilan; + + /// Le bilan avance d'un point par quart d'heure : le relire plus souvent coûterait un + /// aller-retour de 88 échantillons pour rien. Cinq minutes laissent la journée fraîche + /// sans marteler la box. + static const Duration _periode = Duration(minutes: 5); + Timer? _tick; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) => _lireBilan()); + _tick = Timer.periodic(_periode, (_) => _lireBilan()); + } + + @override + void dispose() { + _tick?.cancel(); + super.dispose(); + } + + Future _lireBilan() async { + if (!mounted) return; + final b = await context.read().fetchDailyBalance(); + if (mounted) setState(() => _bilan = b); + } + @override Widget build(BuildContext context) { return Consumer( @@ -29,7 +66,10 @@ class _DashboardScreenState extends State { return Scaffold( backgroundColor: EtmTokens.bgOf(context), body: RefreshIndicator( - onRefresh: () async => service.startSimulation(), + onRefresh: () async { + service.startSimulation(); + await _lireBilan(); + }, color: EtmTokens.ecoOf(context), child: CustomScrollView( slivers: [ @@ -41,7 +81,7 @@ class _DashboardScreenState extends State { const SizedBox(height: 14), EnergyFlowCard(data: data), const SizedBox(height: 14), - KpiRow(data: data), + KpiRow(data: data, balance: _bilan), const SizedBox(height: 14), const HeosDecisionCard(), const SizedBox(height: 14), diff --git a/lib/screens/energy_screen.dart b/lib/screens/energy_screen.dart index 67b904c..cce4ee2 100644 --- a/lib/screens/energy_screen.dart +++ b/lib/screens/energy_screen.dart @@ -237,7 +237,7 @@ class _EnergyScreenState extends State { // Graphe Bilan énergétique _EtmChartCard( - title: 'Bilan énergétique (Wh)', + title: 'Bilan énergétique (kWh)', subtitle: 'Énergie par période', legend: [ _LegendDot(EtmTokens.amber, 'Production'), @@ -431,19 +431,22 @@ class _EnergyScreenState extends State { final bw = _barWidth(); for (int i = 0; i < _data.length; i++) { - final prodWh = i == 0 ? 0.0 : max(0.0, _data[i].totalProductionWh - _data[i - 1].totalProductionWh); - final consoWh = i == 0 ? 0.0 : max(0.0, _data[i].totalConsumptionWh - _data[i - 1].totalConsumptionWh); - maxE = [maxE, prodWh, consoWh].reduce(max); + // Les compteurs sont en kWh — le graphe traçait leur différence en la nommant « Wh » + // et en la divisant encore par mille à l'affichage : une journée de 78 kWh s'y lisait + // « 78 Wh ». Corrigé le 2026-08-27, l'unité vérifiée par recoupement sur `.75`. + final prodKwh = i == 0 ? 0.0 : max(0.0, _data[i].totalProductionKwh - _data[i - 1].totalProductionKwh); + final consoKwh = i == 0 ? 0.0 : max(0.0, _data[i].totalConsumptionKwh - _data[i - 1].totalConsumptionKwh); + maxE = [maxE, prodKwh, consoKwh].reduce(max); groups.add(BarChartGroupData( x: i, barsSpace: 2, barRods: [ BarChartRodData( - toY: prodWh, color: EtmTokens.amber, width: bw, + toY: prodKwh, color: EtmTokens.amber, width: bw, borderRadius: const BorderRadius.vertical(top: Radius.circular(3)), ), BarChartRodData( - toY: consoWh, color: EtmTokens.blue, width: bw, + toY: consoKwh, color: EtmTokens.blue, width: bw, borderRadius: const BorderRadius.vertical(top: Radius.circular(3)), ), ], @@ -518,9 +521,12 @@ class _EnergyScreenState extends State { double _barWidth() => _data.length > 40 ? 3 : _data.length > 20 ? 5 : 8; + /// Les valeurs sont **déjà en kWh** : on descend vers le Wh pour les petites, on ne + /// remonte pas. L'ancienne version divisait par mille des kWh — d'où « 78 Wh » pour une + /// journée entière. String _fmtWh(double v) { - if (v.abs() >= 1000) return '${(v / 1000).toStringAsFixed(1)} kWh'; - return '${v.toStringAsFixed(0)} Wh'; + if (v.abs() < 1) return '${(v * 1000).toStringAsFixed(0)} Wh'; + return '${v.toStringAsFixed(1)} kWh'; } String _fmtTime(DateTime t) => _tabs[_tabIdx].showTime diff --git a/lib/services/nymea_service.dart b/lib/services/nymea_service.dart index c20b95c..10f4311 100644 --- a/lib/services/nymea_service.dart +++ b/lib/services/nymea_service.dart @@ -6,6 +6,7 @@ import 'dart:math'; import 'package:flutter/foundation.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:web_socket_channel/web_socket_channel.dart'; +import '../models/daily_balance.dart'; import '../models/energy_data.dart'; // EnergyData, HistoryPoint, PowerBalanceEntry import '../models/modbus_models.dart'; // ModbusRtuMaster, SerialPort import '../models/nymea_models.dart'; @@ -950,10 +951,10 @@ class NymeaService extends ChangeNotifier { consumptionW: d('consumption'), acquisitionW: d('acquisition'), storageW: d('storage'), - totalProductionWh: d('totalProduction'), - totalConsumptionWh:d('totalConsumption'), - totalReturnWh: d('totalReturn'), - totalAcquisitionWh:d('totalAcquisition'), + totalProductionKwh: d('totalProduction'), + totalConsumptionKwh:d('totalConsumption'), + totalReturnKwh: d('totalReturn'), + totalAcquisitionKwh:d('totalAcquisition'), ); }).toList(); } catch (e) { @@ -962,6 +963,106 @@ class NymeaService extends ChangeNotifier { } } + /// **Le bilan du jour** — de minuit local à maintenant. + /// + /// ## Ce qu'il corrige + /// + /// Le tableau de bord n'avait aucune vue journalière : deux pourcentages instantanés et + /// deux compteurs cumulés **depuis l'origine**, dont deux portaient le mot + /// « aujourd'hui ». Cette méthode fournit la vraie journée. + /// + /// ## Les trois pièges, déjà payés une fois chacun + /// + /// 1. **Minuit LOCAL, et via les logs.** `GetPowerBalance` ne rend que des totaux depuis + /// l'origine : il ne sait rien dire d'une journée. Seul `GetPowerBalanceLogs` permet + /// de prendre deux points et de les soustraire. `DateTime(y, m, d)` donne bien minuit + /// dans le fuseau du téléphone — pas UTC. + /// 2. **Les entrées de log ne portent PAS le préfixe `currentPower`.** Elles s'appellent + /// `production`, `consumption`, `acquisition`, `storage`, à la différence de + /// `GetPowerBalance`. Se tromper de clés ne produit **aucune erreur** : tout tombe sur + /// le défaut `0`, et l'écran affiche une journée vide sur une installation qui a + /// produit. C'est exactement ce qui a fait croire à un défaut de la box côté plugin. + /// Le décodage est déjà correct dans [fetchPowerBalanceLogs] ; cette méthode s'appuie + /// dessus plutôt que de refaire un décodage qui pourrait diverger. + /// 3. **Les cumulés peuvent être corrompus.** `.75` a publié 9,1e+33 kWh exportés. Un + /// bilan bâti là-dessus serait absurde sans le paraître, d'où le contrôle de + /// plausibilité sur les deux bornes AVANT toute soustraction. + /// + /// \return `null` quand la journée n'est **pas mesurable** — jamais un bilan à zéro. + /// « Rien produit » et « on ne sait pas » sont deux états différents, et le second ne + /// doit pas se déguiser en premier. + Future fetchDailyBalance({DateTime? jour}) async { + final maintenant = jour ?? DateTime.now(); + final minuit = + DateTime(maintenant.year, maintenant.month, maintenant.day); + + // Le quart d'heure est le taux le plus fin peuplé sur la durée d'une journée, et il + // place assez de points pour qu'un début de fenêtre tardif se voie. + final entries = await fetchPowerBalanceLogs( + from: minuit, + to: maintenant, + sampleRate: 'SampleRate15Mins', + ); + + // Une différence a besoin de deux points. Un seul échantillon n'est pas « une journée + // à zéro », c'est une journée qui n'a pas encore de quoi être mesurée. + if (entries.length < 2) { + _log('[Bilan] ${entries.length} échantillon(s) depuis minuit — non mesurable', + force: true); + return null; + } + + final tries = [...entries] + ..sort((a, b) => a.timestamp.compareTo(b.timestamp)); + final a = tries.first; + final b = tries.last; + + // Compteurs corrompus : on refuse AVANT de soustraire. Deux valeurs absurdes peuvent + // avoir une différence d'apparence saine. + final bornes = [ + a.totalProductionKwh, b.totalProductionKwh, + a.totalConsumptionKwh, b.totalConsumptionKwh, + a.totalReturnKwh, b.totalReturnKwh, + a.totalAcquisitionKwh, b.totalAcquisitionKwh, + ]; + if (!bornes.every(EnergyData.isPlausibleKwh)) { + _log('[Bilan] compteurs cumulés inexploitables — non mesurable', force: true); + return null; + } + + double delta(double fin, double debut) => fin - debut; + final bilan = DailyBalance( + from: a.timestamp, + to: b.timestamp, + sampleCount: tries.length, + productionKwh: delta(b.totalProductionKwh, a.totalProductionKwh), + consumptionKwh: delta(b.totalConsumptionKwh, a.totalConsumptionKwh), + importKwh: delta(b.totalAcquisitionKwh, a.totalAcquisitionKwh), + exportKwh: delta(b.totalReturnKwh, a.totalReturnKwh), + ); + + // Un compteur qui RECULE = remise à zéro ou redémarrage au milieu de la fenêtre. Le + // bilan serait négatif, donc faux, et un `max(0, …)` le rendrait faux en silence. + if (bilan.productionKwh < 0 || + bilan.consumptionKwh < 0 || + bilan.importKwh < 0 || + bilan.exportKwh < 0) { + _log('[Bilan] compteur en recul entre les deux bornes — non mesurable', + force: true); + return null; + } + + _log( + '[Bilan] ${tries.length} échantillons · ' + 'prod ${bilan.productionKwh.toStringAsFixed(1)} · ' + 'conso ${bilan.consumptionKwh.toStringAsFixed(1)} · ' + 'import ${bilan.importKwh.toStringAsFixed(1)} · ' + 'export ${bilan.exportKwh.toStringAsFixed(1)} kWh · ' + 'identité ${bilan.identityHolds ? "OK" : "FAUSSE"}', + force: true); + return bilan; + } + List _simulatePowerBalance( DateTime from, DateTime to, String sampleRate) { final entries = []; @@ -981,20 +1082,23 @@ class NymeaService extends ChangeNotifier { final storage = excess.clamp(-300.0, 300.0).toDouble(); final acq = (conso - prod - storage).clamp(0.0, double.infinity).toDouble(); final ret = (excess - storage).clamp(0.0, double.infinity).toDouble(); - totalProd += prod * stepHours; - totalConso += conso * stepHours; - totalRet += ret * stepHours; - totalAcq += acq * stepHours; + // Les compteurs cumulés de la box sont en kWh (vérifié sur `.75`) : la simulation + // doit produire la même unité, sinon elle ment d'un facteur mille et un écran qui + // marche en simulation casse contre une vraie box. + totalProd += prod * stepHours / 1000; + totalConso += conso * stepHours / 1000; + totalRet += ret * stepHours / 1000; + totalAcq += acq * stepHours / 1000; entries.add(PowerBalanceEntry( timestamp: t, productionW: prod, consumptionW: conso, acquisitionW: acq, storageW: storage, - totalProductionWh: totalProd, - totalConsumptionWh: totalConso, - totalReturnWh: totalRet, - totalAcquisitionWh: totalAcq, + totalProductionKwh: totalProd, + totalConsumptionKwh: totalConso, + totalReturnKwh: totalRet, + totalAcquisitionKwh: totalAcq, )); } return entries; diff --git a/test/daily_balance_test.dart b/test/daily_balance_test.dart new file mode 100644 index 0000000..dca30ed --- /dev/null +++ b/test/daily_balance_test.dart @@ -0,0 +1,134 @@ +/// Le **bilan du jour** — la première vue journalière de l'app. +/// +/// ## Ce que ces tests protègent +/// +/// Aucune des quatre tuiles du tableau de bord n'était journalière : deux pourcentages +/// instantanés, deux compteurs cumulés depuis l'origine dont deux portaient le mot +/// « aujourd'hui ». Le lot corrige ça, et trois pièges le guettaient — tous déjà payés une +/// fois dans ce projet, aucun ne produisant d'erreur visible : +/// +/// 1. prendre les totaux de `GetPowerBalance` au lieu d'une différence sur les logs ; +/// 2. lire les clés `currentPower*` sur des entrées de log qui n'en portent pas ; +/// 3. bâtir un bilan sur des compteurs corrompus. +/// +/// La fixture est un relevé RÉEL de `.75`, avec ses deltas attendus calculés hors de +/// l'app : un test qui recalcule avec le code testé ne prouve rien. +library; + +import 'dart:convert'; +import 'dart:io'; + +import 'package:etm_powersync_app/models/daily_balance.dart'; +import 'package:flutter_test/flutter_test.dart'; + +Map _fixture() => jsonDecode( + File('test/fixtures/powerbalance_jour_hems75.json').readAsStringSync()) + as Map; + +/// Rejoue le calcul du service : une DIFFÉRENCE entre la première et la dernière borne. +DailyBalance _depuisFixture() { + final e = (_fixture()['powerBalanceLogEntries'] as List) + .cast>() + ..sort((a, b) => (a['timestamp'] as int).compareTo(b['timestamp'] as int)); + final a = e.first, b = e.last; + double d(String k) => (b[k] as num).toDouble() - (a[k] as num).toDouble(); + return DailyBalance( + from: DateTime.fromMillisecondsSinceEpoch((a['timestamp'] as int) * 1000), + to: DateTime.fromMillisecondsSinceEpoch((b['timestamp'] as int) * 1000), + sampleCount: e.length, + productionKwh: d('totalProduction'), + consumptionKwh: d('totalConsumption'), + importKwh: d('totalAcquisition'), + exportKwh: d('totalReturn'), + ); +} + +void main() { + 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(); + final attendu = + _fixture()['_deltas_attendus_kwh'] as Map; + expect(b.productionKwh, closeTo(attendu['production'] as double, 0.001)); + expect(b.consumptionKwh, closeTo(attendu['consumption'] as double, 0.001)); + expect(b.exportKwh, closeTo(attendu['return'] as double, 0.001)); + expect(b.importKwh, closeTo(attendu['acquisition'] as double, 0.001)); + }); + + test('l\'identité des compteurs tient — production + soutiré == conso + injecté', () { + // C'est le seul contrôle disponible sur des compteurs qu'on ne peut pas recouper + // autrement. Il a tenu EXACTEMENT sur le banc : 112,244 des deux côtés. + final b = _depuisFixture(); + expect(b.identityHolds, isTrue); + expect(b.productionKwh + b.importKwh, + closeTo(b.consumptionKwh + b.exportKwh, 0.001)); + }); + + test('les taux du jour se déduisent des énergies, pas de l\'instantané', () { + final b = _depuisFixture(); + // autoconso = (produit − injecté) / produit ; autonomie = (consommé − soutiré) / consommé + expect(b.selfConsumptionKwh, closeTo(78.310 - 37.809, 0.001)); + expect(b.selfConsumptionRate!, closeTo(51.72, 0.1)); + expect(b.autonomyRate!, closeTo(54.41, 0.1)); + }); + + test('l\'unité est le kWh — un ordre de grandeur, pas une convention', () { + // Le piège qui a coûté « 78 Wh » affichés pour une journée de 78 kWh. Une maison + // ne produit ni 78 Wh ni 78 MWh dans la journée : l'ordre de grandeur suffit à + // trancher, et ce test le fige. + final b = _depuisFixture(); + expect(b.productionKwh, greaterThan(1), + reason: 'des Wh donneraient une valeur mille fois plus petite'); + expect(b.productionKwh, lessThan(1000)); + }); + }); + + group('ce qui n\'est PAS mesurable ne s\'affiche pas comme zéro', () { + DailyBalance b({ + double prod = 10, + double conso = 10, + double imp = 0, + double exp = 0, + DateTime? from, + }) => + DailyBalance( + from: from ?? DateTime(2026, 8, 27), + to: DateTime(2026, 8, 27, 21, 45), + sampleCount: 88, + productionKwh: prod, + consumptionKwh: conso, + importKwh: imp, + exportKwh: exp, + ); + + test('sans production, le taux d\'autoconsommation est NUL, pas 0 %', () { + // À 3 h du matin il n'y a rien à autoconsommer. « 0 % » se lirait comme un défaut + // de l'installation ; l'absence se lit comme ce qu'elle est. + expect(b(prod: 0).selfConsumptionRate, isNull); + expect(b(prod: 0).autonomyRate, isNotNull); + }); + + test('sans consommation, le taux d\'autonomie est NUL', () { + expect(b(conso: 0).autonomyRate, isNull); + }); + + test('une identité fausse se voit — compteur cassé ou remis à zéro', () { + // 10 produits + 0 soutiré ne peut pas donner 10 consommés + 5 injectés. + expect(b(prod: 10, conso: 10, imp: 0, exp: 5).identityHolds, isFalse); + }); + + test('la tolérance absorbe l\'arrondi sans absorber une erreur', () { + // 1 % de la production, plancher 0,1 kWh : les compteurs ne s'incrémentent pas au + // même instant, mais un écart réel ne doit pas passer. + expect(b(prod: 100, conso: 100, imp: 0, exp: 0.5).identityHolds, isTrue); + expect(b(prod: 100, conso: 100, imp: 0, exp: 5).identityHolds, isFalse); + }); + + test('une fenêtre qui commence tard est signalée comme partielle', () { + expect(b(from: DateTime(2026, 8, 27, 0, 10)).startsLate, isFalse, + reason: 'dix minutes, c\'est le pas d\'échantillonnage, pas un trou'); + expect(b(from: DateTime(2026, 8, 27, 9, 30)).startsLate, isTrue, + reason: 'la box a démarré à 9 h 30 — le bilan ne couvre pas la journée'); + }); + }); +} diff --git a/test/fixtures/powerbalance_jour_hems75.json b/test/fixtures/powerbalance_jour_hems75.json new file mode 100644 index 0000000..1c3ac5d --- /dev/null +++ b/test/fixtures/powerbalance_jour_hems75.json @@ -0,0 +1,45 @@ +{ + "_source": "Energy.GetPowerBalanceLogs sur 192.168.1.75, SampleRate15Mins, minuit local → 21:45, 2026-08-27", + "_note": "Trois échantillons sur 88. Les compteurs total* sont en kWh — vérifié par recoupement : l'intégration trapézoïdale des puissances (W) sur la fenêtre rend 77 697 Wh quand la différence de totalProduction rend 78,310. L'identité production + soutiré == consommation + injecté tient EXACTEMENT sur ce relevé : 112,244 des deux côtés.", + "_deltas_attendus_kwh": { + "production": 78.31, + "consumption": 74.435, + "return": 37.809, + "acquisition": 33.934 + }, + "powerBalanceLogEntries": [ + { + "acquisition": 2366.78679094468, + "consumption": 3507.135007297016, + "production": -1140.3482163523356, + "storage": 0, + "timestamp": 1787781600, + "totalAcquisition": 62.16060776066769, + "totalConsumption": 6331.145563125843, + "totalProduction": 62032.09747619834, + "totalReturn": 55763.112520833165 + }, + { + "acquisition": -2213.146134015749, + "consumption": 3486.542535677007, + "production": -5699.688669692754, + "storage": 0, + "timestamp": 1787821200, + "totalAcquisition": 79.17562729191732, + "totalConsumption": 6368.860421631056, + "totalProduction": 62072.11789818015, + "totalReturn": 55782.43310384101 + }, + { + "acquisition": -1554.8017688394166, + "consumption": 3357.519044852532, + "production": -4912.320813691948, + "storage": 0, + "timestamp": 1787859900, + "totalAcquisition": 96.09468393254281, + "totalConsumption": 6405.580274597465, + "totalProduction": 62110.40698356833, + "totalReturn": 55800.92139290341 + } + ] +}