diff --git a/lib/models/consumer_series.dart b/lib/models/consumer_series.dart index 4cadf50..2ba9952 100644 --- a/lib/models/consumer_series.dart +++ b/lib/models/consumer_series.dart @@ -24,11 +24,19 @@ /// `.abs()` qui devine juste par hasard est indiscernable d'un `.abs()` qui devine faux. library; -/// Un point d'une série : un instant et la puissance publiée à cet instant. +/// Un point d'une série : un instant, la puissance publiée, et le compteur cumulé. class ConsumerPoint { final DateTime t; final double powerW; - const ConsumerPoint(this.t, this.powerW); + + /// `totalConsumption` de l'appareil, en kWh cumulés. `null` = non publié. + /// + /// C'est LUI qui donne l'énergie d'une période, par différence entre deux points — jamais + /// une intégration de [powerW], qui supposerait que la puissance est restée constante + /// entre deux échantillons. + final double? cumulKwh; + + const ConsumerPoint(this.t, this.powerW, {this.cumulKwh}); } /// La série d'un appareil, avec de quoi le nommer et le colorer. @@ -55,6 +63,26 @@ class ConsumerSeries { /// tracer : une ligne au ras de l'axe, pas une absence. bool get toutAZero => points.isNotEmpty && points.every((p) => p.powerW == 0); + /// L'énergie consommée entre deux instants, ou `null` si l'un des deux cumuls manque. + /// + /// `null` n'est pas zéro : une période dont le compteur n'a pas été publié n'a pas de + /// barre, elle n'a pas une barre vide. + double? energieEntre(DateTime a, DateTime b) { + double? cumul(DateTime t) { + for (final p in points) { + if (p.t == t) return p.cumulKwh; + } + return null; + } + + final x = cumul(a), y = cumul(b); + if (x == null || y == null) return null; + // Un compteur ne recule pas : un delta négatif est une remise à zéro, pas une + // consommation négative. On refuse plutôt que de rendre un nombre absurde. + final d = y - x; + return d >= 0 ? d : null; + } + double get maxW => points.isEmpty ? 0 : points.map((p) => p.powerW.abs()).reduce((a, b) => a > b ? a : b); } diff --git a/lib/screens/energy_screen.dart b/lib/screens/energy_screen.dart index 46cf212..ec97a43 100644 --- a/lib/screens/energy_screen.dart +++ b/lib/screens/energy_screen.dart @@ -421,13 +421,31 @@ class _EnergyScreenState extends State { legend: [ _LegendDot(EtmTokens.selfUse, 'Autoconsommé'), _LegendDot(EtmTokens.amber, 'Exporté'), - _LegendDot(EtmTokens.importColor, 'Soutiré'), + _LegendDot(EtmTokens.blue, 'Soutiré'), ], height: 240, loading: _loading, noData: _noData, child: _buildEnergyBars(), ), + const SizedBox(height: 14), + _EtmChartCard( + title: 'Où est passée la consommation', + subtitle: 'kWh par période, par charge déclarée', + legend: [ + for (final s in _conso.series) + _LegendDot( + _couleursSeries(context)[s.thingId] ?? + EtmTokens.mutedOf(context), + s.label), + const _LegendDot(EtmTokens.brand, 'Reste du foyer'), + ], + height: 240, + loading: _loading, + noData: _noData, + child: _buildConsumerEnergyBars(), + ), + _notesConsommateurs(), ] else ...[ // Graphe Puissances (· SOC % quand il y a une batterie) // @@ -497,7 +515,7 @@ class _EnergyScreenState extends State { // 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(), + _notesConsommateurs(), const SizedBox(height: 14), // Météo & prévision — placeholder @@ -663,6 +681,11 @@ class _EnergyScreenState extends State { reservedSize: 22, interval: xInterval, getTitlesWidget: (v, meta) { + // ⚠️ Pas d'étiquette sur le bord GAUCHE : fl_chart en pose une à `min` en + // plus de celles de l'intervalle, et les deux se superposent quand `min` + // n'est pas un multiple — « 20:4500 » à l'écran le 2026-09-09. Deux heures + // imprimées l'une sur l'autre ne se lisent ni l'une ni l'autre. + if (v <= meta.min) return const SizedBox.shrink(); final idx = v.round(); if (idx < 0 || idx >= _data.length) return const SizedBox.shrink(); return SideTitleWidget( @@ -756,6 +779,48 @@ class _EnergyScreenState extends State { ), ); + /// **Les périodes d'une vue en barres, regroupées par période CIVILE.** + /// + /// 🔴 Ne pas se contenter des paires d'échantillons consécutifs. La box a changé de fuseau + /// le 2026-09-06 (`Europe/London` → `Europe/Paris`) et sa frontière de journée s'est + /// déplacée d'une heure : l'historique porte **deux échantillons pour le 06/09**, à 00:00 + /// et à 01:00. Sur le graphe, ça donnait huit barres pour sept jours, deux étiquettes + /// « 6/9 », et une « journée » d'une heure à côté de journées entières. + /// + /// La couture est **permanente** — elle est dans les données passées, pas dans une + /// requête. Tout écran qui compare des périodes doit donc grouper par période civile, + /// jamais par indices consécutifs. + /// + /// On garde le **premier** échantillon de chaque période, et l'énergie d'une période est + /// la différence avec le premier de la suivante. Aucune énergie n'est perdue : la sliver + /// d'une heure est absorbée dans la journée à laquelle elle appartient. + List<({PowerBalanceEntry a, PowerBalanceEntry b})> _periodes() { + if (_data.length < 2) return const []; + final parPeriode = {}; + for (final e in _data) { + final k = _clePeriode(e.timestamp); + // `putIfAbsent` : on garde le PREMIER de la période, pas le dernier. + parPeriode.putIfAbsent(k, () => e); + } + final cles = parPeriode.keys.toList()..sort(); + return [ + for (int i = 1; i < cles.length; i++) + (a: parPeriode[cles[i - 1]]!, b: parPeriode[cles[i]]!) + ]; + } + + /// La clé de regroupement, selon la granularité de l'onglet. Triable en ordre + /// lexicographique — d'où le zéro devant. + String _clePeriode(DateTime t) { + final tab = _tabs[_tabIdx]; + String d2(int v) => v.toString().padLeft(2, '0'); + return switch (tab.sampleRate) { + 'SampleRate1Hour' => '${t.year}-${d2(t.month)}-${d2(t.day)}T${d2(t.hour)}', + 'SampleRate1Month' => '${t.year}-${d2(t.month)}', + _ => '${t.year}-${d2(t.month)}-${d2(t.day)}', + }; + } + // ── ③ Barres d'ÉNERGIE — vues Jour / Semaine / Mois / Année ──────────────── /// Une période, deux barres empilées, et le bloc qu'elles partagent. @@ -794,10 +859,14 @@ class _EnergyScreenState extends State { var maxKwh = 0.5; final orange = EtmTokens.selfUse; final jaune = EtmTokens.amber; - final rouge = EtmTokens.importColor; + // Le soutiré passe du rouge au BLEU (demande de Patrick, 2026-09-09) : avec l'orange + // partagé sur les deux barres, le rouge en faisait une image trop chaude, et le bleu + // est déjà la couleur de la maison partout ailleurs dans l'app. + final bleu = EtmTokens.blue; - for (int i = 1; i < _data.length; i++) { - final a = _data[i - 1], b = _data[i]; + final periodes = _periodes(); + for (int i = 0; i < periodes.length; i++) { + final a = periodes[i].a, b = periodes[i].b; if (!a.totalsPresent || !b.totalsPresent) continue; double delta(double x, double y) => max(0.0, y - x); final prod = delta(a.totalProductionKwh, b.totalProductionKwh); @@ -827,9 +896,9 @@ class _EnergyScreenState extends State { 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), + BarChartRodStackItem(self.clamp(0.0, conso), conso, bleu), ], - color: rouge, + color: bleu, ), ])); } @@ -885,14 +954,16 @@ class _EnergyScreenState extends State { sideTitles: SideTitles( showTitles: true, reservedSize: 22, - interval: _xInterval(), + interval: _xIntervalPeriodes(), getTitlesWidget: (v, meta) { + final p = _periodes(); final idx = v.round(); - if (idx < 0 || idx >= _data.length) return const SizedBox.shrink(); + if (idx < 0 || idx >= p.length) return const SizedBox.shrink(); + // Étiquetée par le DÉBUT de la période : « 6/9 » désigne la journée du 6, + // pas l'instant où on a arrêté de la compter. return SideTitleWidget( meta: meta, - child: - Text(_fmtTime(_data[idx].timestamp), style: labelStyle)); + child: Text(_fmtTime(p[idx].a.timestamp), style: labelStyle)); }, ), ), @@ -903,13 +974,143 @@ class _EnergyScreenState extends State { /// 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. + /// Espacement des étiquettes, compté en PÉRIODES et non en échantillons. + double _xIntervalPeriodes() { + final n = _periodes().length; + return n > 8 ? (n / 6).ceilToDouble() : 1.0; + } + double _largeurBarre() { - final n = _data.length; + final n = _periodes().length; if (n <= 14) return 9; if (n <= 32) return 5; return 3; } + // ── ④ Répartition de la CONSOMMATION, en énergie ─────────────────────────── + + /// Où est passée la consommation de chaque période — une barre empilée par période. + /// + /// Le pendant en énergie du graphe par consommateur : là-bas des puissances instantanées, + /// ici des **kWh accumulés**, obtenus par différence du compteur cumulé de chaque + /// appareil (`totalConsumption`), jamais par intégration d'une puissance. + /// + /// Le **reste du foyer** ferme la barre à la consommation totale de la période. Il est + /// licite pour la même raison que dans la vue Heures : chaque part est un compteur dédié, + /// le total est le compteur du foyer, la soustraction ne porte que du mesuré. Et un reste + /// **négatif** n'est pas raboté — il prouverait qu'un sous-compteur n'est pas dans le + /// périmètre du total. + /// + /// ⚠️ Une charge dont le compteur n'a pas bougé sur la période donne **0 kWh, qui est une + /// mesure**. Une charge dont le cumul manque à l'une des deux bornes n'a **rien** — pas un + /// zéro. Les deux se ressemblent à l'écran et ne veulent pas dire la même chose. + Widget _buildConsumerEnergyBars() { + if (_loading || _noData || _data.length < 2 || _conso.estVide) { + return const SizedBox.shrink(); + } + final couleurs = _couleursSeries(context); + final groupes = []; + var maxKwh = 0.5; + + final periodes = _periodes(); + for (int i = 0; i < periodes.length; i++) { + final a = periodes[i].a, b = periodes[i].b; + if (!a.totalsPresent || !b.totalsPresent) continue; + final total = + max(0.0, b.totalConsumptionKwh - a.totalConsumptionKwh); + + final parts = <(Color, double)>[]; + var somme = 0.0; + for (final serie in _conso.series) { + final e = serie.energieEntre(a.timestamp, b.timestamp); + if (e == null) continue; // pas de cumul : pas de part, pas un zéro + somme += e; + parts.add((couleurs[serie.thingId] ?? EtmTokens.mutedOf(context), e)); + } + final reste = total - somme; + + final piles = []; + var bas = 0.0; + for (final (c, v) in parts) { + piles.add(BarChartRodStackItem(bas, bas + v, c)); + bas += v; + } + piles.add(BarChartRodStackItem(bas, bas + reste, EtmTokens.brand)); + + maxKwh = max(maxKwh, max(total, bas + reste)); + groupes.add(BarChartGroupData(x: i, barRods: [ + BarChartRodData( + toY: bas + reste, + width: _largeurBarre() * 1.6, + borderRadius: const BorderRadius.vertical(top: Radius.circular(2)), + rodStackItems: piles, + color: EtmTokens.brand, + ), + ])); + } + if (groupes.isEmpty) return const SizedBox.shrink(); + + final labelStyle = EtmTokens.sans(size: 9, color: EtmTokens.mutedOf(context)); + return BarChart( + BarChartData( + 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, + 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: _xIntervalPeriodes(), + getTitlesWidget: (v, meta) { + final p = _periodes(); + final idx = v.round(); + if (idx < 0 || idx >= p.length) return const SizedBox.shrink(); + // Étiquetée par le DÉBUT de la période : « 6/9 » désigne la journée du 6, + // pas l'instant où on a arrêté de la compter. + return SideTitleWidget( + meta: meta, + child: Text(_fmtTime(p[idx].a.timestamp), style: labelStyle)); + }, + ), + ), + ), + ), + ); + } + // ── ② Empilement par CONSOMMATEUR ────────────────────────────────────────── /// Qui consomme, dans le temps — une aire par charge déclarée, plus le reste du foyer. diff --git a/lib/services/nymea_service.dart b/lib/services/nymea_service.dart index 20a1b95..f62d889 100644 --- a/lib/services/nymea_service.dart +++ b/lib/services/nymea_service.dart @@ -1098,6 +1098,10 @@ class NymeaService extends ChangeNotifier { parAppareil.putIfAbsent(nu(id), () => []).add(ConsumerPoint( DateTime.fromMillisecondsSinceEpoch(ts.toInt() * 1000), p.toDouble(), + // `totalConsumption` est le compteur cumulé de l'appareil : c'est lui qui + // donne l'énergie d'une période, par différence. Absent sur une box ou un + // appareil qui ne le publie pas — et alors la période n'a pas de barre. + cumulKwh: (m['totalConsumption'] as num?)?.toDouble(), )); } for (final l in parAppareil.values) {