feat(bilan): la journée vient de la BOX — periodStartedAt, et non minuit du téléphone
Sondé avant de câbler (`+etm56`) : `periodStartedAt` = 1788562800 = 00:00 Londres = 01:00 Paris, `measuredSince` = 07:04 Londres, `timeZone` = Europe/London. Sept heures d'écart entre les deux bornes. - `EnergyRatiosSnapshot` — les deux instants et le fuseau. `baselineRepriseEnJournee` dit quand les ratios de la box ne parlent que depuis un redémarrage : c'est le silence que `measuredSince` existe pour fermer, et quand les deux coïncident — le cas normal — il n'y a rien à dire de plus ; - `fetchEnergyRatios()`, et `fetchDailyBalance()` qui prend désormais la frontière publiée. Repli sur minuit local pour une box antérieure, écrit comme tel. Et un effet de bord attrapé avant qu'il ne parte : `startsLate` comparait à minuit du TÉLÉPHONE. Avec la frontière de la box, il aurait crié « partiel » tous les jours sur un bilan complet — un avertissement permanent cesse d'être lu. Il se compare maintenant à `periodStart`, porté par le bilan, et un test fige le piège. Les taux journaliers de `DailyBalance` sont donc corrigés du même geste : ils se calculent déjà par différence de compteurs sur la fenêtre, c'était la fenêtre qui était fausse. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GKM8FDNHZWogAsjjfB8pSw
This commit is contained in:
parent
cdffb9d804
commit
bcf5b87e43
@ -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);
|
||||
}
|
||||
|
||||
89
lib/models/energy_ratios_snapshot.dart
Normal file
89
lib/models/energy_ratios_snapshot.dart
Normal file
@ -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<String, dynamic> 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;
|
||||
}
|
||||
@ -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<EnergyRatiosSnapshot?> 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<String, dynamic>.from(p));
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<PowerBalanceEntry>> fetchPowerBalanceLogs({
|
||||
required DateTime from,
|
||||
required DateTime to,
|
||||
@ -1034,7 +1056,15 @@ class NymeaService extends ChangeNotifier {
|
||||
/// doit pas se déguiser en premier.
|
||||
Future<DailyBalance?> 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),
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
65
test/energy_ratios_snapshot_test.dart
Normal file
65
test/energy_ratios_snapshot_test.dart
Normal file
@ -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<String, dynamic> _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 = <String, dynamic>{'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é »');
|
||||
});
|
||||
});
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user