etm-powersync-app/lib/models/energy_data.dart
Patrick Schurig 5a51582729 fix(ev): « Boost » devient « Manuel » — le nom promettait le contraire du comportement
Décision de Patrick : trois modes suffisent — Manuel, PV, Min+PV. Le boost n'est
pas un quatrième mode, c'est la dérogation (+etm61).

LE FAIT. Le bouton écrivait `ChargingModeNormal`, qui sort la borne de
l'arbitrage et lui restitue ses valeurs manuelles stockées —
manualChargingEnabled, manualMaxChargingCurrent, manualDesiredPhaseCount. Sur
.75 elles valaient false / 6 A / 3 phases : mesuré le 2026-09-08 à 11:09:51,
l'appui a ARRÊTÉ la charge et armé un verrou de 300 s. Pas une régression —
c'est ce que Normal a toujours fait. C'est le nom qui mentait.

Renommé jusque dans le code (`ChargingMode.boost` -> `ChargingMode.manuel`), pas
seulement sur l'étiquette : un identifiant faux reste un piège pour le prochain
lecteur, et c'est précisément ce qui a duré des mois ici. L'ordre met Manuel en
tête, parce que ChargingModeNormal est aussi le mode d'office d'une borne
détectée.

ET LE RENOMMAGE NE SUFFISAIT PAS. « Manuel » dit QUI commande, pas ce que la
borne va faire : tant que l'app n'offre pas de régler ces valeurs stockées, le
mode veut dire « ce qui traînait la dernière fois ». Passer en manuel demande
donc confirmation, et la confirmation dit la conséquence — la charge peut
s'arrêter — AVANT, pas après.

⚠️ La confirmation ne se déclenche pas sur un ré-appui du mode déjà en place :
une confirmation qu'on apprend à cliquer sans lire ne protège plus de rien. Un
test le fige.

Vérifié sur .75 (1.15.2+etm61) : SetLoadDerogation et ClearLoadDerogation sont
dans Introspect, avec derogationEnded / durationS / restoredMode. Le contrat de
la dérogation est écrit dans CLAUDE.md — durée obligatoire bornée [60, 21600],
état lu dans decision.code == "DEROGATION_ACTIVE" et jamais dans le mode, fin au
niveau de la trame avec six causes. Aucun bouton de boost n'est écrit : c'est le
lot suivant.

Et une sonde pour le moteur, faite au passage : la V2C est bien restée en
ChargingModeNormal et elle est ABSENTE de loads[] — hors arbitrage. Leur
hypothèse est confirmée sans redémarrage.

339 tests.

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

232 lines
10 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;
}
/// Les trois modes de recharge offerts par l'écran.
///
/// 🔴 **`manuel` s'appelait `boost`, et le nom était FAUX.** Il pose
/// `ChargingModeNormal`, qui **sort la borne de l'arbitrage** et restitue ses valeurs
/// manuelles — `manualChargingEnabled`, `manualMaxChargingCurrent`,
/// `manualDesiredPhaseCount`. Sur `.75` ces valeurs valaient `false / 6 A / 3 phases` :
/// mesuré le 2026-09-08 à 11:09:51, le bouton « Boost » a donc **arrêté la charge** et armé
/// un verrou de 300 s.
///
/// Ce n'était pas une régression : c'est ce que `Normal` a toujours fait. Le nom promettait
/// le contraire du comportement, et c'est le nom qui a menti pendant des mois — d'où le
/// renommage jusque dans le code, et pas seulement sur l'étiquette.
///
/// Le vrai boost existe depuis `+etm61`, et **ce n'est pas un mode** : c'est une
/// dérogation (`NymeaEnergy.SetLoadDerogation`) qui se superpose au mode courant, sert la
/// charge à son maximum en tête de passe éco, et s'efface d'elle-même. Elle aura son propre
/// bouton d'action, avec une durée obligatoire — pas une entrée de ce sélecteur.
///
/// L'ordre compte : `manuel` en tête, parce que `ChargingModeNormal` est aussi le mode
/// **d'office** d'une borne détectée (cf. `EvChargerLive.isUnmanaged`).
enum ChargingMode { manuel, pv, minPv }
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,
});
}