fix(flux): les détails ne fabriquent plus de décomposition

Signalé sur banc, et les deux vues détaillées inventaient.

CONSOMMATION — PAC = 38 % de la maison, eau chaude = 18 %, autres = 22 %. Des
ratios posés à la maquette, marqués « estimé »… et affichés tels quels le
2026-09-06 alors que les compteurs ECS et PAC publiaient `{powerW: 0, source:
meter}`. Un compteur à zéro dit « rien ne chauffe » ; l'écran répondait 720 W.

La mention « estimé » était le problème autant que le remède : sous un nombre
inventé, elle le rend acceptable. Les branches viennent désormais de
`GetLoadTelemetry.measurement`, par domaine, et une charge sans mesure publiée
affiche « non publié ». « Autres » disparaît — c'était un reste calculé sur des
parts inventées, donc inventé lui aussi : sans mesure exhaustive, il n'y a pas de
reste connu.

PRODUCTION — « Onduleur 1 » et « Onduleur 2 » portaient chacun la MOITIÉ de la
production, avec l'état `ok`, donc sans même la marque « estimé » des branches de
consommation. Deux appareils qui n'existent nulle part : le banc n'a qu'un seul
compteur de production. La vue dit maintenant « détail par source non publié par
cette box ».

Et un débordement introduit en chemin, attrapé en test : « non publié » ne tient
pas dans un nœud dimensionné pour « 1.2 kW » — 7 px de trop, donc du texte coupé
sur l'appareil. `FittedBox(scaleDown)`, comme pour l'étiquette des tuiles.

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-06 13:05:43 +02:00
parent a14adcfcc3
commit 12c3e912b0
3 changed files with 152 additions and 47 deletions

View File

