Patrick Schurig 23a84fec43 feat(dashboard): le bilan du JOUR — et deux bugs d'unité trouvés en le posant
L'entrée 🔴 du TODO. Aucune des quatre tuiles n'était journalière : deux pourcentages
instantanés, deux compteurs cumulés depuis l'origine dont deux portaient le mot
« aujourd'hui ». Elles montrent désormais la journée, sous un en-tête qui le dit une fois.

MÉTHODE : une DIFFÉRENCE de compteurs entre minuit local et maintenant, jamais une
intégration des puissances. La première est une mesure, prise sur les mêmes compteurs que
ceux du fournisseur ; la seconde est une estimation qui suppose que chaque échantillon
représente son intervalle. Mesuré sur .75 : l'intégration rend 77,70 kWh là où la différence
rend 78,31 — 0,8 % d'écart, systématiquement à la baisse. Il n'y a donc PAS de repli
silencieux de l'une vers l'autre : quand la mesure n'est pas exploitable, le type le dit.

DEUX BUGS D'UNITÉ, trouvés en cherchant à savoir ce que valaient les compteurs. Aucune
documentation ne tranchait, donc recoupement sur machine : l'intégration des puissances (en
W) rend 77 697 Wh quand la différence de totalProduction rend 78,310. Le rapport est mille —
LES COMPTEURS SONT EN kWh, et le modèle Dart les nommait Wh.

  1. L'écran Énergie divisait ces kWh par mille pour les afficher : une journée de 78 kWh
     s'y lisait « 78 Wh », sous un axe intitulé « Bilan énergétique (Wh) ». Invisible sans
     connaître l'ordre de grandeur attendu.
  2. La simulation accumulait des Wh : un écran juste en simulation aurait cassé d'un
     facteur mille contre une vraie box.

LE CONTRÔLE QUI VALIDE LE BILAN : production + soutiré == consommation + injecté. C'est le
seul recoupement disponible sur des compteurs qu'on ne peut pas vérifier autrement, et il
tient EXACTEMENT sur .75 — 112,244 des deux côtés sur 21,75 h. Faux, il désigne un compteur
cassé ou une remise à zéro, et la carte le signale au lieu de présenter comme une mesure ce
qui ne se recoupe pas.

Les trois pièges du TODO n'ont pas été repayés : bornes à minuit LOCAL via
GetPowerBalanceLogs · décodage délégué à fetchPowerBalanceLogs, qui porte déjà les bonnes
clés sans préfixe currentPower · plausibilité vérifiée sur les DEUX bornes avant de
soustraire, parce que deux valeurs absurdes peuvent avoir une différence d'apparence saine.
Plus un quatrième trouvé en chemin : un compteur qui RECULE (redémarrage en milieu de
fenêtre) donne un bilan négatif, et un max(0, …) le rendrait faux en silence.

Rien ne s'affiche jamais à zéro quand c'est inconnu. Sans production, le taux
d'autoconsommation est null et non 0 % — à 3 h du matin, « 0 % » se lirait comme un défaut
alors qu'il n'y a rien à autoconsommer.

VÉRIFIÉ SUR APPAREIL contre .75, en lecture seule (le banc est en préparation V2C, rien n'a
été écrit) : 32 échantillons depuis minuit, prod 28,37 · conso 26,55 · import 11,95 ·
export 13,77 kWh, identité OK, et les tuiles affichent 51 % / 55 % / 13,8 / 11,9.

Le passage sur appareil a trouvé ce qu'aucun des 138 tests unitaires ne pouvait voir : LES
QUATRE LIBELLÉS DÉBORDAIENT — « Autoconsomm… », « Vers réseau · aujou… ». Le suffixe
« aujourd'hui » répété quatre fois mangeait le nom de la grandeur ; il est dit une fois en
en-tête. Le harnais d'appareil échoue désormais sur tout libellé finissant par « … ».

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016vrTVifar2GN5rtUh89Wkq
2026-08-28 07:57:57 +02:00

