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:
parent
a14adcfcc3
commit
12c3e912b0
@ -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)),
|
||||
],
|
||||
),
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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',
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user