diff --git a/lib/models/consumer_series.dart b/lib/models/consumer_series.dart new file mode 100644 index 0000000..4cadf50 --- /dev/null +++ b/lib/models/consumer_series.dart @@ -0,0 +1,97 @@ +/// **Qui consomme, dans le temps** — `Energy.GetThingPowerLogs`, une série par appareil. +/// +/// ## Ce que cet objet porte en plus des courbes +/// +/// La liste de ce qu'on a **demandé sans l'obtenir**. Six Things sondés sur `.75` le +/// 2026-09-08, cinq répondent : la V2C Trydan est absente parce que sa classe ne déclare +/// pas l'interface `smartmeter`. Une réponse partielle sans la liste des manquants se lit +/// comme une réponse complète, et l'écran tracerait alors quatre courbes en laissant croire +/// qu'il n'y en avait que quatre à tracer. +/// +/// **Une série absente ne se dessine pas à zéro.** Une ligne plate affirme « cette charge +/// n'a rien tiré » ; l'absence dit « on ne sait pas ». La borne V2C tire peut-être 4 kW en +/// ce moment — sa puissance instantanée est lisible — elle n'a simplement pas de passé. +/// +/// ## Le signe des sous-compteurs n'est PAS vérifié +/// +/// 🔴 Sur le banc, `PAC-Meter`, `ECS-Meter` et la borne simulée publient **0 W à tous les +/// pas**, toute la journée. Aucun échantillon non nul n'existe, donc **rien ne dit si un +/// consommateur publie sa puissance en positif ou en négatif** — le compteur PV, lui, la +/// publie en négatif, ce qui interdit de supposer. +/// +/// La valeur est donc transportée **brute**, et [ConsumerHistory.signeIndetermine] dit +/// quand la question reste ouverte. Poser un `.abs()` ici trancherait par défaut, et un +/// `.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. +class ConsumerPoint { + final DateTime t; + final double powerW; + const ConsumerPoint(this.t, this.powerW); +} + +/// La série d'un appareil, avec de quoi le nommer et le colorer. +class ConsumerSeries { + final String thingId; + final String label; + + /// `ecs` · `heating` · `ev`, ou `null` si la charge n'a pas de domaine déclaré. + /// `null` n'appelle pas de couleur par défaut : voir `EtmTokens.domaineOf`. + final String? domaine; + + final List points; + + const ConsumerSeries({ + required this.thingId, + required this.label, + required this.domaine, + required this.points, + }); + + /// Vrai si la série ne contient que des zéros — **une mesure, pas un trou**. + /// + /// Un compteur dédié qui lit 0 W dit « rien ne chauffe ». C'est exactement ce qu'il faut + /// tracer : une ligne au ras de l'axe, pas une absence. + bool get toutAZero => points.isNotEmpty && points.every((p) => p.powerW == 0); + + double get maxW => + points.isEmpty ? 0 : points.map((p) => p.powerW.abs()).reduce((a, b) => a > b ? a : b); +} + +/// Le résultat complet d'une interrogation par appareil. +class ConsumerHistory { + /// Les séries obtenues, dans l'ordre où elles ont été demandées. + final List series; + + /// **Les appareils demandés dont la box n'a rien renvoyé**, par leur nom d'affichage. + /// + /// Ce n'est pas une erreur : un appareil sans interface de comptage n'a pas + /// d'historique, et c'est un fait sur l'appareil, pas sur la requête. + final List sansHistorique; + + const ConsumerHistory({required this.series, required this.sansHistorique}); + + static const ConsumerHistory vide = + ConsumerHistory(series: [], sansHistorique: []); + + bool get estVide => series.isEmpty; + + /// 🔴 **Vrai tant qu'aucun échantillon non nul n'a été vu.** Voir la note de tête : sans + /// une seule valeur non nulle, la convention de signe des sous-compteurs reste inconnue, + /// et l'écran ne doit pas présenter l'empilement comme une décomposition établie. + bool get signeIndetermine => series.every((s) => s.maxW == 0); + + /// Les instants communs à toutes les séries — l'axe des abscisses de l'empilement. + /// + /// Pris sur la série la plus longue : les compteurs n'échantillonnent pas tous au même + /// instant, et interpoler inventerait des points. + List get instants { + if (series.isEmpty) return const []; + var plusLongue = series.first; + for (final s in series) { + if (s.points.length > plusLongue.points.length) plusLongue = s; + } + return [for (final p in plusLongue.points) p.t]; + } +} diff --git a/lib/screens/energy_screen.dart b/lib/screens/energy_screen.dart index cce4ee2..0dc5704 100644 --- a/lib/screens/energy_screen.dart +++ b/lib/screens/energy_screen.dart @@ -1,10 +1,12 @@ import 'dart:async'; -import 'dart:math' show max, min; +import 'dart:math' show max; import 'package:fl_chart/fl_chart.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; +import '../models/consumer_series.dart'; import '../models/energy_data.dart'; import '../models/nymea_models.dart'; +import '../providers/load_config_provider.dart'; import '../services/energy_ratios.dart'; import '../services/nymea_service.dart'; import '../theme/etm_tokens.dart'; @@ -16,6 +18,28 @@ import '../main.dart' show DrawerMenuButton; // 4 KPIs · sélecteur période · line chart double axe (kW / SOC%) · bar chart // ───────────────────────────────────────────────────────────────────────────── +/// Une ligne d'explication sous un graphe — elle ne sort que quand elle DIT quelque chose. +class _NoteEcran extends StatelessWidget { + final IconData icon; + final String texte; + const _NoteEcran({required this.icon, required this.texte}); + + @override + Widget build(BuildContext context) { + final c = EtmTokens.mutedOf(context); + return Padding( + padding: const EdgeInsets.fromLTRB(4, 6, 4, 0), + child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Icon(icon, size: 14, color: c), + const SizedBox(width: 7), + Expanded( + child: Text(texte, + style: EtmTokens.sans(size: 10.5, color: c, height: 1.35))), + ]), + ); + } +} + class _Tab { final String label; final Duration range; @@ -42,6 +66,7 @@ class _EnergyScreenState extends State { int _tabIdx = 0; List _data = []; List _socData = []; + ConsumerHistory _conso = ConsumerHistory.vide; bool _loading = true; bool _noData = false; DateTime? _selectedDate; @@ -105,6 +130,24 @@ class _EnergyScreenState extends State { final service = context.read(); final socSource = service.batterySOCSource; + // **La liste des consommateurs vient de `GetLoadConfig`, pas d'une devinette.** + // + // Chaque charge déclarée porte son `meterThingId` — vérifié renseigné sur les quatre + // charges de `.75` le 2026-09-08 — plus son `label` et son `domain`. On demande donc + // l'historique DES COMPTEURS DES CHARGES DÉCLARÉES : rien n'est deviné à partir d'un + // nom d'appareil, et une charge sans compteur déclaré n'entre simplement pas. + final cfg = context.read(); + if (cfg.entries.isEmpty) { + cfg + ..attach(service) + ..load(); + } + final appareils = [ + for (final e in cfg.entries) + if (e.meterThingId.isNotEmpty) + (id: e.meterThingId, label: e.label, domaine: e.domain) + ]; + final results = await Future.wait([ service.fetchPowerBalanceLogs(from: from, to: to, sampleRate: tab.sampleRate), socSource != null @@ -112,11 +155,14 @@ class _EnergyScreenState extends State { thingId: socSource['thingId']!, stateTypeName: socSource['stateName']!, from: from, to: to, sampleRate: tab.sampleRate) : Future>.value([]), + service.fetchThingPowerLogs( + appareils: appareils, from: from, to: to, sampleRate: tab.sampleRate), ]); if (!mounted) return; setState(() { _data = results[0] as List; _socData = results[1] as List; + _conso = results[2] as ConsumerHistory; _loading = false; _noData = _data.isEmpty; }); @@ -225,8 +271,8 @@ class _EnergyScreenState extends State { legend: [ _LegendDot(EtmTokens.amber, 'Production'), _LegendDot(EtmTokens.blue, 'Consommation'), - _LegendDot(EtmTokens.green, 'Autoconso'), - _LegendDot(EtmTokens.green, 'SOC %', dashed: true), + _LegendDot(EtmTokens.green, 'Autoconso', dashed: true), + _LegendDot(EtmTokens.ev, 'SOC %', dashed: true), ], height: 220, loading: _loading, @@ -235,19 +281,24 @@ class _EnergyScreenState extends State { ), const SizedBox(height: 14), - // Graphe Bilan énergétique + // Graphe : qui consomme _EtmChartCard( - title: 'Bilan énergétique (kWh)', - subtitle: 'Énergie par période', + title: 'Qui consomme', + subtitle: 'Puissance empilée, par charge déclarée', legend: [ - _LegendDot(EtmTokens.amber, 'Production'), - _LegendDot(EtmTokens.blue, 'Consommation'), + const _LegendDot(EtmTokens.brand, 'Reste du foyer'), + for (final s in _conso.series) + _LegendDot( + EtmTokens.domaineOf(context, s.domaine) ?? + EtmTokens.mutedOf(context), + s.label), ], - height: 200, + height: 220, loading: _loading, noData: _noData, - child: _buildBarChart(), + child: _buildConsumerChart(), ), + _notesConsommateurs(), const SizedBox(height: 14), // Météo & prévision — placeholder @@ -275,16 +326,31 @@ class _EnergyScreenState extends State { for (int i = 0; i < _data.length; i++) { final d = _data[i]; final x = i.toDouble(); - // Toujours en kW pour l'axe gauche - prodSpots .add(FlSpot(x, d.productionW / 1000)); - consoSpots.add(FlSpot(x, d.consumptionW / 1000)); + // 🔴 **La MAGNITUDE, et c'est ici que le signe se corrige — pas au transport.** + // + // `.75` publie `production` NÉGATIF : −103 W ce matin. Le champ reste brut dans + // `PowerBalanceEntry` (§7.1 du contrat), donc c'est au rendu de prendre la valeur + // absolue. Sans ça, deux courbes disparaissaient pour la même raison — signalé par + // Patrick le 2026-09-08 : la production tracée à −0,1 kW sous une échelle qui monte + // à 3,1, et l'autoconsommation clouée à 0 parce que `production − export` est + // toujours négatif. + // + // `.abs()` est licite **sur une production** : une installation ne produit pas une + // puissance négative, donc le signe ne porte aucune information à préserver. Ce + // 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(d.productionW, d.acquisitionW) / 1000)); + x, EnergyRatiosInterim.selfConsumptionPower(prodW, d.acquisitionW) / 1000)); } // Plage Y gauche en kW - final allKw = _data.expand((d) => [d.productionW / 1000, d.consumptionW / 1000]).toList(); + final allKw = _data + .expand((d) => [d.productionW.abs() / 1000, d.consumptionW.abs() / 1000]) + .toList(); final maxKw = allKw.isEmpty ? 2.5 : allKw.reduce(max); final yMax = max(maxKw * 1.15, 0.5); // min 0.5 kW pour éviter y écrasé @@ -355,7 +421,7 @@ class _EnergyScreenState extends State { meta: meta, child: Text( '$pct%', - style: EtmTokens.sans(size: 9, color: EtmTokens.green), + style: EtmTokens.sans(size: 9, color: EtmTokens.ev), ), ); }, @@ -380,11 +446,28 @@ class _EnergyScreenState extends State { ), ), ), + // ⚠️ **L'autoconsommation est en POINTILLÉ, et ce n'est pas décoratif.** + // + // 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. + // + // 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. lineBarsData: [ _line(prodSpots, EtmTokens.amber), _line(consoSpots, EtmTokens.blue), - _line(autoSpots, EtmTokens.green), - if (hasSoc) _line(socSpots, EtmTokens.green, dashed: true, width: 1.5), + _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. + if (hasSoc) _line(socSpots, EtmTokens.ev, dashed: true, width: 1.5), ], lineTouchData: LineTouchData( touchTooltipData: LineTouchTooltipData( @@ -421,95 +504,177 @@ class _EnergyScreenState extends State { ), ); - // ── ② Bar chart : bilan énergétique ──────────────────────────────────────── + // ── ② Empilement par CONSOMMATEUR ────────────────────────────────────────── - Widget _buildBarChart() { + /// Qui consomme, dans le temps — une aire par charge déclarée, plus le reste du foyer. + /// + /// ## Le RESTE est licite ici, et il ne l'était pas sur le Sankey + /// + /// `CLAUDE.md` interdit « pas de RESTE sans mesure exhaustive » — la règle est née d'un + /// Sankey qui calculait « Autres = 22 % » à partir de parts **inventées**. Ici chaque + /// part est un **compteur dédié** et le total est le compteur du foyer : la soustraction + /// ne porte que des grandeurs mesurées. + /// + /// 🔴 **Et elle porte son propre contrôle** : si un sous-compteur n'est pas réellement à + /// l'intérieur du total du foyer — une borne sur une alimentation séparée, par exemple — + /// le reste devient **négatif**. On ne le rabote pas à zéro : un reste négatif est la + /// preuve d'un périmètre faux, et l'écraser effacerait la seule chose qui le dise. + /// « Qu'est-ce que ce contrôle aurait attrapé s'il avait échoué ? » — exactement ça. + Widget _buildConsumerChart() { if (_loading || _noData || _data.isEmpty) return const SizedBox.shrink(); + if (_conso.estVide) return const SizedBox.shrink(); - final groups = []; - double maxE = 1.0; - final bw = _barWidth(); + final series = _conso.series; + final n = _data.length; - for (int i = 0; i < _data.length; i++) { - // Les compteurs sont en kWh — le graphe traçait leur différence en la nommant « Wh » - // et en la divisant encore par mille à l'affichage : une journée de 78 kWh s'y lisait - // « 78 Wh ». Corrigé le 2026-08-27, l'unité vérifiée par recoupement sur `.75`. - final prodKwh = i == 0 ? 0.0 : max(0.0, _data[i].totalProductionKwh - _data[i - 1].totalProductionKwh); - final consoKwh = i == 0 ? 0.0 : max(0.0, _data[i].totalConsumptionKwh - _data[i - 1].totalConsumptionKwh); - maxE = [maxE, prodKwh, consoKwh].reduce(max); - groups.add(BarChartGroupData( - x: i, - barsSpace: 2, - barRods: [ - BarChartRodData( - toY: prodKwh, color: EtmTokens.amber, width: bw, - borderRadius: const BorderRadius.vertical(top: Radius.circular(3)), - ), - BarChartRodData( - toY: consoKwh, color: EtmTokens.blue, width: bw, - borderRadius: const BorderRadius.vertical(top: Radius.circular(3)), - ), - ], - )); + // Alignement par INDEX, et seulement si les longueurs coïncident : les compteurs + // n'échantillonnent pas tous au même instant, et interpoler inventerait des points. + double valeurAt(ConsumerSeries s, int i) => + s.points.length == n ? s.points[i].powerW : 0.0; + final alignees = series.where((s) => s.points.length == n).toList(); + + // Cumuls empilés, base = reste du foyer. + final restes = []; + for (int i = 0; i < n; i++) { + final somme = alignees.fold(0, (a, s) => a + valeurAt(s, i)); + restes.add(_data[i].consumptionW.abs() - somme); + } + final resteNegatif = restes.any((v) => v < -1); + + final bandes = <({String label, Color couleur, List valeurs})>[ + ( + label: 'Reste du foyer', + couleur: EtmTokens.brand, + valeurs: restes + ), + for (final s in alignees) + ( + label: s.label, + couleur: EtmTokens.domaineOf(context, s.domaine) ?? + EtmTokens.mutedOf(context), + valeurs: [for (int i = 0; i < n; i++) valeurAt(s, i)] + ), + ]; + + // Cumul croissant, puis tracé du HAUT vers le BAS : chaque aire remplie jusqu'à zéro, + // la suivante repeint par-dessus. C'est ce qui donne l'empilement sans `betweenBars`. + final cumuls = >[]; + for (int b = 0; b < bandes.length; b++) { + final prec = b == 0 ? List.filled(n, 0.0) : cumuls[b - 1]; + cumuls.add([for (int i = 0; i < n; i++) prec[i] + bandes[b].valeurs[i]]); } + final maxKw = cumuls.isEmpty + ? 1.0 + : cumuls.last.map((v) => v / 1000).fold(0.5, max); + final yMax = max(maxKw * 1.15, 0.5); final labelStyle = EtmTokens.sans(size: 9, color: EtmTokens.mutedOf(context)); - return BarChart( - BarChartData( - maxY: maxE * 1.15, - alignment: BarChartAlignment.spaceAround, - barTouchData: BarTouchData( - touchTooltipData: BarTouchTooltipData( - getTooltipColor: (_) => EtmTokens.navy.withValues(alpha: 0.85), - getTooltipItem: (group, _, rod, __) => BarTooltipItem( - _fmtWh(rod.toY), - EtmTokens.mono(size: 10, color: rod.color ?? Colors.white), + return Stack(children: [ + LineChart( + LineChartData( + clipData: const FlClipData.all(), + minY: resteNegatif ? -yMax * 0.25 : 0, + maxY: yMax, + gridData: FlGridData( + show: true, + drawVerticalLine: false, + horizontalInterval: yMax / 4, + getDrawingHorizontalLine: (_) => + FlLine(color: EtmTokens.lineOf(context), strokeWidth: 1), + ), + borderData: FlBorderData(show: false), + titlesData: FlTitlesData( + topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)), + rightTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)), + leftTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + reservedSize: 46, + interval: yMax / 4, + getTitlesWidget: (v, meta) => v == meta.max + ? const SizedBox.shrink() + : SideTitleWidget( + meta: meta, + child: Text('${v.toStringAsFixed(1)}kW', style: labelStyle)), + ), + ), + bottomTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + reservedSize: 22, + interval: _xInterval(), + getTitlesWidget: (v, meta) { + final idx = v.round(); + if (idx < 0 || idx >= _data.length) return const SizedBox.shrink(); + return SideTitleWidget( + meta: meta, + child: + Text(_fmtTime(_data[idx].timestamp), style: labelStyle)); + }, + ), ), ), + lineBarsData: [ + for (int b = bandes.length - 1; b >= 0; b--) + LineChartBarData( + spots: [ + for (int i = 0; i < n; i++) + FlSpot(i.toDouble(), cumuls[b][i] / 1000) + ], + isCurved: false, + color: bandes[b].couleur, + barWidth: 1.2, + dotData: const FlDotData(show: false), + belowBarData: BarAreaData( + show: true, + color: bandes[b].couleur.withValues(alpha: 0.55)), + ), + ], + lineTouchData: const LineTouchData(enabled: false), ), - gridData: FlGridData( - show: true, - drawVerticalLine: false, - getDrawingHorizontalLine: (_) => FlLine(color: EtmTokens.lineOf(context), strokeWidth: 1), - ), - borderData: FlBorderData(show: false), - titlesData: FlTitlesData( - topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)), - rightTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)), - leftTitles: AxisTitles( - sideTitles: SideTitles( - showTitles: true, - reservedSize: 52, - interval: maxE * 1.15 / 4, - getTitlesWidget: (v, meta) { - if (v == meta.max) return const SizedBox.shrink(); - return SideTitleWidget( - meta: meta, - child: Text(_fmtWh(v), style: labelStyle), - ); - }, - ), - ), - bottomTitles: AxisTitles( - sideTitles: SideTitles( - showTitles: true, - reservedSize: 22, - getTitlesWidget: (v, meta) { - final idx = v.toInt(); - if (idx < 0 || idx >= _data.length) return const SizedBox.shrink(); - final step = _xInterval().toInt().clamp(1, _data.length); - if (idx % step != 0) return const SizedBox.shrink(); - return SideTitleWidget( - meta: meta, - child: Text(_fmtTime(_data[idx].timestamp), style: labelStyle), - ); - }, - ), - ), - ), - barGroups: groups, ), + ]); + } + + /// Ce que le graphe des consommateurs **ne peut pas** montrer, nommé sous lui. + /// + /// Une charge absente de l'historique ne se dessine pas à zéro : une ligne plate + /// affirmerait « elle n'a rien tiré », quand la vérité est « elle n'a pas de passé ». + /// Vu sur `.75` : la V2C Trydan ne déclare pas d'interface `smartmeter`, donc elle + /// n'entre pas dans `GetThingPowerLogs` — alors que sa puissance instantanée est lisible. + Widget _notesConsommateurs() { + final manquants = _conso.sansHistorique; + final sansBatterie = + (_nymeaService?.thingsLoaded ?? false) && _nymeaService?.batterySOCSource == null; + if (manquants.isEmpty && !sansBatterie && !_conso.signeIndetermine) { + return const SizedBox.shrink(); + } + return Padding( + padding: const EdgeInsets.only(top: 8), + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + if (manquants.isNotEmpty) + _NoteEcran( + icon: Icons.timeline_rounded, + texte: manquants.length == 1 + ? '${manquants.first} : pas d\'historique de puissance — cet appareil ne ' + 'publie pas de comptage. Sa puissance instantanée reste lisible.' + : '${manquants.join(', ')} : pas d\'historique de puissance.', + ), + if (sansBatterie) + _NoteEcran( + icon: Icons.battery_unknown_rounded, + texte: 'Aucune batterie sur cette installation : ni charge, ni décharge, ' + 'ni état de charge. Rien n\'est masqué — il n\'y a rien à tracer.', + ), + if (_conso.signeIndetermine) + _NoteEcran( + icon: Icons.help_outline_rounded, + texte: 'Tous les sous-compteurs lisent 0 W sur la période. C\'est une mesure, ' + 'pas un trou — mais tant qu\'aucune valeur non nulle n\'est vue, la ' + 'convention de signe de ces compteurs reste invérifiée.', + ), + ]), ); } @@ -518,16 +683,9 @@ class _EnergyScreenState extends State { double _xInterval() => _data.length > 8 ? (_data.length / 6).ceilToDouble() : 1.0; - double _barWidth() => - _data.length > 40 ? 3 : _data.length > 20 ? 5 : 8; - /// Les valeurs sont **déjà en kWh** : on descend vers le Wh pour les petites, on ne /// remonte pas. L'ancienne version divisait par mille des kWh — d'où « 78 Wh » pour une /// journée entière. - String _fmtWh(double v) { - if (v.abs() < 1) return '${(v * 1000).toStringAsFixed(0)} Wh'; - return '${v.toStringAsFixed(1)} kWh'; - } String _fmtTime(DateTime t) => _tabs[_tabIdx].showTime ? '${t.hour.toString().padLeft(2, '0')}:${t.minute.toString().padLeft(2, '0')}' diff --git a/lib/services/nymea_service.dart b/lib/services/nymea_service.dart index e884365..d703a56 100644 --- a/lib/services/nymea_service.dart +++ b/lib/services/nymea_service.dart @@ -12,6 +12,7 @@ import '../models/energy_data.dart'; // EnergyData, HistoryPoint, PowerBalanc import '../models/ev_charger_live.dart'; import '../models/modbus_models.dart'; // ModbusRtuMaster, SerialPort import '../models/nymea_models.dart'; +import '../models/consumer_series.dart'; import 'energy_ratios.dart'; // seam interim ratios (§2.1) // ── Protocole de connexion ───────────────────────────────────────────────────── @@ -983,6 +984,77 @@ class NymeaService extends ChangeNotifier { // ignore: use_setters_to_change_properties void adoptRatiosForTest(EnergyRatiosSnapshot r) => _ratios = r; + /// **L'historique de puissance PAR APPAREIL** — `Energy.GetThingPowerLogs`. + /// + /// Rend une entrée par (appareil, instant), regroupée par `thingId`. Les appareils + /// demandés dont la box ne renvoie rien sortent dans `sansHistorique` : c'est un fait sur + /// l'appareil (pas d'interface de comptage), pas une erreur de la requête, et le taire + /// ferait lire une réponse partielle comme une réponse complète. + /// + /// Vérifié sur `.75` le 2026-09-08 : six Things demandés, cinq répondent. La `V2C Trydan` + /// est absente — sa classe déclare `evcharger · power`, pas `smartmeter`. + /// + /// La charge utile porte `currentPower`, `totalConsumption`, `totalProduction` et + /// `thingId`. **Seul `currentPower` est lu ici**, brut : voir `consumer_series.dart` pour + /// pourquoi le signe n'est pas corrigé. + Future fetchThingPowerLogs({ + required List<({String id, String label, String? domaine})> appareils, + required DateTime from, + required DateTime to, + String sampleRate = 'SampleRate15Mins', + }) async { + if (appareils.isEmpty) return ConsumerHistory.vide; + try { + final r = await _sendRequest('Energy.GetThingPowerLogs', { + 'sampleRate': sampleRate, + 'thingIds': [for (final a in appareils) a.id], + 'from': from.millisecondsSinceEpoch ~/ 1000, + 'to': to.millisecondsSinceEpoch ~/ 1000, + }); + final params = r['params'] as Map? ?? r; + final logs = params.values.whereType().firstOrNull ?? const []; + + // Les identifiants voyagent avec ou sans accolades selon l'appel (UI_data_contract + // §9) : on regroupe sur la forme nue, jamais sur la chaîne brute. + String nu(String x) => x.replaceAll('{', '').replaceAll('}', '').toLowerCase(); + + final parAppareil = >{}; + for (final e in logs) { + if (e is! Map) continue; + final m = Map.from(e); + final id = m['thingId']; + final ts = m['timestamp']; + final p = m['currentPower']; + if (id is! String || ts is! num || p is! num) continue; + parAppareil.putIfAbsent(nu(id), () => []).add(ConsumerPoint( + DateTime.fromMillisecondsSinceEpoch(ts.toInt() * 1000), + p.toDouble(), + )); + } + for (final l in parAppareil.values) { + l.sort((a, b) => a.t.compareTo(b.t)); + } + + final series = []; + final absents = []; + for (final a in appareils) { + final pts = parAppareil[nu(a.id)]; + if (pts == null || pts.isEmpty) { + absents.add(a.label); + continue; + } + series.add(ConsumerSeries( + thingId: a.id, label: a.label, domaine: a.domaine, points: pts)); + } + return ConsumerHistory(series: series, sansHistorique: absents); + } catch (e) { + _log('fetchThingPowerLogs: $e'); + // Un échec d'appel n'est pas « aucun appareil n'a d'historique » : on rend le vide, + // et l'écran dira « non lu », pas « pas d'historique ». + return ConsumerHistory.vide; + } + } + Future> fetchPowerBalanceLogs({ required DateTime from, required DateTime to, diff --git a/lib/theme/etm_tokens.dart b/lib/theme/etm_tokens.dart index 373b28e..4fd000a 100644 --- a/lib/theme/etm_tokens.dart +++ b/lib/theme/etm_tokens.dart @@ -77,6 +77,37 @@ class EtmTokens { static Color ecoOf(BuildContext c) => _isDark(c) ? ecoDark : eco; static Color solarOf(BuildContext c) => _isDark(c) ? solarDark : solar; + // ── Couleurs par CONSOMMATEUR ───────────────────────────────────────────── + // + // Distinctes des couleurs de FLUX (solaire, maison, réseau, batterie), qui disent d'où + // vient l'énergie. Celles-ci disent **qui la prend**, et il en fallait de nouvelles : + // le thème n'en portait aucune, et réutiliser une couleur de flux ferait lire une + // origine là où il n'y a qu'une destination. + // + // Trois teintes choisies pour ne heurter aucune couleur de flux existante, et validées + // par Patrick le 2026-09-08 sur la maquette `docs/mockups/ecran_energie_refonte.html`. + static const Color pac = Color(0xFFE88C30); // pompe à chaleur — ambre chaud + static const Color pacDark = Color(0xFFF09A45); + static const Color ecs = Color(0xFF2AA7A0); // eau chaude — sarcelle + static const Color ecsDark = Color(0xFF37BDB5); + static const Color ev = Color(0xFF7C6CE4); // bornes — indigo + static const Color evDark = Color(0xFF8E80EA); + + 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; + + /// La couleur d'un domaine de charge, ou `null` s'il n'en a pas. + /// + /// `null` n'est pas un défaut à combler : une charge sans domaine déclaré n'a pas de + /// couleur, et lui en attribuer une par défaut la ferait passer pour classée. + static Color? domaineOf(BuildContext c, String? domaine) => switch (domaine) { + 'heating' => pacOf(c), + 'ecs' => ecsOf(c), + 'ev' => evOf(c), + _ => null, + }; + static Color lineOf(BuildContext c) => _isDark(c) ? const Color(0xFFFFFFFF).withValues(alpha: 0.09) : navy.withValues(alpha: 0.10); diff --git a/test/consommateurs_test.dart b/test/consommateurs_test.dart new file mode 100644 index 0000000..f63c7ee --- /dev/null +++ b/test/consommateurs_test.dart @@ -0,0 +1,127 @@ +/// **Le graphe par consommateur — ce qu'il a le droit de dire, et ce qu'il doit taire.** +/// +/// Trois faits mesurés sur `.75` le 2026-09-08 tiennent tout ce fichier : +/// +/// - six Things demandés à `Energy.GetThingPowerLogs`, **cinq répondent** — la V2C Trydan +/// ne déclare pas l'interface `smartmeter` ; +/// - les trois sous-compteurs (PAC, ECS, borne simulée) lisent **0 W à tous les pas** ; +/// - `production` est publiée **négative** par la box. +/// +/// Chacun crée une manière différente de mentir à l'écran, et aucune ne se voit sans y +/// penser : une série absente tracée à zéro, un empilement présenté comme établi alors +/// qu'aucune valeur non nulle ne l'a validé, et deux courbes invisibles pour une raison +/// de signe. +library; + +import 'package:etm_powersync_app/models/consumer_series.dart'; +import 'package:etm_powersync_app/services/energy_ratios.dart'; +import 'package:flutter_test/flutter_test.dart'; + +ConsumerSeries _serie(String id, String label, String? dom, List w) => + ConsumerSeries( + thingId: id, + label: label, + domaine: dom, + points: [ + for (int i = 0; i < w.length; i++) + ConsumerPoint(DateTime(2026, 9, 8, 0, 15 * i), w[i]) + ], + ); + +void main() { + group('une série ABSENTE n\'est pas une série à zéro', () { + test('l\'appareil sans historique est NOMMÉ, pas escamoté', () { + final h = ConsumerHistory( + series: [_serie('ecs', 'chauffe-eau', 'ecs', [0, 0, 0])], + sansHistorique: const ['V2C Trydan'], + ); + expect(h.series, hasLength(1)); + expect(h.sansHistorique, ['V2C Trydan']); + // Le contrôle qui MORD : la borne ne doit jamais devenir une série de zéros. + expect(h.series.any((s) => s.label == 'V2C Trydan'), isFalse, + reason: 'une ligne plate affirmerait « elle n\'a rien tiré », ' + 'quand la vérité est « elle n\'a pas de passé »'); + }); + + test('une série à zéro est une MESURE, et elle reste tracée', () { + final s = _serie('pac', 'PAC banc', 'heating', [0, 0, 0, 0]); + expect(s.toutAZero, isTrue); + expect(s.points, hasLength(4), + reason: 'un compteur dédié qui lit 0 W dit « rien ne chauffe » — ' + 'c\'est exactement ce qu\'il faut montrer'); + expect(s.maxW, 0); + }); + + test('une série vide n\'est pas « toute à zéro »', () { + // Deux états distincts sous une même apparence à l'écran : aucun point du tout + // (rien demandé, rien reçu) et des points tous nuls (mesurés). + const vide = ConsumerSeries( + thingId: 'x', label: 'x', domaine: null, points: []); + expect(vide.toutAZero, isFalse); + }); + }); + + group('le signe des sous-compteurs reste INVÉRIFIÉ tant que tout vaut zéro', () { + test('trois compteurs à zéro : la question est ouverte, et le dit', () { + final h = ConsumerHistory(series: [ + _serie('pac', 'PAC banc', 'heating', [0, 0]), + _serie('ecs', 'chauffe-eau', 'ecs', [0, 0]), + ], sansHistorique: const []); + expect(h.signeIndetermine, isTrue, + reason: 'le compteur PV publie sa puissance en NÉGATIF sur cette box — ' + 'rien n\'autorise à supposer la convention des consommateurs'); + }); + + test('une seule valeur non nulle referme la question', () { + final h = ConsumerHistory(series: [ + _serie('pac', 'PAC banc', 'heating', [0, 0]), + _serie('ecs', 'chauffe-eau', 'ecs', [0, 1500]), + ], sansHistorique: const []); + expect(h.signeIndetermine, isFalse); + }); + }); + + group('l\'axe des instants ne s\'invente pas', () { + test('il vient de la série la plus longue, sans interpolation', () { + final h = ConsumerHistory(series: [ + _serie('a', 'a', null, [1, 2]), + _serie('b', 'b', null, [1, 2, 3, 4]), + ], sansHistorique: const []); + expect(h.instants, hasLength(4)); + }); + + test('sans série, aucun instant — et pas une liste de secours', () { + expect(ConsumerHistory.vide.instants, isEmpty); + expect(ConsumerHistory.vide.estVide, isTrue); + }); + }); + + group('la production NÉGATIVE, et les deux courbes qu\'elle effaçait', () { + // Signalé par Patrick le 2026-09-08 : « dans le premier chart on ne voit pas la + // production ni l'autoconsommation ». Une seule cause pour les deux. + const productionPubliee = -103.0; // relevé sur .75 + const acquisition = 3000.0; // soutirage, donc pas d'export + + test('telle quelle, l\'autoconsommation est clouée à zéro', () { + final self = EnergyRatiosInterim.selfConsumptionPower( + productionPubliee, acquisition); + expect(self, 0, + reason: '`production − export` puis clamp à 0 : avec une production ' + 'négative le résultat ne peut JAMAIS être autre chose'); + }); + + test('sur la magnitude, elle rend la vraie valeur', () { + final self = EnergyRatiosInterim.selfConsumptionPower( + productionPubliee.abs(), acquisition); + expect(self, 103, + reason: 'rien n\'est exporté, donc tout le produit est autoconsommé'); + }); + + test('et l\'export reste soustrait quand il y en a', () { + // Le `.abs()` porte sur la PRODUCTION seule. Le réseau garde son signe, parce que + // là le signe dit le SENS — c'est ce qui distingue ce cas de celui du réseau. + final self = EnergyRatiosInterim.selfConsumptionPower(5000, -1200); + expect(self, 3800); + }); + }); +}