@ -16,8 +16,23 @@ class EnergyFlowCard extends StatefulWidget {
/// valeur par défaut que rien ne remplaçait, et le Sankey dessinait donc en permanence
/// un flux vers une voiture qui n'était pas branchée.
final double? evChargerPowerW;
const EnergyFlowCard(
{super.key, required this.data, this.evChargerPowerW});
/// **Puissance MESURÉE par domaine de charge** — `'ecs'`, `'heating'` — issue de
/// `GetLoadTelemetry.measurement`. Clé absente ou valeur `null` = **non publié**.
///
/// ⚠️ Ce paramètre remplace trois ratios en dur (38 %, 18 %, 22 % de la consommation
/// maison) qui étaient posés à la maquette et jamais remplacés. Le 2026-09-06, avec de
/// vrais compteurs ECS et PAC **à 0 W**, l'écran affichait toujours 38 % et 18 % de la
/// maison : des chiffres crédibles, marqués « estimé », et faux — exactement ce que
/// `source: "none"` existe pour empêcher, à l'envers.
final Map<String, double?> mesuresParDomaine;
const EnergyFlowCard({
super.key,
required this.data,
this.evChargerPowerW,
this.mesuresParDomaine = const {},
});
@override
State<EnergyFlowCard> createState() => _EnergyFlowCardState();
@ -125,7 +140,8 @@ class _EnergyFlowCardState extends State<EnergyFlowCard>
builder: (context, constraints) {
final size = constraints.maxWidth;
final segments =
_buildSegments(widget.data, _view, widget.evChargerPowerW);
_buildSegments(widget.data, _view, widget.evChargerPowerW,
widget.mesuresParDomaine);
return AnimatedBuilder(
animation: _particleCtrl,
builder: (context, _) {
@ -154,6 +170,7 @@ class _EnergyFlowCardState extends State<EnergyFlowCard>
view: _view,
data: widget.data,
evChargerPowerW: widget.evChargerPowerW,
mesuresParDomaine: widget.mesuresParDomaine,
pulseAnim: disableAnim ? null : _pulseAnim,
onTapSolar: () => _switchView(_DrillView.prod),
onTapMaison: () => _switchView(_DrillView.cons),
@ -176,8 +193,8 @@ class _EnergyFlowCardState extends State<EnergyFlowCard>
Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(
'Répartition estimée : PAC, eau chaude et autres usages sont des parts '
'types, pas des mesures. Seule la voiture est mesurée à la borne.',
'Chaque branche est une mesure de la charge, ou « non publié ». '
'Le total de la maison peut donc dépasser la somme affichée.',
style: EtmTokens.sans(size: 11, color: muted),
textAlign: TextAlign.center,
),
@ -219,8 +236,8 @@ class _EnergyFlowCardState extends State<EnergyFlowCard>
static const double _nE = 0.22;
static const double _hE = 0.39;
static List<FlowSegment> _buildSegments(
EnergyData d, _DrillView view, double? evW) {
static List<FlowSegment> _buildSegments(EnergyData d, _DrillView view,
double? evW, Map<String, double?> mesures) {
const nE = _nE;
const hE = _hE;
@ -254,20 +271,23 @@ class _EnergyFlowCardState extends State<EnergyFlowCard>
// charge), et l'y brancher est un lot à part — cf. `TODO.md`. En attendant, un
// écran qui annonce son estimation ne trompe personne ; un écran qui affiche
// « PAC 1,3 kW » en dur, si.
final total = d.homePower.clamp(1.0, double.infinity);
// Plus aucun ratio : une branche ne se dessine que si sa puissance est MESURÉE.
// Une branche absente dit « non publié » ; une branche à 38 % de la maison disait
// « voilà ce que consomme votre PAC », ce que personne n'avait mesuré.
return [
FlowSegment(from: Offset(0.5, hE), to: Offset(0.5, nE), color: EtmTokens.heat, powerW: total * 0.38, dashed: true),
FlowSegment(from: Offset(1-hE, 0.5), to: Offset(1-nE, 0.5), color: EtmTokens.water, powerW: total * 0.18, dashed: true),
FlowSegment(from: Offset(0.5, 1-hE), to: Offset(0.5, 1-nE), color: EtmTokens.eco, powerW: evW ?? 0),
FlowSegment(from: Offset(hE, 0.5), to: Offset(nE, 0.5), color: EtmTokens.gridColor, powerW: total * 0.22, dashed: true),
FlowSegment(from: Offset(0.5, hE), to: Offset(0.5, nE), color: EtmTokens.heat, powerW: mesures['heating'] ?? 0),
FlowSegment(from: Offset(1-hE, 0.5), to: Offset(1-nE, 0.5), color: EtmTokens.water, powerW: mesures['ecs'] ?? 0),
FlowSegment(from: Offset(0.5, 1-hE), to: Offset(0.5, 1-nE), color: EtmTokens.eco, powerW: evW ?? 0),
];
case _DrillView.prod:
final half = d.pvPower / 2;
return [
FlowSegment(from: Offset(nE, 0.5), to: Offset(hE, 0.5), color: EtmTokens.solar, powerW: half),
FlowSegment(from: Offset(1-nE, 0.5), to: Offset(1-hE, 0.5), color: EtmTokens.solar, powerW: half),
];
// La production n'était PAS décomposée : elle était COUPÉE EN DEUX, et les deux
// moitiés présentées comme « Onduleur 1 » et « Onduleur 2 » — avec l'état « ok »,
// donc sans même la marque « estimé » que portaient les branches de consommation.
// Le banc n'a qu'un seul compteur de production : les deux onduleurs n'existaient
// nulle part. Tant que la box ne publie pas de détail par source, il n'y a rien à
// dessiner ici.
return const [];
}
}
}
@ -280,6 +300,9 @@ class _NodeLayer extends StatefulWidget {
/// Puissance mesurée de la borne — `null` = non lue. Voir [EnergyFlowCard].
final double? evChargerPowerW;
/// Mesures par domaine — voir [EnergyFlowCard.mesuresParDomaine].
final Map<String, double?> mesuresParDomaine;
final Animation<double>? pulseAnim;
final VoidCallback onTapSolar;
final VoidCallback onTapMaison;
@ -289,6 +312,7 @@ class _NodeLayer extends StatefulWidget {
required this.view,
required this.data,
required this.evChargerPowerW,
required this.mesuresParDomaine,
required this.pulseAnim,
required this.onTapSolar,
required this.onTapMaison,
@ -411,7 +435,6 @@ class _NodeLayerState extends State<_NodeLayer>
Widget _buildCons(BuildContext context) {
final d = widget.data;
final total = d.homePower.clamp(1.0, double.infinity);
return Stack(children: [
// Hub — Maison
_at(Offset(0.5, 0.5),
@ -424,14 +447,15 @@ class _NodeLayerState extends State<_NodeLayer>
borderColor: EtmTokens.lineOf(context),
),
)),
// « non publié » plutôt qu'un pourcentage de la maison. Un compteur ECS à 0 W est
// une MESURE — « rien ne chauffe » — et elle ne doit pas être écrasée par 18 % de la
// consommation du foyer.
_at(Offset(0.5, 0.12), ScaleTransition(scale: _stagger(1),
child: _sat(context, icon: Icons.heat_pump_outlined, color: EtmTokens.heat,
label: 'PAC', value: '${(total * 0.38 / 1000).toStringAsFixed(1)} kW',
estimated: true))),
label: 'PAC', value: _kwOuNonPublie(widget.mesuresParDomaine['heating'])))),
_at(Offset(0.88, 0.5), ScaleTransition(scale: _stagger(2),
child: _sat(context, icon: Icons.water_drop_outlined, color: EtmTokens.water,
label: 'Eau chaude', value: '${(total * 0.18 / 1000).toStringAsFixed(1)} kW',
estimated: true))),
label: 'Eau chaude', value: _kwOuNonPublie(widget.mesuresParDomaine['ecs'])))),
_at(Offset(0.5, 0.88), ScaleTransition(scale: _stagger(3),
child: _sat(context, icon: Icons.ev_station_rounded, color: eco(context),
label: 'Voiture',
@ -440,17 +464,19 @@ class _NodeLayerState extends State<_NodeLayer>
value: widget.evChargerPowerW == null
? 'non lu'
: '${(widget.evChargerPowerW! / 1000).toStringAsFixed(1)} kW'))),
_at(Offset(0.12, 0.5), ScaleTransition(scale: _stagger(4),
child: _sat(context, icon: Icons.devices_other_rounded, color: EtmTokens.gridColor,
label: 'Autres', value: '${(total * 0.22 / 1000).toStringAsFixed(1)} kW',
estimated: true))),
// « Autres » était 22 % de la maison — un reste calculé sur des parts inventées,
// donc inventé lui aussi. Sans mesure par charge exhaustive, il n'y a pas de reste
// à afficher : il faudrait connaître la somme des parts, et on ne la connaît pas.
]);
}
/// Une puissance mesurée, ou **« non publié »** — jamais un zéro ni un pourcentage.
static String _kwOuNonPublie(double? w) =>
w == null ? 'non publié' : '${(w / 1000).toStringAsFixed(1)} kW';
Widget _buildProd(BuildContext context) {
final d = widget.data;
final solar = EtmTokens.solarOf(context);
final half = d.pvPower / 2000;
return Stack(children: [
_at(Offset(0.5, 0.5),
ScaleTransition(scale: _stagger(0),
@ -462,12 +488,16 @@ class _NodeLayerState extends State<_NodeLayer>
borderColor: EtmTokens.lineOf(context),
),
)),
_at(Offset(0.12, 0.5), ScaleTransition(scale: _stagger(1),
child: _sat(context, icon: Icons.solar_power_rounded, color: solar,
label: 'Onduleur 1', value: '${half.toStringAsFixed(1)} kW', state: _NodeState.ok))),
_at(Offset(0.88, 0.5), ScaleTransition(scale: _stagger(2),
child: _sat(context, icon: Icons.solar_power_rounded, color: solar,
label: 'Onduleur 2', value: '${half.toStringAsFixed(1)} kW', state: _NodeState.ok))),
// « Onduleur 1 » et « Onduleur 2 » portaient chacun la MOITIÉ de la production, avec
// l'état « ok ». Deux appareils qui n'existent nulle part — le banc n'a qu'un seul
// compteur de production. Une décomposition par source demande que la box la
// publie ; tant qu'elle ne la publie pas, le détail se résume au total, et le dire
// vaut mieux que de le fabriquer.
_at(Offset(0.5, 0.86), ScaleTransition(scale: _stagger(1),
child: Text('Détail par source non publié par cette box',
textAlign: TextAlign.center,
style: EtmTokens.sans(
size: 11.5, color: EtmTokens.mutedOf(context))))),
]);
}
@ -589,7 +619,20 @@ class _NodeLayerState extends State<_NodeLayer>
children: [
Icon(icon, color: color, size: 21),
const SizedBox(height: 1),
Text(value, style: EtmTokens.mono(size: 11, weight: FontWeight.w600, color: color)),
// Le nœud est un cercle de taille FIXE, et la valeur n'est pas toujours un
// nombre : « non lu », « non publié » débordent là où « 1.2 kW » tenait. Un
// débordement de 7 px, mesuré en test — donc du texte coupé sur l'appareil.
// `scaleDown` réduit ce qu'il faut et ne touche à rien quand la place suffit.
Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: FittedBox(
fit: BoxFit.scaleDown,
child: Text(value,
maxLines: 1,
style: EtmTokens.mono(
size: 11, weight: FontWeight.w600, color: color)),
),
),
Text(label, style: EtmTokens.sans(size: 8.5, color: muted)),
],
),

