etm-powersync-app/lib/models/consumer_series.dart
Patrick Schurig f1547f674a fix(énergie): grouper par période CIVILE, soutiré en bleu, second graphe d'énergie
TROIS DEMANDES DE PATRICK.

1. Le soutiré passe du rouge au BLEU. Avec l'orange partagé sur les deux barres,
   le rouge rendait l'image trop chaude — et le bleu est déjà la couleur de la
   maison partout ailleurs. Il ne reste d'orange que le bloc partagé, celui qui
   doit se voir.

2. Second graphe en vue barres : « Où est passée la consommation » — une barre
   empilée par période, une couleur par charge déclarée, plus le reste du foyer
   qui ferme la barre à la consommation totale.

   L'énergie vient du compteur cumulé de chaque appareil (`totalConsumption`),
   jamais d'une intégration de la puissance — intégrer supposerait la puissance
   constante entre deux échantillons. Vérifié à la sonde avant d'écrire : ECS
   80,8 kWh et PAC 43,1 kWh sur la fenêtre, avec des deltas journaliers réels.

   Une charge dont le compteur n'a pas bougé donne 0 kWh, qui est une MESURE.
   Une charge dont le cumul manque à une borne n'a PAS de part. Les deux se
   ressemblent à l'écran et ne disent pas la même chose. Et un compteur qui
   recule est une remise à zéro, pas une consommation négative : la part est
   refusée plutôt que rendue absurde.

🔴 3. LE « 6/9 » EN DOUBLE N'ÉTAIT PAS UN DÉFAUT DE L'APP. C'est la trace de
   notre propre bascule de fuseau du 2026-09-06 : la frontière de journée de la
   box a glissé d'une heure, et l'historique porte deux échantillons pour le
   06/09 — 00:00 et 01:00. Le graphe affichait fidèlement une « journée » d'une
   heure à côté de journées entières.

   La couture est PERMANENTE : elle est dans les données passées, pas dans une
   requête. Tout écran qui compare des périodes doit grouper par période civile,
   jamais par indices consécutifs. Aucune énergie n'est perdue — la tranche
   d'une heure est absorbée dans sa journée.

Et une étiquette d'axe illisible : fl_chart en pose une à `min` en plus de
celles de l'intervalle, et les deux se superposaient — « 20:4500 ». Deux heures
imprimées l'une sur l'autre ne se lisent ni l'une ni l'autre.

358 tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GKM8FDNHZWogAsjjfB8pSw
2026-09-09 08:52:54 +02:00

126 lines
5.2 KiB
Dart

