fix(énergie): grouper par période CIVILE, soutiré en bleu, second graphe d'énergie

TROIS DEMANDES DE PATRICK.

1. Le soutiré passe du rouge au BLEU. Avec l'orange partagé sur les deux barres,
   le rouge rendait l'image trop chaude — et le bleu est déjà la couleur de la
   maison partout ailleurs. Il ne reste d'orange que le bloc partagé, celui qui
   doit se voir.

2. Second graphe en vue barres : « Où est passée la consommation » — une barre
   empilée par période, une couleur par charge déclarée, plus le reste du foyer
   qui ferme la barre à la consommation totale.

   L'énergie vient du compteur cumulé de chaque appareil (`totalConsumption`),
   jamais d'une intégration de la puissance — intégrer supposerait la puissance
   constante entre deux échantillons. Vérifié à la sonde avant d'écrire : ECS
   80,8 kWh et PAC 43,1 kWh sur la fenêtre, avec des deltas journaliers réels.

   Une charge dont le compteur n'a pas bougé donne 0 kWh, qui est une MESURE.
   Une charge dont le cumul manque à une borne n'a PAS de part. Les deux se
   ressemblent à l'écran et ne disent pas la même chose. Et un compteur qui
   recule est une remise à zéro, pas une consommation négative : la part est
   refusée plutôt que rendue absurde.

🔴 3. LE « 6/9 » EN DOUBLE N'ÉTAIT PAS UN DÉFAUT DE L'APP. C'est la trace de
   notre propre bascule de fuseau du 2026-09-06 : la frontière de journée de la
   box a glissé d'une heure, et l'historique porte deux échantillons pour le
   06/09 — 00:00 et 01:00. Le graphe affichait fidèlement 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 grouper par période civile,
   jamais par indices consécutifs. Aucune énergie n'est perdue — la tranche
   d'une heure est absorbée dans sa journée.

Et une étiquette d'axe illisible : fl_chart en pose une à `min` en plus de
celles de l'intervalle, et les deux se superposaient — « 20:4500 ». Deux heures
imprimées l'une sur l'autre ne se lisent ni l'une ni l'autre.

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:52:54 +02:00
parent 2292c7df8a
commit f1547f674a
3 changed files with 247 additions and 14 deletions

View File

@ -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);
}

View File

@ -421,13 +421,31 @@ class _EnergyScreenState extends State<EnergyScreen> {
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<EnergyScreen> {
// 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<EnergyScreen> {
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<EnergyScreen> {
),
);
/// **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 = <String, PowerBalanceEntry>{};
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<EnergyScreen> {
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<EnergyScreen> {
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<EnergyScreen> {
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<EnergyScreen> {
/// 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 = <BarChartGroupData>[];
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 = <BarChartRodStackItem>[];
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.

View File

@ -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) {