diff --git a/lib/models/energy_data.dart b/lib/models/energy_data.dart index 8f38154..b4cfb30 100644 --- a/lib/models/energy_data.dart +++ b/lib/models/energy_data.dart @@ -123,10 +123,4 @@ class PowerBalanceEntry { this.totalReturnWh = 0, this.totalAcquisitionWh = 0, }); - - /// Autoconsommation instantanée (W) = production locale non injectée au réseau. - double get autoconsommationW => - (consumptionW + (storageW > 0 ? storageW : 0)) - .clamp(0.0, productionW) - .toDouble(); } \ No newline at end of file diff --git a/lib/screens/energy_screen.dart b/lib/screens/energy_screen.dart index 6cb0216..381811c 100644 --- a/lib/screens/energy_screen.dart +++ b/lib/screens/energy_screen.dart @@ -5,6 +5,7 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../models/energy_data.dart'; import '../models/nymea_models.dart'; +import '../services/energy_ratios.dart'; import '../services/nymea_service.dart'; import '../theme/etm_tokens.dart'; import '../main.dart' show DrawerMenuButton; @@ -277,7 +278,9 @@ class _EnergyScreenState extends State { // Toujours en kW pour l'axe gauche prodSpots .add(FlSpot(x, d.productionW / 1000)); consoSpots.add(FlSpot(x, d.consumptionW / 1000)); - autoSpots .add(FlSpot(x, d.autoconsommationW / 1000)); + // INTERIM (§2.2) — bande autoconso via la formule canonique du seam. + autoSpots .add(FlSpot( + x, EnergyRatiosInterim.selfConsumptionPower(d.productionW, d.acquisitionW) / 1000)); } // Plage Y gauche en kW diff --git a/lib/services/energy_ratios.dart b/lib/services/energy_ratios.dart new file mode 100644 index 0000000..0cc98bf --- /dev/null +++ b/lib/services/energy_ratios.dart @@ -0,0 +1,99 @@ +/// Ratios énergétiques affichés (autoconsommation / autonomie), en %. +/// `null` = **n/a** (dénominateur nul) — jamais de NaN, jamais d'aberration. +class EnergyRatios { + final double? autoconsommation; // % dans [0, 100], ou null = n/a + final double? autonomie; // % dans [0, 100], ou null = n/a + + const EnergyRatios({this.autoconsommation, this.autonomie}); + + static const none = EnergyRatios(); +} + +/// **SEAM UNIQUE — INTERIM (§2.1).** +/// +/// Tout le calcul app-side des ratios vit **ici et nulle part ailleurs**. +/// Cible Phase 2 : remplacer le corps de [compute] par la lecture d'**UN** state +/// unique de l'energymanager → le swap se fait en **un seul endroit**. +/// +/// Méthode interim (§2.1 / §8.1) : dériver par **Δ de cumuls** sur la période +/// affichée (journée depuis minuit local), **pas** par intégration du signal +/// live → déterministe, pas de drift de polling. +/// +/// ``` +/// autonomie = (Δ totalConsumption − Δ totalAcquisition) / Δ totalConsumption +/// autoconsommation = (Δ totalProduction − Δ totalReturn) / Δ totalProduction +/// ``` +class EnergyRatiosInterim { + // Baseline = cumuls au début de la période courante (minuit local / reseed). + double? _baseProduction; + double? _baseReturn; + double? _baseConsumption; + double? _baseAcquisition; + int? _baseDay; // index de jour local + + /// Réinitialise la baseline (ex. switch d'installation, §Étape 8). + void reset() { + _baseProduction = null; + _baseReturn = null; + _baseConsumption = null; + _baseAcquisition = null; + _baseDay = null; + } + + /// Calcule les ratios à partir des **cumuls** courants (mêmes unités en entrée, + /// elles s'annulent dans le ratio). `now` sert à détecter le changement de jour. + EnergyRatios compute({ + required double totalProduction, + required double totalReturn, + required double totalConsumption, + required double totalAcquisition, + required DateTime now, + }) { + final day = _localDay(now); + + // Reseed si : 1er appel, nouveau jour local, OU compteur **non monotone** + // (Δ<0 → restart nymead / re-add thing / rollover). Jamais de ratio négatif. + final nonMonotone = _baseProduction != null && + (totalProduction < _baseProduction! || + totalReturn < _baseReturn! || + totalConsumption < _baseConsumption! || + totalAcquisition < _baseAcquisition!); + + if (_baseDay != day || _baseProduction == null || nonMonotone) { + _baseDay = day; + _baseProduction = totalProduction; + _baseReturn = totalReturn; + _baseConsumption = totalConsumption; + _baseAcquisition = totalAcquisition; + } + + final dProduction = totalProduction - _baseProduction!; + final dReturn = totalReturn - _baseReturn!; + final dConsumption = totalConsumption - _baseConsumption!; + final dAcquisition = totalAcquisition - _baseAcquisition!; + + return EnergyRatios( + autoconsommation: _ratio(dProduction - dReturn, dProduction), + autonomie: _ratio(dConsumption - dAcquisition, dConsumption), + ); + } + + /// Ratio en % borné [0, 100]. Dénominateur ≤ 0 (nuit sans prod, etc.) → `null` + /// (n/a) — **jamais de NaN**. + static double? _ratio(double numerator, double denominator) { + if (denominator <= 0) return null; + return (numerator / denominator * 100).clamp(0.0, 100.0); + } + + static int _localDay(DateTime t) => + DateTime(t.year, t.month, t.day).millisecondsSinceEpoch ~/ 86400000; + + /// **INTERIM (§2.2)** — bande d'autoconsommation **instantanée** (W). + /// Formule canonique unique : `max(production − max(export, 0), 0)`. + /// Réseau net-signé (négatif = export). **Aucun `.abs()`, aucun flip de signe.** + static double selfConsumptionPower(double production, double acquisition) { + final export = acquisition < 0 ? -acquisition : 0.0; + final self = production - export; + return self > 0 ? self : 0.0; + } +} diff --git a/lib/services/nymea_service.dart b/lib/services/nymea_service.dart index 6cb9085..90be88c 100644 --- a/lib/services/nymea_service.dart +++ b/lib/services/nymea_service.dart @@ -8,6 +8,7 @@ import 'package:shared_preferences/shared_preferences.dart'; import 'package:web_socket_channel/web_socket_channel.dart'; import '../models/energy_data.dart'; // EnergyData, HistoryPoint, PowerBalanceEntry import '../models/nymea_models.dart'; +import 'energy_ratios.dart'; // seam interim ratios (§2.1) // ── Protocole de connexion ───────────────────────────────────────────────────── enum NymeaProtocol { @@ -77,6 +78,8 @@ class NymeaService extends ChangeNotifier { } EnergyData _energyData = const EnergyData(); + // SEAM UNIQUE des ratios (interim §2.1) — calcul app-side isolé ici. + final EnergyRatiosInterim _ratiosSeam = EnergyRatiosInterim(); List _historyPoints = []; List _things = []; List _thingClasses = []; @@ -501,6 +504,7 @@ class NymeaService extends ChangeNotifier { /// utilisateur globale, hors box). void resetState() { _energyData = const EnergyData(); + _ratiosSeam.reset(); // baseline ratios par installation (interim §2.1) _historyPoints = []; _things = []; _thingClasses = []; @@ -749,26 +753,30 @@ class NymeaService extends ChangeNotifier { double? n(String k) => (p[k] as num?)?.toDouble(); - // Magnitudes pour l'affichage (signes nymea : production +, conso −). - final rawProd = n('currentPowerProduction'); - final pv = rawProd != null ? rawProd.abs() : 0.0; - - final home = n('currentPowerConsumption')?.abs() ?? 0.0; - final grid = n('currentPowerAcquisition') ?? 0.0; // + import, 0 si export + // Signes nymea BRUTS — ne corriger aucun signe (§7.1). Le power balance + // agrégé donne production positive et acquisition net-signée (+import/−export). + final pv = n('currentPowerProduction') ?? 0.0; + final home = n('currentPowerConsumption') ?? 0.0; + final grid = n('currentPowerAcquisition') ?? 0.0; final bat = n('currentPowerStorage') ?? 0.0; - // Totaux cumulés en kWh → convertir en Wh pour l'affichage + // Totaux cumulés (kWh). final totalProdKwh = n('totalProduction') ?? 0.0; final totalRetKwh = n('totalReturn') ?? 0.0; final totalConsoKwh = n('totalConsumption') ?? 0.0; final totalAcqKwh = n('totalAcquisition') ?? 0.0; - // Autoconsommation = production - injection réseau + // Ratios : PLUS de calcul ici (§2). Délégués au SEAM unique (interim Δ cumuls). + final ratios = _ratiosSeam.compute( + totalProduction: totalProdKwh, + totalReturn: totalRetKwh, + totalConsumption: totalConsoKwh, + totalAcquisition: totalAcqKwh, + now: DateTime.now(), + ); + + // Hors scope ratios : énergie autoconso (Wh) + gains (€) — conservés tels quels. final selfConsoKwh = totalProdKwh - totalRetKwh; - // Taux autoconsommation = autoconso / production * 100 - final selfRate = totalProdKwh > 0 ? (selfConsoKwh / totalProdKwh * 100).clamp(0.0, 100.0) : 0.0; - // Taux autonomie = (production + batterie utilisée) / conso * 100 - final autoRate = totalConsoKwh > 0 ? ((totalConsoKwh - totalAcqKwh) / totalConsoKwh * 100).clamp(0.0, 100.0) : 0.0; _energyData = _energyData.copyWith( pvPower: pv, @@ -778,8 +786,9 @@ class NymeaService extends ChangeNotifier { dayProductionWh: totalProdKwh * 1000, dayGridInjectionWh: totalRetKwh * 1000, daySelfConsumptionWh: selfConsoKwh * 1000, - selfConsumptionRate: selfRate, - autonomyRate: autoRate, + // n/a (seam → null) mappé à 0 pour le champ non-nullable (UX §2 item 4). + selfConsumptionRate: ratios.autoconsommation ?? 0.0, + autonomyRate: ratios.autonomie ?? 0.0, dayGains: totalRetKwh * 0.13 + selfConsoKwh * 0.22, // estimation tarifaire ); notifyListeners(); @@ -817,11 +826,11 @@ class NymeaService extends ChangeNotifier { return PowerBalanceEntry( timestamp: DateTime.fromMillisecondsSinceEpoch( (m['timestamp'] as num).toInt() * 1000), - // Signes nymea (firmware 1.15.2) : production positive, consommation - // NÉGATIVE. Le graphe trace des magnitudes → abs sur les deux (sinon - // conso<0 cloue l'autoconso à 0 via le clamp et fausse l'axe Y). - productionW: d('production').abs(), - consumptionW: d('consumption').abs(), + // Signes nymea BRUTS — ne corriger aucun signe (§7.1). L'autoconso + // instantanée se dérive via la formule canonique du seam + // (EnergyRatiosInterim.selfConsumptionPower), jamais par .abs(). + productionW: d('production'), + consumptionW: d('consumption'), acquisitionW: d('acquisition'), storageW: d('storage'), totalProductionWh: d('totalProduction'), diff --git a/test/energy_ratios_test.dart b/test/energy_ratios_test.dart new file mode 100644 index 0000000..b09c20b --- /dev/null +++ b/test/energy_ratios_test.dart @@ -0,0 +1,90 @@ +import 'package:etm_powersync_app/services/energy_ratios.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// Couvre l'interim ratios (§2.1) : dérivation par Δ cumuls, reseed (jour / +/// non-monotone), garde-fous (n/a, pas de NaN, clamp), formule autoconso band. +void main() { + final d1 = DateTime(2026, 6, 28, 12); + final d1bis = DateTime(2026, 6, 28, 13); + final d2 = DateTime(2026, 6, 29, 9); + + test('1er échantillon : Δ=0 → n/a (null), jamais NaN', () { + final s = EnergyRatiosInterim(); + final r = s.compute( + totalProduction: 10, + totalReturn: 2, + totalConsumption: 8, + totalAcquisition: 3, + now: d1); + expect(r.autoconsommation, isNull); + expect(r.autonomie, isNull); + }); + + test('Δ cumuls même jour → ratios déterministes, bornés [0,100]', () { + final s = EnergyRatiosInterim(); + s.compute( + totalProduction: 10, + totalReturn: 2, + totalConsumption: 8, + totalAcquisition: 3, + now: d1); // baseline + final r = s.compute( + totalProduction: 20, // Δprod=10 + totalReturn: 4, // Δret=2 → autoconso=(10-2)/10=80 + totalConsumption: 18, // Δcons=10 + totalAcquisition: 8, // Δacq=5 → autonomie=(10-5)/10=50 + now: d1bis); + expect(r.autoconsommation, closeTo(80, 0.001)); + expect(r.autonomie, closeTo(50, 0.001)); + }); + + test('nouveau jour local → reseed baseline (Δ repart de 0)', () { + final s = EnergyRatiosInterim(); + s.compute( + totalProduction: 10, totalReturn: 2, totalConsumption: 8, + totalAcquisition: 3, now: d1); + final r = s.compute( + totalProduction: 99, totalReturn: 9, totalConsumption: 99, + totalAcquisition: 9, now: d2); // jour différent → reseed → Δ=0 + expect(r.autoconsommation, isNull); + expect(r.autonomie, isNull); + }); + + test('compteur non monotone (Δ<0) → reseed, jamais de ratio négatif', () { + final s = EnergyRatiosInterim(); + s.compute( + totalProduction: 100, totalReturn: 10, totalConsumption: 80, + totalAcquisition: 5, now: d1); // baseline haute + // restart nymead : cumuls repartent bas → non monotone → reseed + final r = s.compute( + totalProduction: 5, totalReturn: 1, totalConsumption: 4, + totalAcquisition: 1, now: d1bis); + expect(r.autoconsommation, anyOf(isNull, greaterThanOrEqualTo(0))); + expect(r.autonomie, anyOf(isNull, greaterThanOrEqualTo(0))); + }); + + test('dénominateur nul (nuit sans prod) → n/a, pas de NaN', () { + final s = EnergyRatiosInterim(); + s.compute( + totalProduction: 10, totalReturn: 2, totalConsumption: 8, + totalAcquisition: 3, now: d1); + final r = s.compute( + totalProduction: 10, // Δprod=0 → autoconso n/a + totalReturn: 2, + totalConsumption: 8, // Δcons=0 → autonomie n/a + totalAcquisition: 3, + now: d1bis); + expect(r.autoconsommation, isNull); + expect(r.autonomie, isNull); + }); + + test('selfConsumptionPower (§2.2) : net-signé, sans abs', () { + // export (acq<0) retranché de la production + expect(EnergyRatiosInterim.selfConsumptionPower(4869, -231), + closeTo(4638, 0.001)); + // import (acq>0) → tout est autoconsommé + expect(EnergyRatiosInterim.selfConsumptionPower(2000, 500), 2000); + // export > production → borné à 0 (pas de négatif) + expect(EnergyRatiosInterim.selfConsumptionPower(100, -300), 0); + }); +}