feat(énergie): le graphe de flux EMPILE au lieu de superposer

Validé par Patrick. Sa proposition — production en jaune, autoconso en orange,
et des traits pleins — partait d'un bon constat, mais la couleur ne pouvait pas
régler le problème : superposées, autoconsommation et production sont égales au
watt près dès que rien n'est exporté, et deux traits pleins superposés n'en
laissent voir qu'un, quelles que soient leurs teintes. Le défaut est
géométrique, pas chromatique — et le pointillé posé avant n'était qu'un
contournement.

Empilées, le recouvrement devient impossible :

    jaune   PV -> réseau (exporté)      ┐
    orange  PV -> maison (autoconsommé) ┘  sommet = production totale

La production cesse d'être une courbe : elle EST le haut de la pile. Quand rien
n'est exporté la bande jaune a une hauteur nulle — vrai, et lisible d'un coup
d'œil. L'export devient une SURFACE au lieu d'un écart à mesurer à l'œil entre
deux traits.

Vérifié à l'écran : l'orange porte le fond de la journée, le jaune ne sort que
sur l'épisode d'export de fin d'après-midi.

Deux détails qui vont avec :
- `_aire` ne courbe pas (`isCurved: false`) — une spline peut passer sous zéro
  entre deux points, et une bande d'énergie sous l'axe affiche une grandeur qui
  n'existe pas ;
- les lignes n'ont plus de remplissage par défaut : un voile à 7 % recouvrait
  les bandes et faussait leurs teintes.

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-08 11:00:38 +02:00
parent 67bc0ca7ea
commit 6d0ba26e07
2 changed files with 67 additions and 27 deletions

View File

