etm-powersync-app/lib/models/pv_forecast.dart
Patrick Schurig adb069f1d2 feat(énergie): prévision PV lue sur la box, marché spot masqué (+etm68)
Consigne app etm66/etm68, vérifiée contre le banc hems (1.15.2+etm68) le 2026-10-14.

- PvForecastCard remplace le placeholder « Météo & prévision » : lit
  NymeaEnergy.GetForecastSnapshot à l'ouverture puis toutes les 12 min
  (aucune notification n'existe). Pas des créneaux DÉDUIT, créneau absent
  = trou (jamais zéro), fault ⇒ pas de courbe avec son motif, libellé
  « plafond optimiste », corrections/plafond AC en mode installateur.
- MISSING_FORECAST_SITE : les candidats de GetForecastSite sont proposés,
  la désignation (SetForecastSite) se confirme ; acLimitW omis = inchangé.
- Production mesurée superposée depuis GetPowerBalanceLogs, signe inversé
  (pas .abs()), aucun cumul total*.
- Marché spot masqué dans Tarifs (SPOT_MARKET_NOT_IN_BUILD) : mode Spot
  Market, fournisseurs Tibber/Octopus/ENTSO-E et la carte de prix codés en dur.
- Bornes VE envoyées en {uuid} minuscules jusqu'à etm69 (SetChargingInfo,
  SetLoadConfig, Set/ClearLoadDerogation) via idBorneCanonique().

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 07:40:20 +02:00

237 lines
8.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.

