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>
This commit is contained in:
parent
bedeabf767
commit
adb069f1d2
21
CLAUDE.md
21
CLAUDE.md
@ -227,7 +227,7 @@ proprement** (`cause: "modeChanged"`, `restoredMode` absent — on ne restitue p
|
||||
|
||||
> `Energy.*` s'arrête là. **`SetChargingMode` n'existe pas** — ni ici, ni ailleurs.
|
||||
|
||||
### `NymeaEnergy.*` — powersync-energy-plugin-etm · **20 méthodes / 14 notifications**
|
||||
### `NymeaEnergy.*` — powersync-energy-plugin-etm · **26 méthodes / 14 notifications** (compté sur le banc en `+etm68`, 2026-10-14)
|
||||
|
||||
> ⚠️ Le namespace est **`NymeaEnergy`**, pas `EnergyPlugin`. Ce dernier n'existe pas.
|
||||
> Source d'autorité : `energyplugin/nymeaenergyjsonhandler.cpp` et `JSONRPC.Introspect`.
|
||||
@ -258,7 +258,24 @@ proprement** (`cause: "modeChanged"`, `restoredMode` absent — on ne restitue p
|
||||
| `GetAcquisitionTolerance` / `SetAcquisitionTolerance(Double)` | Seuil de surplus au démarrage — **unité non documentée par la box** | Community |
|
||||
| `GetBatteryLevelConsideration` / `SetBatteryLevelConsideration(Double)` | Part du stockage prise en compte | Community |
|
||||
|
||||
**Tarifs dynamiques**
|
||||
**Prévision PV** (`+etm68`, vérifié sur le banc le 2026-10-14) — carte `PvForecastCard` de l'écran Énergie
|
||||
| Méthode | Usage | Tier |
|
||||
|---|---|---|
|
||||
| `GetForecastSnapshot` | Courbe **en vigueur** : `forecastPoints[{targetAt, forecastWh}]`, ou `fault` | Community |
|
||||
| `GetForecastSite` / `SetForecastSite(forecastSiteThingId, o:acLimitW)` | Site désigné + candidats ; jamais d'adoption par l'app | Community |
|
||||
| `GetPvForecast(from, to, o:knownAt)` | Table enregistrée (historique, révisions) — non câblé | Community |
|
||||
|
||||
> - `targetAt` = **début** du créneau, en UTC ; `forecastWh` = énergie du créneau. Le **pas n'est
|
||||
> pas publié** : il se déduit (`PvForecastSnapshot.pas`), jamais codé en dur (1 h aujourd'hui).
|
||||
> - **Créneau absent = trou, jamais zéro.** `fault` présent ⇒ pas de courbe.
|
||||
> - **Aucune notification** : l'écran relit à l'ouverture puis toutes les 12 min.
|
||||
> - C'est un **plafond optimiste** (ombrage, salissure non modélisés) : à dire à l'écran.
|
||||
> - 🔴 `SetForecastSite.acLimitW` : **absent = inchangé**, `null` = effacer. L'app l'omet.
|
||||
> - Bornes VE : identifiant en `{uuid}` minuscules **obligatoire jusqu'à `etm69`**, sinon la
|
||||
> borne est vue absente — `idBorneCanonique()` dans `nymea_service.dart`.
|
||||
|
||||
**Tarifs dynamiques** — 🔴 **masqués** : depuis `+etm68` la liste des fournisseurs est vide et
|
||||
`SetSpotMarketConfiguration{enabled:true}` rend `fault: "SPOT_MARKET_NOT_IN_BUILD"`.
|
||||
| Méthode | Usage | Tier |
|
||||
|---|---|---|
|
||||
| `GetAvailableSpotMarketProviders` | Liste providers | Predict AI |
|
||||
|
||||
236
lib/models/pv_forecast.dart
Normal file
236
lib/models/pv_forecast.dart
Normal file
@ -0,0 +1,236 @@
|
||||
/// **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?,
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -51,8 +51,6 @@ class _TariffScreenState extends State<TariffScreen> {
|
||||
const SizedBox(height: 12),
|
||||
_HcHpCard(tariff),
|
||||
],
|
||||
const SizedBox(height: 12),
|
||||
_PricesCard(tariff),
|
||||
],
|
||||
],
|
||||
),
|
||||
@ -70,7 +68,10 @@ class _ProviderCard extends StatelessWidget {
|
||||
return _SectionCard(
|
||||
title: 'Provider actif',
|
||||
child: Column(
|
||||
children: TariffProviderType.values.map((p) {
|
||||
// Les fournisseurs de prix dynamiques (Tibber, Octopus, ENTSO-E) sont masqués :
|
||||
// le marché spot n'est pas dans le build du moteur (`+etm68` :
|
||||
// `SPOT_MARKET_NOT_IN_BUILD`, liste des fournisseurs vide).
|
||||
children: TariffProviderType.values.where((p) => !p.isPro).map((p) {
|
||||
final selected = tariff.activeProvider == p;
|
||||
return _ProviderTile(
|
||||
provider: p,
|
||||
@ -138,14 +139,18 @@ class _ModeCard extends StatelessWidget {
|
||||
return _SectionCard(
|
||||
title: 'Mode tarifaire',
|
||||
child: DropdownButtonFormField<TariffMode>(
|
||||
value: tariff.mode,
|
||||
value: tariff.mode == TariffMode.spotMarket ? null : tariff.mode,
|
||||
decoration: InputDecoration(
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12)),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 14, vertical: 12),
|
||||
),
|
||||
// « Spot Market » masqué : `SetSpotMarketConfiguration{enabled:true}` rend
|
||||
// `SPOT_MARKET_NOT_IN_BUILD` depuis `+etm68`. Proposer un mode que la box refuse
|
||||
// ferait croire à un réglage pris.
|
||||
items: TariffMode.values
|
||||
.where((m) => m != TariffMode.spotMarket)
|
||||
.map((m) =>
|
||||
DropdownMenuItem(value: m, child: Text(m.label)))
|
||||
.toList(),
|
||||
@ -430,84 +435,6 @@ class _ManualTariffCardState extends State<_ManualTariffCard> {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Carte prix actuels ────────────────────────────────────────────────────────
|
||||
class _PricesCard extends StatelessWidget {
|
||||
final TariffProvider tariff;
|
||||
const _PricesCard(this.tariff);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final p = tariff.prices;
|
||||
|
||||
String _fmtTime(DateTime? dt) {
|
||||
if (dt == null) return '';
|
||||
return ' (${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')})';
|
||||
}
|
||||
|
||||
return _SectionCard(
|
||||
title: 'Prix actuels (live)',
|
||||
child: Column(
|
||||
children: [
|
||||
_PriceItem(
|
||||
label: 'Maintenant',
|
||||
value: '${p.now.toStringAsFixed(3)} €/kWh',
|
||||
),
|
||||
_PriceItem(
|
||||
label: 'Dans 1h',
|
||||
value: '${p.inOneHour.toStringAsFixed(3)} €/kWh',
|
||||
),
|
||||
_PriceItem(
|
||||
label: 'Minimum 24h',
|
||||
value:
|
||||
'${p.min24h.toStringAsFixed(3)} €/kWh${_fmtTime(p.minTime)}',
|
||||
color: AppTheme.primaryGreen,
|
||||
),
|
||||
_PriceItem(
|
||||
label: 'Maximum 24h',
|
||||
value:
|
||||
'${p.max24h.toStringAsFixed(3)} €/kWh${_fmtTime(p.maxTime)}',
|
||||
color: AppTheme.boostRed,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PriceItem extends StatelessWidget {
|
||||
final String label;
|
||||
final String value;
|
||||
final Color? color;
|
||||
|
||||
const _PriceItem({
|
||||
required this.label,
|
||||
required this.value,
|
||||
this.color,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 6),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(label,
|
||||
style: const TextStyle(fontSize: 13))),
|
||||
Text(
|
||||
value,
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: color ?? AppTheme.textDark,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Card générique ────────────────────────────────────────────────────────────
|
||||
class _SectionCard extends StatelessWidget {
|
||||
final String title;
|
||||
|
||||
@ -11,6 +11,7 @@ import '../providers/load_config_provider.dart';
|
||||
import '../services/energy_ratios.dart';
|
||||
import '../services/nymea_service.dart';
|
||||
import '../theme/etm_tokens.dart';
|
||||
import '../widgets/pv_forecast_card.dart';
|
||||
import '../main.dart' show DrawerMenuButton;
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@ -662,8 +663,8 @@ class _EnergyScreenState extends State<EnergyScreen> {
|
||||
_notesConsommateurs(),
|
||||
const SizedBox(height: 14),
|
||||
|
||||
// Météo & prévision — placeholder
|
||||
_ForecastPlaceholder(),
|
||||
// Prévision PV — lue sur la box (`GetForecastSnapshot`, `+etm68`).
|
||||
const PvForecastCard(),
|
||||
]),
|
||||
),
|
||||
),
|
||||
@ -1958,63 +1959,3 @@ class _LegendDot extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────── Météo placeholder ─────────────────────────────────
|
||||
|
||||
class _ForecastPlaceholder extends StatelessWidget {
|
||||
const _ForecastPlaceholder();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(18),
|
||||
decoration: BoxDecoration(
|
||||
color: EtmTokens.surfaceOf(context),
|
||||
borderRadius: BorderRadius.circular(EtmTokens.radiusLg),
|
||||
boxShadow: EtmTokens.cardShadow,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text('Météo & prévision',
|
||||
style: EtmTokens.sans(size: 16, weight: FontWeight.w600)),
|
||||
Text('aujourd\'hui',
|
||||
style: EtmTokens.sans(size: 12, color: EtmTokens.mutedOf(context))),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: EtmTokens.bgOf(context),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.cloud_outlined, color: EtmTokens.faintOf(context), size: 30),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('Prévisions non disponibles',
|
||||
style: EtmTokens.sans(size: 13, weight: FontWeight.w500,
|
||||
color: EtmTokens.mutedOf(context))),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Configurez un provider dans Tarifs & Héos '
|
||||
'pour activer les prévisions PV et tarifaires.',
|
||||
style: EtmTokens.sans(size: 11, color: EtmTokens.faintOf(context))),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -12,11 +12,29 @@ import '../models/energy_data.dart'; // EnergyData, HistoryPoint, PowerBalanc
|
||||
import '../models/ev_charger_live.dart';
|
||||
import '../models/modbus_models.dart'; // ModbusRtuMaster, SerialPort
|
||||
import '../models/nymea_models.dart';
|
||||
import '../models/pv_forecast.dart';
|
||||
import '../models/consumer_series.dart';
|
||||
import '../models/load_telemetry.dart' show kDerogationMinS, kDerogationMaxS;
|
||||
import 'energy_ratios.dart'; // seam interim ratios (§2.1)
|
||||
|
||||
// ── Protocole de connexion ─────────────────────────────────────────────────────
|
||||
/// **Identifiant de borne à la forme que le moteur exige : `{uuid}` en minuscules.**
|
||||
///
|
||||
/// Obligatoire jusqu'à la release des correctifs moteur (`etm69`, consigne du 2026-10-13) :
|
||||
/// sous une autre forme, le rang de la borne est ignoré et elle est vue comme **absente** —
|
||||
/// sans erreur. Les Things arrivent déjà sous cette forme de `GetThings` ; ce garde-fou vise
|
||||
/// les autres sources (saisie, identifiant sans accolades, majuscules).
|
||||
///
|
||||
/// Un identifiant qui n'est pas un UUID (`chauffe-eau`) est rendu **tel quel** : c'est le
|
||||
/// nom d'une charge déclarée, et le réécrire la ferait disparaître.
|
||||
String idBorneCanonique(String id) {
|
||||
final nu = id.trim().replaceAll(RegExp(r'^\{|\}$'), '');
|
||||
final estUuid = RegExp(
|
||||
r'^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$')
|
||||
.hasMatch(nu);
|
||||
return estUuid ? '{${nu.toLowerCase()}}' : id;
|
||||
}
|
||||
|
||||
enum NymeaProtocol {
|
||||
/// TCP brut port 2222 — nymea parle en premier (\n-delimited JSON)
|
||||
tcpRaw,
|
||||
@ -977,6 +995,76 @@ class NymeaService extends ChangeNotifier {
|
||||
|
||||
EnergyRatiosSnapshot? _ratios;
|
||||
|
||||
// ─── Prévision PV (`+etm68`) ───────────────────────────────────────────────
|
||||
//
|
||||
// Aucune notification `*Changed` n'existe pour la prévision, par décision du moteur :
|
||||
// l'écran RELIT, à l'ouverture puis périodiquement. Ces appels ne cachent rien.
|
||||
|
||||
/// `NymeaEnergy.GetForecastSnapshot` — la prévision **en vigueur**, lue sur le canal.
|
||||
///
|
||||
/// `erreur` non nulle = **non lu** : ce n'est pas « rien de publié ». Une box antérieure
|
||||
/// qui ne connaît pas la méthode rend `NoSuchMethod`, un échec de transport son message.
|
||||
Future<({PvForecastSnapshot? snapshot, String? erreur})>
|
||||
fetchForecastSnapshot() async {
|
||||
if (_isSimulation) return (snapshot: null, erreur: 'Simulation');
|
||||
if (!_connected) return (snapshot: null, erreur: 'Disconnected');
|
||||
try {
|
||||
final r = await _sendRequest('NymeaEnergy.GetForecastSnapshot', {});
|
||||
final p = r['params'];
|
||||
if (r['status'] != 'success' || p is! Map) {
|
||||
final e = '${r['error'] ?? r['status']}';
|
||||
return (
|
||||
snapshot: null,
|
||||
erreur: e.toLowerCase().contains('no such method') ? 'NoSuchMethod' : e,
|
||||
);
|
||||
}
|
||||
return (
|
||||
snapshot: PvForecastSnapshot.fromJson(Map<String, dynamic>.from(p)),
|
||||
erreur: null,
|
||||
);
|
||||
} catch (e) {
|
||||
_log('fetchForecastSnapshot: $e');
|
||||
return (snapshot: null, erreur: e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
/// `NymeaEnergy.GetForecastSite`. `null` = non lu.
|
||||
Future<PvForecastSite?> fetchForecastSite() async {
|
||||
if (_isSimulation || !_connected) return null;
|
||||
try {
|
||||
final r = await _sendRequest('NymeaEnergy.GetForecastSite', {});
|
||||
final p = r['params'];
|
||||
if (r['status'] != 'success' || p is! Map) return null;
|
||||
return PvForecastSite.fromJson(Map<String, dynamic>.from(p));
|
||||
} catch (e) {
|
||||
_log('fetchForecastSite: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// `NymeaEnergy.SetForecastSite` — désigne le site dont la prévision est gardée.
|
||||
///
|
||||
/// Geste **explicite** de l'utilisateur, jamais une adoption automatique.
|
||||
///
|
||||
/// `acLimitW` est **omis** : c'est le seul champ que le moteur ne remplace pas, et
|
||||
/// l'omettre le laisse **inchangé**. L'envoyer à `null` l'effacerait — ce que ce geste
|
||||
/// ne demande pas.
|
||||
///
|
||||
/// Rend `null` si la box a accepté, sinon son code de refus.
|
||||
Future<String?> setForecastSite(String siteThingId) async {
|
||||
if (_isSimulation || !_connected) return 'Disconnected';
|
||||
try {
|
||||
final r = await _sendRequest('NymeaEnergy.SetForecastSite',
|
||||
{'forecastSiteThingId': siteThingId});
|
||||
if (r['status'] != 'success') return '${r['error'] ?? r['status']}';
|
||||
final err = r['params']?['energyError'] as String?;
|
||||
return (err == null || err == 'EnergyErrorNoError') ? null : err;
|
||||
} catch (e) {
|
||||
_log('setForecastSite: $e');
|
||||
return e.toString();
|
||||
}
|
||||
}
|
||||
|
||||
/// Le dernier instantané lu, ou `null` tant que rien ne l'a demandé. Les écrans de
|
||||
/// configuration s'en servent pour **qualifier une heure**, pas pour afficher un taux.
|
||||
EnergyRatiosSnapshot? get energyRatios => _ratios;
|
||||
@ -1037,7 +1125,7 @@ class NymeaService extends ChangeNotifier {
|
||||
}
|
||||
try {
|
||||
final r = await _sendRequest('NymeaEnergy.SetLoadDerogation', {
|
||||
'loadId': loadId,
|
||||
'loadId': idBorneCanonique(loadId),
|
||||
'durationS': durationS,
|
||||
'targetWh': ?targetWh,
|
||||
});
|
||||
@ -1057,7 +1145,7 @@ class NymeaService extends ChangeNotifier {
|
||||
Future<String?> clearLoadDerogation(String loadId) async {
|
||||
try {
|
||||
final r = await _sendRequest(
|
||||
'NymeaEnergy.ClearLoadDerogation', {'loadId': loadId});
|
||||
'NymeaEnergy.ClearLoadDerogation', {'loadId': idBorneCanonique(loadId)});
|
||||
final err = (r['params']?['energyError'] ?? r['energyError']) as String?;
|
||||
return (err == null || err == 'EnergyErrorNoError') ? null : err;
|
||||
} catch (e) {
|
||||
@ -1524,7 +1612,7 @@ class NymeaService extends ChangeNotifier {
|
||||
|
||||
final info = <String, dynamic>{
|
||||
...courant,
|
||||
'evChargerId': vise,
|
||||
'evChargerId': idBorneCanonique(vise),
|
||||
'chargingMode': apiMode,
|
||||
};
|
||||
// `chargingState` est en lecture seule (`r:`) au schéma : le renvoyer ferait
|
||||
@ -1863,8 +1951,17 @@ class NymeaService extends ChangeNotifier {
|
||||
return 'Disconnected';
|
||||
}
|
||||
try {
|
||||
// Les bornes partent sous `{uuid}` minuscules (cf. [idBorneCanonique]). Les autres
|
||||
// charges restent verbatim : leur `id` est un nom, pas un Thing.
|
||||
final envoi = [
|
||||
for (final p in payloads)
|
||||
if (p['adapter'] == 'evcharger' && p['id'] is String)
|
||||
{...p, 'id': idBorneCanonique(p['id'] as String)}
|
||||
else
|
||||
p,
|
||||
];
|
||||
final r = await _sendRequest(
|
||||
'NymeaEnergy.SetLoadConfig', {'loadConfigs': payloads});
|
||||
'NymeaEnergy.SetLoadConfig', {'loadConfigs': envoi});
|
||||
final err = r['params']?['energyError'] as String?;
|
||||
_log('[LoadConfig] SetLoadConfig(${payloads.length} charge(s)) ← '
|
||||
'${err ?? r['status']}', force: true);
|
||||
|
||||
623
lib/widgets/pv_forecast_card.dart
Normal file
623
lib/widgets/pv_forecast_card.dart
Normal file
@ -0,0 +1,623 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:fl_chart/fl_chart.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../models/pv_forecast.dart';
|
||||
import '../providers/installer_mode_provider.dart';
|
||||
import '../services/nymea_service.dart';
|
||||
import '../theme/etm_tokens.dart';
|
||||
|
||||
/// Une production mesurée, prête à superposer : instant absolu et puissance en W,
|
||||
/// **déjà retournée** (nymea publie la production négative).
|
||||
typedef ProductionMesuree = ({DateTime t, double w});
|
||||
|
||||
/// Intervalle de relecture. La prévision ne notifie RIEN (décision moteur) : sans
|
||||
/// relecture, l'écran laissé ouvert montrerait la courbe du matin le soir.
|
||||
const Duration kRelecturePrevision = Duration(minutes: 12);
|
||||
|
||||
/// **Carte « Prévision PV »** — lit la box, relit périodiquement, et délègue le rendu à
|
||||
/// [PvForecastView].
|
||||
///
|
||||
/// Remplace le placeholder « Météo & prévision » de l'écran Énergie : la courbe existe
|
||||
/// désormais sur la box (`NymeaEnergy.GetForecastSnapshot`, `+etm68`).
|
||||
class PvForecastCard extends StatefulWidget {
|
||||
const PvForecastCard({super.key});
|
||||
|
||||
@override
|
||||
State<PvForecastCard> createState() => _PvForecastCardState();
|
||||
}
|
||||
|
||||
class _PvForecastCardState extends State<PvForecastCard> {
|
||||
Timer? _timer;
|
||||
bool _chargement = true;
|
||||
PvForecastSnapshot? _snapshot;
|
||||
String? _erreur;
|
||||
PvForecastSite? _site;
|
||||
List<ProductionMesuree> _mesure = const [];
|
||||
String? _refusDesignation;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _lire());
|
||||
_timer = Timer.periodic(kRelecturePrevision, (_) => _lire());
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_timer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _lire() async {
|
||||
if (!mounted) return;
|
||||
final service = context.read<NymeaService>();
|
||||
final r = await service.fetchForecastSnapshot();
|
||||
if (!mounted) return;
|
||||
|
||||
PvForecastSite? site;
|
||||
var mesure = const <ProductionMesuree>[];
|
||||
final snap = r.snapshot;
|
||||
if (snap?.etat == PvForecastEtat.siteManquant) {
|
||||
site = await service.fetchForecastSite();
|
||||
} else if (snap?.etat == PvForecastEtat.courbe) {
|
||||
final debut = snap!.points.first.debutUtc;
|
||||
final maintenant = DateTime.now().toUtc();
|
||||
if (debut.isBefore(maintenant)) {
|
||||
final logs = await service.fetchPowerBalanceLogs(
|
||||
from: debut.toLocal(), to: maintenant.toLocal());
|
||||
mesure = [
|
||||
// `production` est NÉGATIVE dans nymea : on INVERSE le signe, on ne prend pas
|
||||
// la valeur absolue. Un onduleur qui consomme la nuit publie une production
|
||||
// positive ; `.abs()` la ferait lire comme une production.
|
||||
for (final e in logs) (t: e.timestamp, w: -e.productionW),
|
||||
];
|
||||
}
|
||||
}
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_chargement = false;
|
||||
_snapshot = snap;
|
||||
_erreur = r.erreur;
|
||||
_site = site;
|
||||
_mesure = mesure;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _designer(String siteId) async {
|
||||
final service = context.read<NymeaService>();
|
||||
final err = await service.setForecastSite(siteId);
|
||||
if (!mounted) return;
|
||||
setState(() => _refusDesignation = err);
|
||||
await _lire();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final service = context.watch<NymeaService>();
|
||||
final installateur = context.watch<InstallerModeProvider>().isUnlocked;
|
||||
return PvForecastView(
|
||||
chargement: _chargement,
|
||||
snapshot: _snapshot,
|
||||
erreur: _erreur,
|
||||
site: _site,
|
||||
mesure: _mesure,
|
||||
maintenant: DateTime.now(),
|
||||
installateur: installateur,
|
||||
nomDuThing: (id) => _nom(service, id),
|
||||
refusDesignation: _refusDesignation,
|
||||
onDesigner: _designer,
|
||||
onRelire: _lire,
|
||||
);
|
||||
}
|
||||
|
||||
static String _nom(NymeaService s, String id) {
|
||||
final nu = id.replaceAll(RegExp(r'[{}]'), '').toLowerCase();
|
||||
for (final t in s.things) {
|
||||
if (t.id.replaceAll(RegExp(r'[{}]'), '').toLowerCase() == nu) return t.name;
|
||||
}
|
||||
return id;
|
||||
}
|
||||
}
|
||||
|
||||
/// Rendu pur de la prévision — tout ce qu'il affiche lui est donné.
|
||||
class PvForecastView extends StatelessWidget {
|
||||
final bool chargement;
|
||||
final PvForecastSnapshot? snapshot;
|
||||
|
||||
/// Non nul = la prévision n'a pas été LUE. Ce n'est pas « rien de publié ».
|
||||
final String? erreur;
|
||||
final PvForecastSite? site;
|
||||
final List<ProductionMesuree> mesure;
|
||||
final DateTime maintenant;
|
||||
final bool installateur;
|
||||
final String Function(String thingId) nomDuThing;
|
||||
final String? refusDesignation;
|
||||
final Future<void> Function(String siteId)? onDesigner;
|
||||
final Future<void> Function()? onRelire;
|
||||
|
||||
const PvForecastView({
|
||||
super.key,
|
||||
required this.chargement,
|
||||
required this.snapshot,
|
||||
this.erreur,
|
||||
this.site,
|
||||
this.mesure = const [],
|
||||
required this.maintenant,
|
||||
this.installateur = false,
|
||||
required this.nomDuThing,
|
||||
this.refusDesignation,
|
||||
this.onDesigner,
|
||||
this.onRelire,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final muted = EtmTokens.mutedOf(context);
|
||||
final snap = snapshot;
|
||||
final pas = snap?.pas;
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(18),
|
||||
decoration: BoxDecoration(
|
||||
color: EtmTokens.surfaceOf(context),
|
||||
borderRadius: BorderRadius.circular(EtmTokens.radiusLg),
|
||||
boxShadow: EtmTokens.cardShadow,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text('Prévision PV',
|
||||
style: EtmTokens.sans(size: 16, weight: FontWeight.w600)),
|
||||
if (snap?.etat == PvForecastEtat.courbe && pas != null)
|
||||
Text('par créneau de ${_duree(pas)}',
|
||||
style: EtmTokens.sans(size: 12, color: muted)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
..._corps(context),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
List<Widget> _corps(BuildContext context) {
|
||||
if (chargement) {
|
||||
return const [
|
||||
SizedBox(
|
||||
height: 60,
|
||||
child: Center(
|
||||
child: SizedBox(
|
||||
width: 22,
|
||||
height: 22,
|
||||
child: CircularProgressIndicator(strokeWidth: 2)))),
|
||||
];
|
||||
}
|
||||
final snap = snapshot;
|
||||
if (snap == null) {
|
||||
if (erreur == 'NoSuchMethod') {
|
||||
return [
|
||||
_Encadre(
|
||||
icone: Icons.cloud_off_outlined,
|
||||
titre: 'Cette box ne publie pas de prévision PV',
|
||||
texte: 'Le moteur installé ne connaît pas encore '
|
||||
'« NymeaEnergy.GetForecastSnapshot ». La carte s\'activera après '
|
||||
'mise à jour.'),
|
||||
];
|
||||
}
|
||||
if (erreur == 'Simulation') {
|
||||
return [
|
||||
_Encadre(
|
||||
icone: Icons.science_outlined,
|
||||
titre: 'Pas de prévision en simulation',
|
||||
texte: 'La prévision vient de la box ; aucune n\'est inventée ici.'),
|
||||
];
|
||||
}
|
||||
return [
|
||||
_Encadre(
|
||||
icone: Icons.sync_problem_rounded,
|
||||
titre: 'Prévision non lue',
|
||||
texte: 'La box n\'a pas répondu (${erreur ?? 'raison inconnue'}). '
|
||||
'Ce n\'est pas une absence de prévision : elle n\'a pas pu être lue.',
|
||||
action: onRelire == null
|
||||
? null
|
||||
: TextButton(onPressed: onRelire, child: const Text('Relire'))),
|
||||
];
|
||||
}
|
||||
|
||||
switch (snap.etat) {
|
||||
case PvForecastEtat.siteManquant:
|
||||
return _siteManquant(context);
|
||||
case PvForecastEtat.chargeRefusee:
|
||||
final cause = snap.faultParams['cause'];
|
||||
return [
|
||||
_Encadre(
|
||||
icone: Icons.report_gmailerrorred_rounded,
|
||||
titre: 'Prévision refusée par la box',
|
||||
texte: 'Le producteur météo publie une prévision que le moteur ne sait '
|
||||
'pas lire — c\'est lui qu\'il faut regarder, pas ce réglage.'
|
||||
'${cause != null ? '\nCause : $cause' : ''}',
|
||||
code: 'FORECAST_PAYLOAD_REFUSED'),
|
||||
];
|
||||
case PvForecastEtat.faultInconnu:
|
||||
return [
|
||||
_Encadre(
|
||||
icone: Icons.help_outline,
|
||||
titre: 'Pas de courbe : défaut publié par la box',
|
||||
texte: 'Ce défaut est inconnu de cette version de l\'app.'
|
||||
'${snap.faultParams.isNotEmpty ? '\n${snap.faultParams}' : ''}',
|
||||
code: snap.fault),
|
||||
];
|
||||
case PvForecastEtat.rienPublie:
|
||||
return [
|
||||
_Encadre(
|
||||
icone: Icons.cloud_queue_rounded,
|
||||
titre: 'Aucune prévision publiée',
|
||||
texte: 'Pas de plugin météo sur cette box, ou ses données ne sont pas '
|
||||
'encore arrivées. La box ne distingue pas les deux cas.'),
|
||||
];
|
||||
case PvForecastEtat.courbe:
|
||||
return _courbe(context, snap);
|
||||
}
|
||||
}
|
||||
|
||||
List<Widget> _siteManquant(BuildContext context) {
|
||||
final candidats = site?.candidats ?? const <String>[];
|
||||
return [
|
||||
_Encadre(
|
||||
icone: Icons.place_outlined,
|
||||
titre: 'Aucun site de prévision désigné',
|
||||
texte: candidats.isEmpty
|
||||
? 'Des champs PV publient une prévision, mais aucun site n\'est '
|
||||
'désigné et la liste des sites possibles n\'a pas pu être lue.'
|
||||
: 'Des champs PV publient une prévision. Choisissez le site dont la '
|
||||
'production doit être prévue — tous ses champs seront additionnés.',
|
||||
code: 'MISSING_FORECAST_SITE'),
|
||||
for (final c in candidats)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
child: Row(children: [
|
||||
const Icon(Icons.solar_power_outlined, size: 18),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(nomDuThing(c),
|
||||
style: EtmTokens.sans(size: 13, weight: FontWeight.w500))),
|
||||
TextButton(
|
||||
onPressed: onDesigner == null ? null : () => _confirmer(context, c),
|
||||
child: const Text('Désigner'),
|
||||
),
|
||||
]),
|
||||
),
|
||||
if (refusDesignation != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
child: Text('La box a refusé la désignation : $refusDesignation',
|
||||
style: EtmTokens.sans(size: 12, color: EtmTokens.danger)),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
Future<void> _confirmer(BuildContext context, String siteId) async {
|
||||
final ok = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('Désigner ce site ?'),
|
||||
content: Text('La prévision de « ${nomDuThing(siteId)} » sera gardée et '
|
||||
'utilisée par le moteur. Le choix est enregistré sur la box.'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, false),
|
||||
child: const Text('Annuler')),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(ctx, true),
|
||||
child: const Text('Désigner')),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (ok == true) await onDesigner?.call(siteId);
|
||||
}
|
||||
|
||||
List<Widget> _courbe(BuildContext context, PvForecastSnapshot snap) {
|
||||
final muted = EtmTokens.mutedOf(context);
|
||||
final manquants = snap.creneauxManquants;
|
||||
final change = snap.contentChangedAt;
|
||||
return [
|
||||
Wrap(spacing: 14, runSpacing: 4, children: [
|
||||
_Legende(EtmTokens.solarOf(context), 'Prévision (plafond optimiste)'),
|
||||
if (mesure.isNotEmpty) _Legende(EtmTokens.ecoOf(context), 'Production mesurée'),
|
||||
]),
|
||||
const SizedBox(height: 10),
|
||||
SizedBox(height: 200, child: _graphe(context, snap)),
|
||||
const SizedBox(height: 10),
|
||||
_Note(
|
||||
icone: Icons.info_outline_rounded,
|
||||
texte: 'Une prévision, pas une mesure — et un plafond optimiste : ombrage et '
|
||||
'salissure ne sont pas modélisés, la production réelle sera en général '
|
||||
'en dessous.'),
|
||||
if (manquants > 0)
|
||||
_Note(
|
||||
icone: Icons.more_horiz_rounded,
|
||||
texte: '$manquants créneau${manquants > 1 ? 'x' : ''} sans prévision — '
|
||||
'laissé${manquants > 1 ? 's' : ''} vide${manquants > 1 ? 's' : ''}, '
|
||||
'pas à zéro.'),
|
||||
_Note(
|
||||
icone: Icons.update_rounded,
|
||||
texte: change != null
|
||||
? 'Courbe inchangée depuis ${_jourHeure(change)}.'
|
||||
: 'Date du dernier changement de la courbe non publiée par la box.'),
|
||||
if (installateur) ...[
|
||||
const SizedBox(height: 6),
|
||||
Text('Détails installateur',
|
||||
style: EtmTokens.sans(size: 12, weight: FontWeight.w600, color: muted)),
|
||||
const SizedBox(height: 4),
|
||||
_ligne(context, 'Corrections',
|
||||
snap.corrections == null
|
||||
? 'non publié'
|
||||
: snap.corrections!.isEmpty
|
||||
? 'aucune (GTI × kWc × PR brut)'
|
||||
: snap.corrections!.map(_correction).join(', ')),
|
||||
if (snap.acLimitW != null)
|
||||
_ligne(context, 'Plafond AC du site', '${snap.acLimitW} W (courbe écrêtée)'),
|
||||
if (snap.perField.isNotEmpty)
|
||||
_ligne(context, 'Champs additionnés', '${snap.perField.length}'),
|
||||
for (final f in snap.perField)
|
||||
_ligne(context, ' ${nomDuThing(f.id)}',
|
||||
f.corrections.isEmpty ? 'aucune correction' : f.corrections.map(_correction).join(', ')),
|
||||
if (snap.model != null) _ligne(context, 'Modèle', snap.model!),
|
||||
if (snap.conversion != null) _ligne(context, 'Conversion', snap.conversion!),
|
||||
_ligne(context, 'Run émis',
|
||||
snap.issuedAt != null ? _jourHeure(snap.issuedAt!) : 'non publié'),
|
||||
if (snap.capturedAt != null)
|
||||
_ligne(context, 'Lue à', _jourHeure(snap.capturedAt!)),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
Widget _graphe(BuildContext context, PvForecastSnapshot snap) {
|
||||
final t0 = snap.points.first.debutUtc;
|
||||
final pas = snap.pas;
|
||||
double x(DateTime t) => t.toUtc().difference(t0).inSeconds / 3600.0;
|
||||
|
||||
// L'énergie d'un créneau devient une puissance MOYENNE sur ce créneau : seule unité
|
||||
// commune avec la production mesurée. Simple changement d'unité, pas un calcul de
|
||||
// prévision. Sans pas connu (un point seul), on ne sait pas sur quoi diviser.
|
||||
final prev = <FlSpot>[];
|
||||
if (pas != null) {
|
||||
final h = pas.inSeconds / 3600.0;
|
||||
for (final seg in snap.segments) {
|
||||
if (prev.isNotEmpty) prev.add(FlSpot.nullSpot); // un trou, jamais un zéro
|
||||
for (final p in seg) {
|
||||
final kw = p.wh / h / 1000;
|
||||
prev
|
||||
..add(FlSpot(x(p.debutUtc), kw))
|
||||
..add(FlSpot(x(p.debutUtc) + h, kw));
|
||||
}
|
||||
}
|
||||
}
|
||||
final xMax = pas == null
|
||||
? max(1.0, x(snap.points.last.debutUtc))
|
||||
: x(snap.points.last.debutUtc) + pas.inSeconds / 3600.0;
|
||||
|
||||
final mes = <FlSpot>[];
|
||||
DateTime? avant;
|
||||
for (final m in mesure) {
|
||||
final xm = x(m.t);
|
||||
if (xm < 0 || xm > xMax) continue;
|
||||
// Un trou de mesure reste un trou : plus de deux fois l'écart attendu (15 min).
|
||||
if (avant != null && m.t.difference(avant) > const Duration(minutes: 31)) {
|
||||
mes.add(FlSpot.nullSpot);
|
||||
}
|
||||
mes.add(FlSpot(xm, m.w / 1000));
|
||||
avant = m.t;
|
||||
}
|
||||
|
||||
final valeurs = [
|
||||
for (final s in [...prev, ...mes])
|
||||
if (s != FlSpot.nullSpot) s.y
|
||||
];
|
||||
final yMax = max(0.5, (valeurs.isEmpty ? 0.0 : valeurs.reduce(max)) * 1.15);
|
||||
final yMin = min(0.0, valeurs.isEmpty ? 0.0 : valeurs.reduce(min));
|
||||
final xNow = x(maintenant);
|
||||
final label = EtmTokens.sans(size: 9, color: EtmTokens.mutedOf(context));
|
||||
final solaire = EtmTokens.solarOf(context);
|
||||
final eco = EtmTokens.ecoOf(context);
|
||||
|
||||
return LineChart(
|
||||
LineChartData(
|
||||
clipData: const FlClipData.all(),
|
||||
minX: 0,
|
||||
maxX: xMax,
|
||||
minY: yMin,
|
||||
maxY: yMax,
|
||||
borderData: FlBorderData(show: false),
|
||||
gridData: FlGridData(
|
||||
show: true,
|
||||
drawVerticalLine: false,
|
||||
horizontalInterval: yMax / 4,
|
||||
getDrawingHorizontalLine: (_) =>
|
||||
FlLine(color: EtmTokens.lineOf(context), strokeWidth: 1),
|
||||
),
|
||||
extraLinesData: ExtraLinesData(verticalLines: [
|
||||
if (xNow > 0 && xNow < xMax)
|
||||
VerticalLine(
|
||||
x: xNow,
|
||||
color: EtmTokens.mutedOf(context),
|
||||
strokeWidth: 1,
|
||||
dashArray: const [3, 3]),
|
||||
]),
|
||||
titlesData: FlTitlesData(
|
||||
topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
|
||||
rightTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
|
||||
leftTitles: AxisTitles(
|
||||
sideTitles: SideTitles(
|
||||
showTitles: true,
|
||||
reservedSize: 40,
|
||||
interval: yMax / 4,
|
||||
getTitlesWidget: (v, meta) => v == meta.max
|
||||
? const SizedBox.shrink()
|
||||
: SideTitleWidget(
|
||||
meta: meta,
|
||||
child: Text('${v.toStringAsFixed(1)}kW', style: label)),
|
||||
),
|
||||
),
|
||||
bottomTitles: AxisTitles(
|
||||
sideTitles: SideTitles(
|
||||
showTitles: true,
|
||||
reservedSize: 22,
|
||||
interval: 1,
|
||||
getTitlesWidget: (v, meta) {
|
||||
if (v <= meta.min || v >= meta.max) return const SizedBox.shrink();
|
||||
// Heure LOCALE à l'affichage seulement ; la grille reste en UTC.
|
||||
final t = t0.add(Duration(seconds: (v * 3600).round())).toLocal();
|
||||
if (t.minute != 0 || t.hour % 6 != 0) return const SizedBox.shrink();
|
||||
final txt = t.hour == 0
|
||||
? '${t.day}/${t.month}'
|
||||
: '${t.hour.toString().padLeft(2, '0')}h';
|
||||
return SideTitleWidget(meta: meta, child: Text(txt, style: label));
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
lineTouchData: const LineTouchData(enabled: false),
|
||||
lineBarsData: [
|
||||
LineChartBarData(
|
||||
spots: prev,
|
||||
isCurved: false,
|
||||
color: solaire,
|
||||
barWidth: 1.5,
|
||||
dashArray: const [5, 3],
|
||||
dotData: FlDotData(show: pas == null),
|
||||
belowBarData:
|
||||
BarAreaData(show: true, color: solaire.withValues(alpha: 0.18)),
|
||||
),
|
||||
if (mes.isNotEmpty)
|
||||
LineChartBarData(
|
||||
spots: mes,
|
||||
isCurved: false,
|
||||
color: eco,
|
||||
barWidth: 1.8,
|
||||
dotData: const FlDotData(show: false),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _ligne(BuildContext context, String k, String v) => Padding(
|
||||
padding: const EdgeInsets.only(top: 2),
|
||||
child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
SizedBox(
|
||||
width: 130,
|
||||
child: Text(k,
|
||||
style: EtmTokens.sans(size: 11.5, color: EtmTokens.mutedOf(context)))),
|
||||
Expanded(child: Text(v, style: EtmTokens.mono(size: 11.5))),
|
||||
]),
|
||||
);
|
||||
|
||||
static String _correction(String c) => switch (c) {
|
||||
'temperature' => 'température de module',
|
||||
'ac-limit' => 'plafond onduleur',
|
||||
_ => c,
|
||||
};
|
||||
|
||||
static String _duree(Duration d) => d.inMinutes % 60 == 0
|
||||
? '${d.inHours} h'
|
||||
: '${d.inMinutes} min';
|
||||
|
||||
static String _jourHeure(DateTime t) {
|
||||
final l = t.toLocal();
|
||||
String d2(int v) => v.toString().padLeft(2, '0');
|
||||
return '${l.day}/${l.month} ${d2(l.hour)}:${d2(l.minute)}';
|
||||
}
|
||||
}
|
||||
|
||||
class _Encadre extends StatelessWidget {
|
||||
final IconData icone;
|
||||
final String titre;
|
||||
final String texte;
|
||||
final String? code;
|
||||
final Widget? action;
|
||||
|
||||
const _Encadre({
|
||||
required this.icone,
|
||||
required this.titre,
|
||||
required this.texte,
|
||||
this.code,
|
||||
this.action,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final muted = EtmTokens.mutedOf(context);
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: EtmTokens.bgOf(context),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Icon(icone, color: EtmTokens.faintOf(context), size: 26),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Text(titre,
|
||||
style: EtmTokens.sans(size: 13, weight: FontWeight.w600, color: muted)),
|
||||
const SizedBox(height: 4),
|
||||
Text(texte,
|
||||
style: EtmTokens.sans(size: 11.5, color: muted).copyWith(height: 1.35)),
|
||||
// Le code brut rend le rapport de terrain actionnable.
|
||||
if (code != null) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(code!, style: EtmTokens.mono(size: 10.5, color: EtmTokens.faintOf(context))),
|
||||
],
|
||||
?action,
|
||||
]),
|
||||
),
|
||||
]),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Note extends StatelessWidget {
|
||||
final IconData icone;
|
||||
final String texte;
|
||||
const _Note({required this.icone, required this.texte});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final muted = EtmTokens.mutedOf(context);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 4),
|
||||
child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Icon(icone, size: 13, color: muted),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: Text(texte,
|
||||
style: EtmTokens.sans(size: 11, color: muted).copyWith(height: 1.35))),
|
||||
]),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Legende extends StatelessWidget {
|
||||
final Color couleur;
|
||||
final String texte;
|
||||
const _Legende(this.couleur, this.texte);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Row(mainAxisSize: MainAxisSize.min, children: [
|
||||
Container(
|
||||
width: 14,
|
||||
height: 3,
|
||||
decoration: BoxDecoration(
|
||||
color: couleur, borderRadius: BorderRadius.circular(2))),
|
||||
const SizedBox(width: 5),
|
||||
Text(texte, style: EtmTokens.sans(size: 11, color: EtmTokens.mutedOf(context))),
|
||||
]);
|
||||
}
|
||||
212
test/fixtures/forecast_snapshot_hems_etm68.json
vendored
Normal file
212
test/fixtures/forecast_snapshot_hems_etm68.json
vendored
Normal file
@ -0,0 +1,212 @@
|
||||
{
|
||||
"capturedAt": "2026-10-14T05:24:48Z",
|
||||
"contentChangedAt": "2026-10-14T05:15:00Z",
|
||||
"conversion": "physics",
|
||||
"corrections": [
|
||||
"temperature"
|
||||
],
|
||||
"forecastPoints": [
|
||||
{
|
||||
"forecastWh": 0,
|
||||
"targetAt": "2026-10-13T22:00:00Z"
|
||||
},
|
||||
{
|
||||
"forecastWh": 0,
|
||||
"targetAt": "2026-10-13T23:00:00Z"
|
||||
},
|
||||
{
|
||||
"forecastWh": 0,
|
||||
"targetAt": "2026-10-14T00:00:00Z"
|
||||
},
|
||||
{
|
||||
"forecastWh": 0,
|
||||
"targetAt": "2026-10-14T01:00:00Z"
|
||||
},
|
||||
{
|
||||
"forecastWh": 0,
|
||||
"targetAt": "2026-10-14T02:00:00Z"
|
||||
},
|
||||
{
|
||||
"forecastWh": 0,
|
||||
"targetAt": "2026-10-14T03:00:00Z"
|
||||
},
|
||||
{
|
||||
"forecastWh": 0,
|
||||
"targetAt": "2026-10-14T04:00:00Z"
|
||||
},
|
||||
{
|
||||
"forecastWh": 0,
|
||||
"targetAt": "2026-10-14T05:00:00Z"
|
||||
},
|
||||
{
|
||||
"forecastWh": 153,
|
||||
"targetAt": "2026-10-14T06:00:00Z"
|
||||
},
|
||||
{
|
||||
"forecastWh": 525,
|
||||
"targetAt": "2026-10-14T07:00:00Z"
|
||||
},
|
||||
{
|
||||
"forecastWh": 896,
|
||||
"targetAt": "2026-10-14T08:00:00Z"
|
||||
},
|
||||
{
|
||||
"forecastWh": 1118,
|
||||
"targetAt": "2026-10-14T09:00:00Z"
|
||||
},
|
||||
{
|
||||
"forecastWh": 1123,
|
||||
"targetAt": "2026-10-14T10:00:00Z"
|
||||
},
|
||||
{
|
||||
"forecastWh": 1230,
|
||||
"targetAt": "2026-10-14T11:00:00Z"
|
||||
},
|
||||
{
|
||||
"forecastWh": 1191,
|
||||
"targetAt": "2026-10-14T12:00:00Z"
|
||||
},
|
||||
{
|
||||
"forecastWh": 997,
|
||||
"targetAt": "2026-10-14T13:00:00Z"
|
||||
},
|
||||
{
|
||||
"forecastWh": 733,
|
||||
"targetAt": "2026-10-14T14:00:00Z"
|
||||
},
|
||||
{
|
||||
"forecastWh": 375,
|
||||
"targetAt": "2026-10-14T15:00:00Z"
|
||||
},
|
||||
{
|
||||
"forecastWh": 33,
|
||||
"targetAt": "2026-10-14T16:00:00Z"
|
||||
},
|
||||
{
|
||||
"forecastWh": 0,
|
||||
"targetAt": "2026-10-14T17:00:00Z"
|
||||
},
|
||||
{
|
||||
"forecastWh": 0,
|
||||
"targetAt": "2026-10-14T18:00:00Z"
|
||||
},
|
||||
{
|
||||
"forecastWh": 0,
|
||||
"targetAt": "2026-10-14T19:00:00Z"
|
||||
},
|
||||
{
|
||||
"forecastWh": 0,
|
||||
"targetAt": "2026-10-14T20:00:00Z"
|
||||
},
|
||||
{
|
||||
"forecastWh": 0,
|
||||
"targetAt": "2026-10-14T21:00:00Z"
|
||||
},
|
||||
{
|
||||
"forecastWh": 0,
|
||||
"targetAt": "2026-10-14T22:00:00Z"
|
||||
},
|
||||
{
|
||||
"forecastWh": 0,
|
||||
"targetAt": "2026-10-14T23:00:00Z"
|
||||
},
|
||||
{
|
||||
"forecastWh": 0,
|
||||
"targetAt": "2026-10-15T00:00:00Z"
|
||||
},
|
||||
{
|
||||
"forecastWh": 0,
|
||||
"targetAt": "2026-10-15T01:00:00Z"
|
||||
},
|
||||
{
|
||||
"forecastWh": 0,
|
||||
"targetAt": "2026-10-15T02:00:00Z"
|
||||
},
|
||||
{
|
||||
"forecastWh": 0,
|
||||
"targetAt": "2026-10-15T03:00:00Z"
|
||||
},
|
||||
{
|
||||
"forecastWh": 0,
|
||||
"targetAt": "2026-10-15T04:00:00Z"
|
||||
},
|
||||
{
|
||||
"forecastWh": 0,
|
||||
"targetAt": "2026-10-15T05:00:00Z"
|
||||
},
|
||||
{
|
||||
"forecastWh": 140,
|
||||
"targetAt": "2026-10-15T06:00:00Z"
|
||||
},
|
||||
{
|
||||
"forecastWh": 495,
|
||||
"targetAt": "2026-10-15T07:00:00Z"
|
||||
},
|
||||
{
|
||||
"forecastWh": 761,
|
||||
"targetAt": "2026-10-15T08:00:00Z"
|
||||
},
|
||||
{
|
||||
"forecastWh": 1094,
|
||||
"targetAt": "2026-10-15T09:00:00Z"
|
||||
},
|
||||
{
|
||||
"forecastWh": 1278,
|
||||
"targetAt": "2026-10-15T10:00:00Z"
|
||||
},
|
||||
{
|
||||
"forecastWh": 1305,
|
||||
"targetAt": "2026-10-15T11:00:00Z"
|
||||
},
|
||||
{
|
||||
"forecastWh": 1226,
|
||||
"targetAt": "2026-10-15T12:00:00Z"
|
||||
},
|
||||
{
|
||||
"forecastWh": 1029,
|
||||
"targetAt": "2026-10-15T13:00:00Z"
|
||||
},
|
||||
{
|
||||
"forecastWh": 711,
|
||||
"targetAt": "2026-10-15T14:00:00Z"
|
||||
},
|
||||
{
|
||||
"forecastWh": 304,
|
||||
"targetAt": "2026-10-15T15:00:00Z"
|
||||
},
|
||||
{
|
||||
"forecastWh": 21,
|
||||
"targetAt": "2026-10-15T16:00:00Z"
|
||||
},
|
||||
{
|
||||
"forecastWh": 0,
|
||||
"targetAt": "2026-10-15T17:00:00Z"
|
||||
},
|
||||
{
|
||||
"forecastWh": 0,
|
||||
"targetAt": "2026-10-15T18:00:00Z"
|
||||
},
|
||||
{
|
||||
"forecastWh": 0,
|
||||
"targetAt": "2026-10-15T19:00:00Z"
|
||||
},
|
||||
{
|
||||
"forecastWh": 0,
|
||||
"targetAt": "2026-10-15T20:00:00Z"
|
||||
},
|
||||
{
|
||||
"forecastWh": 0,
|
||||
"targetAt": "2026-10-15T21:00:00Z"
|
||||
}
|
||||
],
|
||||
"issuedAt": "2026-10-14T00:00:00Z",
|
||||
"model": "meteofrance_seamless",
|
||||
"perField": [
|
||||
{
|
||||
"corrections": [
|
||||
"temperature"
|
||||
],
|
||||
"id": "{3cee9703-5406-426e-9a85-913746175253}"
|
||||
}
|
||||
]
|
||||
}
|
||||
219
test/pv_forecast_test.dart
Normal file
219
test/pv_forecast_test.dart
Normal file
@ -0,0 +1,219 @@
|
||||
/// 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/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,
|
||||
bool installateur = false,
|
||||
List<ProductionMesuree> mesure = const []}) =>
|
||||
PvForecastView(
|
||||
chargement: false,
|
||||
snapshot: s,
|
||||
erreur: erreur,
|
||||
site: site,
|
||||
mesure: mesure,
|
||||
maintenant: DateTime.utc(2026, 10, 14, 9),
|
||||
installateur: installateur,
|
||||
nomDuThing: (id) => id == '{8e0b7b38-3180-46be-ba41-08b08d630173}'
|
||||
? 'Kutzenhausen'
|
||||
: id,
|
||||
);
|
||||
|
||||
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);
|
||||
expect(find.textContaining('2 créneaux sans prévision'), findsOneWidget);
|
||||
});
|
||||
});
|
||||
|
||||
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, corrections en mode installateur seulement',
|
||||
(t) async {
|
||||
final s = PvForecastSnapshot.fromJson(_banc());
|
||||
await t.pumpWidget(_monte(_vue(s)));
|
||||
expect(find.textContaining('plafond optimiste'), findsWidgets);
|
||||
expect(find.textContaining('température de module'), findsNothing);
|
||||
|
||||
await t.pumpWidget(_monte(_vue(s, installateur: true)));
|
||||
expect(find.textContaining('température de module'), findsWidgets);
|
||||
});
|
||||
|
||||
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: [
|
||||
(t: DateTime.utc(2026, 10, 14, 8), w: 600),
|
||||
(t: DateTime.utc(2026, 10, 14, 8, 15), w: 700),
|
||||
])));
|
||||
final bars = t.widget<LineChart>(find.byType(LineChart)).data.lineBarsData;
|
||||
expect(bars, hasLength(2));
|
||||
expect(bars[1].spots.map((p) => p.y), [0.6, 0.7]);
|
||||
});
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user