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:
parent
9491077497
commit
2292c7df8a
@ -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.
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user