feat(bilan): deux grandeurs, deux phrases — les ratios de la box à côté de la journée

Les tuiles disent la JOURNÉE, par différence de compteurs sur la fenêtre publiée.
La nouvelle ligne dit ce que la BOX publie, qui part de la baseline — et elle ne
s'affiche QUE lorsque cette origine diffère du début de journée.

Le cas est arrivé de lui-même : `nymead` redémarré à 09:23, sonde à 09:26, et la
box publiait 100 % d'autoconsommation. Vrai sur trois minutes ; absurde intitulé
« aujourd'hui ». La ligne le rend lisible — « Instantané depuis 09:23 » — au lieu
de le laisser passer pour la journée.

Vérifié avec le MODÈLE de l'app et non à l'œil sur le JSON : `ratios_check.dart`
importe `EnergyRatiosSnapshot` (idiome de `measurement_check.dart`) et rend
`baselineRepriseEnJournee: true`, ratios couvrant 3 minutes. Le banc est passé en
Europe/Paris entre-temps, et `periodStartedAt` suit sans que le code bouge —
c'est l'intérêt d'une frontière publiée.

Quand les deux coïncident, rien n'est ajouté : une phrase permanente cesse d'être
lue, comme un avertissement permanent.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GKM8FDNHZWogAsjjfB8pSw
This commit is contained in:
Patrick Schurig 2026-09-05 09:31:44 +02:00
parent bcf5b87e43
commit 763df68a2a
4 changed files with 205 additions and 4 deletions

View File

