etm-powersync-app/lib/services/energy_ratios.dart
Patrick Schurig cd8dd02989 docs: la journée de reporting suit l'installation — et l'app prend celle du téléphone
+etm55 vérifié (posé le 04/09 18:21:08). Le taux constant est en ligne : la
maquette porte le bon chiffrage — ×4,75 sur une fenêtre quotidienne, facteur
ln(T·P/E) + 1 — et les deux interdits du régime `unmeasurable` : ni annoncer que
l'achat va s'intensifier, ni lire `atMaximum: false` comme « tout va bien », qui
signifie qu'il n'y a aucun étalement, donc rien qui puisse cesser.

La troisième nature d'heure murale touche du code livré : `fetchDailyBalance()`
et la baseline des ratios prennent minuit du TÉLÉPHONE, alors qu'une journée de
reporting suit l'installation. Sur `.75` en Europe/London vue d'un téléphone
français, entre minuit et 1 h l'app compte une journée que la box compte encore
dans la précédente.

L'app ne peut pas le corriger seule : `System.GetTime` publie un nom IANA, et un
décalage ne s'en déduit pas sans base de fuseaux — qu'il faudrait embarquer pour
recalculer une frontière que la box connaît, et qui resterait fausse les jours de
changement d'heure. Demandé au moteur : publier le DÉBUT de la journée de
reporting en cours, en epoch, à côté du fuseau. La divergence est écrite aux deux
endroits du code en attendant.

Noté sans conclure : le banc n'a aucune charge `unmeasurable`, donc la loi est
déployée mais pas exerçable de bout en bout.

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

106 lines
4.3 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.

/// 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).
//
// ⚠️ « local » = fuseau du TÉLÉPHONE, et une journée de reporting devrait suivre
// l'INSTALLATION (cf. `NymeaService.fetchDailyBalance`). Sur une box en `Europe/London`
// vue d'un téléphone français, la bascule de jour arrive une heure trop tôt. La cible
// Phase 2 — lire UN state de l'energymanager — fait disparaître le problème avec le
// calcul ; d'ici là, la divergence est connue et écrite.
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;
}
}