/// **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; import 'box_instant.dart'; 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, }); /// Voir `box_instant.dart` : un instant publié par la box se rend chez le lecteur, et /// c'est la même fonction pour l'epoch et pour l'ISO. static DateTime? _epoch(Object? v) => v is num ? instantDeLaBox(v) : 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); } /// **Les deux horloges divergent-elles ?** — sans base de données de fuseaux. /// /// [periodStartedAt] est **minuit de l'installation**, publié en epoch. Rendu dans le /// fuseau de l'appareil, il tombe donc sur 00:00 **si et seulement si** les deux partagent /// le même décalage. Sur `.75` en `Europe/London` vu d'un téléphone français, il tombait /// sur 01:00 ; les deux étant passés en `Europe/Paris`, il tombe sur 00:00. /// /// C'est exact pour l'instant présent, y compris les jours de changement d'heure, et ça /// n'exige **rien d'autre que ce que la box publie** — le nom IANA ne donnerait pas de /// décalage, et une base embarquée pour le recalculer serait le mauvais geste. /// /// `false` quand la date manque : on ne conclut pas d'une absence. /// /// ## ⚠️ Ne PAS réutiliser tel quel pour une plage tarifaire /// /// Ce test porte sur le **décalage**, pas sur le nom — et c'est juste ici : une échéance /// qualifie une **horloge**, donc deux fuseaux au même décalage donnent la même heure et /// il n'y a rien à signaler. `Europe/Berlin` et `Europe/Paris` ne divergeront jamais, été /// comme hiver : afficher un avertissement parce que les *noms* diffèrent alors que les /// *heures* coïncident serait exactement le bruit que cette condition existe pour éviter. /// /// Une **plage tarifaire** pose l'autre question. Elle est définie par le fournisseur, donc /// elle suit **son** fuseau et non celui de la box — et « même décalage, autre autorité » /// reste une divergence réelle : le jour où l'un des deux change de règle d'heure d'été, la /// plage bouge sans que la box ait bougé. Le lot tarifaire aura donc besoin d'une /// comparaison de **provenance**, pas de décalage, et d'un fuseau de référence publié avec /// la configuration tarifaire — jamais codé en dur côté app. bool get fuseauDiffereDeLAppareil { final d = periodStartedAt?.toLocal(); if (d == null) return false; return d.hour != 0 || d.minute != 0; } /// 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; }