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
This commit is contained in:
parent
74b7a84913
commit
23a84fec43
BIN
docs/captures/2026-08-28_bilan-du-jour.png
Normal file
BIN
docs/captures/2026-08-28_bilan-du-jour.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 240 KiB |
178
integration_test/daily_balance_on_device_test.dart
Normal file
178
integration_test/daily_balance_on_device_test.dart
Normal file
@ -0,0 +1,178 @@
|
||||
/// Le **bilan du jour**, sur l'appareil, contre les vrais compteurs de `.75`.
|
||||
///
|
||||
/// ## Pourquoi il faut le voir sur appareil
|
||||
///
|
||||
/// `test/daily_balance_test.dart` prouve le calcul sur une fixture. Il ne prouve ni que
|
||||
/// les bornes de minuit local sont les bonnes une fois dans le fuseau du téléphone, ni que
|
||||
/// le décodage des clés de log tient bout à bout, ni que les tuiles se remplissent après
|
||||
/// le premier rendu — le bilan arrive **après** la première image, il faut un aller-retour.
|
||||
///
|
||||
/// ## LECTURE SEULE — le banc est en préparation
|
||||
///
|
||||
/// La V2C arrive, `.75` doit rester dans l'état où la prépa l'a laissé. Ce test n'écrit
|
||||
/// **rien** : ni `SetLoadConfig`, ni `SetChargingInfo`, ni réglage. Il lit des logs et
|
||||
/// regarde un écran.
|
||||
///
|
||||
/// Lancement :
|
||||
/// ```sh
|
||||
/// flutter test integration_test/daily_balance_on_device_test.dart -d dee659e2
|
||||
/// ```
|
||||
library;
|
||||
|
||||
import 'package:etm_powersync_app/main.dart' as app;
|
||||
import 'package:etm_powersync_app/models/daily_balance.dart';
|
||||
import 'package:etm_powersync_app/services/connection_manager.dart';
|
||||
import 'package:etm_powersync_app/screens/dashboard_screen.dart';
|
||||
import 'package:etm_powersync_app/services/nymea_service.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:integration_test/integration_test.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
const _hote = '192.168.1.75';
|
||||
|
||||
Future<void> _attendre(
|
||||
WidgetTester tester,
|
||||
bool Function() cond, {
|
||||
Duration limite = const Duration(seconds: 30),
|
||||
String quoi = 'condition',
|
||||
}) async {
|
||||
final fin = DateTime.now().add(limite);
|
||||
while (DateTime.now().isBefore(fin)) {
|
||||
await tester.pump(const Duration(milliseconds: 250));
|
||||
if (cond()) return;
|
||||
}
|
||||
fail('délai dépassé en attendant : $quoi');
|
||||
}
|
||||
|
||||
void main() {
|
||||
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
testWidgets('le bilan du jour, sur appareil, contre .75', (tester) async {
|
||||
final debordements = <String>[];
|
||||
final precedent = FlutterError.onError;
|
||||
FlutterError.onError = (details) {
|
||||
final t = details.exceptionAsString();
|
||||
if (t.contains('overflowed by')) {
|
||||
final l = t.split('\n').first;
|
||||
if (!debordements.contains(l)) debordements.add(l);
|
||||
return;
|
||||
}
|
||||
precedent?.call(details);
|
||||
};
|
||||
addTearDown(() => FlutterError.onError = precedent);
|
||||
|
||||
app.main();
|
||||
await tester.pump(const Duration(seconds: 3));
|
||||
|
||||
final ctx = tester.element(find.byType(MaterialApp).first);
|
||||
final svc = Provider.of<NymeaService>(ctx, listen: false);
|
||||
final cnx = Provider.of<ConnectionManager>(ctx, listen: false);
|
||||
if (!svc.isConnected) {
|
||||
// ignore: unawaited_futures
|
||||
cnx.connectNew(host: _hote);
|
||||
}
|
||||
await _attendre(tester, () => svc.isConnected,
|
||||
limite: const Duration(seconds: 60), quoi: 'la connexion à $_hote');
|
||||
debugPrint('[appareil] connecté à $_hote');
|
||||
|
||||
// ── 1. Le bilan tel que le service le lit ────────────────────────────────
|
||||
final DailyBalance? b = await svc.fetchDailyBalance();
|
||||
expect(b, isNotNull,
|
||||
reason: '.75 publie des logs depuis minuit — la journée doit être mesurable');
|
||||
|
||||
debugPrint('[appareil] fenêtre ${b!.from} → ${b.to} · ${b.sampleCount} échantillons');
|
||||
debugPrint('[appareil] prod ${b.productionKwh.toStringAsFixed(2)} · '
|
||||
'conso ${b.consumptionKwh.toStringAsFixed(2)} · '
|
||||
'import ${b.importKwh.toStringAsFixed(2)} · '
|
||||
'export ${b.exportKwh.toStringAsFixed(2)} kWh');
|
||||
debugPrint('[appareil] autoconso ${b.selfConsumptionRate?.toStringAsFixed(1)} % · '
|
||||
'autonomie ${b.autonomyRate?.toStringAsFixed(1)} % · '
|
||||
'identité ${b.identityHolds ? "OK" : "FAUSSE"} · '
|
||||
'partielle : ${b.startsLate}');
|
||||
|
||||
// ── 2. Les bornes sont bien celles du JOUR, dans le fuseau du téléphone ──
|
||||
final maintenant = DateTime.now();
|
||||
final minuit = DateTime(maintenant.year, maintenant.month, maintenant.day);
|
||||
expect(b.from.isBefore(minuit), isFalse,
|
||||
reason: 'la fenêtre ne doit pas déborder sur la veille — minuit LOCAL');
|
||||
expect(b.to.isAfter(minuit), isTrue);
|
||||
expect(b.sampleCount, greaterThanOrEqualTo(2));
|
||||
|
||||
// ── 3. L'ordre de grandeur tranche l'unité, bout à bout ─────────────────
|
||||
//
|
||||
// C'est le piège qui a fait afficher « 78 Wh » pour une journée de 78 kWh. Une maison
|
||||
// ne produit ni des dixièmes de kWh ni des mégawattheures dans la journée.
|
||||
expect(b.productionKwh, greaterThan(0.5),
|
||||
reason: 'des Wh donneraient une valeur mille fois trop petite');
|
||||
expect(b.productionKwh, lessThan(500));
|
||||
|
||||
// ── 4. L'identité des compteurs, sur la vraie machine ───────────────────
|
||||
expect(b.identityHolds, isTrue,
|
||||
reason: 'production + soutiré doit égaler consommation + injecté : '
|
||||
'${b.productionKwh + b.importKwh} vs ${b.consumptionKwh + b.exportKwh}');
|
||||
|
||||
// ── 5. Aller AU tableau de bord ─────────────────────────────────────────
|
||||
//
|
||||
// Se connecter ne navigue pas : l'app reste sur l'écran Installations, et chercher les
|
||||
// tuiles là-bas ne prouve que leur absence. Le contexte du `MaterialApp` est au-dessus
|
||||
// du Router — on prend celui d'un Scaffold affiché.
|
||||
GoRouter.of(tester.element(find.byType(Scaffold).first)).go('/');
|
||||
await tester.pump(const Duration(seconds: 3));
|
||||
await _attendre(
|
||||
tester,
|
||||
() => find.byType(DashboardScreen).evaluate().isNotEmpty,
|
||||
quoi: 'le montage du tableau de bord',
|
||||
);
|
||||
debugPrint('[appareil] tableau de bord monté');
|
||||
|
||||
// ── 6. Les tuiles se remplissent APRÈS le premier rendu ─────────────────
|
||||
//
|
||||
// Le tableau de bord lit le bilan dans un post-frame callback : la première image
|
||||
// porte « — ». Si l'anneau ne se réarmait pas, il resterait vide pour toujours.
|
||||
await _attendre(
|
||||
tester,
|
||||
() => find.text('Autoconsommation').evaluate().isNotEmpty,
|
||||
quoi: 'les tuiles du jour',
|
||||
);
|
||||
expect(find.text('Aujourd\'hui'), findsOneWidget,
|
||||
reason: 'la période est dite UNE fois, en tête des quatre tuiles');
|
||||
expect(find.text('Vers réseau'), findsOneWidget);
|
||||
expect(find.text('Depuis réseau'), findsOneWidget);
|
||||
expect(find.textContaining('cumulé').evaluate(), isEmpty,
|
||||
reason: 'plus aucune tuile ne montre un compteur depuis l\'origine');
|
||||
|
||||
// Les libellés ne doivent pas être coupés au milieu d'un mot — « Autoconsomm… » est
|
||||
// ce que la capture d'appareil a montré au premier passage, et aucun test unitaire ne
|
||||
// pouvait le voir : ils ne rendent pas ces widgets à la largeur d'un vrai écran.
|
||||
for (final t in tester.widgetList<Text>(find.byType(Text))) {
|
||||
final d = t.data;
|
||||
if (d != null && d.endsWith('…')) {
|
||||
fail('libellé tronqué à l\'écran : « $d »');
|
||||
}
|
||||
}
|
||||
|
||||
// Laisser le temps au bilan d'arriver et à l'anneau de s'animer, puis regarder.
|
||||
for (var i = 0; i < 60; i++) {
|
||||
await tester.pump(const Duration(milliseconds: 250));
|
||||
}
|
||||
expect(find.text('— %').evaluate(), isEmpty,
|
||||
reason: 'les anneaux doivent porter une valeur une fois le bilan lu');
|
||||
|
||||
debugPrint('[appareil] ÉCRAN « tableau de bord · bilan du jour » — maintenu 15 s');
|
||||
for (var i = 0; i < 60; i++) {
|
||||
await tester.pump(const Duration(milliseconds: 250));
|
||||
}
|
||||
|
||||
if (debordements.isNotEmpty) {
|
||||
debugPrint('[appareil] DÉBORDEMENTS (${debordements.length}) :');
|
||||
for (final d in debordements) {
|
||||
debugPrint('[appareil] • $d');
|
||||
}
|
||||
} else {
|
||||
debugPrint('[appareil] aucun débordement de mise en page');
|
||||
}
|
||||
expect(debordements, isEmpty);
|
||||
});
|
||||
}
|
||||
@ -1,38 +1,78 @@
|
||||
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;
|
||||
const KpiRow({super.key, required this.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 autoconso = data.selfConsumptionRate.clamp(0.0, 100.0);
|
||||
final autonomy = data.autonomyRate.clamp(0.0, 100.0);
|
||||
// Deux compteurs DISTINCTS de la box, et non un seul et son opposé.
|
||||
//
|
||||
// Ces deux tuiles lisaient toutes les deux `dayGridInjectionWh` — l'export — la
|
||||
// seconde en prenant son opposé. Or l'import a son propre compteur
|
||||
// (`totalAcquisition`) : dès que l'export était positif, « Depuis réseau » affichait
|
||||
// 0,0 kWh en permanence, quelle que soit l'électricité réellement achetée.
|
||||
//
|
||||
// `null` = la box publie une valeur inexploitable ; la tuile le dit au lieu
|
||||
// d'imprimer un nombre absurde (constaté sur `.75` : 9,1e+33 kWh exportés, alors que
|
||||
// `totalProduction` restait sain — les compteurs cumulés de l'experience-plugin
|
||||
// énergie sont corrompus, pas ceux du Thing compteur).
|
||||
final toGrid = data.gridExportKwhOrNull;
|
||||
final fromGrid = data.gridImportKwhOrNull;
|
||||
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: autoconso,
|
||||
value: b?.selfConsumptionRate,
|
||||
color: EtmTokens.solarOf(context),
|
||||
),
|
||||
),
|
||||
@ -40,7 +80,7 @@ class KpiRow extends StatelessWidget {
|
||||
Expanded(
|
||||
child: _RingTile(
|
||||
label: 'Autonomie',
|
||||
value: autonomy,
|
||||
value: b?.autonomyRate,
|
||||
color: EtmTokens.ecoOf(context),
|
||||
),
|
||||
),
|
||||
@ -51,10 +91,8 @@ class KpiRow extends StatelessWidget {
|
||||
children: [
|
||||
Expanded(
|
||||
child: _ValueTile(
|
||||
// « aujourd'hui » était faux : `totalReturn` est cumulé depuis
|
||||
// l'origine et n'est pas remis à zéro chaque nuit.
|
||||
label: 'Vers réseau · cumulé',
|
||||
value: toGrid,
|
||||
label: 'Vers réseau',
|
||||
value: b?.exportKwh,
|
||||
icon: Icons.north_west_rounded,
|
||||
color: EtmTokens.ecoOf(context),
|
||||
),
|
||||
@ -62,24 +100,48 @@ class KpiRow extends StatelessWidget {
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: _ValueTile(
|
||||
label: 'Depuis réseau · cumulé',
|
||||
value: fromGrid,
|
||||
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;
|
||||
final double value;
|
||||
|
||||
/// `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});
|
||||
|
||||
@ -97,13 +159,32 @@ class _RingTileState extends State<_RingTile>
|
||||
void initState() {
|
||||
super.initState();
|
||||
_ctrl = AnimationController(vsync: this, duration: const Duration(milliseconds: 1100));
|
||||
_ring = Tween<double>(begin: 0, end: widget.value / 100)
|
||||
.animate(CurvedAnimation(parent: _ctrl, curve: Curves.easeOutCubic));
|
||||
_count = IntTween(begin: 0, end: widget.value.round())
|
||||
.animate(CurvedAnimation(parent: _ctrl, curve: Curves.easeOutCubic));
|
||||
_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();
|
||||
@ -142,24 +223,38 @@ class _RingTileState extends State<_RingTile>
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
AnimatedBuilder(
|
||||
animation: _count,
|
||||
builder: (context, _) => Text(
|
||||
'${_count.value} %',
|
||||
style: EtmTokens.mono(
|
||||
size: 18,
|
||||
weight: FontWeight.w600,
|
||||
color: EtmTokens.inkOf(context),
|
||||
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.label,
|
||||
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,
|
||||
),
|
||||
],
|
||||
),
|
||||
@ -228,11 +323,12 @@ class _ValueTile extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(height: 3),
|
||||
Text(
|
||||
value == null ? '$label · compteur box invalide' : label,
|
||||
value == null ? '$label · pas mesurable' : label,
|
||||
style: EtmTokens.sans(
|
||||
size: 10.5,
|
||||
color: EtmTokens.mutedOf(context),
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
@ -243,3 +339,27 @@ class _ValueTile extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 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)),
|
||||
),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
111
lib/models/daily_balance.dart
Normal file
111
lib/models/daily_balance.dart
Normal file
@ -0,0 +1,111 @@
|
||||
/// **Bilan du jour** — l'énergie réellement comptée entre minuit local et maintenant.
|
||||
///
|
||||
/// ## Pourquoi ce type existe
|
||||
///
|
||||
/// Le tableau de bord affichait quatre tuiles dont **aucune n'était journalière** : deux
|
||||
/// pourcentages instantanés, et deux compteurs cumulés **depuis l'origine de
|
||||
/// l'installation** — `totalReturn` et `totalAcquisition` ne sont pas remis à zéro chaque
|
||||
/// nuit. Les deux dernières portaient même le mot « aujourd'hui », ce qui était faux.
|
||||
///
|
||||
/// ## La méthode : une DIFFÉRENCE de compteurs, jamais une intégration
|
||||
///
|
||||
/// Le bilan se calcule par `total(maintenant) − total(minuit)` sur les quatre compteurs
|
||||
/// cumulés des entrées de log. C'est une **mesure**, prise sur les mêmes compteurs que
|
||||
/// ceux du fournisseur.
|
||||
///
|
||||
/// L'alternative — intégrer les puissances instantanées — est une **estimation** : elle
|
||||
/// suppose que chaque échantillon représente son intervalle, ce qui est faux dès qu'une
|
||||
/// charge démarre entre deux points. Vérifié sur `.75` le 2026-08-27 : sur 88 échantillons
|
||||
/// de 15 minutes, l'intégration donne **77,70 kWh** là où la différence des compteurs donne
|
||||
/// **78,31** — 0,8 % d'écart, systématiquement à la baisse.
|
||||
///
|
||||
/// Les deux ne sont donc pas interchangeables, et **il n'y a pas de repli silencieux de
|
||||
/// l'une vers l'autre** : quand la mesure n'est pas exploitable, ce type le dit.
|
||||
///
|
||||
/// ## L'unité est le kWh, et le contraire a coûté un bug
|
||||
///
|
||||
/// Les champs `total*` des entrées de log sont en **kWh**, malgré le suffixe `Wh` que
|
||||
/// portait le modèle Dart. Vérifié par recoupement : l'intégration des puissances (en W)
|
||||
/// rend 77 697 Wh quand la différence des compteurs rend 78,31 — le rapport est mille.
|
||||
/// L'écran Énergie affichait donc « 78 Wh » pour une journée de 78 kWh.
|
||||
library;
|
||||
|
||||
/// Bilan d'une journée. Toutes les énergies en **kWh**.
|
||||
class DailyBalance {
|
||||
/// Début et fin réels de la fenêtre mesurée — **pas** les bornes demandées.
|
||||
///
|
||||
/// Le premier échantillon peut arriver après minuit (service redémarré, box installée
|
||||
/// dans la journée). Publier la borne demandée laisserait croire à une journée complète.
|
||||
final DateTime from;
|
||||
final DateTime to;
|
||||
|
||||
/// Nombre d'échantillons dans la fenêtre. Deux au minimum : une différence a besoin de
|
||||
/// deux points.
|
||||
final int sampleCount;
|
||||
|
||||
final double productionKwh;
|
||||
final double consumptionKwh;
|
||||
|
||||
/// Soutiré au réseau (`totalAcquisition`).
|
||||
final double importKwh;
|
||||
|
||||
/// Injecté vers le réseau (`totalReturn`).
|
||||
final double exportKwh;
|
||||
|
||||
const DailyBalance({
|
||||
required this.from,
|
||||
required this.to,
|
||||
required this.sampleCount,
|
||||
required this.productionKwh,
|
||||
required this.consumptionKwh,
|
||||
required this.importKwh,
|
||||
required this.exportKwh,
|
||||
});
|
||||
|
||||
/// Énergie produite ET consommée sur place — ce qui n'est pas parti au réseau.
|
||||
double get selfConsumptionKwh => productionKwh - exportKwh;
|
||||
|
||||
/// Part de la production consommée sur place. `null` sans production : à 3 h du matin,
|
||||
/// « 0 % d'autoconsommation » se lirait comme un défaut alors qu'il n'y a rien à
|
||||
/// autoconsommer.
|
||||
double? get selfConsumptionRate {
|
||||
if (productionKwh <= 0) return null;
|
||||
return (selfConsumptionKwh / productionKwh * 100).clamp(0, 100);
|
||||
}
|
||||
|
||||
/// Part de la consommation couverte sans le réseau. `null` sans consommation.
|
||||
double? get autonomyRate {
|
||||
if (consumptionKwh <= 0) return null;
|
||||
return ((consumptionKwh - importKwh) / consumptionKwh * 100).clamp(0, 100);
|
||||
}
|
||||
|
||||
/// **L'identité que les compteurs doivent vérifier** :
|
||||
/// `production + import == consommation + export`.
|
||||
///
|
||||
/// Ce n'est pas décoratif. Elle est le seul contrôle disponible sur des compteurs qu'on
|
||||
/// ne peut pas recouper autrement, et elle a tenu **exactement** sur `.75` (112,244 des
|
||||
/// deux côtés). Fausse, elle désigne un compteur cassé ou une remise à zéro au milieu de
|
||||
/// la fenêtre — et le bilan ne doit alors pas être présenté comme une mesure.
|
||||
///
|
||||
/// Tolérance : 1 % de la production, plancher 0,1 kWh. Les valeurs voyagent en `Double`
|
||||
/// et les compteurs ne s'incrémentent pas tous au même instant.
|
||||
bool get identityHolds {
|
||||
final ecart = (productionKwh + importKwh) - (consumptionKwh + exportKwh);
|
||||
final tolerance = (productionKwh * 0.01).clamp(0.1, double.infinity);
|
||||
return ecart.abs() <= tolerance;
|
||||
}
|
||||
|
||||
/// Durée réellement couverte.
|
||||
Duration get span => to.difference(from);
|
||||
|
||||
/// Vrai si la fenêtre commence **sensiblement après minuit** — le bilan est alors
|
||||
/// partiel, et le dire vaut mieux que de le présenter comme la journée entière.
|
||||
///
|
||||
/// Seuil à 20 minutes : un échantillonnage au quart d'heure ne place pas forcément un
|
||||
/// point à 00:00 pile, et crier « partiel » pour un décalage d'échantillonnage rendrait
|
||||
/// l'avertissement inutile à force d'être affiché.
|
||||
bool get startsLate {
|
||||
final minuit = DateTime(from.year, from.month, from.day);
|
||||
return from.difference(minuit) > const Duration(minutes: 20);
|
||||
}
|
||||
}
|
||||
@ -147,10 +147,25 @@ class PowerBalanceEntry {
|
||||
final double consumptionW; // consommation maison (W)
|
||||
final double acquisitionW; // import réseau (W, positif)
|
||||
final double storageW; // batterie : +charge / -décharge (W)
|
||||
final double totalProductionWh; // cumulé depuis l'origine (Wh)
|
||||
final double totalConsumptionWh;
|
||||
final double totalReturnWh; // injection réseau cumulée (Wh)
|
||||
final double totalAcquisitionWh;
|
||||
/// Compteurs cumulés depuis l'origine, en **kWh** — et non en Wh comme le nom de ces
|
||||
/// champs l'a longtemps dit.
|
||||
///
|
||||
/// Vérifié sur `.75` le 2026-08-27 par recoupement, parce qu'aucune documentation ne le
|
||||
/// tranchait : sur une fenêtre de 21,75 h, l'intégration trapézoïdale des puissances
|
||||
/// instantanées (qui sont bien en **W**) rend **77 697 Wh**, quand la différence de
|
||||
/// `totalProduction` sur la même fenêtre rend **78,31**. Le rapport est mille.
|
||||
///
|
||||
/// Le mauvais nom n'était pas cosmétique : l'écran Énergie affichait « 78 Wh » pour une
|
||||
/// journée de 78 kWh, et personne ne pouvait le voir sans connaître l'ordre de grandeur
|
||||
/// attendu.
|
||||
final double totalProductionKwh;
|
||||
final double totalConsumptionKwh;
|
||||
|
||||
/// Injecté vers le réseau, cumulé (kWh).
|
||||
final double totalReturnKwh;
|
||||
|
||||
/// Soutiré au réseau, cumulé (kWh).
|
||||
final double totalAcquisitionKwh;
|
||||
|
||||
const PowerBalanceEntry({
|
||||
required this.timestamp,
|
||||
@ -158,10 +173,9 @@ class PowerBalanceEntry {
|
||||
this.consumptionW = 0,
|
||||
this.acquisitionW = 0,
|
||||
this.storageW = 0,
|
||||
this.totalProductionWh = 0,
|
||||
this.totalConsumptionWh = 0,
|
||||
this.totalReturnWh = 0,
|
||||
this.totalAcquisitionWh = 0,
|
||||
this.totalProductionKwh = 0,
|
||||
this.totalConsumptionKwh = 0,
|
||||
this.totalReturnKwh = 0,
|
||||
this.totalAcquisitionKwh = 0,
|
||||
});
|
||||
|
||||
}
|
||||
@ -1,5 +1,8 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../models/daily_balance.dart';
|
||||
import '../main.dart' show DrawerMenuButton;
|
||||
import '../providers/app_settings_provider.dart';
|
||||
import '../services/nymea_service.dart';
|
||||
@ -21,6 +24,40 @@ class _DashboardScreenState extends State<DashboardScreen> {
|
||||
// n'atteint le dashboard qu'avec une session active (box réelle ou démo).
|
||||
// La démo est un choix explicite depuis l'écran Installations.
|
||||
|
||||
/// Bilan du jour. `null` tant qu'il n'est pas lu, et `null` aussi s'il n'est pas
|
||||
/// mesurable — les tuiles font la même chose des deux : elles disent « — ».
|
||||
///
|
||||
/// Il n'est **pas** dans `EnergyData` : celui-ci porte l'instantané poussé par les
|
||||
/// notifications, alors que le bilan demande un aller-retour sur les logs et ne change
|
||||
/// qu'au quart d'heure. Les mêler ferait relire 88 échantillons à chaque trame de
|
||||
/// puissance.
|
||||
DailyBalance? _bilan;
|
||||
|
||||
/// Le bilan avance d'un point par quart d'heure : le relire plus souvent coûterait un
|
||||
/// aller-retour de 88 échantillons pour rien. Cinq minutes laissent la journée fraîche
|
||||
/// sans marteler la box.
|
||||
static const Duration _periode = Duration(minutes: 5);
|
||||
Timer? _tick;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _lireBilan());
|
||||
_tick = Timer.periodic(_periode, (_) => _lireBilan());
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_tick?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _lireBilan() async {
|
||||
if (!mounted) return;
|
||||
final b = await context.read<NymeaService>().fetchDailyBalance();
|
||||
if (mounted) setState(() => _bilan = b);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Consumer<NymeaService>(
|
||||
@ -29,7 +66,10 @@ class _DashboardScreenState extends State<DashboardScreen> {
|
||||
return Scaffold(
|
||||
backgroundColor: EtmTokens.bgOf(context),
|
||||
body: RefreshIndicator(
|
||||
onRefresh: () async => service.startSimulation(),
|
||||
onRefresh: () async {
|
||||
service.startSimulation();
|
||||
await _lireBilan();
|
||||
},
|
||||
color: EtmTokens.ecoOf(context),
|
||||
child: CustomScrollView(
|
||||
slivers: [
|
||||
@ -41,7 +81,7 @@ class _DashboardScreenState extends State<DashboardScreen> {
|
||||
const SizedBox(height: 14),
|
||||
EnergyFlowCard(data: data),
|
||||
const SizedBox(height: 14),
|
||||
KpiRow(data: data),
|
||||
KpiRow(data: data, balance: _bilan),
|
||||
const SizedBox(height: 14),
|
||||
const HeosDecisionCard(),
|
||||
const SizedBox(height: 14),
|
||||
|
||||
@ -237,7 +237,7 @@ class _EnergyScreenState extends State<EnergyScreen> {
|
||||
|
||||
// Graphe Bilan énergétique
|
||||
_EtmChartCard(
|
||||
title: 'Bilan énergétique (Wh)',
|
||||
title: 'Bilan énergétique (kWh)',
|
||||
subtitle: 'Énergie par période',
|
||||
legend: [
|
||||
_LegendDot(EtmTokens.amber, 'Production'),
|
||||
@ -431,19 +431,22 @@ class _EnergyScreenState extends State<EnergyScreen> {
|
||||
final bw = _barWidth();
|
||||
|
||||
for (int i = 0; i < _data.length; i++) {
|
||||
final prodWh = i == 0 ? 0.0 : max(0.0, _data[i].totalProductionWh - _data[i - 1].totalProductionWh);
|
||||
final consoWh = i == 0 ? 0.0 : max(0.0, _data[i].totalConsumptionWh - _data[i - 1].totalConsumptionWh);
|
||||
maxE = [maxE, prodWh, consoWh].reduce(max);
|
||||
// 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: prodWh, color: EtmTokens.amber, width: bw,
|
||||
toY: prodKwh, color: EtmTokens.amber, width: bw,
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(3)),
|
||||
),
|
||||
BarChartRodData(
|
||||
toY: consoWh, color: EtmTokens.blue, width: bw,
|
||||
toY: consoKwh, color: EtmTokens.blue, width: bw,
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(3)),
|
||||
),
|
||||
],
|
||||
@ -518,9 +521,12 @@ class _EnergyScreenState extends State<EnergyScreen> {
|
||||
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() >= 1000) return '${(v / 1000).toStringAsFixed(1)} kWh';
|
||||
return '${v.toStringAsFixed(0)} Wh';
|
||||
if (v.abs() < 1) return '${(v * 1000).toStringAsFixed(0)} Wh';
|
||||
return '${v.toStringAsFixed(1)} kWh';
|
||||
}
|
||||
|
||||
String _fmtTime(DateTime t) => _tabs[_tabIdx].showTime
|
||||
|
||||
@ -6,6 +6,7 @@ import 'dart:math';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:web_socket_channel/web_socket_channel.dart';
|
||||
import '../models/daily_balance.dart';
|
||||
import '../models/energy_data.dart'; // EnergyData, HistoryPoint, PowerBalanceEntry
|
||||
import '../models/modbus_models.dart'; // ModbusRtuMaster, SerialPort
|
||||
import '../models/nymea_models.dart';
|
||||
@ -950,10 +951,10 @@ class NymeaService extends ChangeNotifier {
|
||||
consumptionW: d('consumption'),
|
||||
acquisitionW: d('acquisition'),
|
||||
storageW: d('storage'),
|
||||
totalProductionWh: d('totalProduction'),
|
||||
totalConsumptionWh:d('totalConsumption'),
|
||||
totalReturnWh: d('totalReturn'),
|
||||
totalAcquisitionWh:d('totalAcquisition'),
|
||||
totalProductionKwh: d('totalProduction'),
|
||||
totalConsumptionKwh:d('totalConsumption'),
|
||||
totalReturnKwh: d('totalReturn'),
|
||||
totalAcquisitionKwh:d('totalAcquisition'),
|
||||
);
|
||||
}).toList();
|
||||
} catch (e) {
|
||||
@ -962,6 +963,106 @@ class NymeaService extends ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
/// **Le bilan du jour** — de minuit local à maintenant.
|
||||
///
|
||||
/// ## Ce qu'il corrige
|
||||
///
|
||||
/// Le tableau de bord n'avait aucune vue journalière : deux pourcentages instantanés et
|
||||
/// deux compteurs cumulés **depuis l'origine**, dont deux portaient le mot
|
||||
/// « aujourd'hui ». Cette méthode fournit la vraie journée.
|
||||
///
|
||||
/// ## Les trois pièges, déjà payés une fois chacun
|
||||
///
|
||||
/// 1. **Minuit LOCAL, et via les logs.** `GetPowerBalance` ne rend que des totaux depuis
|
||||
/// l'origine : il ne sait rien dire d'une journée. Seul `GetPowerBalanceLogs` permet
|
||||
/// de prendre deux points et de les soustraire. `DateTime(y, m, d)` donne bien minuit
|
||||
/// dans le fuseau du téléphone — pas UTC.
|
||||
/// 2. **Les entrées de log ne portent PAS le préfixe `currentPower`.** Elles s'appellent
|
||||
/// `production`, `consumption`, `acquisition`, `storage`, à la différence de
|
||||
/// `GetPowerBalance`. Se tromper de clés ne produit **aucune erreur** : tout tombe sur
|
||||
/// le défaut `0`, et l'écran affiche une journée vide sur une installation qui a
|
||||
/// produit. C'est exactement ce qui a fait croire à un défaut de la box côté plugin.
|
||||
/// Le décodage est déjà correct dans [fetchPowerBalanceLogs] ; cette méthode s'appuie
|
||||
/// dessus plutôt que de refaire un décodage qui pourrait diverger.
|
||||
/// 3. **Les cumulés peuvent être corrompus.** `.75` a publié 9,1e+33 kWh exportés. Un
|
||||
/// bilan bâti là-dessus serait absurde sans le paraître, d'où le contrôle de
|
||||
/// plausibilité sur les deux bornes AVANT toute soustraction.
|
||||
///
|
||||
/// \return `null` quand la journée n'est **pas mesurable** — jamais un bilan à zéro.
|
||||
/// « Rien produit » et « on ne sait pas » sont deux états différents, et le second ne
|
||||
/// doit pas se déguiser en premier.
|
||||
Future<DailyBalance?> fetchDailyBalance({DateTime? jour}) async {
|
||||
final maintenant = jour ?? DateTime.now();
|
||||
final minuit =
|
||||
DateTime(maintenant.year, maintenant.month, maintenant.day);
|
||||
|
||||
// Le quart d'heure est le taux le plus fin peuplé sur la durée d'une journée, et il
|
||||
// place assez de points pour qu'un début de fenêtre tardif se voie.
|
||||
final entries = await fetchPowerBalanceLogs(
|
||||
from: minuit,
|
||||
to: maintenant,
|
||||
sampleRate: 'SampleRate15Mins',
|
||||
);
|
||||
|
||||
// Une différence a besoin de deux points. Un seul échantillon n'est pas « une journée
|
||||
// à zéro », c'est une journée qui n'a pas encore de quoi être mesurée.
|
||||
if (entries.length < 2) {
|
||||
_log('[Bilan] ${entries.length} échantillon(s) depuis minuit — non mesurable',
|
||||
force: true);
|
||||
return null;
|
||||
}
|
||||
|
||||
final tries = [...entries]
|
||||
..sort((a, b) => a.timestamp.compareTo(b.timestamp));
|
||||
final a = tries.first;
|
||||
final b = tries.last;
|
||||
|
||||
// Compteurs corrompus : on refuse AVANT de soustraire. Deux valeurs absurdes peuvent
|
||||
// avoir une différence d'apparence saine.
|
||||
final bornes = [
|
||||
a.totalProductionKwh, b.totalProductionKwh,
|
||||
a.totalConsumptionKwh, b.totalConsumptionKwh,
|
||||
a.totalReturnKwh, b.totalReturnKwh,
|
||||
a.totalAcquisitionKwh, b.totalAcquisitionKwh,
|
||||
];
|
||||
if (!bornes.every(EnergyData.isPlausibleKwh)) {
|
||||
_log('[Bilan] compteurs cumulés inexploitables — non mesurable', force: true);
|
||||
return null;
|
||||
}
|
||||
|
||||
double delta(double fin, double debut) => fin - debut;
|
||||
final bilan = DailyBalance(
|
||||
from: a.timestamp,
|
||||
to: b.timestamp,
|
||||
sampleCount: tries.length,
|
||||
productionKwh: delta(b.totalProductionKwh, a.totalProductionKwh),
|
||||
consumptionKwh: delta(b.totalConsumptionKwh, a.totalConsumptionKwh),
|
||||
importKwh: delta(b.totalAcquisitionKwh, a.totalAcquisitionKwh),
|
||||
exportKwh: delta(b.totalReturnKwh, a.totalReturnKwh),
|
||||
);
|
||||
|
||||
// Un compteur qui RECULE = remise à zéro ou redémarrage au milieu de la fenêtre. Le
|
||||
// bilan serait négatif, donc faux, et un `max(0, …)` le rendrait faux en silence.
|
||||
if (bilan.productionKwh < 0 ||
|
||||
bilan.consumptionKwh < 0 ||
|
||||
bilan.importKwh < 0 ||
|
||||
bilan.exportKwh < 0) {
|
||||
_log('[Bilan] compteur en recul entre les deux bornes — non mesurable',
|
||||
force: true);
|
||||
return null;
|
||||
}
|
||||
|
||||
_log(
|
||||
'[Bilan] ${tries.length} échantillons · '
|
||||
'prod ${bilan.productionKwh.toStringAsFixed(1)} · '
|
||||
'conso ${bilan.consumptionKwh.toStringAsFixed(1)} · '
|
||||
'import ${bilan.importKwh.toStringAsFixed(1)} · '
|
||||
'export ${bilan.exportKwh.toStringAsFixed(1)} kWh · '
|
||||
'identité ${bilan.identityHolds ? "OK" : "FAUSSE"}',
|
||||
force: true);
|
||||
return bilan;
|
||||
}
|
||||
|
||||
List<PowerBalanceEntry> _simulatePowerBalance(
|
||||
DateTime from, DateTime to, String sampleRate) {
|
||||
final entries = <PowerBalanceEntry>[];
|
||||
@ -981,20 +1082,23 @@ class NymeaService extends ChangeNotifier {
|
||||
final storage = excess.clamp(-300.0, 300.0).toDouble();
|
||||
final acq = (conso - prod - storage).clamp(0.0, double.infinity).toDouble();
|
||||
final ret = (excess - storage).clamp(0.0, double.infinity).toDouble();
|
||||
totalProd += prod * stepHours;
|
||||
totalConso += conso * stepHours;
|
||||
totalRet += ret * stepHours;
|
||||
totalAcq += acq * stepHours;
|
||||
// Les compteurs cumulés de la box sont en kWh (vérifié sur `.75`) : la simulation
|
||||
// doit produire la même unité, sinon elle ment d'un facteur mille et un écran qui
|
||||
// marche en simulation casse contre une vraie box.
|
||||
totalProd += prod * stepHours / 1000;
|
||||
totalConso += conso * stepHours / 1000;
|
||||
totalRet += ret * stepHours / 1000;
|
||||
totalAcq += acq * stepHours / 1000;
|
||||
entries.add(PowerBalanceEntry(
|
||||
timestamp: t,
|
||||
productionW: prod,
|
||||
consumptionW: conso,
|
||||
acquisitionW: acq,
|
||||
storageW: storage,
|
||||
totalProductionWh: totalProd,
|
||||
totalConsumptionWh: totalConso,
|
||||
totalReturnWh: totalRet,
|
||||
totalAcquisitionWh: totalAcq,
|
||||
totalProductionKwh: totalProd,
|
||||
totalConsumptionKwh: totalConso,
|
||||
totalReturnKwh: totalRet,
|
||||
totalAcquisitionKwh: totalAcq,
|
||||
));
|
||||
}
|
||||
return entries;
|
||||
|
||||
134
test/daily_balance_test.dart
Normal file
134
test/daily_balance_test.dart
Normal file
@ -0,0 +1,134 @@
|
||||
/// Le **bilan du jour** — la première vue journalière de l'app.
|
||||
///
|
||||
/// ## Ce que ces tests protègent
|
||||
///
|
||||
/// Aucune des quatre tuiles du tableau de bord n'était journalière : deux pourcentages
|
||||
/// instantanés, deux compteurs cumulés depuis l'origine dont deux portaient le mot
|
||||
/// « aujourd'hui ». Le lot corrige ça, et trois pièges le guettaient — tous déjà payés une
|
||||
/// fois dans ce projet, aucun ne produisant d'erreur visible :
|
||||
///
|
||||
/// 1. prendre les totaux de `GetPowerBalance` au lieu d'une différence sur les logs ;
|
||||
/// 2. lire les clés `currentPower*` sur des entrées de log qui n'en portent pas ;
|
||||
/// 3. bâtir un bilan sur des compteurs corrompus.
|
||||
///
|
||||
/// La fixture est un relevé RÉEL de `.75`, avec ses deltas attendus calculés hors de
|
||||
/// l'app : un test qui recalcule avec le code testé ne prouve rien.
|
||||
library;
|
||||
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:etm_powersync_app/models/daily_balance.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
Map<String, dynamic> _fixture() => jsonDecode(
|
||||
File('test/fixtures/powerbalance_jour_hems75.json').readAsStringSync())
|
||||
as Map<String, dynamic>;
|
||||
|
||||
/// Rejoue le calcul du service : une DIFFÉRENCE entre la première et la dernière borne.
|
||||
DailyBalance _depuisFixture() {
|
||||
final e = (_fixture()['powerBalanceLogEntries'] as List)
|
||||
.cast<Map<String, dynamic>>()
|
||||
..sort((a, b) => (a['timestamp'] as int).compareTo(b['timestamp'] as int));
|
||||
final a = e.first, b = e.last;
|
||||
double d(String k) => (b[k] as num).toDouble() - (a[k] as num).toDouble();
|
||||
return DailyBalance(
|
||||
from: DateTime.fromMillisecondsSinceEpoch((a['timestamp'] as int) * 1000),
|
||||
to: DateTime.fromMillisecondsSinceEpoch((b['timestamp'] as int) * 1000),
|
||||
sampleCount: e.length,
|
||||
productionKwh: d('totalProduction'),
|
||||
consumptionKwh: d('totalConsumption'),
|
||||
importKwh: d('totalAcquisition'),
|
||||
exportKwh: d('totalReturn'),
|
||||
);
|
||||
}
|
||||
|
||||
void main() {
|
||||
group('bilan du jour — sur le relevé réel de .75', () {
|
||||
test('les deltas sont ceux calculés hors de l\'app', () {
|
||||
final b = _depuisFixture();
|
||||
final attendu =
|
||||
_fixture()['_deltas_attendus_kwh'] as Map<String, dynamic>;
|
||||
expect(b.productionKwh, closeTo(attendu['production'] as double, 0.001));
|
||||
expect(b.consumptionKwh, closeTo(attendu['consumption'] as double, 0.001));
|
||||
expect(b.exportKwh, closeTo(attendu['return'] as double, 0.001));
|
||||
expect(b.importKwh, closeTo(attendu['acquisition'] as double, 0.001));
|
||||
});
|
||||
|
||||
test('l\'identité des compteurs tient — production + soutiré == conso + injecté', () {
|
||||
// C'est le seul contrôle disponible sur des compteurs qu'on ne peut pas recouper
|
||||
// autrement. Il a tenu EXACTEMENT sur le banc : 112,244 des deux côtés.
|
||||
final b = _depuisFixture();
|
||||
expect(b.identityHolds, isTrue);
|
||||
expect(b.productionKwh + b.importKwh,
|
||||
closeTo(b.consumptionKwh + b.exportKwh, 0.001));
|
||||
});
|
||||
|
||||
test('les taux du jour se déduisent des énergies, pas de l\'instantané', () {
|
||||
final b = _depuisFixture();
|
||||
// autoconso = (produit − injecté) / produit ; autonomie = (consommé − soutiré) / consommé
|
||||
expect(b.selfConsumptionKwh, closeTo(78.310 - 37.809, 0.001));
|
||||
expect(b.selfConsumptionRate!, closeTo(51.72, 0.1));
|
||||
expect(b.autonomyRate!, closeTo(54.41, 0.1));
|
||||
});
|
||||
|
||||
test('l\'unité est le kWh — un ordre de grandeur, pas une convention', () {
|
||||
// Le piège qui a coûté « 78 Wh » affichés pour une journée de 78 kWh. Une maison
|
||||
// ne produit ni 78 Wh ni 78 MWh dans la journée : l'ordre de grandeur suffit à
|
||||
// trancher, et ce test le fige.
|
||||
final b = _depuisFixture();
|
||||
expect(b.productionKwh, greaterThan(1),
|
||||
reason: 'des Wh donneraient une valeur mille fois plus petite');
|
||||
expect(b.productionKwh, lessThan(1000));
|
||||
});
|
||||
});
|
||||
|
||||
group('ce qui n\'est PAS mesurable ne s\'affiche pas comme zéro', () {
|
||||
DailyBalance b({
|
||||
double prod = 10,
|
||||
double conso = 10,
|
||||
double imp = 0,
|
||||
double exp = 0,
|
||||
DateTime? from,
|
||||
}) =>
|
||||
DailyBalance(
|
||||
from: from ?? DateTime(2026, 8, 27),
|
||||
to: DateTime(2026, 8, 27, 21, 45),
|
||||
sampleCount: 88,
|
||||
productionKwh: prod,
|
||||
consumptionKwh: conso,
|
||||
importKwh: imp,
|
||||
exportKwh: exp,
|
||||
);
|
||||
|
||||
test('sans production, le taux d\'autoconsommation est NUL, pas 0 %', () {
|
||||
// À 3 h du matin il n'y a rien à autoconsommer. « 0 % » se lirait comme un défaut
|
||||
// de l'installation ; l'absence se lit comme ce qu'elle est.
|
||||
expect(b(prod: 0).selfConsumptionRate, isNull);
|
||||
expect(b(prod: 0).autonomyRate, isNotNull);
|
||||
});
|
||||
|
||||
test('sans consommation, le taux d\'autonomie est NUL', () {
|
||||
expect(b(conso: 0).autonomyRate, isNull);
|
||||
});
|
||||
|
||||
test('une identité fausse se voit — compteur cassé ou remis à zéro', () {
|
||||
// 10 produits + 0 soutiré ne peut pas donner 10 consommés + 5 injectés.
|
||||
expect(b(prod: 10, conso: 10, imp: 0, exp: 5).identityHolds, isFalse);
|
||||
});
|
||||
|
||||
test('la tolérance absorbe l\'arrondi sans absorber une erreur', () {
|
||||
// 1 % de la production, plancher 0,1 kWh : les compteurs ne s'incrémentent pas au
|
||||
// même instant, mais un écart réel ne doit pas passer.
|
||||
expect(b(prod: 100, conso: 100, imp: 0, exp: 0.5).identityHolds, isTrue);
|
||||
expect(b(prod: 100, conso: 100, imp: 0, exp: 5).identityHolds, isFalse);
|
||||
});
|
||||
|
||||
test('une fenêtre qui commence tard est signalée comme partielle', () {
|
||||
expect(b(from: DateTime(2026, 8, 27, 0, 10)).startsLate, isFalse,
|
||||
reason: 'dix minutes, c\'est le pas d\'échantillonnage, pas un trou');
|
||||
expect(b(from: DateTime(2026, 8, 27, 9, 30)).startsLate, isTrue,
|
||||
reason: 'la box a démarré à 9 h 30 — le bilan ne couvre pas la journée');
|
||||
});
|
||||
});
|
||||
}
|
||||
45
test/fixtures/powerbalance_jour_hems75.json
vendored
Normal file
45
test/fixtures/powerbalance_jour_hems75.json
vendored
Normal file
@ -0,0 +1,45 @@
|
||||
{
|
||||
"_source": "Energy.GetPowerBalanceLogs sur 192.168.1.75, SampleRate15Mins, minuit local → 21:45, 2026-08-27",
|
||||
"_note": "Trois échantillons sur 88. Les compteurs total* sont en kWh — vérifié par recoupement : l'intégration trapézoïdale des puissances (W) sur la fenêtre rend 77 697 Wh quand la différence de totalProduction rend 78,310. L'identité production + soutiré == consommation + injecté tient EXACTEMENT sur ce relevé : 112,244 des deux côtés.",
|
||||
"_deltas_attendus_kwh": {
|
||||
"production": 78.31,
|
||||
"consumption": 74.435,
|
||||
"return": 37.809,
|
||||
"acquisition": 33.934
|
||||
},
|
||||
"powerBalanceLogEntries": [
|
||||
{
|
||||
"acquisition": 2366.78679094468,
|
||||
"consumption": 3507.135007297016,
|
||||
"production": -1140.3482163523356,
|
||||
"storage": 0,
|
||||
"timestamp": 1787781600,
|
||||
"totalAcquisition": 62.16060776066769,
|
||||
"totalConsumption": 6331.145563125843,
|
||||
"totalProduction": 62032.09747619834,
|
||||
"totalReturn": 55763.112520833165
|
||||
},
|
||||
{
|
||||
"acquisition": -2213.146134015749,
|
||||
"consumption": 3486.542535677007,
|
||||
"production": -5699.688669692754,
|
||||
"storage": 0,
|
||||
"timestamp": 1787821200,
|
||||
"totalAcquisition": 79.17562729191732,
|
||||
"totalConsumption": 6368.860421631056,
|
||||
"totalProduction": 62072.11789818015,
|
||||
"totalReturn": 55782.43310384101
|
||||
},
|
||||
{
|
||||
"acquisition": -1554.8017688394166,
|
||||
"consumption": 3357.519044852532,
|
||||
"production": -4912.320813691948,
|
||||
"storage": 0,
|
||||
"timestamp": 1787859900,
|
||||
"totalAcquisition": 96.09468393254281,
|
||||
"totalConsumption": 6405.580274597465,
|
||||
"totalProduction": 62110.40698356833,
|
||||
"totalReturn": 55800.92139290341
|
||||
}
|
||||
]
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user