etm-powersync-app/lib/models/daily_balance.dart
Patrick Schurig bcf5b87e43 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
2026-09-05 09:20:31 +02:00

122 lines
5.7 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/// **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;
/// **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;
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.periodStart,
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é.
/// ⚠️ **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);
}