etm-powersync-app/lib/models/energy_data.dart
Patrick Schurig a14adcfcc3 fix(bilan): un cumul ABSENT n'est pas un cumul nul
`double d(String k) => (m[k] as num?)?.toDouble() ?? 0.0` : un champ manquant
devenait 0,0 au décodage, deux bornes à zéro donnaient une différence à zéro, et
l'écran affichait « 0,0 kWh · Vers réseau » comme une mesure. C'est le zéro forgé
que nous traquons depuis une semaine, cette fois de NOTRE côté.

Le cas est réel et n'attend pas sa cause : un compteur racine parfaitement en
place, la puissance instantanée qui arrive, et les cumuls qui n'arrivent pas.

`PowerBalanceEntry.totalsPresent` porte la distinction, et `fetchDailyBalance`
refuse de présenter comme mesure un bilan bâti là-dessus — au même titre qu'un
compteur corrompu. « Rien échangé aujourd'hui » et « la box ne publie pas ses
cumuls » sont deux états, et le second n'a pas le droit de porter le premier.
Le repli à 0 reste, pour ne pas propager des nullables dans tout l'écran Énergie,
mais il est désormais ACCOMPAGNÉ du fait qu'il est un repli.

Et `abs()` sur la production devient un choix DOCUMENTÉ plutôt qu'un correctif
tacite : la production n'est pas signée, c'est le compteur qui l'est, donc la
valeur absolue masquerait un compteur câblé à l'envers dont le signe serait la
seule trace. Le correctif appartient à la source. On la garde en connaissance de
cause — un signe faux se voit tous les jours, un câblage inversé ne se voit
jamais — et on ne revient pas en arrière sans savoir ce que la box publiera.

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

212 lines
8.8 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.

