etm-powersync-app/lib/models/consumer_series.dart
Patrick Schurig 9c19791e9a feat(énergie): les deux graphes — flux corrigé, et l'empilement par consommateur
Maquette validée par Patrick (couleurs comprises). Deux graphes, et trois
défauts d'affichage trouvés en chemin — dont deux à l'écran seulement.

GRAPHE 1 — les trois « on ne voit pas » avaient trois causes.

  production : publiée NÉGATIVE (−103 W). La magnitude est prise AU RENDU, pas
  au transport : le champ reste brut (§7.1), et `.abs()` est licite sur une
  production, dont le signe ne porte aucune information — contrairement au
  réseau, où le signe dit le sens (d'où gridImportW/gridExportW là-bas).

  autoconsommation : `production − export` clampé à 0, donc TOUJOURS zéro avec
  une production négative. Même racine, corrigée par la même magnitude.

  SOC : correctement absent — aucune batterie sur l'installation. Ce qui
  manquait, c'était de le DIRE ; c'est maintenant une note sous le graphe.

  ⚠️ Et un deuxième empêchement, vu seulement à l'écran : le signe réparé, la
  production restait invisible SOUS l'autoconsommation. Tant que rien n'est
  exporté les deux sont égales au pixel près, et la verte était tracée par
  dessus. L'autoconso passe en pointillé — des tirets sur la bande ambre disent
  « tout est consommé sur place », leur décrochage dit l'export. Deux courbes
  qui coïncident doivent pouvoir se VOIR coïncider.

  ⚠️ Ce correctif en créait un autre : le SOC était aussi vert pointillé. Sur
  une installation AVEC batterie les deux auraient été indiscernables — collision
  qu'aucun banc sans batterie ne pouvait montrer. Le SOC passe en indigo.

GRAPHE 2 — qui consomme, empilé.

  `Energy.GetThingPowerLogs`, et la liste des appareils vient de GetLoadConfig :
  chaque charge déclarée porte son `meterThingId`, son label et son domaine —
  vérifié renseigné sur les quatre charges de .75. Rien n'est deviné d'un nom
  d'appareil.

  Un appareil demandé sans réponse sort dans `sansHistorique` et est NOMMÉ sous
  le graphe, jamais tracé à zéro : la V2C ne déclare pas `smartmeter`, donc elle
  n'a pas de passé — ce qui n'est pas « elle n'a rien tiré ».

  Le RESTE du foyer est licite ici, contrairement au Sankey qui l'avait fait
  interdire : chaque part est un compteur dédié et le total est le compteur du
  foyer, donc la soustraction ne porte que du mesuré. Et un reste NÉGATIF n'est
  pas raboté — c'est la preuve qu'un sous-compteur n'est pas dans le périmètre
  du total, et l'écraser effacerait la seule chose qui le dise.

  🔴 Le signe des sous-compteurs n'est PAS vérifié : les trois lisent 0 W à tous
  les pas, donc aucun échantillon non nul n'existe. Le compteur PV publie en
  négatif — rien n'autorise à supposer. La valeur est transportée brute et
  `signeIndetermine` le dit à l'écran.

Couleurs par consommateur ajoutées au thème (pac ambre, ecs sarcelle, ev
indigo) : distinctes des couleurs de FLUX, qui disent d'où vient l'énergie, là
où celles-ci disent qui la prend.

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

98 lines
4.1 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 et la puissance publiée à cet instant.
class ConsumerPoint {
final DateTime t;
final double powerW;
const ConsumerPoint(this.t, this.powerW);
}
/// 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);
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];
}
}