refactor(energy): retire le calcul de ratios app-side, isole l'interim derrière EnergyRatiosInterim
- supprime selfRate/autoRate de _parsePowerBalance, le getter autoconsommationW, les .abs() interim - ratios dérivés par Δ-de-cumuls (§2.1/§8.1) dans lib/services/energy_ratios.dart - reseed jour-roulant & Δ non-monotone ; gardes : dén≤0→null, pas de NaN, clamp [0,100] - selfConsumptionPower net-signé sans .abs() (§2.2) ; signes nymea bruts préservés (§7.1) - seam swappable vers un state plugin en Phase 2 (une seule fonction) Interim (Phase 1). Gestion du signe d'affichage renvoyée à la Phase 4 (§1.1). Réf. UI_DATA_CONTRACT.md rev.5. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
b5abe42fcc
commit
3821bf3fdb
@ -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();
|
||||
}
|
||||
@ -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<EnergyScreen> {
|
||||
// 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
|
||||
|
||||
99
lib/services/energy_ratios.dart
Normal file
99
lib/services/energy_ratios.dart
Normal file
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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<HistoryPoint> _historyPoints = [];
|
||||
List<NymeaThing> _things = [];
|
||||
List<NymeaThingClass> _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'),
|
||||
|
||||
90
test/energy_ratios_test.dart
Normal file
90
test/energy_ratios_test.dart
Normal file
@ -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);
|
||||
});
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user