/// **La prévision PV telle que la box la publie** — `NymeaEnergy.GetForecastSnapshot`.
///
/// Vérifié sur le banc `hems` (10.42.60.21) le 2026-10-14, moteur `1.15.2+etm68`,
/// `powersync-plugin-openmeteo 1.15.2+etm15` : 48 créneaux d'une heure, de
/// `2026-10-13T22:00:00Z` à `2026-10-15T21:00:00Z`.
///
/// ## Trois règles, chacune tenue ici plutôt qu'à l'écran
///
/// 1. **Un créneau absent n'est pas un zéro.** `forecastWh: 0` dit « on attend la nuit » ;
/// un créneau manquant dit « on ne sait pas ». Les points sont gardés tels quels, et
/// c'est la courbe qui laisse un trou là où il en manque un.
/// 2. **Le pas n'est pas publié** : il se DÉDUIT des écarts entre créneaux. Une heure
/// aujourd'hui ; le coder en dur ferait dessiner une barre d'une heure sur un créneau
/// de quinze minutes le jour où le producteur change de grille — sans erreur.
/// 3. **`fault` présent ⇒ pas de courbe**, même si des points traînaient dans la réponse.
///
/// L'app ne calcule aucune prévision : elle lit, convertit l'unité pour l'affichage, et
/// c'est tout.
library;
import 'box_instant.dart';
/// Un créneau prévu : `[debut, debut + pas)`, et l'énergie attendue dessus.
class PvForecastPoint {
/// Début du créneau, **en UTC** — converti en heure locale à l'affichage seulement.
final DateTime debutUtc;
/// Énergie prévue sur le créneau, en Wh. Jamais une puissance.
final double wh;
const PvForecastPoint(this.debutUtc, this.wh);
}
/// Pourquoi il n'y a pas de courbe — ou qu'il y en a une.
enum PvForecastEtat {
/// Une courbe est publiée.
courbe,
/// `MISSING_FORECAST_SITE` : des plans publient, mais aucun site n'est désigné. Le geste
/// est de configurer — proposer les candidats, jamais en adopter un d'office.
siteManquant,
/// `FORECAST_PAYLOAD_REFUSED` : le producteur météo publie quelque chose d'illisible.
/// Le geste est de regarder le producteur, `faultParams.cause` dit quoi.
chargeRefusee,
/// Un `fault` que cette version de l'app ne connaît pas : il est montré tel quel,
/// jamais tu — le taire ferait lire « rien à signaler ».
faultInconnu,
/// Ni courbe ni `fault` : pas de plugin météo, ou données pas encore arrivées. La box ne
/// distingue pas les deux, l'écran ne le prétend pas non plus.
rienPublie,
}
class PvForecastSnapshot {
final List<PvForecastPoint> points;
final String? fault;
final Map<String, dynamic> faultParams;
/// Quand la réponse a été construite.
final DateTime? capturedAt;
/// Quand le contenu actuel est apparu pour la première fois. C'est **lui** qui date la
/// courbe : sans lui, un producteur gelé se lit comme une prévision stable. Absent quand
/// la box ne l'a jamais vu — et alors on ne le devine pas.
final DateTime? contentChangedAt;
/// Émission du run météo. Peut manquer.
final DateTime? issuedAt;
final String? model;
final String? conversion;
/// Corrections physiques appliquées (`temperature`, `ac-limit`). Vide = GTI × kWc × PR
/// brut. `null` = la box ne publie pas le champ (antérieure à `+etm68`).
final List<String>? corrections;
/// Plafond AC du site en W, quand il est configuré : la courbe est alors écrêtée.
final int? acLimitW;
/// Un plan sommé par entrée : son Thing et ses corrections déclarées.
final List<({String id, List<String> corrections})> perField;
const PvForecastSnapshot({
required this.points,
this.fault,
this.faultParams = const {},
this.capturedAt,
this.contentChangedAt,
this.issuedAt,
this.model,
this.conversion,
this.corrections,
this.acLimitW,
this.perField = const [],
});
factory PvForecastSnapshot.fromJson(Map<String, dynamic> m) {
final pts = <PvForecastPoint>[];
final raw = m['forecastPoints'];
if (raw is List) {
for (final e in raw) {
if (e is! Map) continue;
final t = DateTime.tryParse(e['targetAt'] as String? ?? '');
final wh = e['forecastWh'];
// Un point illisible est un trou, pas un zéro : on le saute.
if (t == null || wh is! num) continue;
pts.add(PvForecastPoint(_enUtc(t), wh.toDouble()));
}
}
pts.sort((a, b) => a.debutUtc.compareTo(b.debutUtc));
List<String>? chaines(Object? v) =>
v is List ? v.whereType<String>().toList() : null;
final fp = m['faultParams'];
final champs = m['perField'];
final fault = m['fault'];
return PvForecastSnapshot(
points: pts,
fault: fault is String && fault.isNotEmpty ? fault : null,
faultParams: fp is Map ? Map<String, dynamic>.from(fp) : const {},
capturedAt: instantDeLaBox(m['capturedAt']),
contentChangedAt: instantDeLaBox(m['contentChangedAt']),
issuedAt: instantDeLaBox(m['issuedAt']),
model: m['model'] as String?,
conversion: m['conversion'] as String?,
corrections: chaines(m['corrections']),
acLimitW: (m['acLimitW'] as num?)?.toInt(),
perField: [
if (champs is List)
for (final c in champs)
if (c is Map)
(
id: c['id'] as String? ?? '',
corrections: chaines(c['corrections']) ?? const <String>[],
),
],
);
}
PvForecastEtat get etat {
switch (fault) {
case 'MISSING_FORECAST_SITE':
return PvForecastEtat.siteManquant;
case 'FORECAST_PAYLOAD_REFUSED':
return PvForecastEtat.chargeRefusee;
case null:
return points.isEmpty ? PvForecastEtat.rienPublie : PvForecastEtat.courbe;
default:
return PvForecastEtat.faultInconnu;
}
}
/// Le pas des créneaux, **déduit** : le plus petit écart positif entre deux débuts
/// consécutifs. Un trou élargit un écart, il ne le rétrécit jamais — le minimum reste
/// donc le pas même quand des créneaux manquent.
///
/// `null` sous deux créneaux : un point seul ne dit pas sa durée, et l'inventer serait
/// exactement le pas codé en dur que ce calcul remplace.
Duration? get pas {
Duration? min;
for (var i = 1; i < points.length; i++) {
final d = points[i].debutUtc.difference(points[i - 1].debutUtc);
if (d <= Duration.zero) continue;
if (min == null || d < min) min = d;
}
return min;
}
/// Les points regroupés en **segments contigus** : un nouveau segment commence à chaque
/// créneau manquant. C'est ce découpage qui fait le trou dans la courbe.
List<List<PvForecastPoint>> get segments {
final p = pas;
final out = <List<PvForecastPoint>>[];
for (final pt in points) {
if (out.isEmpty ||
p == null ||
pt.debutUtc.difference(out.last.last.debutUtc) != p) {
out.add([pt]);
} else {
out.last.add(pt);
}
}
return out;
}
/// Nombre de créneaux manquants entre le premier et le dernier publié.
int get creneauxManquants {
final p = pas;
if (p == null || points.length < 2) return 0;
final attendus =
points.last.debutUtc.difference(points.first.debutUtc).inSeconds ~/
p.inSeconds +
1;
return attendus - points.length;
}
static DateTime _enUtc(DateTime d) => d.isUtc
? d
// Même politique que `instantDeLaBox` : une chaîne nue venue de la box est de l'UTC.
: DateTime.utc(d.year, d.month, d.day, d.hour, d.minute, d.second);
}
/// `NymeaEnergy.GetForecastSite` — quel site est prévu, lesquels pourraient l'être.
class PvForecastSite {
/// Site désigné, forme canonique `{uuid}` minuscules. `null` = aucun — un état normal.
final String? siteThingId;
/// Chaque site dont au moins un plan publie une courbe : ce que l'écran propose.
final List<String> candidats;
/// Plafond AC du site en W. `null` = aucun plafond configuré.
final int? acLimitW;
final String? fault;
const PvForecastSite({
this.siteThingId,
this.candidats = const [],
this.acLimitW,
this.fault,
});
factory PvForecastSite.fromJson(Map<String, dynamic> m) {
final c = m['candidateSiteThingIds'];
final id = m['forecastSiteThingId'];
return PvForecastSite(
siteThingId: id is String && id.isNotEmpty ? id : null,
candidats: c is List ? c.whereType<String>().toList() : const [],
acLimitW: (m['acLimitW'] as num?)?.toInt(),
fault: m['fault'] as String?,
);
}
}