diff --git a/lib/models/daily_balance.dart b/lib/models/daily_balance.dart index e5604e9..c8d1c56 100644 --- a/lib/models/daily_balance.dart +++ b/lib/models/daily_balance.dart @@ -39,6 +39,13 @@ class DailyBalance { final DateTime from; final DateTime to; + /// **Le début de la période demandé** — la frontière de journée de l'installation + /// (`periodStartedAt`), ou minuit local en repli sur une box antérieure à `+etm56`. + /// + /// Distinct de [from], qui est le premier échantillon **réellement** trouvé. C'est leur + /// écart, et lui seul, qui dit qu'un bilan est partiel. + final DateTime periodStart; + /// Nombre d'échantillons dans la fenêtre. Deux au minimum : une différence a besoin de /// deux points. final int sampleCount; @@ -55,6 +62,7 @@ class DailyBalance { const DailyBalance({ required this.from, required this.to, + required this.periodStart, required this.sampleCount, required this.productionKwh, required this.consumptionKwh, @@ -104,8 +112,10 @@ class DailyBalance { /// 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); - } + /// ⚠️ **Se compare à [periodStart], jamais à minuit du téléphone.** La journée de la box + /// commence à SA minuit : sur `.75` en `Europe/London` vue d'un appareil français, c'est + /// 01:00 local. Comparer à minuit local ferait crier « partiel » **tous les jours**, sur un + /// bilan parfaitement complet — un avertissement permanent cesse d'être lu. + bool get startsLate => + from.difference(periodStart) > const Duration(minutes: 20); } diff --git a/lib/models/energy_ratios_snapshot.dart b/lib/models/energy_ratios_snapshot.dart new file mode 100644 index 0000000..29d09ac --- /dev/null +++ b/lib/models/energy_ratios_snapshot.dart @@ -0,0 +1,89 @@ +/// **Les ratios publiés par la box, et depuis QUAND ils parlent.** +/// +/// ## Deux bornes, et c'est leur écart qui porte la phrase +/// +/// `NymeaEnergy.GetEnergyRatios` publie deux instants depuis `+etm56`, et ils ne disent pas +/// la même chose : +/// +/// - [periodStartedAt] — le **début de la journée de reporting**, à minuit de +/// l'installation. C'est la frontière que l'app cherchait : elle ne se dérive pas d'un nom +/// de fuseau IANA, et elle reste juste les jours à 23 ou 25 heures ; +/// - [measuredSince] — depuis quand la **baseline** des ratios tient. Elle se repose à chaque +/// redémarrage de `nymead` et à chaque recul de compteur, donc **en plein jour**. +/// +/// Relevé sur `.75` le 2026-09-05 : `periodStartedAt` à 00:00 Londres (01:00 Paris), +/// `measuredSince` à 07:04 Londres — **sept heures d'écart**. Un ratio étiqueté +/// « aujourd'hui » aurait donc parlé d'une fenêtre commencée à 08:04, et rien ne l'aurait +/// signalé. +/// +/// C'est la même paire, pour la même raison, que `progress.measuredSince` / `targetWh` : +/// une borne de période et une borne de mesure ne sont pas la même chose, et quand elles +/// coïncident — le cas normal — il n'y a rien à dire de plus. +/// +/// ## Ce que ces ratios NE sont pas +/// +/// Ce ne sont **pas** des ratios journaliers. Ce sont des ratios *depuis la baseline*. Les +/// intituler « du jour » serait faux, et les afficher sans dire depuis quand est exactement +/// le silence que [measuredSince] existe pour fermer. Le journalier se calcule ailleurs, par +/// différence de compteurs sur la fenêtre [periodStartedAt] → maintenant (`DailyBalance`). +library; + +class EnergyRatiosSnapshot { + /// Part de la consommation couverte sans le réseau, en %. `null` = non publié. + final double? autonomyRate; + + /// Part de la production consommée sur place, en %. `null` = non publié. + final double? selfConsumptionRate; + + /// Début de la journée de reporting — **minuit de l'installation**, publié et non dérivé. + final DateTime? periodStartedAt; + + /// Depuis quand la baseline tient. Voir la note de tête. + final DateTime? measuredSince; + + /// Fuseau de l'installation (`Europe/London` sur le banc). Le **nom**, pas un décalage : + /// c'est la box qui datera, l'app se contente de le dire. + final String? timeZone; + + const EnergyRatiosSnapshot({ + this.autonomyRate, + this.selfConsumptionRate, + this.periodStartedAt, + this.measuredSince, + this.timeZone, + }); + + static DateTime? _epoch(Object? v) => v is num + ? DateTime.fromMillisecondsSinceEpoch(v.toInt() * 1000, isUtc: true) + .toLocal() + : null; + + factory EnergyRatiosSnapshot.fromJson(Map m) => + EnergyRatiosSnapshot( + autonomyRate: (m['autonomyRate'] as num?)?.toDouble(), + selfConsumptionRate: (m['selfConsumptionRate'] as num?)?.toDouble(), + periodStartedAt: _epoch(m['periodStartedAt']), + measuredSince: _epoch(m['measuredSince']), + timeZone: (m['timeZone'] is String && (m['timeZone'] as String).isNotEmpty) + ? m['timeZone'] as String + : null, + ); + + /// **La baseline a été reposée APRÈS le début de la journée** — un redémarrage, un recul + /// de compteur. Les ratios ne parlent alors que depuis [measuredSince], et l'écran doit le + /// dire. + /// + /// Tolérance d'une minute : les deux instants viennent de la même box, mais rien ne garantit + /// qu'ils tombent sur la même seconde au premier cycle après minuit. + bool get baselineRepriseEnJournee { + final debut = periodStartedAt; + final depuis = measuredSince; + if (debut == null || depuis == null) return false; + return depuis.difference(debut) > const Duration(minutes: 1); + } + + /// Vrai quand la box publie de quoi dater la journée. Sans ça, l'app ne **peut pas** + /// calculer la bonne fenêtre — et elle le dit plutôt que de prendre minuit du téléphone + /// en silence. + bool get datePeriode => periodStartedAt != null; +} diff --git a/lib/services/nymea_service.dart b/lib/services/nymea_service.dart index e70830b..0bda747 100644 --- a/lib/services/nymea_service.dart +++ b/lib/services/nymea_service.dart @@ -7,6 +7,7 @@ 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_ratios_snapshot.dart'; import '../models/energy_data.dart'; // EnergyData, HistoryPoint, PowerBalanceEntry import '../models/ev_charger_live.dart'; import '../models/modbus_models.dart'; // ModbusRtuMaster, SerialPort @@ -949,6 +950,27 @@ class NymeaService extends ChangeNotifier { /// > Ce n'est pas un cas isolé : deux réponses de la même box peuvent employer deux /// > conventions sans qu'aucun signal ne le dise, et l'erreur est toujours **muette**. /// > Le motif et ses occurrences connues sont dans `docs/UI_data_contract.md` §9. + /// **Les ratios de la box, avec leurs deux bornes** — `NymeaEnergy.GetEnergyRatios`. + /// + /// Ce ne sont pas des ratios journaliers : ils partent de la **baseline**, reposée à chaque + /// redémarrage. C'est [EnergyRatiosSnapshot] qui porte la nuance ; ici on ne fait que lire. + /// + /// L'appel sert aussi — et surtout — à obtenir `periodStartedAt`, la **frontière de journée + /// de l'installation**. Elle ne se dérive pas : un nom de fuseau IANA ne donne pas de + /// décalage sans base de données, et une base de données ne donnerait pas les jours à 23 + /// ou 25 heures. + Future fetchEnergyRatios() async { + if (_isSimulation || !_connected) return null; + try { + final r = await _sendRequest('NymeaEnergy.GetEnergyRatios', {}); + final p = r['params']; + if (p is! Map) return null; + return EnergyRatiosSnapshot.fromJson(Map.from(p)); + } catch (_) { + return null; + } + } + Future> fetchPowerBalanceLogs({ required DateTime from, required DateTime to, @@ -1034,7 +1056,15 @@ class NymeaService extends ChangeNotifier { /// doit pas se déguiser en premier. Future fetchDailyBalance({DateTime? jour}) async { final maintenant = jour ?? DateTime.now(); - final minuit = + // La frontière vient de la BOX (`periodStartedAt`, `+etm56`) : c'est sa journée de + // reporting, pas celle du téléphone. Sur `.75` en `Europe/London` vue d'un appareil + // français, l'écart est d'une heure — et entre minuit et 1 h, l'ancien calcul comptait + // une journée que la box comptait encore dans la précédente. + // + // Repli sur minuit local si la box est antérieure : la divergence redevient possible, + // mais un bilan vaut mieux qu'un écran vide, et elle est bornée au fuseau. + final debutPublie = (await fetchEnergyRatios())?.periodStartedAt; + final minuit = debutPublie ?? 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 @@ -1075,6 +1105,7 @@ class NymeaService extends ChangeNotifier { final bilan = DailyBalance( from: a.timestamp, to: b.timestamp, + periodStart: minuit, sampleCount: tries.length, productionKwh: delta(b.totalProductionKwh, a.totalProductionKwh), consumptionKwh: delta(b.totalConsumptionKwh, a.totalConsumptionKwh), diff --git a/test/daily_balance_test.dart b/test/daily_balance_test.dart index dca30ed..7dfb452 100644 --- a/test/daily_balance_test.dart +++ b/test/daily_balance_test.dart @@ -35,6 +35,8 @@ DailyBalance _depuisFixture() { return DailyBalance( from: DateTime.fromMillisecondsSinceEpoch((a['timestamp'] as int) * 1000), to: DateTime.fromMillisecondsSinceEpoch((b['timestamp'] as int) * 1000), + periodStart: + DateTime.fromMillisecondsSinceEpoch((a['timestamp'] as int) * 1000), sampleCount: e.length, productionKwh: d('totalProduction'), consumptionKwh: d('totalConsumption'), @@ -90,10 +92,14 @@ void main() { double imp = 0, double exp = 0, DateTime? from, + DateTime? periodStart, }) => DailyBalance( from: from ?? DateTime(2026, 8, 27), to: DateTime(2026, 8, 27, 21, 45), + // La frontière de la BOX, pas minuit du téléphone : sur `.75` en `Europe/London` + // vue d'un appareil français, la journée commence à 01:00 local. + periodStart: periodStart ?? DateTime(2026, 8, 27, 1), sampleCount: 88, productionKwh: prod, consumptionKwh: conso, @@ -125,10 +131,21 @@ void main() { }); test('une fenêtre qui commence tard est signalée comme partielle', () { - expect(b(from: DateTime(2026, 8, 27, 0, 10)).startsLate, isFalse, + // Le retard se mesure contre le début de période PUBLIÉ (01:00 ici), jamais contre + // minuit du téléphone. + expect(b(from: DateTime(2026, 8, 27, 1, 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'); }); + + test('la journée de la BOX ne se lit pas « partielle » — le piège du fuseau', () { + // Sur `.75` (Europe/London) vue d'un téléphone français, la journée commence à 01:00 + // local. Comparer à minuit du téléphone ferait crier « partiel » TOUS LES JOURS sur + // un bilan complet — et un avertissement permanent cesse d'être lu. + final complet = b( + periodStart: DateTime(2026, 8, 27, 1), from: DateTime(2026, 8, 27, 1)); + expect(complet.startsLate, isFalse); + }); }); } diff --git a/test/energy_ratios_snapshot_test.dart b/test/energy_ratios_snapshot_test.dart new file mode 100644 index 0000000..0278b49 --- /dev/null +++ b/test/energy_ratios_snapshot_test.dart @@ -0,0 +1,65 @@ +/// **Les deux bornes des ratios de la box** — et la phrase que leur écart autorise. +/// +/// Relevé sur `.75` le 2026-09-05 en `+etm56` : `periodStartedAt` à 00:00 Londres +/// (01:00 Paris), `measuredSince` à 07:04 Londres. **Sept heures d'écart.** Un ratio +/// étiqueté « aujourd'hui » aurait donc parlé d'une fenêtre commencée à 08:04 sans que rien +/// ne le signale — c'est ce silence que la paire ferme. +library; + +import 'package:etm_powersync_app/models/energy_ratios_snapshot.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// La charge utile réelle, telle que la sonde l'a lue. +Map _releve({int? measuredSince}) => { + 'autonomyRate': 41.0, + 'selfConsumptionRate': 63.0, + 'periodStartedAt': 1788562800, + 'measuredSince': measuredSince ?? 1788588269, + 'timeZone': 'Europe/London', + }; + +void main() { + group('les deux instants se lisent, et leur écart parle', () { + test('la frontière de journée est PUBLIÉE, pas dérivée', () { + final r = EnergyRatiosSnapshot.fromJson(_releve()); + // 1788562800 = 2026-09-04T23:00:00Z = minuit à Londres. L'app ne recalcule rien : + // un nom de fuseau IANA ne donne pas de décalage, et une base de fuseaux ne donnerait + // pas les jours à 23 ou 25 heures. + expect(r.periodStartedAt!.toUtc(), + DateTime.utc(2026, 9, 4, 23, 0, 0)); + expect(r.timeZone, 'Europe/London'); + expect(r.datePeriode, isTrue); + }); + + test('baseline reprise en journée : l\'écran doit le dire', () { + expect(EnergyRatiosSnapshot.fromJson(_releve()).baselineRepriseEnJournee, + isTrue, + reason: 'sept heures après le début de journée — un redémarrage de nymead'); + }); + + test('les deux coïncident : rien à dire de plus', () { + // Le cas normal. Une phrase supplémentaire y serait du bruit. + final r = EnergyRatiosSnapshot.fromJson(_releve(measuredSince: 1788562800)); + expect(r.baselineRepriseEnJournee, isFalse); + }); + + test('une minute d\'écart n\'est pas une reprise', () { + // Les deux instants viennent de la même box, mais rien ne garantit qu'ils tombent + // sur la même seconde au premier cycle après minuit. + final r = EnergyRatiosSnapshot.fromJson(_releve(measuredSince: 1788562840)); + expect(r.baselineRepriseEnJournee, isFalse); + }); + }); + + group('box antérieure : on ne conclut pas d\'une absence', () { + test('sans les champs, aucune date et aucune alerte', () { + const vide = {'autonomyRate': 41.0}; + final r = EnergyRatiosSnapshot.fromJson(vide); + expect(r.periodStartedAt, isNull); + expect(r.datePeriode, isFalse, + reason: 'le bilan retombera sur minuit local, et c\'est écrit'); + expect(r.baselineRepriseEnJournee, isFalse, + reason: 'ne pas savoir n\'est pas « la baseline a bougé »'); + }); + }); +}