/// **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 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 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 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 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]; } }