@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import '../../../models/daily_balance.dart';
import '../../../models/energy_ratios_snapshot.dart';
import '../../../models/energy_data.dart';
import '../../../theme/etm_tokens.dart';
import '../painters/ring_painter.dart';
@ -24,13 +25,42 @@ import '../painters/ring_painter.dart';
/// 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.
/// La phrase des ratios de la box — **avec leur origine, puisqu'elle diffère**.
///
/// Appelée seulement quand la baseline a été reposée après le début de journée : sans ça,
/// il n'y a rien à dire de plus que ce que les tuiles disent déjà.
String _instantane(EnergyRatiosSnapshot r) {
String pc(double? v) => v == null ? '—' : '${v.round()} %';
final d = r.measuredSince!;
final h = '${d.hour.toString().padLeft(2, '0')}:'
'${d.minute.toString().padLeft(2, '0')}';
return 'Instantané depuis $h — autoconsommation ${pc(r.selfConsumptionRate)}, '
'autonomie ${pc(r.autonomyRate)}';
}
class KpiRow extends StatelessWidget {
final EnergyData 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});
/// **Les ratios de la BOX, qui ne sont pas ceux des tuiles.**
///
/// Deux grandeurs voisines et deux phrases différentes, comme `purchasedWh` et
/// `deliveredWh` : les tuiles disent la **journée**, calculée par différence de compteurs
/// sur la fenêtre `periodStartedAt → maintenant` ; ceux-ci partent de la **baseline**, qui
/// se repose à chaque redémarrage de `nymead`.
///
/// Mesuré sur `.75` le 2026-09-05 à 09:26, après un redémarrage à 09:23 : la box publiait
/// **100 % d'autoconsommation** — vrai sur trois minutes, absurde intitulé « aujourd'hui ».
/// D'où la règle : on les garde comme instantanés, et on **dit leur origine** quand elle
/// diffère du début de journée. Quand les deux coïncident — le cas normal — on n'ajoute
/// rien : une phrase permanente cesse d'être lue, exactement comme un avertissement
/// permanent.
final EnergyRatiosSnapshot? ratios;
const KpiRow(
{super.key, required this.data, this.balance, this.ratios});
@override
Widget build(BuildContext context) {
@ -86,6 +116,24 @@ class KpiRow extends StatelessWidget {
),
],
),
if (ratios?.baselineRepriseEnJournee ?? false) ...[
const SizedBox(height: 8),
Padding(
padding: const EdgeInsets.only(left: 2),
child: Row(children: [
Icon(Icons.bolt_rounded,
size: 13, color: EtmTokens.mutedOf(context)),
const SizedBox(width: 5),
Expanded(
child: Text(
_instantane(ratios!),
style: EtmTokens.sans(
size: 11, color: EtmTokens.mutedOf(context)),
),
),
]),
),
],
const SizedBox(height: 10),
Row(
children: [

View File

@ -3,6 +3,7 @@ import 'dart:async';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../models/daily_balance.dart';
import '../models/energy_ratios_snapshot.dart';
import '../main.dart' show DrawerMenuButton;
import '../providers/app_settings_provider.dart';
import '../services/nymea_service.dart';
@ -33,6 +34,9 @@ class _DashboardScreenState extends State<DashboardScreen> {
/// puissance.
DailyBalance? _bilan;
/// Les ratios de la box — lus à côté du bilan, et affichés à côté de lui. Voir [KpiRow].
EnergyRatiosSnapshot? _ratios;
/// 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.
@ -54,8 +58,17 @@ class _DashboardScreenState extends State<DashboardScreen> {
Future<void> _lireBilan() async {
if (!mounted) return;
final b = await context.read<NymeaService>().fetchDailyBalance();
if (mounted) setState(() => _bilan = b);
final svc = context.read<NymeaService>();
// Les ratios de la box sont lus à côté du bilan, pas à sa place : ils ne couvrent pas
// la même fenêtre, et c'est justement ce que l'écran doit pouvoir dire.
final r = await svc.fetchEnergyRatios();
final b = await svc.fetchDailyBalance();
if (mounted) {
setState(() {
_bilan = b;
_ratios = r;
});
}
}
@override
@ -86,7 +99,7 @@ class _DashboardScreenState extends State<DashboardScreen> {
evChargerPowerW:
service.evCharger?.currentPowerW),
const SizedBox(height: 14),
KpiRow(data: data, balance: _bilan),
KpiRow(data: data, balance: _bilan, ratios: _ratios),
const SizedBox(height: 14),
const HeosDecisionCard(),
const SizedBox(height: 14),

91
test/kpi_ratios_test.dart Normal file
View File

@ -0,0 +1,91 @@
/// **Deux grandeurs, deux phrases, aucune ne ment.**
///
/// Les tuiles disent la JOURNÉE — différence de compteurs sur `periodStartedAt → maintenant`.
/// La ligne des ratios dit ce que la BOX publie, qui part de la **baseline** et non du début
/// de journée.
///
/// Le cas est arrivé de lui-même le 2026-09-05 : `nymead` redémarré à 09:23, sonde à 09:26,
/// et la box publiait **100 % d'autoconsommation**. Vrai sur trois minutes ; absurde intitulé
/// « aujourd'hui ». C'est ce que le drapeau rend lisible — et c'est pour ça que la ligne ne
/// s'affiche QUE lorsque l'origine diffère : quand les deux coïncident, une phrase de plus
/// serait du bruit, et un texte permanent cesse d'être lu.
library;
import 'package:etm_powersync_app/features/dashboard/widgets/kpi_row.dart';
import 'package:etm_powersync_app/models/daily_balance.dart';
import 'package:etm_powersync_app/models/energy_data.dart';
import 'package:etm_powersync_app/models/energy_ratios_snapshot.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
/// Relevé `.75` du 2026-09-05 09:26, après le redémarrage de 09:23.
EnergyRatiosSnapshot _releve({int measuredSince = 1788592995}) =>
EnergyRatiosSnapshot.fromJson({
'autonomyRate': 28.570199887530475,
'selfConsumptionRate': 100,
'periodStartedAt': 1788559200,
'measuredSince': measuredSince,
'timeZone': 'Europe/Paris',
});
DailyBalance _bilan() => DailyBalance(
from: DateTime(2026, 9, 5, 0, 15),
to: DateTime(2026, 9, 5, 9, 26),
periodStart: DateTime(2026, 9, 5),
sampleCount: 37,
productionKwh: 8.0,
consumptionKwh: 12.0,
importKwh: 5.0,
exportKwh: 1.0,
);
Widget _harness({EnergyRatiosSnapshot? ratios}) => MaterialApp(
home: Scaffold(
body: SingleChildScrollView(
child: KpiRow(
data: const EnergyData(), balance: _bilan(), ratios: ratios),
),
),
);
void main() {
testWidgets('baseline reposée en journée : l\'origine est DITE', (tester) async {
await tester.binding.setSurfaceSize(const Size(1000, 2000));
addTearDown(() => tester.binding.setSurfaceSize(null));
await tester.pumpWidget(_harness(ratios: _releve()));
expect(find.textContaining('Instantané depuis 09:23'), findsOneWidget);
// Le 100 % est affiché — il est vrai — mais jamais sous le mot « aujourd'hui ».
expect(find.textContaining('autoconsommation 100 %'), findsOneWidget);
});
testWidgets('les tuiles restent la JOURNÉE, calculée sur la fenêtre de la box',
(tester) async {
await tester.binding.setSurfaceSize(const Size(1000, 2000));
addTearDown(() => tester.binding.setSurfaceSize(null));
await tester.pumpWidget(_harness(ratios: _releve()));
// Les anneaux animent leur valeur : sans laisser l'animation finir, ils affichent 0 —
// une absence qui se lirait comme un succès si on cherchait justement une absence.
await tester.pumpAndSettle();
expect(find.text('Aujourd\'hui'), findsOneWidget);
// 8 kWh produits, 1 exporté -> 87,5 % ; et surtout PAS le 100 % de la box.
expect(find.textContaining('88'), findsOneWidget);
});
testWidgets('origine confondue avec le début de journée : rien de plus',
(tester) async {
await tester.binding.setSurfaceSize(const Size(1000, 2000));
addTearDown(() => tester.binding.setSurfaceSize(null));
// Le cas normal : la baseline date du début de journée.
await tester.pumpWidget(_harness(ratios: _releve(measuredSince: 1788559200)));
expect(find.textContaining('Instantané depuis'), findsNothing);
});
testWidgets('box antérieure : aucune ligne, aucune supposition', (tester) async {
await tester.binding.setSurfaceSize(const Size(1000, 2000));
addTearDown(() => tester.binding.setSurfaceSize(null));
await tester.pumpWidget(_harness());
expect(find.textContaining('Instantané'), findsNothing);
});
}

View File

@ -0,0 +1,49 @@
// Ce que l'APP conclut des deux bornes de `GetEnergyRatios` — pas ce que le JSON montre.
//
// ⚠️ `dart run` : cette sonde **importe le modèle de l'app** (`EnergyRatiosSnapshot`) plutôt
// que de relire la charge utile à sa façon. Une lecture parallèle ne prouverait rien sur ce
// que l'écran dira — c'est la leçon de `measurement_check.dart`.
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:etm_powersync_app/models/energy_ratios_snapshot.dart';
Future<void> main(List<String> args) async {
final host = args.isNotEmpty ? args[0] : '192.168.1.75';
final ws = await WebSocket.connect('ws://$host:4444')
.timeout(const Duration(seconds: 8));
var id = 0;
final pending = <int, Completer<Map<String, dynamic>>>{};
ws.listen((raw) {
final m = jsonDecode(raw as String) as Map<String, dynamic>;
final rid = m['id'];
if (rid is int && pending.containsKey(rid)) pending.remove(rid)!.complete(m);
});
Future<Map<String, dynamic>> call(String m) {
final myId = ++id;
final c = Completer<Map<String, dynamic>>();
pending[myId] = c;
ws.add(jsonEncode({'id': myId, 'method': m, 'params': const {}}));
return c.future.timeout(const Duration(seconds: 15));
}
await call('JSONRPC.Hello');
final r = await call('NymeaEnergy.GetEnergyRatios');
final p = Map<String, dynamic>.from(r['params'] as Map);
print('brut : ${jsonEncode(p)}');
final s = EnergyRatiosSnapshot.fromJson(p);
String q(DateTime? d) => d == null ? '—' : d.toLocal().toString();
print('périodeDébut : ${q(s.periodStartedAt)} (frontière de journée → BILAN)');
print('mesuréDepuis : ${q(s.measuredSince)} (baseline → RATIOS)');
print('fuseau : ${s.timeZone}');
print('datePeriode : ${s.datePeriode}');
print('baselineRepriseEnJournee : ${s.baselineRepriseEnJournee}');
if (s.baselineRepriseEnJournee) {
final couvert = DateTime.now().difference(s.measuredSince!);
print(' -> les ratios ne couvrent que ${couvert.inMinutes} min, '
'pas la journée. C\'est CE que le drapeau rend lisible.');
}
await ws.close();
}