View File

@ -6,6 +6,8 @@ import '../models/daily_balance.dart';
import '../models/energy_ratios_snapshot.dart';
import '../main.dart' show DrawerMenuButton;
import '../providers/app_settings_provider.dart';
import '../providers/load_config_provider.dart';
import '../providers/load_telemetry_provider.dart';
import '../services/nymea_service.dart';
import '../theme/etm_tokens.dart';
import '../features/dashboard/widgets/energy_flow_card.dart';
@ -71,6 +73,36 @@ class _DashboardScreenState extends State<DashboardScreen> {
}
}
/// Puissance mesurée par domaine (`ecs`, `heating`), lue dans `GetLoadTelemetry`.
///
/// Une charge dont la mesure n'est pas publiée — `source: "none"`, ou charge absente de
/// `loads[]` — n'entre pas dans la carte : son domaine reste **sans valeur**, et le
/// détail affichera « non publié ». C'est la seule lecture honnête : un compteur ECS à
/// 0 W dit « rien ne chauffe », une absence dit « on ne sait pas », et un pourcentage de
/// la consommation du foyer ne dit ni l'un ni l'autre.
Map<String, double?> _mesuresParDomaine(BuildContext context) {
final tel = context.watch<LoadTelemetryProvider>();
final cfg = context.watch<LoadConfigProvider>();
final out = <String, double?>{};
for (final e in tel.telemetry?.loads ?? const []) {
if (!e.measurement.mesuree) continue;
// Les identifiants voyagent avec ou sans accolades selon l'appel (UI_data_contract
// §9) : on compare sur la forme nue, jamais sur la chaîne brute.
String nu(String x) => x.replaceAll('{', '').replaceAll('}', '').toLowerCase();
final cible = nu(e.loadId);
String? domaine;
for (final c in cfg.entries) {
if (nu(c.id) == cible) {
domaine = c.domain;
break;
}
}
if (domaine == null || domaine.isEmpty) continue;
out[domaine] = (out[domaine] ?? 0) + e.measurement.powerW!;
}
return out;
}
@override
Widget build(BuildContext context) {
return Consumer<NymeaService>(
@ -97,7 +129,11 @@ class _DashboardScreenState extends State<DashboardScreen> {
EnergyFlowCard(
data: data,
evChargerPowerW:
service.evCharger?.currentPowerW),
service.evCharger?.currentPowerW,
// Les mesures RÉELLES par domaine, prises dans la télémétrie
// d'arbitrage. Un domaine absent de la carte = la box ne le
// publie pas, et le détail le dira au lieu d'inventer une part.
mesuresParDomaine: _mesuresParDomaine(context)),
const SizedBox(height: 14),
KpiRow(data: data, balance: _bilan, ratios: _ratios),
const SizedBox(height: 14),

View File

@ -18,7 +18,8 @@ import 'package:flutter_test/flutter_test.dart';
/// Largeur contrainte : le schéma est un carré, et en pleine largeur de la fenêtre de test
/// (800 px) il déborde des 600 px de haut — le nœud « Maison » se retrouve hors écran et
/// aucune touche ne l'atteint.
Widget _harness({double? evChargerPowerW}) => MediaQuery(
Widget _harness({double? evChargerPowerW, Map<String, double?>? mesures}) =>
MediaQuery(
data: const MediaQueryData(disableAnimations: true),
child: MaterialApp(
home: Scaffold(
@ -28,6 +29,7 @@ Widget _harness({double? evChargerPowerW}) => MediaQuery(
child: EnergyFlowCard(
data: const EnergyData(homePower: 4000, pvPower: 5000),
evChargerPowerW: evChargerPowerW,
mesuresParDomaine: mesures ?? const {},
),
),
),
@ -63,20 +65,44 @@ void main() {
expect(find.textContaining('Répartition estimée'), findsNothing);
});
testWidgets('la vue consommation ANNONCE que trois branches sont estimées',
testWidgets('un compteur a 0 W affiche 0 W, pas 18 % de la maison',
(tester) async {
await tester.pumpWidget(_harness(evChargerPowerW: 0));
// Le cas exact du 2026-09-06 : ECS et PAC publiaient {powerW: 0, source: meter}
// pendant que l'ecran montrait des parts de la consommation du foyer.
await tester.pumpWidget(_harness(mesures: {'ecs': 0.0, 'heating': 0.0}));
await _ouvrirConso(tester);
final note = find.textContaining('Répartition estimée');
expect(note, findsOneWidget);
final texte = tester.widget<Text>(note).data!;
// Les trois branches sont NOMMÉES : « certaines valeurs sont estimées » laisserait
// chercher lesquelles, et quatre chiffres en kW se ressemblent tous.
expect(texte, contains('PAC'));
expect(texte, contains('eau chaude'));
expect(texte, contains('autres usages'));
expect(texte, contains('Seule la voiture est mesurée'));
expect(find.text('0.0 kW'), findsNWidgets(2));
// 18 % et 38 % de 4 000 W : les deux nombres qui ne doivent plus exister.
expect(find.textContaining('0.7'), findsNothing);
expect(find.textContaining('1.5'), findsNothing);
});
testWidgets('un domaine sans mesure publiee dit non publie', (tester) async {
await tester.pumpWidget(_harness(mesures: {'ecs': 1200.0}));
await _ouvrirConso(tester);
expect(find.text('1.2 kW'), findsOneWidget);
expect(find.text('non publié'), findsOneWidget);
});
testWidgets('la note ne parle plus d estimation, il n y a plus rien d estime',
(tester) async {
// La mention « estimé » etait le probleme autant que le remede : sous un nombre
// invente, elle le rend acceptable. L'enlever oblige a ne montrer que du vrai.
await tester.pumpWidget(_harness(mesures: {'ecs': 1200.0}));
await _ouvrirConso(tester);
expect(find.textContaining('Répartition estimée'), findsNothing);
expect(find.textContaining('parts types'), findsNothing);
expect(find.textContaining('non publié'), findsWidgets);
});
testWidgets('Autres a disparu : un reste calcule sur des parts inventees',
(tester) async {
await tester.pumpWidget(_harness(mesures: {'ecs': 1200.0, 'heating': 800.0}));
await _ouvrirConso(tester);
expect(find.text('Autres'), findsNothing);
});
testWidgets('sans borne, la voiture affiche « non lu » — jamais 0,0 kW',