From 2292c7df8a679340a71867088b1e1195a96352e5 Mon Sep 17 00:00:00 2001 From: Patrick Schurig Date: Wed, 9 Sep 2026 08:17:43 +0200 Subject: [PATCH] =?UTF-8?q?feat(=C3=A9nergie):=20cinq=20onglets=20qui=20di?= =?UTF-8?q?sent=20vrai,=20et=20les=20vues=20d'=C3=A9nergie=20en=20barres?= =?UTF-8?q?=20empil=C3=A9es?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Les onglets portaient chacun le nom d'un cran EN DESSOUS de leur plage : « Jour » montrait 7 jours, « Semaine » 28, « Mois » 365. Quatre étiquettes, quatre mensonges — et la journée que le premier promettait n'était nulle part. L'onglet « Année » manquait, puisque c'est « Mois » qui la montrait. Heures 24 h au ¼ h courbes, axe glissable Jour 00:00 -> 24:00 barres, au pas horaire Semaine 7 jours barres, au jour Mois 30 jours barres, au jour Année 365 jours barres, au mois LES BARRES. Deux barres par période, chacune empilée, et le segment « autoconsommé » est LE MÊME des deux côtés, à la même couleur : Production = autoconsommé + exporté Consommation = autoconsommé + soutiré Deux barres simples diraient laquelle est la plus haute ; celles-ci disent où est passée la différence — la question qu'on se pose devant un bilan. Pas de diagramme divergent, et la raison est propre à ce dépôt : la box publie déjà `production` avec un signe négatif, défaut qui nous a coûté deux écrans et trois jours. Dessiner la production sous l'axe rendrait une capture correcte indiscernable de ce bug. L'énergie vient des CUMULS, jamais des puissances : chaque barre est une différence de compteurs. Une période dont une borne ne publie pas ses cumuls n'a pas de barre — pas une barre à zéro. Et la fenêtre demandée déborde d'un échantillon avant le début, sans quoi la première barre n'a pas de prédécesseur. Quatre défauts trouvés à l'écran, tous invisibles au test : - `barGroups` était OUBLIÉ. Les groupes étaient calculés, `maxY` en découlait, donc l'axe montait à la bonne hauteur — et aucune barre n'était dessinée. Tout ce qui entoure les données avait l'air correct ; - « 80.0kWh » ne tenait pas dans 46 px et se coupait en « 80.0kW / h ». Une unité coupée en deux lignes se lit comme une autre unité ; - une plage de 7 jours partant de 08:12 coupe deux journées en deux : deux barres portaient « 6/9 » et la semaine en comptait huit. Toutes les vues en barres se calent maintenant sur minuit ; - les notes par consommateur s'affichaient sous les barres, où aucun consommateur n'est tracé. 358 tests. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01GKM8FDNHZWogAsjjfB8pSw --- lib/screens/energy_screen.dart | 257 ++++++++++++++++++++++++++++++++- 1 file changed, 249 insertions(+), 8 deletions(-) diff --git a/lib/screens/energy_screen.dart b/lib/screens/energy_screen.dart index f333ce1..46cf212 100644 --- a/lib/screens/energy_screen.dart +++ b/lib/screens/energy_screen.dart @@ -41,12 +41,29 @@ class _NoteEcran extends StatelessWidget { } } +/// Comment une vue se dessine — et ce n'est pas un détail de style. +/// +/// `courbes` porte des **puissances** (kW) échantillonnées ; `barres` porte des **énergies** +/// (kWh) accumulées sur la période. Une semaine de puissances instantanées ne veut rien +/// dire : au pas du jour, ce qu'on peut lire est ce qui a été produit et consommé, pas ce +/// qui passait à un instant. +enum _Rendu { courbes, barres } + class _Tab { final String label; final Duration range; final String sampleRate; final bool showTime; - const _Tab(this.label, this.range, this.sampleRate, {required this.showTime}); + final _Rendu rendu; + + /// Vrai si la plage se cale sur **une journée civile** (00:00 → 24:00) plutôt que sur + /// les N dernières heures. C'est ce que « Jour » promet, et ce qu'il ne faisait pas. + final bool journeeCivile; + + const _Tab(this.label, this.range, this.sampleRate, + {required this.showTime, + this.rendu = _Rendu.courbes, + this.journeeCivile = false}); } class EnergyScreen extends StatefulWidget { @@ -57,11 +74,20 @@ class EnergyScreen extends StatefulWidget { } class _EnergyScreenState extends State { + /// 🔴 **Chaque onglet portait le nom d'un cran EN DESSOUS de sa plage** — « Jour » + /// montrait 7 jours, « Semaine » 28, « Mois » 365. Quatre étiquettes, quatre mensonges, + /// et personne ne pouvait obtenir la journée que le premier promettait. Corrigé le + /// 2026-09-09 ; l'onglet « Année » manquait, puisque c'est « Mois » qui la montrait. static const _tabs = [ - _Tab('Heures', Duration(hours: 24), 'SampleRate15Mins', showTime: true), - _Tab('Jour', Duration(days: 7), 'SampleRate1Hour', showTime: false), - _Tab('Semaine', Duration(days: 28), 'SampleRate1Day', showTime: false), - _Tab('Mois', Duration(days: 365), 'SampleRate1Week', showTime: false), + _Tab('Heures', Duration(hours: 24), 'SampleRate15Mins', showTime: true), + _Tab('Jour', Duration(days: 1), 'SampleRate1Hour', + showTime: true, rendu: _Rendu.barres, journeeCivile: true), + _Tab('Semaine', Duration(days: 7), 'SampleRate1Day', + showTime: false, rendu: _Rendu.barres), + _Tab('Mois', Duration(days: 30), 'SampleRate1Day', + showTime: false, rendu: _Rendu.barres), + _Tab('Année', Duration(days: 365), 'SampleRate1Month', + showTime: false, rendu: _Rendu.barres), ]; int _tabIdx = 0; @@ -203,8 +229,7 @@ class _EnergyScreenState extends State { if (!mounted) return; setState(() { _loading = true; _noData = false; }); final tab = _tabs[_tabIdx]; - final to = _anchorDate(); - final from = to.subtract(tab.range); + final (from, to) = _fenetreRequete(tab); final service = context.read(); final socSource = service.batterySOCSource; @@ -251,6 +276,41 @@ class _EnergyScreenState extends State { }); } + /// La fenêtre demandée à la box, **et pourquoi elle déborde d'un échantillon**. + /// + /// Les barres portent des ÉNERGIES, obtenues par différence de compteurs cumulés entre + /// deux échantillons. Il faut donc un point AVANT le début de la période, sans quoi la + /// première barre n'a pas de prédécesseur — et une barre sans prédécesseur ne vaut pas + /// zéro, elle n'existe pas. Demander une heure de plus coûte un point et évite d'inventer + /// la première. + /// + /// Pour « Jour », la fenêtre se cale sur la **journée civile** 00:00 → 24:00 : c'est ce + /// que l'onglet promet. Les autres restent glissantes à partir de maintenant. + (DateTime, DateTime) _fenetreRequete(_Tab tab) { + final ancre = _anchorDate(); + // 🔴 **Toutes les vues en barres se calent sur minuit**, pas seulement « Jour ». + // + // Sans ça, une plage de 7 jours partant de 08:12 coupe deux journées en deux : le + // premier et le dernier échantillon tombent le même jour civil, l'axe affiche « 6/9 » + // deux fois, et on obtient huit barres pour une semaine. Vu à l'écran le 2026-09-09. + // Une barre journalière doit couvrir une journée entière ou ne pas exister. + final fin = (tab.journeeCivile || tab.rendu == _Rendu.barres) + ? DateTime(ancre.year, ancre.month, ancre.day).add(const Duration(days: 1)) + : ancre; + final debut = fin.subtract(tab.range); + if (tab.rendu != _Rendu.barres) return (debut, fin); + return (debut.subtract(_pasEchantillon(tab)), fin); + } + + static Duration _pasEchantillon(_Tab tab) => switch (tab.sampleRate) { + 'SampleRate15Mins' => const Duration(minutes: 15), + 'SampleRate1Hour' => const Duration(hours: 1), + 'SampleRate1Day' => const Duration(days: 1), + 'SampleRate1Week' => const Duration(days: 7), + 'SampleRate1Month' => const Duration(days: 31), + _ => const Duration(hours: 1), + }; + DateTime _anchorDate() => _selectedDate != null ? DateTime(_selectedDate!.year, _selectedDate!.month, _selectedDate!.day, 23, 59, 59) : DateTime.now(); @@ -347,6 +407,28 @@ class _EnergyScreenState extends State { ], const SizedBox(height: 16), + // ── Vues d'ÉNERGIE : une seule carte, en barres ────────────── + // + // Au pas du jour, une puissance instantanée ne veut rien dire : ce + // qu'on peut lire est ce qui a été produit et consommé. Les deux + // graphes de puissance n'ont donc pas de version « semaine » — ils + // sont remplacés, pas empilés avec. + if (_tabs[_tabIdx].rendu == _Rendu.barres) ...[ + _EtmChartCard( + title: 'Bilan énergétique', + subtitle: 'kWh par période — le bloc orange est partagé : ' + 'ce qui a été produit ET gardé', + legend: [ + _LegendDot(EtmTokens.selfUse, 'Autoconsommé'), + _LegendDot(EtmTokens.amber, 'Exporté'), + _LegendDot(EtmTokens.importColor, 'Soutiré'), + ], + height: 240, + loading: _loading, + noData: _noData, + child: _buildEnergyBars(), + ), + ] else ...[ // Graphe Puissances (· SOC % quand il y a une batterie) // // ⚠️ **La légende ne promet pas une courbe qui n'existe pas.** @@ -410,7 +492,12 @@ class _EnergyScreenState extends State { texte: 'Glissez horizontalement sur un graphe pour vous déplacer ' 'dans la journée. Les deux gardent le même cadrage.', ), - _notesConsommateurs(), + ], + // Les notes parlent des séries par consommateur : elles n'ont aucun + // sens sous un graphe d'énergie qui n'en montre aucune. Vu à l'écran : + // « V2C Trydan n'est mesurée que depuis 00:00 » sous les barres de la + // semaine, où aucune V2C n'est tracée. + if (_tabs[_tabIdx].rendu == _Rendu.courbes) _notesConsommateurs(), const SizedBox(height: 14), // Météo & prévision — placeholder @@ -669,6 +756,160 @@ class _EnergyScreenState extends State { ), ); + // ── ③ Barres d'ÉNERGIE — vues Jour / Semaine / Mois / Année ──────────────── + + /// Une période, deux barres empilées, et le bloc qu'elles partagent. + /// + /// ## Pourquoi pas la production sous l'axe + /// + /// Un diagramme divergent — consommation au-dessus, production en dessous — est une + /// convention défendable, et je l'ai écartée pour une raison propre à ce dépôt : la box + /// publie déjà `production` avec un signe négatif, et ce défaut nous a coûté deux écrans + /// et trois jours. Dessiner la production **sous l'axe** rendrait une capture d'écran + /// correcte indiscernable de ce bug. Une production n'est pas une quantité négative ; + /// l'afficher comme telle entretient exactement la confusion qu'on vient de payer. + /// + /// ## Ce que les deux barres disent, et ce que le bloc commun ajoute + /// + /// ``` + /// Production = autoconsommé + exporté + /// Consommation = autoconsommé + soutiré + /// ``` + /// + /// Le segment **autoconsommé** est le même des deux côtés, à la même couleur : c'est ce + /// qui a été produit ET gardé. Deux barres simples (production, consommation) diraient + /// laquelle est la plus haute ; celles-ci disent en plus **où est passée la différence**, + /// et c'est la question qu'on se pose devant un bilan. + /// + /// ## L'énergie vient des CUMULS, jamais des puissances + /// + /// Chaque barre est une différence de compteurs entre deux échantillons. Une période dont + /// l'une des deux bornes ne publie pas ses cumuls n'a **pas de barre** — pas une barre à + /// zéro : « la box ne l'a pas publié » et « rien n'a été consommé » sont deux états, et + /// une nuit à 0 kWh serait indiscernable d'un trou. + Widget _buildEnergyBars() { + if (_loading || _noData || _data.length < 2) return const SizedBox.shrink(); + + final groupes = []; + var maxKwh = 0.5; + final orange = EtmTokens.selfUse; + final jaune = EtmTokens.amber; + final rouge = EtmTokens.importColor; + + for (int i = 1; i < _data.length; i++) { + final a = _data[i - 1], b = _data[i]; + if (!a.totalsPresent || !b.totalsPresent) continue; + double delta(double x, double y) => max(0.0, y - x); + final prod = delta(a.totalProductionKwh, b.totalProductionKwh); + final conso = delta(a.totalConsumptionKwh, b.totalConsumptionKwh); + final exp = delta(a.totalReturnKwh, b.totalReturnKwh); + final imp = delta(a.totalAcquisitionKwh, b.totalAcquisitionKwh); + // Autoconsommé = ce qui a été produit et n'est pas parti. Borné à la production : + // un export supérieur à la production est une incohérence de compteurs, pas une + // autoconsommation négative. + final self = (prod - exp).clamp(0.0, prod); + + maxKwh = [maxKwh, prod, conso].reduce(max); + groupes.add(BarChartGroupData(x: i, barsSpace: 2, barRods: [ + BarChartRodData( + toY: prod, + width: _largeurBarre(), + borderRadius: const BorderRadius.vertical(top: Radius.circular(2)), + rodStackItems: [ + BarChartRodStackItem(0, self, orange), + BarChartRodStackItem(self, prod, jaune), + ], + color: jaune, + ), + BarChartRodData( + toY: conso, + width: _largeurBarre(), + borderRadius: const BorderRadius.vertical(top: Radius.circular(2)), + rodStackItems: [ + BarChartRodStackItem(0, self.clamp(0.0, conso), orange), + BarChartRodStackItem(self.clamp(0.0, conso), conso, rouge), + ], + color: rouge, + ), + ])); + } + if (groupes.isEmpty) return const SizedBox.shrink(); + + final labelStyle = EtmTokens.sans(size: 9, color: EtmTokens.mutedOf(context)); + return BarChart( + BarChartData( + // ⚠️ `barGroups` était OUBLIÉ : les groupes étaient calculés, `maxY` en découlait — + // donc l'axe montait bien à la bonne hauteur — et aucune barre n'était dessinée. + // Un graphe vide sous un axe juste, ce qui rend le défaut difficile à lire : tout + // ce qui entoure les données avait l'air correct. + barGroups: groupes, + maxY: maxKwh * 1.15, + alignment: BarChartAlignment.spaceAround, + gridData: FlGridData( + show: true, + drawVerticalLine: false, + getDrawingHorizontalLine: (_) => + FlLine(color: EtmTokens.lineOf(context), strokeWidth: 1), + ), + borderData: FlBorderData(show: false), + barTouchData: BarTouchData( + touchTooltipData: BarTouchTooltipData( + getTooltipColor: (_) => EtmTokens.navy.withValues(alpha: 0.9), + getTooltipItem: (g, _, rod, __) => BarTooltipItem( + '${rod.toY.toStringAsFixed(2)} kWh', + EtmTokens.mono(size: 11, color: Colors.white), + ), + ), + ), + titlesData: FlTitlesData( + topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)), + rightTitles: + const AxisTitles(sideTitles: SideTitles(showTitles: false)), + leftTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + // 54 px et pas de décimale au-dessus de 10 : « 80.0kWh » ne tenait pas dans + // 46 px et se coupait en « 80.0kW / h ». Une unité coupée en deux lignes se + // lit comme une autre unité. + reservedSize: 54, + getTitlesWidget: (v, meta) => v == meta.max + ? const SizedBox.shrink() + : SideTitleWidget( + meta: meta, + child: Text( + '${v >= 10 ? v.toStringAsFixed(0) : v.toStringAsFixed(1)} kWh', + style: labelStyle)), + ), + ), + bottomTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + reservedSize: 22, + interval: _xInterval(), + getTitlesWidget: (v, meta) { + final idx = v.round(); + if (idx < 0 || idx >= _data.length) return const SizedBox.shrink(); + return SideTitleWidget( + meta: meta, + child: + Text(_fmtTime(_data[idx].timestamp), style: labelStyle)); + }, + ), + ), + ), + ), + ); + } + + /// Des barres lisibles quel qu'en soit le nombre : 24 heures, 30 jours ou 12 mois ne + /// tiennent pas à la même largeur, et une barre de 1 px n'est plus une barre. + double _largeurBarre() { + final n = _data.length; + if (n <= 14) return 9; + if (n <= 32) return 5; + return 3; + } + // ── ② Empilement par CONSOMMATEUR ────────────────────────────────────────── /// Qui consomme, dans le temps — une aire par charge déclarée, plus le reste du foyer.