class EnergyData {
final double pvPower; // Watts - production solaire
final double homePower; // Watts - consommation maison
final double batteryPower; // Watts - puissance batterie (+ charge, - décharge)
/// **Réseau, signé : `+` SOUTIRÉ, `−` INJECTÉ.**
///
/// ⚠️ Le commentaire précédent disait l'inverse — « + injection, − soutirage » — et la
/// carte de flux l'a cru : le 2026-09-06, une installation qui **exportait 2,5 kW**
/// affichait « consommation réseau », flèche à l'envers. Le sens de l'animation était
/// inversé, et rien dans les tests ne pouvait le voir.
///
/// La convention vient de la box et se vérifie à la sonde : `currentPowerAcquisition`
/// vaut **−2 511 W** pendant que la production (5,76 kW) dépasse la consommation
/// (3,25 kW) de 2,5 kW. Elle est transportée telle quelle (§7.1, signes bruts).
///
/// Ne pas relire ce signe à la main : passer par [gridImportW] / [gridExportW]. Le
/// dépôt portait DEUX conventions opposées dans deux widgets voisins, et celle qui était
/// à l'écran était la fausse.
final double gridPower;
final double batterySOC; // % - état de charge batterie
final double temperature; // °C
final String weatherIcon;
// Journée
final double dayProductionWh;
final double daySelfConsumptionWh;
final double dayGridInjectionWh;
/// Compteurs CUMULÉS de la box (kWh), tels qu'elle les publie — pas des totaux du jour.
///
/// `Energy.GetPowerBalance` rend `totalReturn` (exporté) et `totalAcquisition`
/// (importé). Ils sont **cumulés depuis toujours**, pas remis à zéro chaque nuit : les
/// afficher sous un libellé « aujourd'hui » serait faux.
///
/// `NaN` signifie **valeur non exploitable**, et c'est un état à part entière : le banc
/// `.75` publie des compteurs manifestement corrompus (7,5e+33 kWh importés) alors que
/// `totalProduction` reste sain. L'app ne les corrige pas — elle refuse de les
/// présenter comme une mesure (cf. [gridExportKwhOrNull]).
final double gridExportKwh;
final double gridImportKwh;
final double selfConsumptionRate; // %
final double autonomyRate; // %
final double dayGains; // €
// ── Borne de recharge : PLUS RIEN ICI ──────────────────────────────────────
//
// `chargingMode`, `chargingPower` (3,6 kW) et `solarSourcePercent` (82 %) ont vécu ici
// avec des valeurs par défaut que rien ne remplaçait : `_updateEnergyData` ne les
// touchait pas, et le mode n'avançait qu'en optimiste après une commande envoyée depuis
// l'app. Trois écrans les affichaient comme des mesures — la tuile des favoris, le
// Sankey du tableau de bord, et la carte véhicule (supprimée le 2026-08-28).
//
// Ce que la box publie vraiment sur une borne vit désormais dans `EvChargerLive`
// (`models/ev_charger_live.dart`), champ par champ, nullable : `null` veut dire « non
// publié », jamais zéro.
final bool gridLimitOk;
const EnergyData({
this.pvPower = 0,
this.homePower = 0,
this.batteryPower = 0,
this.gridPower = 0,
this.batterySOC = 0,
this.temperature = 0,
this.weatherIcon = 'cloud_snow',
this.dayProductionWh = 0,
this.daySelfConsumptionWh = 0,
this.dayGridInjectionWh = 0,
this.gridExportKwh = 0,
this.gridImportKwh = 0,
this.selfConsumptionRate = 0,
this.autonomyRate = 0,
this.dayGains = 0,
this.gridLimitOk = true,
});
/// Puissance **soutirée** au réseau, ou 0. Voir [gridPower] pour la convention.
double get gridImportW => gridPower > 0 ? gridPower : 0;
/// Puissance **injectée** vers le réseau, ou 0.
double get gridExportW => gridPower < 0 ? -gridPower : 0;
EnergyData copyWith({
double? pvPower,
double? homePower,
double? batteryPower,
double? gridPower,
double? batterySOC,
double? temperature,
String? weatherIcon,
double? dayProductionWh,
double? daySelfConsumptionWh,
double? dayGridInjectionWh,
double? gridExportKwh,
double? gridImportKwh,
double? selfConsumptionRate,
double? autonomyRate,
double? dayGains,
bool? gridLimitOk,
}) {
return EnergyData(
pvPower: pvPower ?? this.pvPower,
homePower: homePower ?? this.homePower,
batteryPower: batteryPower ?? this.batteryPower,
gridPower: gridPower ?? this.gridPower,
batterySOC: batterySOC ?? this.batterySOC,
temperature: temperature ?? this.temperature,
weatherIcon: weatherIcon ?? this.weatherIcon,
dayProductionWh: dayProductionWh ?? this.dayProductionWh,
daySelfConsumptionWh: daySelfConsumptionWh ?? this.daySelfConsumptionWh,
dayGridInjectionWh: dayGridInjectionWh ?? this.dayGridInjectionWh,
gridExportKwh: gridExportKwh ?? this.gridExportKwh,
gridImportKwh: gridImportKwh ?? this.gridImportKwh,
selfConsumptionRate: selfConsumptionRate ?? this.selfConsumptionRate,
autonomyRate: autonomyRate ?? this.autonomyRate,
dayGains: dayGains ?? this.dayGains,
gridLimitOk: gridLimitOk ?? this.gridLimitOk,
);
}
/// Plafond de plausibilité pour un compteur d'énergie domestique cumulé, en kWh.
///
/// 10 GWh, soit environ **trois mille ans** de consommation d'une maison. Aucune
/// installation résidentielle ne l'atteindra ; tout ce qui le dépasse est un compteur
/// cassé, pas un record. Le seuil est volontairement absurde-haut : il ne s'agit pas de
/// juger une valeur, seulement d'attraper les corrompues.
static const double kPlausibleMaxKwh = 1e7;
/// Vrai si [v] peut être présenté comme une mesure.
static bool isPlausibleKwh(double v) =>
v.isFinite && v >= 0 && v <= kPlausibleMaxKwh;
/// Export cumulé, ou `null` si la box publie une valeur inexploitable.
double? get gridExportKwhOrNull =>
isPlausibleKwh(gridExportKwh) ? gridExportKwh : null;
/// Import cumulé, ou `null` si la box publie une valeur inexploitable.
double? get gridImportKwhOrNull =>
isPlausibleKwh(gridImportKwh) ? gridImportKwh : null;
}
enum ChargingMode { pv, minPv, boost }
class HistoryPoint {
final DateTime time;
final double pvWh;
final double homeWh;
final double gridWh;
final double batteryWh;
const HistoryPoint({
required this.time,
this.pvWh = 0,
this.homeWh = 0,
this.gridWh = 0,
this.batteryWh = 0,
});
}
// ── Entrée Energy.GetPowerBalanceLogs ─────────────────────────────────────────
class PowerBalanceEntry {
final DateTime timestamp;
final double productionW; // puissance PV (valeur absolue, W)
final double consumptionW; // consommation maison (W)
final double acquisitionW; // import réseau (W, positif)
final double storageW; // batterie : +charge / -décharge (W)
/// Compteurs cumulés depuis l'origine, en **kWh** — et non en Wh comme le nom de ces
/// champs l'a longtemps dit.
///
/// Vérifié sur `.75` le 2026-08-27 par recoupement, parce qu'aucune documentation ne le
/// tranchait : sur une fenêtre de 21,75 h, l'intégration trapézoïdale des puissances
/// instantanées (qui sont bien en **W**) rend **77 697 Wh**, quand la différence de
/// `totalProduction` sur la même fenêtre rend **78,31**. Le rapport est mille.
///
/// Le mauvais nom n'était pas cosmétique : l'écran Énergie affichait « 78 Wh » pour une
/// journée de 78 kWh, et personne ne pouvait le voir sans connaître l'ordre de grandeur
/// attendu.
final double totalProductionKwh;
final double totalConsumptionKwh;
/// Injecté vers le réseau, cumulé (kWh).
final double totalReturnKwh;
/// Soutiré au réseau, cumulé (kWh).
final double totalAcquisitionKwh;
/// **Les quatre cumuls étaient-ils PRÉSENTS dans la charge utile ?**
///
/// ⚠️ Sans ce drapeau, un champ absent devient `0.0` au décodage — et un bilan bâti sur
/// deux bornes à zéro s'affiche « 0,0 kWh · Vers réseau » comme s'il était mesuré. C'est
/// le zéro forgé, du côté de l'écran cette fois : « rien échangé » et « la box ne le
/// publie pas » sont deux états, et le second n'a pas le droit de porter le premier.
///
/// Le cas est réel : un compteur racine bien en place et des cumuls qui n'arrivent pas —
/// une intégration incomplète côté box. La distinction doit tenir **quelle que soit la
/// raison** de l'absence, donc sans attendre que cette raison soit corrigée.
final bool totalsPresent;
const PowerBalanceEntry({
required this.timestamp,
this.totalsPresent = false,
this.productionW = 0,
this.consumptionW = 0,
this.acquisitionW = 0,
this.storageW = 0,
this.totalProductionKwh = 0,
this.totalConsumptionKwh = 0,
this.totalReturnKwh = 0,
this.totalAcquisitionKwh = 0,
});
}