feat(énergie): cinq onglets qui disent vrai, et les vues d'énergie en barres empilées

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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GKM8FDNHZWogAsjjfB8pSw
This commit is contained in:
Patrick Schurig 2026-09-09 08:17:43 +02:00
parent 9491077497
commit 2292c7df8a

View File

@ -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<EnergyScreen> {
/// 🔴 **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<EnergyScreen> {
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<NymeaService>();
final socSource = service.batterySOCSource;
@ -251,6 +276,41 @@ class _EnergyScreenState extends State<EnergyScreen> {
});
}
/// 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<EnergyScreen> {
],
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<EnergyScreen> {
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<EnergyScreen> {
),
);
// ── ③ 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 = <BarChartGroupData>[];
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.