@ -288,12 +288,12 @@ class _EnergyScreenState extends State<EnergyScreen> {
? 'Puissances · SOC %'
: 'Puissances',
subtitle: _aUneBatterie
? 'kW (gauche) · % batterie (droite)'
: 'kW',
? 'kW — le sommet de la pile est la production · % batterie (droite)'
: 'kW — le sommet de la pile est la production',
legend: [
_LegendDot(EtmTokens.amber, 'Production'),
_LegendDot(EtmTokens.selfUse, 'Autoconsommé'),
_LegendDot(EtmTokens.amber, 'Exporté'),
_LegendDot(EtmTokens.blue, 'Consommation'),
_LegendDot(EtmTokens.green, 'Autoconso', dashed: true),
if (_aUneBatterie)
_LegendDot(EtmTokens.ev, 'SOC %', dashed: true),
],
@ -372,11 +372,15 @@ class _EnergyScreenState extends State<EnergyScreen> {
// n'est pas vrai du réseau, où le signe DIT le sens — d'où `gridImportW` /
// `gridExportW` plutôt qu'un `.abs()` là-bas.
final prodW = d.productionW.abs();
prodSpots .add(FlSpot(x, prodW / 1000));
consoSpots.add(FlSpot(x, d.consumptionW.abs() / 1000));
// INTERIM (§2.2) — bande autoconso via la formule canonique du seam.
autoSpots .add(FlSpot(
x, EnergyRatiosInterim.selfConsumptionPower(prodW, d.acquisitionW) / 1000));
final selfW = EnergyRatiosInterim.selfConsumptionPower(prodW, d.acquisitionW);
// `prodSpots` porte désormais le SOMMET DE LA PILE, c'est-à-dire la production :
// autoconsommé + exporté. L'égalité est exacte par construction — l'exporté est
// défini comme `production − autoconsommé` — donc la pile ne peut pas mentir sur
// son total.
prodSpots .add(FlSpot(x, prodW / 1000));
autoSpots .add(FlSpot(x, selfW / 1000));
consoSpots.add(FlSpot(x, d.consumptionW.abs() / 1000));
}
// Plage Y gauche en kW
@ -478,27 +482,35 @@ class _EnergyScreenState extends State<EnergyScreen> {
),
),
),
// ⚠️ **L'autoconsommation est en POINTILLÉ, et ce n'est pas décoratif.**
// ⚠️ **On EMPILE, on ne superpose pas.** Validé par Patrick le 2026-09-08.
//
// Tant que rien n'est exporté, autoconsommation == production **exactement** :
// tracée pleine et par-dessus, elle recouvrait la production au pixel près. Vu à
// l'écran le 2026-09-08, juste après avoir corrigé le signe — la courbe ambre
// n'apparaissait qu'aux rares moments d'export, c'est-à-dire là où les deux
// diffèrent. Le signe était réparé et la production toujours invisible, pour une
// deuxième raison qui n'avait rien à voir avec la première.
// Superposées, autoconsommation et production sont identiques au watt près dès
// que rien n'est exporté — et deux traits pleins superposés n'en laissent voir
// qu'un, quelles que soient leurs couleurs. Le problème est géométrique, pas
// chromatique : changer de teinte ne l'aurait pas réglé, et le pointillé essayé
// avant n'était qu'un contournement.
//
// En pointillé elle se lit COMME une superposition : sur la bande ambre, des
// tirets verts disent « tout ce qui est produit est consommé sur place », et leur
// décrochage dit l'export. Deux courbes qui coïncident doivent pouvoir se voir
// coïncider — sinon l'une des deux ment par absence.
// Empilées, il n'y a plus de recouvrement possible :
//
// jaune PV → réseau (exporté) ┐
// orange PV → maison (autoconsommé) ┘ sommet = production totale
//
// La production cesse d'être une courbe : elle EST le haut de la pile. Et quand
// rien n'est exporté, la bande jaune a une hauteur nulle — ce qui est vrai, et se
// lit d'un coup d'œil : « tout ce qui est produit est consommé sur place ».
// L'export devient une SURFACE au lieu d'un écart à mesurer à l'œil entre deux
// traits.
//
// Tracé du haut vers le bas : chaque aire est remplie jusqu'à zéro et la suivante
// repeint par-dessus. Même procédé que l'empilement par consommateur.
lineBarsData: [
_line(prodSpots, EtmTokens.amber),
_aire(prodSpots, EtmTokens.amber), // sommet = production
_aire(autoSpots, EtmTokens.selfUse), // repeint la part autoconsommée
_line(consoSpots, EtmTokens.blue),
_line(autoSpots, EtmTokens.green, dashed: true, width: 1.8),
// Le SOC passe en indigo : il était vert pointillé, comme l'autoconsommation
// ci-dessus. Sur une installation AVEC batterie les deux auraient été
// indiscernables — collision créée à l'instant où l'autoconso est passée en
// pointillé, et qu'aucun banc sans batterie n'aurait montrée.
// Le SOC est en indigo, pas en vert pointillé : le vert pointillé a servi à
// l'autoconsommation le temps d'un essai, et sur une installation AVEC batterie
// les deux auraient été indiscernables. Aucun banc sans batterie ne pouvait le
// montrer.
if (hasSoc) _line(socSpots, EtmTokens.ev, dashed: true, width: 1.5),
],
lineTouchData: LineTouchData(
@ -520,8 +532,22 @@ class _EnergyScreenState extends State<EnergyScreen> {
);
}
/// Une bande PLEINE de l'empilement — remplie jusqu'à zéro, sans courbure.
///
/// Pas de `isCurved` ici : une spline peut passer sous zéro entre deux points, et une
/// bande d'énergie qui plonge sous l'axe affiche une grandeur qui n'existe pas.
LineChartBarData _aire(List<FlSpot> spots, Color color) => LineChartBarData(
spots: spots,
isCurved: false,
color: color,
barWidth: 1.2,
dotData: const FlDotData(show: false),
belowBarData:
BarAreaData(show: true, color: color.withValues(alpha: 0.62)),
);
LineChartBarData _line(List<FlSpot> spots, Color color,
{bool dashed = false, double width = 2.0}) =>
{bool dashed = false, double width = 2.0, bool fill = false}) =>
LineChartBarData(
spots: spots,
isCurved: true,
@ -530,8 +556,11 @@ class _EnergyScreenState extends State<EnergyScreen> {
barWidth: width,
dotData: const FlDotData(show: false),
dashArray: dashed ? [4, 5] : null,
// Aucun remplissage par défaut depuis l'empilement : un voile, même à 7 %,
// recouvre les bandes et fausse leurs teintes. La consommation est une LIGNE
// au-dessus de la pile, pas une surface de plus.
belowBarData: BarAreaData(
show: !dashed,
show: fill && !dashed,
color: color.withValues(alpha: 0.07),
),
);

View File

@ -93,6 +93,17 @@ class EtmTokens {
static const Color ev = Color(0xFF7C6CE4); // bornes — indigo
static const Color evDark = Color(0xFF8E80EA);
/// **L'autoconsommé**, dans l'empilement du graphe de flux — orange, sous le jaune de
/// l'exporté dont le sommet donne la production totale.
///
/// ⚠️ Voisine de [pac] (ambre-orange), et c'est assumé : les deux n'apparaissent jamais
/// dans le même graphe — celle-ci décompose la PRODUCTION, celle-là un CONSOMMATEUR. Si
/// la proximité gêne à l'écran sur la page complète, c'est [pac] qui bougera : la paire
/// jaune/orange a été choisie par Patrick pour ce graphe-ci.
static const Color selfUse = Color(0xFFF4661B);
static const Color selfUseDark = Color(0xFFFA7A34);
static Color selfUseOf(BuildContext c) => _isDark(c) ? selfUseDark : selfUse;
static Color pacOf(BuildContext c) => _isDark(c) ? pacDark : pac;
static Color ecsOf(BuildContext c) => _isDark(c) ? ecsDark : ecs;
static Color evOf(BuildContext c) => _isDark(c) ? evDark : ev;