etm-powersync-app/test/pv_forecast_test.dart
Patrick Schurig c7f857ecb3 feat(prévision): fourchette météo datée et invalidée (openmeteo etm16)
Consigne app §D, mise à jour du 2026-10-15, vérifiée sur le banc (etm16) :
un plan qui publie forecastWeatherSpreadValid ne compte que si ce témoin
vaut true et que le pourcentage du jour est un nombre (null sinon) — pas
de bande, jamais une bande à 0. Un 0 % valide est une vraie mesure. La
fourchette datée (forecastWeatherSpreadTime) n'est plus « non datée ».
Les plans antérieurs gardent l'ancienne règle.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 08:22:14 +02:00

448 lines
18 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 de `+etm68` — `NymeaEnergy.GetForecastSnapshot`.
///
/// La fixture est la réponse RÉELLE du banc `hems` (10.42.60.21), capturée le 2026-10-14.
/// Les cas de trou, de pas et de défaut sont dérivés d'elle ou écrits à la main, et chaque
/// test dit ce qu'il aurait attrapé s'il avait échoué.
library;
import 'dart:convert';
import 'dart:io';
import 'package:etm_powersync_app/models/axe_echantillons.dart';
import 'package:etm_powersync_app/models/pv_forecast.dart';
import 'package:etm_powersync_app/services/nymea_service.dart';
import 'package:etm_powersync_app/widgets/pv_forecast_card.dart';
import 'package:fl_chart/fl_chart.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
Map<String, dynamic> _banc() => jsonDecode(
File('test/fixtures/forecast_snapshot_hems_etm68.json').readAsStringSync())
as Map<String, dynamic>;
Map<String, dynamic> _pt(String t, num wh) => {'targetAt': t, 'forecastWh': wh};
Widget _monte(PvForecastView v) => MaterialApp(
// Contrainte BORNÉE en largeur, hauteur infinie : c'est le montage réel (un
// ListView dans l'écran Énergie).
home: Scaffold(body: ListView(children: [v])),
);
PvForecastView _vue(PvForecastSnapshot? s,
{String? erreur,
PvForecastSite? site,
List<({double x, double w})> mesure = const []}) {
// Axe de test : heures depuis le premier créneau — l'écran Énergie fournit le sien.
final t0 = s != null && s.points.isNotEmpty
? s.points.first.debutUtc
: DateTime.utc(2026, 10, 14);
double x(DateTime t) => t.toUtc().difference(t0).inSeconds / 3600;
return PvForecastView(
chargement: false,
snapshot: s,
erreur: erreur,
site: site,
avecMesure: mesure.isNotEmpty,
nomDuThing: (id) => id == '{8e0b7b38-3180-46be-ba41-08b08d630173}'
? 'Kutzenhausen'
: id,
graphe: (_, snap) => PvForecastLineChart(
snapshot: snap,
mesure: mesure,
x: x,
minX: 0,
maxX: x(snap.points.last.debutUtc) + 1,
etiquette: (_) => null,
xInterval: 1,
),
);
}
void main() {
group('lecture de la réponse du banc', () {
final s = PvForecastSnapshot.fromJson(_banc());
test('48 créneaux, en UTC, triés', () {
expect(s.etat, PvForecastEtat.courbe);
expect(s.points, hasLength(48));
expect(s.points.first.debutUtc.isUtc, isTrue);
expect(s.points.first.debutUtc, DateTime.utc(2026, 10, 13, 22));
});
test('le pas est DÉDUIT : une heure sur le banc', () {
expect(s.pas, const Duration(hours: 1));
expect(s.creneauxManquants, 0);
expect(s.segments, hasLength(1));
});
test('métadonnées +etm68 lues', () {
expect(s.corrections, ['temperature']);
expect(s.conversion, 'physics');
expect(s.model, 'meteofrance_seamless');
expect(s.perField.single.corrections, ['temperature']);
expect(s.acLimitW, isNull); // pas de plafond configuré sur le banc
// Un INSTANT se rend chez le lecteur : l'attendu se dérive de toLocal(), il ne se
// recopie pas de la charge utile (sous TZ=UTC ce contrôle ne mord pas).
expect(s.contentChangedAt,
DateTime.utc(2026, 10, 14, 5, 15).toLocal());
expect(s.contentChangedAt!.isUtc, isFalse);
});
});
group('un créneau absent n\'est pas un zéro', () {
final s = PvForecastSnapshot.fromJson({
'capturedAt': '2026-10-14T05:00:00Z',
'forecastPoints': [
_pt('2026-10-14T08:00:00Z', 500),
_pt('2026-10-14T09:00:00Z', 900),
// 10:00 et 11:00 manquent
_pt('2026-10-14T12:00:00Z', 1100),
],
});
test('le pas reste une heure malgré le trou', () {
// Aurait attrapé un pas pris sur le premier écart venu, ou sur la moyenne.
expect(s.pas, const Duration(hours: 1));
expect(s.creneauxManquants, 2);
expect(s.segments.map((g) => g.length), [2, 1]);
});
testWidgets('la courbe est COUPÉE au trou, sans point à zéro', (t) async {
await t.pumpWidget(_monte(_vue(s)));
final chart = t.widget<LineChart>(find.byType(LineChart));
final spots = chart.data.lineBarsData.first.spots;
expect(spots.where((p) => p == FlSpot.nullSpot), hasLength(1));
// Aucun zéro n'a été inventé pour combler 10:00–12:00.
expect(spots.where((p) => p != FlSpot.nullSpot && p.y == 0), isEmpty);
// Plus de texte sous le graphe (Patrick, 2026-10-15) : le trou se voit sur la courbe.
expect(find.textContaining('sans prévision'), findsNothing);
});
});
test('un pas de 15 min se déduit aussi — rien n\'est codé en dur', () {
final s = PvForecastSnapshot.fromJson({
'forecastPoints': [
_pt('2026-10-14T08:00:00Z', 100),
_pt('2026-10-14T08:15:00Z', 120),
_pt('2026-10-14T08:30:00Z', 140),
],
});
expect(s.pas, const Duration(minutes: 15));
});
testWidgets('Wh par créneau → kW moyen : 250 Wh sur 15 min = 1 kW', (t) async {
final s = PvForecastSnapshot.fromJson({
'forecastPoints': [
_pt('2026-10-14T08:00:00Z', 250),
_pt('2026-10-14T08:15:00Z', 250),
],
});
await t.pumpWidget(_monte(_vue(s)));
final spots =
t.widget<LineChart>(find.byType(LineChart)).data.lineBarsData.first.spots;
// Aurait attrapé une heure codée en dur (0,25 kW) au lieu du pas déduit.
expect(spots.first.y, closeTo(1.0, 1e-9));
});
group('fault présent ⇒ pas de courbe', () {
testWidgets('MISSING_FORECAST_SITE propose les candidats, n\'adopte rien',
(t) async {
final s = PvForecastSnapshot.fromJson({
'capturedAt': '2026-10-14T05:00:00Z',
'fault': 'MISSING_FORECAST_SITE',
// Des points qui traîneraient ne doivent PAS être tracés.
'forecastPoints': [_pt('2026-10-14T08:00:00Z', 500)],
});
expect(s.etat, PvForecastEtat.siteManquant);
await t.pumpWidget(_monte(_vue(s,
site: const PvForecastSite(
candidats: ['{8e0b7b38-3180-46be-ba41-08b08d630173}']))));
expect(find.byType(LineChart), findsNothing);
expect(find.text('Kutzenhausen'), findsOneWidget);
expect(find.text('Désigner'), findsOneWidget);
});
testWidgets('FORECAST_PAYLOAD_REFUSED montre sa cause', (t) async {
final s = PvForecastSnapshot.fromJson({
'fault': 'FORECAST_PAYLOAD_REFUSED',
'faultParams': {'cause': 'unknown version 3'},
});
await t.pumpWidget(_monte(_vue(s)));
expect(find.byType(LineChart), findsNothing);
expect(find.textContaining('unknown version 3'), findsOneWidget);
});
testWidgets('un défaut inconnu est montré, jamais tu', (t) async {
final s = PvForecastSnapshot.fromJson({'fault': 'SOMETHING_NEW'});
expect(s.etat, PvForecastEtat.faultInconnu);
await t.pumpWidget(_monte(_vue(s)));
expect(find.text('SOMETHING_NEW'), findsOneWidget);
});
});
testWidgets('ni courbe ni fault : « aucune prévision publiée »', (t) async {
final s = PvForecastSnapshot.fromJson({'capturedAt': '2026-10-14T05:00:00Z'});
expect(s.etat, PvForecastEtat.rienPublie);
await t.pumpWidget(_monte(_vue(s)));
expect(find.text('Aucune prévision publiée'), findsOneWidget);
expect(find.byType(LineChart), findsNothing);
});
testWidgets('non lu ≠ rien de publié', (t) async {
await t.pumpWidget(_monte(_vue(null, erreur: 'TimeoutException')));
expect(find.text('Prévision non lue'), findsOneWidget);
expect(find.text('Aucune prévision publiée'), findsNothing);
});
testWidgets('libellé honnête, et plus de détails installateur sous le graphe',
(t) async {
// Retirés à la demande de Patrick le 2026-10-14.
final s = PvForecastSnapshot.fromJson(_banc());
await t.pumpWidget(_monte(_vue(s)));
expect(find.textContaining('plafond optimiste'), findsWidgets);
expect(find.text('Détails installateur'), findsNothing);
});
testWidgets('la production mesurée se superpose, signe déjà retourné',
(t) async {
final s = PvForecastSnapshot.fromJson(_banc());
await t.pumpWidget(_monte(_vue(s, mesure: [
(x: 10.0, w: 600),
(x: 10.25, w: 700),
])));
final bars = t.widget<LineChart>(find.byType(LineChart)).data.lineBarsData;
expect(bars, hasLength(2));
// Plein = réel, contour = prévu : la mesure est une surface, la prévision un trait
// pointillé sans remplissage, tracé par-dessus.
expect(bars[0].spots.map((p) => p.y), [0.6, 0.7]);
expect(bars[0].belowBarData.show, isTrue);
expect(bars[1].dashArray, isNotNull);
expect(bars[1].belowBarData.show, isFalse);
});
group('bornes VE : `{uuid}` minuscules (jusqu\'à etm69)', () {
test('sans accolades, en majuscules → canonique', () {
expect(idBorneCanonique('4D1ADAFD-CAFC-4813-93B1-CA8933CE71AB'),
'{4d1adafd-cafc-4813-93b1-ca8933ce71ab}');
});
test('déjà canonique → inchangé', () {
const id = '{4d1adafd-cafc-4813-93b1-ca8933ce71ab}';
expect(idBorneCanonique(id), id);
});
test('un nom de charge n\'est pas réécrit', () {
expect(idBorneCanonique('chauffe-eau'), 'chauffe-eau');
});
});
group('axe commun avec l\'écran Énergie', () {
final t0 = DateTime.utc(2026, 10, 14, 8);
final q = const Duration(minutes: 15);
// 08:00, 08:15, [08:30 manquant], 08:45
final axe = AxeEchantillons([t0, t0.add(q), t0.add(q * 3)], q);
test('un index entier rend l\'horodatage exact de l\'échantillon', () {
expect(axe.instant(2), t0.add(q * 3));
expect(axe.x(t0.add(q * 3)), 2);
});
test('entre deux échantillons : interpolé, même par-dessus un trou', () {
// 08:30 est au milieu de 08:15 et 08:45 — index 1,5 et non 2.
// Aurait attrapé une conversion « (t − t0) / pas » qui ignore le trou et décale
// toute la prévision d'un quart d'heure par rapport aux courbes du dessus.
expect(axe.x(t0.add(q * 2)), closeTo(1.5, 1e-9));
});
test('au-delà de la dernière mesure : extrapolé au pas — le futur', () {
expect(axe.x(t0.add(q * 3 + const Duration(hours: 1))), closeTo(6, 1e-9));
expect(axe.instant(6), t0.add(q * 3 + const Duration(hours: 1)));
});
});
group('fourchette météo (section D)', () {
// Valeurs RÉELLES du plan « Toiture sud 2 kWc » du banc, lues le 2026-10-14.
const banc = (
id: '{3cee9703-5406-426e-9a85-913746175253}',
dispersionAujourdhuiPct: 7.8,
dispersionDemainPct: 19.7,
modele: 'icon_seamless',
kwc: 2.0,
valide: null, // plugin antérieur à etm16 : ni daté, ni invalidé
instant: null,
);
final snap = PvForecastSnapshot.fromJson(_banc());
final f = FourchetteMeteo.depuisPlans([banc]);
final matin = DateTime.utc(2026, 10, 14, 8); // 10:00 à Paris
final bande = bandePrevision(snap, f, maintenant: matin)!;
PvForecastPoint pt(DateTime t) => snap.points.firstWhere((p) => p.debutUtc == t);
test('jour civil de PARIS, heure d\'été comprise', () {
// 22:00Z le 13 = minuit à Paris le 14 (CEST, +2).
expect(jourParis(DateTime.utc(2026, 10, 13, 22)), DateTime.utc(2026, 10, 14));
expect(jourParis(DateTime.utc(2026, 10, 13, 21, 59)), DateTime.utc(2026, 10, 13));
// Après le 25 octobre 01:00Z : CET, +1.
expect(jourParis(DateTime.utc(2026, 10, 26, 22, 30)), DateTime.utc(2026, 10, 26));
expect(jourParis(DateTime.utc(2026, 10, 26, 23)), DateTime.utc(2026, 10, 27));
});
test('±s autour de la courbe : 10:00 Paris aujourd\'hui, 7,8 %', () {
final t = DateTime.utc(2026, 10, 14, 8);
final b = bande.parCreneau[t]!;
expect(bande.source, SourceBande.fourchetteMeteo);
expect(b.basWh, closeTo(pt(t).wh * (1 - 0.078), 1e-6));
expect(b.hautWh, closeTo(pt(t).wh * (1 + 0.078), 1e-6));
});
test('demain a SA dispersion (19,7 %), pas celle du jour', () {
// Aurait attrapé une dispersion du jour appliquée aux 48 créneaux.
final t = DateTime.utc(2026, 10, 15, 8);
final b = bande.parCreneau[t]!;
expect(b.basWh, closeTo(pt(t).wh * (1 - 0.197), 1e-6));
});
test('le haut est arrêté à la crête des plans (2 kWc → 2 000 Wh/h)', () {
// 1 900 Wh × 1,197 = 2 274 Wh dépasserait ce que 2 kWc peuvent produire en une heure.
final s = PvForecastSnapshot.fromJson({
'forecastPoints': [
_pt('2026-10-15T10:00:00Z', 1900),
_pt('2026-10-15T11:00:00Z', 1900),
],
});
final b = bandePrevision(s, f, maintenant: matin)!;
expect(b.parCreneau.values.first.hautWh, 2000);
});
test('le plafond AC l\'emporte sur la crête quand il existe', () {
final s = PvForecastSnapshot.fromJson({
'acLimitW': 1500,
'forecastPoints': [
_pt('2026-10-15T10:00:00Z', 1400),
_pt('2026-10-15T11:00:00Z', 1400),
],
});
expect(bandePrevision(s, f, maintenant: matin)!.parCreneau.values.first.hautWh,
1500);
});
test('le bas est arrêté à 0', () {
final large = FourchetteMeteo.depuisPlans(
[(id: 'x', dispersionAujourdhuiPct: 140.0, dispersionDemainPct: null, modele: 'm', kwc: 2.0, valide: null, instant: null)]);
final b = bandePrevision(snap, large, maintenant: matin)!;
expect(b.parCreneau[DateTime.utc(2026, 10, 14, 8)]!.basWh, 0);
// Et sans dispersion pour demain, demain n'a pas de bande.
expect(b.parCreneau[DateTime.utc(2026, 10, 15, 8)], isNull);
});
test('plusieurs plans : la plus forte dispersion l\'emporte, les crêtes s\'additionnent',
() {
final g = FourchetteMeteo.depuisPlans([
banc,
(id: 'y', dispersionAujourdhuiPct: 12.0, dispersionDemainPct: 5.0, modele: 'icon_seamless', kwc: 3.0, valide: null, instant: null),
]);
expect(g.aujourdhui, closeTo(0.12, 1e-9));
expect(g.demain, closeTo(0.197, 1e-9));
expect(g.kwcTotal, 5.0);
});
test('dispersion absente → pas de bande (0 % sans modèle = état jamais écrit)', () {
final jamais = FourchetteMeteo.depuisPlans(
[(id: 'x', dispersionAujourdhuiPct: 0.0, dispersionDemainPct: 0.0, modele: '', kwc: 2.0, valide: null, instant: null)]);
expect(jamais.vide, isTrue);
expect(bandePrevision(snap, jamais, maintenant: matin), isNull);
expect(bandePrevision(snap, null, maintenant: matin), isNull);
});
test('non datée tant qu\'openmeteo ne date pas la valeur', () {
expect(bande.attenuee, isTrue);
});
testWidgets('un créneau absent est un trou — bande comprise', (t) async {
final troue = PvForecastSnapshot.fromJson({
'forecastPoints': [
_pt('2026-10-14T08:00:00Z', 500),
_pt('2026-10-14T09:00:00Z', 900),
_pt('2026-10-14T11:00:00Z', 1100), // 10:00Z manque
],
});
final b = bandePrevision(troue, f, maintenant: matin)!;
expect(b.parCreneau.containsKey(DateTime.utc(2026, 10, 14, 10)), isFalse);
final t0 = troue.points.first.debutUtc;
await t.pumpWidget(_monte(PvForecastView(
chargement: false,
snapshot: troue,
nomDuThing: (id) => id,
bande: b,
graphe: (_, s) => PvForecastLineChart(
snapshot: s,
bande: b,
x: (d) => d.difference(t0).inSeconds / 3600,
minX: 0,
maxX: 4,
etiquette: (_) => null,
xInterval: 1,
),
)));
final data = t.widget<LineChart>(find.byType(LineChart)).data;
// Deux segments de bande, chacun une paire (haut, bas) remplie entre elles.
// Aurait attrapé un remplissage qui enjambe le trou.
expect(data.betweenBarsData, hasLength(2));
expect(find.textContaining('Fourchette météo (dispersion des modèles) · non datée'),
findsOneWidget);
expect(find.textContaining('intervalle'), findsNothing);
expect(find.textContaining('confiance'), findsNothing);
});
test('p10W / p90W présents → ils REMPLACENT la fourchette météo', () {
final appris = PvForecastSnapshot.fromJson({
'forecastPoints': [
{'targetAt': '2026-10-14T08:00:00Z', 'forecastWh': 500, 'p10W': 200, 'p90W': 600},
{'targetAt': '2026-10-14T09:00:00Z', 'forecastWh': 900, 'p10W': 400, 'p90W': 1000},
],
});
final b = bandePrevision(appris, f, maintenant: matin)!;
expect(b.source, SourceBande.p10p90);
expect(b.parCreneau[DateTime.utc(2026, 10, 14, 8)],
(basWh: 200.0, hautWh: 600.0));
expect(b.attenuee, isFalse);
});
});
group('fourchette météo datée (openmeteo etm16)', () {
final snap = PvForecastSnapshot.fromJson(_banc());
final matin = DateTime.utc(2026, 10, 14, 8);
PlanMeteo plan({required bool valide, double? auj = 11.7, double? dem = 24.6}) => (
id: '{3cee9703-5406-426e-9a85-913746175253}',
dispersionAujourdhuiPct: auj,
dispersionDemainPct: dem,
modele: 'icon_seamless',
kwc: 2.0,
valide: valide,
instant: DateTime.utc(2026, 10, 15, 6, 11),
);
test('valide et daté : bande tracée, plus de « non datée »', () {
final f = FourchetteMeteo.depuisPlans([plan(valide: true)]);
expect(f.datee, isTrue);
final b = bandePrevision(snap, f, maintenant: matin)!;
expect(b.attenuee, isFalse);
});
test('invalide : pas de bande — jamais une bande à 0', () {
// Aurait attrapé une valeur gardée après échec de l'API d'ensemble.
final f = FourchetteMeteo.depuisPlans([plan(valide: false)]);
expect(f.vide, isTrue);
expect(bandePrevision(snap, f, maintenant: matin), isNull);
});
test('valide mais pourcentage null (JSON) : pas de bande ce jour-là', () {
final f = FourchetteMeteo.depuisPlans([plan(valide: true, auj: null)]);
expect(f.aujourdhui, isNull);
expect(f.demain, closeTo(0.246, 1e-9));
});
test('valide et 0 % : une vraie mesure, la bande se confond avec la courbe', () {
final f = FourchetteMeteo.depuisPlans([plan(valide: true, auj: 0)]);
expect(f.aujourdhui, 0);
});
});
}