/// **Qui consomme, dans le temps** — `Energy.GetThingPowerLogs`, une série par appareil.
///
/// ## Ce que cet objet porte en plus des courbes
///
/// La liste de ce qu'on a **demandé sans l'obtenir**. Six Things sondés sur `.75` le
/// 2026-09-08, cinq répondent : la V2C Trydan est absente parce que sa classe ne déclare
/// pas l'interface `smartmeter`. Une réponse partielle sans la liste des manquants se lit
/// comme une réponse complète, et l'écran tracerait alors quatre courbes en laissant croire
/// qu'il n'y en avait que quatre à tracer.
///
/// **Une série absente ne se dessine pas à zéro.** Une ligne plate affirme « cette charge
/// n'a rien tiré » ; l'absence dit « on ne sait pas ». La borne V2C tire peut-être 4 kW en
/// ce moment — sa puissance instantanée est lisible — elle n'a simplement pas de passé.
///
/// ## Le signe des sous-compteurs n'est PAS vérifié
///
/// 🔴 Sur le banc, `PAC-Meter`, `ECS-Meter` et la borne simulée publient **0 W à tous les
/// pas**, toute la journée. Aucun échantillon non nul n'existe, donc **rien ne dit si un
/// consommateur publie sa puissance en positif ou en négatif** — le compteur PV, lui, la
/// publie en négatif, ce qui interdit de supposer.
///
/// La valeur est donc transportée **brute**, et [ConsumerHistory.signeIndetermine] dit
/// quand la question reste ouverte. Poser un `.abs()` ici trancherait par défaut, et un
/// `.abs()` qui devine juste par hasard est indiscernable d'un `.abs()` qui devine faux.
library;
/// Un point d'une série : un instant, la puissance publiée, et le compteur cumulé.
class ConsumerPoint {
final DateTime t;
final double powerW;
/// `totalConsumption` de l'appareil, en kWh cumulés. `null` = non publié.
///
/// C'est LUI qui donne l'énergie d'une période, par différence entre deux points — jamais
/// une intégration de [powerW], qui supposerait que la puissance est restée constante
/// entre deux échantillons.
final double? cumulKwh;
const ConsumerPoint(this.t, this.powerW, {this.cumulKwh});
}
/// La série d'un appareil, avec de quoi le nommer et le colorer.
class ConsumerSeries {
final String thingId;
final String label;
/// `ecs` · `heating` · `ev`, ou `null` si la charge n'a pas de domaine déclaré.
/// `null` n'appelle pas de couleur par défaut : voir `EtmTokens.domaineOf`.
final String? domaine;
final List<ConsumerPoint> points;
const ConsumerSeries({
required this.thingId,
required this.label,
required this.domaine,
required this.points,
});
/// Vrai si la série ne contient que des zéros — **une mesure, pas un trou**.
///
/// Un compteur dédié qui lit 0 W dit « rien ne chauffe ». C'est exactement ce qu'il faut
/// tracer : une ligne au ras de l'axe, pas une absence.
bool get toutAZero => points.isNotEmpty && points.every((p) => p.powerW == 0);
/// L'énergie consommée entre deux instants, ou `null` si l'un des deux cumuls manque.
///
/// `null` n'est pas zéro : une période dont le compteur n'a pas été publié n'a pas de
/// barre, elle n'a pas une barre vide.
double? energieEntre(DateTime a, DateTime b) {
double? cumul(DateTime t) {
for (final p in points) {
if (p.t == t) return p.cumulKwh;
}
return null;
}
final x = cumul(a), y = cumul(b);
if (x == null || y == null) return null;
// Un compteur ne recule pas : un delta négatif est une remise à zéro, pas une
// consommation négative. On refuse plutôt que de rendre un nombre absurde.
final d = y - x;
return d >= 0 ? d : null;
}
double get maxW =>
points.isEmpty ? 0 : points.map((p) => p.powerW.abs()).reduce((a, b) => a > b ? a : b);
}
/// Le résultat complet d'une interrogation par appareil.
class ConsumerHistory {
/// Les séries obtenues, dans l'ordre où elles ont été demandées.
final List<ConsumerSeries> series;
/// **Les appareils demandés dont la box n'a rien renvoyé**, par leur nom d'affichage.
///
/// Ce n'est pas une erreur : un appareil sans interface de comptage n'a pas
/// d'historique, et c'est un fait sur l'appareil, pas sur la requête.
final List<String> sansHistorique;
const ConsumerHistory({required this.series, required this.sansHistorique});
static const ConsumerHistory vide =
ConsumerHistory(series: [], sansHistorique: []);
bool get estVide => series.isEmpty;
/// 🔴 **Vrai tant qu'aucun échantillon non nul n'a été vu.** Voir la note de tête : sans
/// une seule valeur non nulle, la convention de signe des sous-compteurs reste inconnue,
/// et l'écran ne doit pas présenter l'empilement comme une décomposition établie.
bool get signeIndetermine => series.every((s) => s.maxW == 0);
/// Les instants communs à toutes les séries — l'axe des abscisses de l'empilement.
///
/// Pris sur la série la plus longue : les compteurs n'échantillonnent pas tous au même
/// instant, et interpoler inventerait des points.
List<DateTime> get instants {
if (series.isEmpty) return const [];
var plusLongue = series.first;
for (final s in series) {
if (s.points.length > plusLongue.points.length) plusLongue = s;
}
return [for (final p in plusLongue.points) p.t];
}
}