366 lines
13 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import 'package:flutter/material.dart';
import '../../../models/daily_balance.dart';
import '../../../models/energy_data.dart';
import '../../../theme/etm_tokens.dart';
import '../painters/ring_painter.dart';
/// Les quatre tuiles sous le flux énergétique — **le bilan du JOUR**.
///
/// ## Ce qu'elles montraient, et pourquoi c'était incohérent
///
/// Aucune n'était journalière, et elles mélangeaient deux natures de grandeur sans le
/// dire : *Autoconsommation* et *Autonomie* étaient des **pourcentages instantanés**,
/// *Vers réseau* et *Depuis réseau* des **kWh cumulés depuis l'origine** de l'installation.
/// Les deux dernières ont même porté le mot « aujourd'hui », qui était faux —
/// `totalReturn` n'est pas remis à zéro chaque nuit.
///
/// L'instantané n'est pas perdu : c'est ce que montre le **flux énergétique juste
/// au-dessus**. Les tuiles disent la journée, la carte dit l'instant, et chacune ne dit
/// qu'une chose.
///
/// ## Ce qu'elles font des trous
///
/// [balance] à `null` = journée **non mesurable** : moins de deux échantillons, compteurs
/// corrompus, ou compteur en recul. Les tuiles affichent alors « — » et le disent. Elles
/// n'affichent **jamais zéro** : « rien produit » et « on ne sait pas » sont deux états
/// différents, et à 3 h du matin le premier est vrai sans que le second le soit.
class KpiRow extends StatelessWidget {
final EnergyData data;
/// Bilan du jour, ou `null` tant qu'il n'est pas lu — ou s'il n'est pas mesurable.
final DailyBalance? balance;
const KpiRow({super.key, required this.data, this.balance});
@override
Widget build(BuildContext context) {
final b = balance;
// Identité fausse = compteur cassé ou remis à zéro dans la fenêtre. On garde les
// chiffres — ils restent la seule chose que la box ait dite — mais la carte le
// signale, plutôt que de présenter comme une mesure ce qui ne se recoupe pas.
final suspect = b != null && !b.identityHolds;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Le mot « aujourd'hui » est dit UNE fois, au-dessus des quatre tuiles.
//
// Répété dans chaque libellé, il les faisait toutes déborder — constaté à la
// capture d'appareil : « Autoconsomm… », « Vers réseau · aujou… ». Un suffixe qui
// mange le nom de la grandeur coûte plus qu'il n'apporte, alors qu'un en-tête
// porte la même information pour les quatre.
Padding(
padding: const EdgeInsets.only(left: 2, bottom: 8),
child: Row(children: [
Icon(Icons.today_rounded, size: 14, color: EtmTokens.mutedOf(context)),
const SizedBox(width: 6),
Text('Aujourd\'hui',
style: EtmTokens.sans(
size: 12,
weight: FontWeight.w600,
color: EtmTokens.mutedOf(context))),
if (b != null && b.startsLate) ...[
const SizedBox(width: 6),
Text('· depuis ${_hhmm(b.from)}',
style: EtmTokens.sans(
size: 11, color: EtmTokens.mutedOf(context))),
],
]),
),
Row(
children: [
Expanded(
child: _RingTile(
label: 'Autoconsommation',
value: b?.selfConsumptionRate,
color: EtmTokens.solarOf(context),
),
),
const SizedBox(width: 10),
Expanded(
child: _RingTile(
label: 'Autonomie',
value: b?.autonomyRate,
color: EtmTokens.ecoOf(context),
),
),
],
),
const SizedBox(height: 10),
Row(
children: [
Expanded(
child: _ValueTile(
label: 'Vers réseau',
value: b?.exportKwh,
icon: Icons.north_west_rounded,
color: EtmTokens.ecoOf(context),
),
),
const SizedBox(width: 10),
Expanded(
child: _ValueTile(
label: 'Depuis réseau',
value: b?.importKwh,
icon: Icons.south_east_rounded,
color: EtmTokens.importColor,
),
),
],
),
if (b != null && b.startsLate) ...[
const SizedBox(height: 8),
_Mention(
icon: Icons.schedule_rounded,
texte: 'Journée partielle : la box ne publie rien avant '
'${_hhmm(b.from)}. Le bilan part de cette heure, pas de minuit.',
),
],
if (suspect) ...[
const SizedBox(height: 8),
_Mention(
icon: Icons.rule_rounded,
alerte: true,
texte: 'Les compteurs ne se recoupent pas : production + soutiré devrait '
'égaler consommation + injecté. Un compteur a probablement été remis à '
'zéro dans la journée.',
),
],
],
);
}
static String _hhmm(DateTime t) =>
'${t.hour.toString().padLeft(2, '0')}:${t.minute.toString().padLeft(2, '0')}';
}
// ── Ring tile — horizontal (ring left, text right) ────────────────────────────
class _RingTile extends StatefulWidget {
final String label;
/// `null` = **non mesurable**, et surtout pas 0 %. Un anneau vide et un anneau à zéro se
/// ressemblent trop pour qu'on laisse le second dire le premier.
final double? value;
final Color color;
const _RingTile({required this.label, required this.value, required this.color});
@override
State<_RingTile> createState() => _RingTileState();
}
class _RingTileState extends State<_RingTile>
with SingleTickerProviderStateMixin {
late final AnimationController _ctrl;
late Animation<double> _ring;
late Animation<int> _count;
@override
void initState() {
super.initState();
_ctrl = AnimationController(vsync: this, duration: const Duration(milliseconds: 1100));
_armer();
_ctrl.forward();
}
void _armer() {
final v = widget.value ?? 0;
_ring = Tween<double>(begin: 0, end: v / 100)
.animate(CurvedAnimation(parent: _ctrl, curve: Curves.easeOutCubic));
_count = IntTween(begin: 0, end: v.round())
.animate(CurvedAnimation(parent: _ctrl, curve: Curves.easeOutCubic));
}
// Le bilan arrive APRÈS la première image — il faut un aller-retour RPC. Sans ce
// rejeu, l'anneau resterait figé sur la valeur qu'il avait au montage, c'est-à-dire
// aucune, et le tableau de bord paraîtrait vide sur une installation qui produit.
@override
void didUpdateWidget(covariant _RingTile old) {
super.didUpdateWidget(old);
if (old.value != widget.value) {
_armer();
_ctrl
..reset()
..forward();
}
}
@override
void dispose() {
_ctrl.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(
color: EtmTokens.surfaceOf(context),
borderRadius: BorderRadius.circular(EtmTokens.radiusCard),
boxShadow: EtmTokens.cardShadowOf(context),
border: Border.all(color: EtmTokens.lineOf(context), width: 1),
),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 13),
child: Row(
children: [
// Ring — 50×50, strokeWidth 5 (matches HTML .ring SVG)
SizedBox(
width: 50, height: 50,
child: AnimatedBuilder(
animation: _ring,
builder: (context, _) => CustomPaint(
size: const Size(50, 50),
painter: RingPainter(
value: _ring.value,
color: widget.color,
strokeWidth: 5,
),
),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (widget.value == null)
Text('—',
style: EtmTokens.mono(
size: 18,
weight: FontWeight.w600,
color: EtmTokens.mutedOf(context),
))
else
AnimatedBuilder(
animation: _count,
builder: (context, _) => Text(
'${_count.value} %',
style: EtmTokens.mono(
size: 18,
weight: FontWeight.w600,
color: EtmTokens.inkOf(context),
),
),
),
const SizedBox(height: 3),
Text(
widget.value == null
? '${widget.label} · pas mesurable'
: widget.label,
style: EtmTokens.sans(
size: 11,
color: EtmTokens.mutedOf(context),
),
// Deux lignes plutôt qu'un mot coupé : « Autoconsomm… » ne se lit pas,
// et la tuile a la hauteur pour le porter entier.
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
),
),
],
),
);
}
}
// ── Value tile — horizontal (icon left, text right) ───────────────────────────
class _ValueTile extends StatelessWidget {
final String label;
/// Valeur en kWh, ou `null` si la box en publie une **inexploitable**.
///
/// Le cas n'est pas théorique : `.75` publie 9,1e+33 kWh exportés. Imprimer ce nombre
/// le présente comme une mesure ; l'effacer laisse croire à zéro. On affiche donc « — »
/// et on dit d'où vient le trou, ce qui est la seule lecture honnête des trois.
final double? value;
final IconData icon;
final Color color;
const _ValueTile({
required this.label,
required this.value,
required this.icon,
required this.color,
});
@override
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(
color: EtmTokens.surfaceOf(context),
borderRadius: BorderRadius.circular(EtmTokens.radiusCard),
boxShadow: EtmTokens.cardShadowOf(context),
border: Border.all(color: EtmTokens.lineOf(context), width: 1),
),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 13),
child: Row(
children: [
Container(
width: 30, height: 30,
decoration: BoxDecoration(
color: color.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(9),
),
child: Icon(icon, color: color, size: 16),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
value == null ? '—' : '${value!.toStringAsFixed(1)} kWh',
style: EtmTokens.mono(
size: 16,
weight: FontWeight.w600,
color: value == null
? EtmTokens.mutedOf(context)
: EtmTokens.inkOf(context),
),
),
const SizedBox(height: 3),
Text(
value == null ? '$label · pas mesurable' : label,
style: EtmTokens.sans(
size: 10.5,
color: EtmTokens.mutedOf(context),
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
),
),
],
),
);
}
}
/// Ligne d'explication sous les tuiles. Elle n'apparaît que quand il y a quelque chose à
/// dire — une journée partielle, ou des compteurs qui ne se recoupent pas.
class _Mention extends StatelessWidget {
final IconData icon;
final String texte;
final bool alerte;
const _Mention({required this.icon, required this.texte, this.alerte = false});
@override
Widget build(BuildContext context) {
final couleur =
alerte ? EtmTokens.importColor : EtmTokens.mutedOf(context);
return Row(crossAxisAlignment: CrossAxisAlignment.start, children: [
Icon(icon, size: 14, color: couleur),
const SizedBox(width: 7),
Expanded(
child: Text(texte,
style: EtmTokens.sans(size: 10.5, color: couleur)),
),
]);
}
}