diff --git a/lib/features/dashboard/widgets/journee_card.dart b/lib/features/dashboard/widgets/journee_card.dart new file mode 100644 index 0000000..c84ee89 --- /dev/null +++ b/lib/features/dashboard/widgets/journee_card.dart @@ -0,0 +1,504 @@ +import 'dart:math'; + +import 'package:fl_chart/fl_chart.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../../../models/consumer_series.dart'; +import '../../../models/energy_data.dart'; +import '../../../models/journee.dart'; +import '../../../models/pv_forecast.dart'; +import '../../../providers/load_config_provider.dart'; +import '../../../providers/pv_forecast_provider.dart'; +import '../../../services/nymea_service.dart'; +import '../../../theme/etm_tokens.dart'; + +/// **Carte « Journée »** de l'accueil — variante E de la maquette, retenue par Patrick le +/// 2026-10-15 : par heure, deux colonnes « à la Victron ». +/// +/// - à gauche, la **consommation**, décomposée par consommateur déclaré (borne, PAC, ECS…) +/// au-dessus de la « Maison », c'est-à-dire ce que les compteurs déclarés n'expliquent pas ; +/// - à droite, le **solaire** : autoconsommé (jaune doré) puis injecté (jaune pâle) pour les +/// heures écoulées, la prévision en contour pour les heures à venir. +/// +/// Code couleur de Patrick : jaune = solaire, bleu = maison, vert = batterie. +/// +/// Les icônes météo HEURE PAR HEURE n'existent pas encore côté box : demandées à openmeteo +/// (`consignes/CONSIGNE-openmeteo-meteo-horaire.md`, etm16). D'ici là, seule la météo de +/// maintenant est affichée — rien n'est deviné pour les autres heures. +class JourneeCard extends StatefulWidget { + const JourneeCard({super.key}); + + @override + State createState() => _JourneeCardState(); +} + +class _JourneeCardState extends State { + List _bilan = const []; + ConsumerHistory _conso = ConsumerHistory.vide; + List _sansCompteur = const []; + DateTime? _luePour; + bool _lue = false; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + context.read().assurer(); + final cfg = context.read(); + if (cfg.entries.isEmpty) { + cfg + ..attach(context.read()) + ..load(); + } + }); + } + + /// La mesure suit la prévision : relue à chaque lecture du provider (12 min), sans second + /// minuteur — et quand la liste des charges arrive. + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final lue = context.watch().snapshot?.capturedAt; + final n = context.watch().entries.length; + final cle = lue?.add(Duration(microseconds: n)); + if (cle != null && cle != _luePour) { + _luePour = cle; + _lire(); + } + } + + Future _lire() async { + final service = context.read(); + final cfg = context.read(); + final now = DateTime.now(); + final debut = DateTime(now.year, now.month, now.day); + // Seules les charges qui déclarent un compteur ont une colonne : rien n'est deviné à + // partir d'un nom d'appareil. Les autres sont nommées sous la carte. + final appareils = [ + for (final e in cfg.entries) + if (e.meterThingId.isNotEmpty) + (id: e.meterThingId, label: e.label, domaine: e.domain) + ]; + final sansCompteur = [ + for (final e in cfg.entries) + if (e.meterThingId.isEmpty && e.adapter != 'evcharger') e.label + ]; + final r = await Future.wait([ + service.fetchPowerBalanceLogs( + from: debut, to: now, sampleRate: 'SampleRate1Hour'), + service.fetchThingPowerLogs( + appareils: appareils, from: debut, to: now, sampleRate: 'SampleRate1Hour'), + ]); + if (!mounted) return; + setState(() { + _bilan = r[0] as List; + _conso = r[1] as ConsumerHistory; + _sansCompteur = sansCompteur; + _lue = true; + }); + } + + @override + Widget build(BuildContext context) { + final pv = context.watch(); + final service = context.watch(); + final snap = pv.snapshot?.etat == PvForecastEtat.courbe ? pv.snapshot : null; + final plans = [for (final f in snap?.perField ?? const []) f.id]; + final fourchette = context.select((s) => + plans.isEmpty ? null : FourchetteMeteo.depuisPlans(s.plansMeteo(plans))); + final maintenant = DateTime.now(); + final heures = construireJournee( + jourLocal: maintenant, + snapshot: snap, + bilanHoraire: _bilan, + consoHoraire: _conso); + final etats = service.etatsParNom('openMeteoSite'); + return JourneeView( + heures: heures, + series: _conso.series, + meteo: etats == null ? null : MeteoActuelle.depuisEtats(etats), + bande: snap == null + ? null + : bandePrevision(snap, fourchette, maintenant: maintenant), + etatPrevision: pv.chargement && pv.snapshot == null + ? null + : pv.snapshot?.etat ?? (pv.erreur != null ? null : PvForecastEtat.rienPublie), + previsionNonLue: pv.erreur != null && pv.snapshot == null, + sansCompteur: _sansCompteur, + sansHistorique: _conso.sansHistorique, + chargement: !_lue, + ); + } +} + +/// Rendu pur de la carte — tout ce qu'il affiche lui est donné. +class JourneeView extends StatelessWidget { + final List heures; + final List series; + final MeteoActuelle? meteo; + final BandePrevision? bande; + final PvForecastEtat? etatPrevision; + final bool previsionNonLue; + final List sansCompteur; + final List sansHistorique; + final bool chargement; + + const JourneeView({ + super.key, + required this.heures, + this.series = const [], + this.meteo, + this.bande, + this.etatPrevision, + this.previsionNonLue = false, + this.sansCompteur = const [], + this.sansHistorique = const [], + this.chargement = false, + }); + + /// Couleur d'un consommateur : celle de son domaine, nuancée s'il y en a plusieurs — + /// la même règle que le graphe « Qui consomme » de l'écran Énergie. + static Map couleurs(BuildContext c, List series) { + final rangs = {}; + final out = {}; + for (final s in series) { + final base = EtmTokens.domaineOf(c, s.domaine); + if (base == null) { + out[s.thingId] = EtmTokens.mutedOf(c); + continue; + } + final cle = s.domaine ?? ''; + final rang = rangs[cle] ?? 0; + rangs[cle] = rang + 1; + out[s.thingId] = EtmTokens.nuance(base, rang); + } + return out; + } + + @override + Widget build(BuildContext context) { + final muted = EtmTokens.mutedOf(context); + final teintes = couleurs(context, series); + 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('Journée', style: EtmTokens.sans(size: 16, weight: FontWeight.w600)), + Text('aujourd\'hui', style: EtmTokens.sans(size: 12, color: muted)), + ]), + const SizedBox(height: 8), + _meteo(context), + const SizedBox(height: 10), + SizedBox( + height: 190, + child: chargement + ? const Center( + child: SizedBox( + width: 22, + height: 22, + child: CircularProgressIndicator(strokeWidth: 2))) + : JourneeChart(heures: heures, series: series, teintes: teintes), + ), + const SizedBox(height: 10), + Wrap(spacing: 12, runSpacing: 4, children: [ + _Puce(EtmTokens.brand, 'Maison'), + for (final s in series) _Puce(teintes[s.thingId] ?? muted, s.label), + _Puce(EtmTokens.selfUse, 'Solaire autoconsommé'), + _Puce(EtmTokens.exportSolaire, 'Solaire injecté'), + _Puce(EtmTokens.solar, 'Prévision', contour: true), + ]), + const SizedBox(height: 10), + _totaux(context), + ..._notes(context), + ]), + ); + } + + Widget _meteo(BuildContext context) { + final muted = EtmTokens.mutedOf(context); + final m = meteo; + final style = EtmTokens.sans(size: 12, color: muted); + if (m == null) { + return Text('Météo non publiée par la box', style: style); + } + String deg(double v) => '${v.toStringAsFixed(1).replaceAll('.', ',')} °C'; + String hm(DateTime t) => + '${t.hour.toString().padLeft(2, '0')}:${t.minute.toString().padLeft(2, '0')}'; + return Wrap(spacing: 14, runSpacing: 4, crossAxisAlignment: WrapCrossAlignment.center, children: [ + Row(mainAxisSize: MainAxisSize.min, children: [ + Icon(iconeMeteo(m.condition), size: 18, color: EtmTokens.inkOf(context)), + const SizedBox(width: 6), + Text('${m.libelle}${m.temperature != null ? ' ${deg(m.temperature!)}' : ''}', + style: EtmTokens.sans(size: 12.5, weight: FontWeight.w500)), + ]), + if (m.min != null && m.max != null) + Text('min ${deg(m.min!)} · max ${deg(m.max!)}', style: style), + if (m.lever != null && m.coucher != null) + Row(mainAxisSize: MainAxisSize.min, children: [ + Icon(Icons.wb_twilight_rounded, size: 15, color: EtmTokens.solarOf(context)), + const SizedBox(width: 4), + Text('${hm(m.lever!)} – ${hm(m.coucher!)}', style: style), + ]), + ]); + } + + /// Icône de l'état du ciel publié par le plugin (`weatherCondition`). + static IconData iconeMeteo(String c) => switch (c) { + 'clear-day' => Icons.wb_sunny_rounded, + 'clear-night' => Icons.nightlight_round, + 'few-clouds-day' => Icons.wb_cloudy_outlined, + 'few-clouds-night' => Icons.nights_stay_outlined, + 'clouds' || 'overcast' => Icons.cloud_rounded, + 'light-rain' || 'shower-rain' => Icons.grain_rounded, + 'thunderstorm' => Icons.thunderstorm_rounded, + 'snow' => Icons.ac_unit_rounded, + 'fog' => Icons.foggy, + _ => Icons.help_outline_rounded, + }; + + Widget _totaux(BuildContext context) { + String kwh(double wh) => wh.abs() < 1000 + ? '${(wh / 1000).toStringAsFixed(2).replaceAll('.', ',')} kWh' + : '${(wh / 1000).toStringAsFixed(1).replaceAll('.', ',')} kWh'; + final mes = heures.where((h) => h.mesuree).toList(); + final lignes = []; + if (mes.isNotEmpty) { + final prod = mes.fold(0.0, (a, h) => a + max(0.0, h.prodWh ?? 0)); + final cons = mes.fold(0.0, (a, h) => a + (h.consWh ?? 0)); + final imp = mes.fold(0.0, (a, h) => a + (h.importWh ?? 0)); + lignes.add('Solaire ${kwh(prod)} · consommé ${kwh(cons)} dont réseau ${kwh(imp)}'); + } + // Prévu sur la journée : seulement si CHAQUE heure en a une ; la fourchette, seulement + // si chaque créneau en a une aussi. + if (heures.isNotEmpty && heures.every((h) => h.prevuWh != null)) { + var l = 'Prévu aujourd\'hui ${kwh(heures.fold(0.0, (a, h) => a + h.prevuWh!))}'; + final b = bande; + if (b != null) { + final debut = heures.first.debut.toUtc(); + final fin = heures.last.debut.toUtc().add(const Duration(hours: 1)); + final creneaux = b.parCreneau.keys + .where((t) => !t.isBefore(debut) && t.isBefore(fin)) + .toList(); + final attendus = fin.difference(debut).inHours; + if (creneaux.length >= attendus) { + final bas = creneaux.fold(0.0, (a, t) => a + b.parCreneau[t]!.basWh); + final haut = creneaux.fold(0.0, (a, t) => a + b.parCreneau[t]!.hautWh); + l += b.source == SourceBande.p10p90 + ? ' (p10–p90 ${kwh(bas)} – ${kwh(haut)})' + : ' (fourchette météo ${kwh(bas)} – ${kwh(haut)}' + '${b.attenuee ? ', non datée' : ''})'; + } + } + lignes.add(l); + } + if (lignes.isEmpty) return const SizedBox.shrink(); + return Text(lignes.join('\n'), + style: EtmTokens.mono(size: 11, color: EtmTokens.mutedOf(context))); + } + + List _notes(BuildContext context) { + final notes = [ + if (previsionNonLue) + 'Prévision non lue : la box n\'a pas répondu.' + else + switch (etatPrevision) { + PvForecastEtat.siteManquant => + 'Pas de prévision : aucun site désigné — à choisir dans l\'écran Énergie.', + PvForecastEtat.chargeRefusee => + 'Pas de prévision : le producteur météo publie une courbe illisible.', + PvForecastEtat.faultInconnu => 'Pas de prévision : défaut publié par la box.', + PvForecastEtat.rienPublie => 'Aucune prévision publiée par la box.', + _ => '', + }, + for (final l in sansCompteur) + '$l : aucun compteur déclaré, comptée dans « Maison ».', + for (final l in sansHistorique) '$l : pas d\'historique de puissance.', + if (heures.any((h) => (h.resteWh ?? 0) < -1)) + 'Les compteurs déclarés dépassent la consommation totale sur certaines heures.', + ].where((n) => n.isNotEmpty).toList(); + return [ + for (final n in notes) + Padding( + padding: const EdgeInsets.only(top: 4), + child: Text(n, + style: EtmTokens.sans(size: 11, color: EtmTokens.mutedOf(context))), + ), + ]; + } +} + +/// Le graphe : deux colonnes par heure. +class JourneeChart extends StatelessWidget { + final List heures; + final List series; + final Map teintes; + + const JourneeChart( + {super.key, required this.heures, required this.series, required this.teintes}); + + @override + Widget build(BuildContext context) { + final solaire = EtmTokens.solarOf(context); + final label = EtmTokens.sans(size: 9, color: EtmTokens.mutedOf(context)); + final haut = heures.fold( + 0, + (a, h) => [ + a, + h.consWh ?? 0, + max(0.0, h.prodWh ?? 0), + h.prevuWh ?? 0, + ].reduce(max)); + final yMax = max(0.5, haut / 1000 * 1.15); + + return LayoutBuilder(builder: (context, c) { + // Deux colonnes par heure, et un vide entre les heures pour qu'on lise les paires. + final groupe = max(4.0, (c.maxWidth - 40) / max(1, heures.length)); + final largeur = max(1.5, groupe * 0.36); + return BarChart( + BarChartData( + maxY: yMax, + minY: 0, + alignment: BarChartAlignment.spaceAround, + borderData: FlBorderData(show: false), + barTouchData: BarTouchData(enabled: false), + gridData: FlGridData( + show: true, + drawVerticalLine: false, + horizontalInterval: yMax / 4, + getDrawingHorizontalLine: (_) => + FlLine(color: EtmTokens.lineOf(context), strokeWidth: 1), + ), + titlesData: FlTitlesData( + topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)), + rightTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)), + leftTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + reservedSize: 36, + interval: yMax / 4, + getTitlesWidget: (v, meta) => v == meta.max + ? const SizedBox.shrink() + : SideTitleWidget( + meta: meta, + child: Text(v.toStringAsFixed(1), style: label)), + ), + ), + bottomTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + reservedSize: 20, + getTitlesWidget: (v, meta) { + final i = v.toInt(); + if (i < 0 || i >= heures.length) return const SizedBox.shrink(); + final t = heures[i].debut; + if (t.hour % 6 != 0) return const SizedBox.shrink(); + return SideTitleWidget( + meta: meta, child: Text('${t.hour}h', style: label)); + }, + ), + ), + ), + barGroups: [ + for (var i = 0; i < heures.length; i++) + BarChartGroupData(x: i, barsSpace: 1, barRods: [ + _conso(context, heures[i], largeur), + _solaire(heures[i], largeur, solaire), + ]), + ], + ), + ); + }); + } + + /// Colonne de gauche : la Maison en bas, puis chaque consommateur déclaré. + BarChartRodData _conso(BuildContext context, HeureJournee h, double w) { + if (!h.mesuree) { + return BarChartRodData(toY: 0, width: w, color: Colors.transparent); + } + final parts = <(double, Color)>[ + (max(0.0, h.resteWh ?? 0), EtmTokens.brand), + for (final s in series) + if ((h.parConsommateur[s.thingId] ?? 0) > 0) + (h.parConsommateur[s.thingId]!, teintes[s.thingId] ?? EtmTokens.mutedOf(context)), + ]; + final items = []; + var acc = 0.0; + for (final (wh, couleur) in parts) { + if (wh <= 0) continue; + items.add(BarChartRodStackItem(acc / 1000, (acc + wh) / 1000, couleur)); + acc += wh; + } + return BarChartRodData( + toY: acc / 1000, + width: w, + color: Colors.transparent, + rodStackItems: items, + borderRadius: BorderRadius.zero, + ); + } + + /// Colonne de droite : le solaire mesuré (doré + pâle), ou la prévision en contour. + BarChartRodData _solaire(HeureJournee h, double w, Color solaire) { + final prevu = h.prevuWh; + if (h.mesuree) { + final auto = h.autoWh ?? 0; + final exp = h.exportWh ?? 0; + return BarChartRodData( + toY: (auto + exp) / 1000, + width: w, + color: Colors.transparent, + borderRadius: BorderRadius.zero, + rodStackItems: [ + if (auto > 0) BarChartRodStackItem(0, auto / 1000, EtmTokens.selfUse), + if (exp > 0) + BarChartRodStackItem(auto / 1000, (auto + exp) / 1000, EtmTokens.exportSolaire), + ], + // Le niveau qui était prévu, en fond pâle : comparer sans cacher la mesure. + backDrawRodData: BackgroundBarChartRodData( + show: prevu != null && prevu > 0, + toY: (prevu ?? 0) / 1000, + color: solaire.withValues(alpha: 0.18), + ), + ); + } + if (prevu == null || prevu <= 0) { + return BarChartRodData(toY: 0, width: w, color: Colors.transparent); + } + return BarChartRodData( + toY: prevu / 1000, + width: w, + color: solaire.withValues(alpha: 0.30), + borderRadius: BorderRadius.zero, + borderSide: BorderSide(color: solaire, width: 1), + ); + } +} + +class _Puce extends StatelessWidget { + final Color couleur; + final String texte; + final bool contour; + const _Puce(this.couleur, this.texte, {this.contour = false}); + + @override + Widget build(BuildContext context) => Row(mainAxisSize: MainAxisSize.min, children: [ + Container( + width: 10, + height: 10, + decoration: BoxDecoration( + color: contour ? couleur.withValues(alpha: 0.30) : couleur, + border: contour ? Border.all(color: couleur) : null, + borderRadius: BorderRadius.circular(2), + ), + ), + const SizedBox(width: 5), + Text(texte, style: EtmTokens.sans(size: 11, color: EtmTokens.mutedOf(context))), + ]); +} diff --git a/lib/features/dashboard/widgets/pv_day_card.dart b/lib/features/dashboard/widgets/pv_day_card.dart deleted file mode 100644 index 4ddc7fd..0000000 --- a/lib/features/dashboard/widgets/pv_day_card.dart +++ /dev/null @@ -1,143 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; - -import '../../../providers/pv_forecast_provider.dart'; -import '../../../models/pv_forecast.dart'; -import '../../../services/nymea_service.dart'; -import '../../../theme/etm_tokens.dart'; -import '../../../widgets/pv_forecast_card.dart'; - -/// **Production du jour — prévue et mesurée**, sur l'accueil, sous les décisions. -/// -/// Ce n'est pas l'ancien « Plan de la journée » (retiré le 2026-09-08) : un plan dit qui -/// consommera quoi, et la box n'en publie aucun. Ce qui existe, c'est la production prévue -/// (`GetForecastSnapshot`) et la production mesurée (`GetPowerBalanceLogs`, au pas horaire) : -/// cette carte montre ces deux-là, et rien d'autre. -class PvDayCard extends StatefulWidget { - const PvDayCard({super.key}); - - @override - State createState() => _PvDayCardState(); -} - -class _PvDayCardState extends State { - List _mesures = const []; - DateTime? _luePour; - - @override - void initState() { - super.initState(); - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) context.read().assurer(); - }); - } - - /// La mesure suit la prévision : relue à chaque nouvelle lecture du provider, donc au - /// même rythme, sans second minuteur. - @override - void didChangeDependencies() { - super.didChangeDependencies(); - final lue = context.watch().snapshot?.capturedAt; - if (lue != null && lue != _luePour) { - _luePour = lue; - _lireMesures(); - } - } - - Future _lireMesures() async { - final service = context.read(); - final now = DateTime.now(); - final logs = await service.fetchPowerBalanceLogs( - from: DateTime(now.year, now.month, now.day), - to: now, - sampleRate: 'SampleRate1Hour'); - if (!mounted) return; - // `production` est NÉGATIVE dans nymea : signe inversé, pas `.abs()`. - setState(() => _mesures = [for (final e in logs) (t: e.timestamp, w: -e.productionW)]); - } - - @override - Widget build(BuildContext context) { - final pv = context.watch(); - final service = context.watch(); - final maintenant = DateTime.now(); - final heures = heuresDuJour( - jourLocal: maintenant, snapshot: pv.snapshot, mesuresHoraires: _mesures); - final snap = pv.snapshot; - final plans = [for (final f in snap?.perField ?? const []) f.id]; - final fourchette = context.select((s) => - plans.isEmpty ? null : FourchetteMeteo.depuisPlans(s.plansMeteo(plans))); - final bande = snap == null || snap.etat != PvForecastEtat.courbe - ? null - : bandePrevision(snap, fourchette, maintenant: maintenant); - return PvForecastView( - titre: 'Production du jour', - chargement: pv.chargement && pv.snapshot == null, - snapshot: pv.snapshot, - erreur: pv.erreur, - site: pv.site, - nomDuThing: (id) => _nom(service, id), - refusDesignation: pv.refusDesignation, - onDesigner: pv.designer, - onRelire: pv.relire, - avecMesure: heures.any((h) => h.mesureWh != null), - hauteurGraphe: 170, - bande: bande, - sousLegende: _bilan(context, heures, snap, bande), - graphe: (_, _) => PvForecastDayChart(heures: heures, maintenant: maintenant), - ); - } - - /// Les totaux — seulement quand ils sont COMPLETS. Un total sur des heures trouées serait - /// un reste sur des parts manquantes : il ne se calcule pas, il se fabrique. - Widget? _bilan(BuildContext context, List heures, - PvForecastSnapshot? snap, BandePrevision? bande) { - String kwh(double wh) => '${(wh / 1000).toStringAsFixed(1)} kWh'; - final lignes = []; - if (heures.isNotEmpty && heures.every((h) => h.prevuWh != null)) { - var ligne = - 'Prévu aujourd\'hui : ${kwh(heures.fold(0.0, (a, h) => a + h.prevuWh!))}'; - // La fourchette du total : la somme des bornes de chaque créneau du jour — si, et - // seulement si, CHAQUE créneau du jour en a une. - final debut = heures.first.debut.toUtc(); - final fin = heures.last.debut.toUtc().add(const Duration(hours: 1)); - final duJour = [ - for (final p in snap?.points ?? const []) - if (!p.debutUtc.isBefore(debut) && p.debutUtc.isBefore(fin)) p.debutUtc - ]; - if (bande != null && - duJour.isNotEmpty && - duJour.every(bande.parCreneau.containsKey)) { - final bas = duJour.fold(0.0, (a, t) => a + bande.parCreneau[t]!.basWh); - final haut = duJour.fold(0.0, (a, t) => a + bande.parCreneau[t]!.hautWh); - ligne += bande.source == SourceBande.p10p90 - ? ' (p10–p90 : ${kwh(bas)} – ${kwh(haut)})' - : ' (fourchette météo ${kwh(bas)} – ${kwh(haut)}' - '${bande.attenuee ? ', non datée' : ''})'; - } - lignes.add(ligne); - } - final mesurees = heures.where((h) => h.mesureWh != null).toList(); - if (mesurees.isNotEmpty) { - final fin = mesurees.last.debut.add(const Duration(hours: 1)); - // Comparable seulement si TOUTES les heures écoulées sont mesurées ET prévues. - final ecoulees = heures.where((h) => h.debut.isBefore(fin)).toList(); - if (ecoulees.every((h) => h.mesureWh != null && h.prevuWh != null)) { - final m = ecoulees.fold(0.0, (a, h) => a + h.mesureWh!); - final p = ecoulees.fold(0.0, (a, h) => a + h.prevuWh!); - lignes.add('Jusqu\'à ${fin.hour}h : mesuré ${kwh(m)} · prévu ${kwh(p)}'); - } - } - if (lignes.isEmpty) return null; - return Text(lignes.join('\n'), - style: EtmTokens.mono(size: 11, color: EtmTokens.mutedOf(context))); - } - - 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; - } -} diff --git a/lib/models/journee.dart b/lib/models/journee.dart new file mode 100644 index 0000000..0489f2f --- /dev/null +++ b/lib/models/journee.dart @@ -0,0 +1,181 @@ +/// **La journée heure par heure** — ce que la carte « Journée » de l'accueil affiche. +/// +/// Variante E de la maquette, retenue par Patrick le 2026-10-15 (« deux colonnes à la +/// Victron ») : par heure, la consommation décomposée par consommateur, et la production +/// solaire. Le passé est MESURÉ ; l'avenir n'a que la prévision PV — la box ne publie aucun +/// plan de consommation, et la carte n'en invente pas. +/// +/// ## La règle de date d'un échantillon horaire +/// +/// Un échantillon `SampleRate1Hour` horodaté T porte la puissance moyenne sur `(T − 1 h, T]` +/// (`energylogger.cpp`, `samplePowerBalance`, même logique pour les Things). Il appartient +/// donc à l'heure qui COMMENCE à T − 1 h — celle de `forecastPoints[].targetAt`. Moyenne en +/// W × 1 h = Wh. +library; + +import 'dart:math'; + +import 'consumer_series.dart'; +import 'energy_data.dart'; +import 'pv_forecast.dart'; + +/// Une heure de la journée. Tout `null` veut dire « inconnu » — jamais zéro. +class HeureJournee { + final DateTime debut; + + /// Énergie prévue sur l'heure, seulement si l'heure est entièrement couverte. + final double? prevuWh; + + /// Production mesurée — signe nymea INVERSÉ (la production y est négative), pas `.abs()`. + final double? prodWh; + final double? consWh; + final double? importWh; + final double? exportWh; + + /// Consommation par appareil déclaré (clé = thingId), mesurée. + final Map parConsommateur; + + const HeureJournee({ + required this.debut, + this.prevuWh, + this.prodWh, + this.consWh, + this.importWh, + this.exportWh, + this.parConsommateur = const {}, + }); + + bool get mesuree => consWh != null; + + /// Solaire gardé sur place. Sur un pas horaire, import et export d'une même heure se + /// compensent dans le net : c'est une approximation par excès, assumée et dite. + double? get autoWh => + prodWh == null ? null : max(0.0, max(0.0, prodWh!) - (exportWh ?? 0)); + + /// Ce que les appareils déclarés n'expliquent pas. Négatif = les sous-compteurs dépassent + /// la consommation totale : un fait à signaler, pas à masquer. + double? get resteWh => consWh == null + ? null + : consWh! - parConsommateur.values.fold(0.0, (a, v) => a + v); +} + +List construireJournee({ + required DateTime jourLocal, + required PvForecastSnapshot? snapshot, + required List bilanHoraire, + required ConsumerHistory consoHoraire, +}) { + final debutJour = DateTime(jourLocal.year, jourLocal.month, jourLocal.day); + final finJour = DateTime(jourLocal.year, jourLocal.month, jourLocal.day + 1); + const heure = Duration(hours: 1); + + final pas = snapshot?.pas; + final prevus = { + for (final p in snapshot?.points ?? const []) p.debutUtc: p.wh + }; + final bilans = { + for (final e in bilanHoraire) e.timestamp.toUtc().subtract(heure): e + }; + final appareils = >{ + for (final s in consoHoraire.series) + s.thingId: { + for (final p in s.points) p.t.toUtc().subtract(heure): p.powerW, + } + }; + + final out = []; + // En temps absolu d'une minuit locale à la suivante : 23 ou 25 heures aux changements + // d'heure, jamais 24 par hypothèse. + for (var h = debutJour.toUtc(); h.isBefore(finJour.toUtc()); h = h.add(heure)) { + double? prevu; + if (pas != null && pas.inSeconds > 0 && 3600 % pas.inSeconds == 0) { + var somme = 0.0; + var complet = true; + for (var t = h; t.isBefore(h.add(heure)); t = t.add(pas)) { + final v = prevus[t]; + if (v == null) { + complet = false; + break; + } + somme += v; + } + if (complet) prevu = somme; + } + final b = bilans[h]; + out.add(HeureJournee( + debut: h.toLocal(), + prevuWh: prevu, + prodWh: b == null ? null : -b.productionW, + consWh: b?.consumptionW.abs(), + importWh: b == null ? null : max(0.0, b.acquisitionW), + exportWh: b == null ? null : max(0.0, -b.acquisitionW), + parConsommateur: b == null + ? const {} + : { + for (final e in appareils.entries) + if (e.value[h] != null) e.key: e.value[h]!, + }, + )); + } + return out; +} + +/// **La météo de maintenant**, lue sur le Thing site openmeteo. +/// +/// 🔴 Les états du site ont des valeurs par défaut trompeuses : `weatherCondition` vaut +/// `clear-day` et `temperature` 0 tant que rien n'a été écrit. Une box qui n'a jamais reçu de +/// météo afficherait un soleil à 0 °C. `updateTime` (0 par défaut) est le témoin : tant qu'il +/// vaut 0, il n'y a pas de météo — `null`, pas une météo par défaut. +class MeteoActuelle { + final String condition; + final double? temperature; + final double? min; + final double? max; + final DateTime? lever; + final DateTime? coucher; + + const MeteoActuelle({ + required this.condition, + this.temperature, + this.min, + this.max, + this.lever, + this.coucher, + }); + + static MeteoActuelle? depuisEtats(Map e) { + final maj = e['updateTime']; + final cond = e['weatherCondition']; + if (maj is! num || maj <= 0 || cond is! String || cond.isEmpty) return null; + double? d(String k) => (e[k] as num?)?.toDouble(); + DateTime? t(String k) { + final v = e[k]; + return v is num && v > 0 + ? DateTime.fromMillisecondsSinceEpoch(v.toInt() * 1000, isUtc: true).toLocal() + : null; + } + + return MeteoActuelle( + condition: cond, + temperature: d('temperature'), + min: d('temperatureMin'), + max: d('temperatureMax'), + lever: t('sunriseTime'), + coucher: t('sunsetTime'), + ); + } + + /// Libellé français de l'état du ciel (le plugin publie une description en anglais). + String get libelle => switch (condition) { + 'clear-day' || 'clear-night' => 'Dégagé', + 'few-clouds-day' || 'few-clouds-night' => 'Peu nuageux', + 'clouds' => 'Nuageux', + 'overcast' => 'Couvert', + 'light-rain' => 'Pluie faible', + 'shower-rain' => 'Averses', + 'thunderstorm' => 'Orage', + 'snow' => 'Neige', + 'fog' => 'Brouillard', + _ => condition, + }; +} diff --git a/lib/screens/dashboard_screen.dart b/lib/screens/dashboard_screen.dart index efef86d..5b6eea0 100644 --- a/lib/screens/dashboard_screen.dart +++ b/lib/screens/dashboard_screen.dart @@ -13,7 +13,7 @@ import '../theme/etm_tokens.dart'; import '../features/dashboard/widgets/energy_flow_card.dart'; import '../features/dashboard/widgets/kpi_row.dart'; import '../features/dashboard/widgets/heos_decision_card.dart'; -import '../features/dashboard/widgets/pv_day_card.dart'; +import '../features/dashboard/widgets/journee_card.dart'; class DashboardScreen extends StatefulWidget { const DashboardScreen({super.key}); @@ -183,11 +183,12 @@ class _DashboardScreenState extends State { const SizedBox(height: 14), const HeosDecisionCard(), const SizedBox(height: 14), - // Production du jour, prévue et mesurée — APRÈS les décisions - // (Patrick, 2026-10-14) : maintenant, pourquoi, puis ce qui vient. - // Ce n'est pas le « Plan » retiré ci-dessous : aucune répartition - // par charge, la box n'en publie pas. - const PvDayCard(), + // La journée heure par heure — APRÈS les décisions (Patrick, + // 2026-10-14). Variante E de la maquette, retenue le 2026-10-15 : + // consommation par consommateur et solaire, deux colonnes par heure. + // Ce n'est pas le « Plan » retiré ci-dessous : le passé est mesuré, + // l'avenir n'a que la prévision PV — aucun plan n'est publié. + const JourneeCard(), const SizedBox(height: 14), // ── « Plan de la journée » : RETIRÉE le 2026-09-08 ────────── // diff --git a/lib/services/nymea_service.dart b/lib/services/nymea_service.dart index dc4ca13..75fe822 100644 --- a/lib/services/nymea_service.dart +++ b/lib/services/nymea_service.dart @@ -1778,6 +1778,33 @@ class NymeaService extends ChangeNotifier { ]; } + /// **Les états d'un Thing, par nom**, pour le premier Thing de la classe [nomClasse] + /// (`openMeteoSite` pour la météo de l'accueil). `null` si aucun Thing de cette classe + /// n'est chargé — l'écran dit alors « pas de météo », il n'en invente pas. + /// + /// Les valeurs suivent `Integrations.StateChanged`, déjà appliqué à [things]. + Map? etatsParNom(String nomClasse, {String? preferer}) { + NymeaThingClass? cls; + for (final c in _thingClasses) { + if (c.name == nomClasse) { + cls = c; + break; + } + } + if (cls == null) return null; + String nu(String x) => x.replaceAll(RegExp(r'[{}]'), '').toLowerCase(); + final candidats = _things.where((t) => t.thingClassId == cls!.id).toList(); + if (candidats.isEmpty) return null; + final t = candidats.firstWhere( + (t) => preferer != null && nu(t.id) == nu(preferer), + orElse: () => candidats.first); + final noms = {for (final st in cls.stateTypes) st.id: st.name}; + return { + for (final v in t.states) + if (noms[v.stateTypeId] != null) noms[v.stateTypeId]!: v.value, + }; + } + /// Valeur d'un paramètre du Thing par son nom — même logique que [_stateByName]. Object? _paramByName(NymeaThing thing, String name) { NymeaThingClass? cls; diff --git a/lib/widgets/pv_forecast_card.dart b/lib/widgets/pv_forecast_card.dart index 32d9b0f..e3dc3f0 100644 --- a/lib/widgets/pv_forecast_card.dart +++ b/lib/widgets/pv_forecast_card.dart @@ -6,10 +6,6 @@ import 'package:flutter/material.dart'; import '../models/pv_forecast.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}); - /// Rendu pur de la prévision — tout ce qu'il affiche lui est donné. class PvForecastView extends StatelessWidget { final bool chargement; @@ -582,140 +578,3 @@ class PvForecastLineChart extends StatelessWidget { } } -/// Une heure de la journée : sa prévision et sa mesure, en Wh. `null` = inconnu — jamais 0. -typedef HeureDuJour = ({DateTime debut, double? prevuWh, double? mesureWh}); - -/// **Les heures de la journée civile locale**, prévision et mesure côte à côte. -/// -/// - **Prévu** : somme des créneaux qui tombent dans l'heure, seulement si elle est -/// COMPLÈTE. Une heure à moitié prévue n'a pas de prévision — la sommer à moitié -/// inventerait une baisse. -/// - **Mesuré** : un échantillon `SampleRate1Hour` horodaté T porte la puissance moyenne -/// sur `(T − 1 h, T]` (`energylogger.cpp`, `samplePowerBalance`) ; il appartient donc à -/// l'heure qui COMMENCE à T − 1 h. Moyenne en W × 1 h = Wh. -/// -/// Les jours de changement d'heure ont 23 ou 25 heures : on avance en temps absolu d'une -/// minuit locale à la suivante, sans compter sur 24. -List heuresDuJour({ - required DateTime jourLocal, - required PvForecastSnapshot? snapshot, - required List mesuresHoraires, -}) { - final debutJour = DateTime(jourLocal.year, jourLocal.month, jourLocal.day); - final finJour = DateTime(jourLocal.year, jourLocal.month, jourLocal.day + 1); - final pas = snapshot?.pas; - final parDebut = { - for (final p in snapshot?.points ?? const []) p.debutUtc: p.wh - }; - final mesures = { - for (final m in mesuresHoraires) - m.t.toUtc().subtract(const Duration(hours: 1)): m.w - }; - - final out = []; - for (var h = debutJour.toUtc(); - h.isBefore(finJour.toUtc()); - h = h.add(const Duration(hours: 1))) { - double? prevu; - if (pas != null && pas.inSeconds > 0 && 3600 % pas.inSeconds == 0) { - var somme = 0.0; - var complet = true; - for (var t = h; t.isBefore(h.add(const Duration(hours: 1))); t = t.add(pas)) { - final v = parDebut[t]; - if (v == null) { - complet = false; - break; - } - somme += v; - } - if (complet) prevu = somme; - } - out.add((debut: h.toLocal(), prevuWh: prevu, mesureWh: mesures[h])); - } - return out; -} - -/// **Barres horaires de la journée** — prévision en semi-transparent derrière, production -/// mesurée en plein devant. Pas de couleur par charge : aucun plan n'est publié par la box, -/// et une répartition dessinée ici serait inventée. -class PvForecastDayChart extends StatelessWidget { - final List heures; - final DateTime maintenant; - - const PvForecastDayChart( - {super.key, required this.heures, required this.maintenant}); - - @override - Widget build(BuildContext context) { - final solaire = EtmTokens.solarOf(context); - final eco = EtmTokens.ecoOf(context); - final label = EtmTokens.sans(size: 9, color: EtmTokens.mutedOf(context)); - final valeurs = [ - for (final h in heures) ...[?h.prevuWh, ?h.mesureWh] - ]; - final yMax = - max(0.5, (valeurs.isEmpty ? 0.0 : valeurs.reduce(max)) / 1000 * 1.15); - return BarChart( - BarChartData( - maxY: yMax, - minY: 0, - alignment: BarChartAlignment.spaceBetween, - borderData: FlBorderData(show: false), - barTouchData: BarTouchData(enabled: false), - gridData: FlGridData( - show: true, - drawVerticalLine: false, - horizontalInterval: yMax / 4, - getDrawingHorizontalLine: (_) => - FlLine(color: EtmTokens.lineOf(context), strokeWidth: 1), - ), - 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)}kWh', style: label)), - ), - ), - bottomTitles: AxisTitles( - sideTitles: SideTitles( - showTitles: true, - reservedSize: 22, - getTitlesWidget: (v, meta) { - final i = v.toInt(); - if (i < 0 || i >= heures.length) return const SizedBox.shrink(); - final t = heures[i].debut; - if (t.hour % 6 != 0) return const SizedBox.shrink(); - return SideTitleWidget( - meta: meta, child: Text('${t.hour}h', style: label)); - }, - ), - ), - ), - barGroups: [ - for (var i = 0; i < heures.length; i++) - BarChartGroupData(x: i, barRods: [ - BarChartRodData( - toY: (heures[i].mesureWh ?? 0) / 1000, - color: heures[i].mesureWh == null ? Colors.transparent : eco, - width: 7, - borderRadius: BorderRadius.circular(2), - backDrawRodData: BackgroundBarChartRodData( - show: heures[i].prevuWh != null, - toY: (heures[i].prevuWh ?? 0) / 1000, - color: solaire.withValues(alpha: 0.35), - ), - ), - ]), - ], - ), - ); - } -} diff --git a/test/journee_test.dart b/test/journee_test.dart new file mode 100644 index 0000000..789c915 --- /dev/null +++ b/test/journee_test.dart @@ -0,0 +1,178 @@ +/// La carte « Journée » de l'accueil — variante E, retenue par Patrick le 2026-10-15. +/// +/// Les attendus d'heure se dérivent de `DateTime(...)` local, jamais d'une heure recopiée : +/// sous TZ=UTC certains contrôles ne mordent pas tous, d'où la suite lancée aussi en +/// Europe/Paris. +library; + +import 'package:etm_powersync_app/features/dashboard/widgets/journee_card.dart'; +import 'package:etm_powersync_app/models/consumer_series.dart'; +import 'package:etm_powersync_app/models/energy_data.dart'; +import 'package:etm_powersync_app/models/journee.dart'; +import 'package:etm_powersync_app/models/pv_forecast.dart'; +import 'package:fl_chart/fl_chart.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +PowerBalanceEntry _bilan(DateTime t, + {double prod = 0, double cons = 0, double acq = 0}) => + PowerBalanceEntry( + totalsPresent: false, + timestamp: t, + productionW: prod, + consumptionW: cons, + acquisitionW: acq, + storageW: 0, + totalProductionKwh: 0, + totalConsumptionKwh: 0, + totalReturnKwh: 0, + totalAcquisitionKwh: 0, + ); + +void main() { + final jour = DateTime(2026, 10, 14); + final dix = DateTime(2026, 10, 14, 10); // 10:00 locale + final onze = DateTime(2026, 10, 14, 11); + + final snap = PvForecastSnapshot.fromJson({ + 'forecastPoints': [ + for (var i = -1; i < 3; i++) + { + 'targetAt': dix.toUtc().add(Duration(hours: i)).toIso8601String(), + 'forecastWh': 500.0 + i + } + ], + }); + + // La V2C a tiré 3 000 W en moyenne de 10:00 à 11:00 : échantillon horodaté 11:00. + final conso = ConsumerHistory(series: [ + ConsumerSeries( + thingId: 'v2c', + label: 'V2C Trydan', + domaine: 'ev', + points: [ConsumerPoint(onze, 3000)]), + ], sansHistorique: const []); + + final heures = construireJournee( + jourLocal: jour, + snapshot: snap, + // Production nymea NÉGATIVE ; acquisition négative = injection. + bilanHoraire: [_bilan(onze, prod: -900, cons: 3600, acq: 3000)], + consoHoraire: conso, + ); + HeureJournee a(int h) => heures.firstWhere((x) => x.debut.hour == h); + + test('un échantillon horodaté T appartient à l\'heure qui COMMENCE à T − 1 h', () { + // Aurait attrapé une mesure posée sur l'heure de son horodatage : décalée d'une heure + // par rapport à la prévision et aux icônes. + expect(a(10).consWh, 3600); + expect(a(11).consWh, isNull); + expect(a(10).prevuWh, 500); + }); + + test('production : signe inversé, et décomposée en autoconsommé / injecté', () { + expect(a(10).prodWh, 900); + expect(a(10).exportWh, 0); + expect(a(10).autoWh, 900); + final inj = construireJournee( + jourLocal: jour, + snapshot: null, + bilanHoraire: [_bilan(onze, prod: -900, cons: 400, acq: -500)], + consoHoraire: ConsumerHistory.vide, + ).firstWhere((x) => x.debut.hour == 10); + expect(inj.exportWh, 500); + expect(inj.autoWh, 400); + }); + + test('la Maison est ce que les compteurs déclarés n\'expliquent pas', () { + expect(a(10).parConsommateur['v2c'], 3000); + expect(a(10).resteWh, 600); + }); + + test('une heure non mesurée et non prévue reste inconnue — pas zéro', () { + expect(a(3).consWh, isNull); + expect(a(3).prevuWh, isNull); + }); + + test('une heure à moitié prévue n\'est pas sommée à moitié', () { + final quarts = PvForecastSnapshot.fromJson({ + 'forecastPoints': [ + for (final m in [0, 15, 30]) // 10:45 manque + { + 'targetAt': dix.toUtc().add(Duration(minutes: m)).toIso8601String(), + 'forecastWh': 100 + } + ], + }); + final h = construireJournee( + jourLocal: jour, + snapshot: quarts, + bilanHoraire: const [], + consoHoraire: ConsumerHistory.vide) + .firstWhere((x) => x.debut.hour == 10); + expect(h.prevuWh, isNull); + }); + + test('le jour du passage à l\'heure d\'hiver compte ses heures, pas 24', () { + final n = construireJournee( + jourLocal: DateTime(2026, 10, 25), + snapshot: null, + bilanHoraire: const [], + consoHoraire: ConsumerHistory.vide) + .length; + expect(n, + DateTime(2026, 10, 26).toUtc().difference(DateTime(2026, 10, 25).toUtc()).inHours); + }); + + group('météo de maintenant', () { + test('états jamais écrits (updateTime 0) : pas de soleil à 0 °C', () { + // Les défauts du plugin : weatherCondition « clear-day », temperature 0. + expect( + MeteoActuelle.depuisEtats( + {'updateTime': 0, 'weatherCondition': 'clear-day', 'temperature': 0}), + isNull); + }); + + test('états écrits : lus, et nommés en français', () { + final m = MeteoActuelle.depuisEtats({ + 'updateTime': 1792039500, + 'weatherCondition': 'overcast', + 'temperature': 4.6, + 'temperatureMin': 4.1, + 'temperatureMax': 19.8, + 'sunriseTime': 1792043332, + 'sunsetTime': 1792082350, + })!; + expect(m.libelle, 'Couvert'); + expect(m.temperature, 4.6); + expect(m.lever, + DateTime.fromMillisecondsSinceEpoch(1792043332 * 1000, isUtc: true).toLocal()); + }); + }); + + testWidgets('deux colonnes par heure : consommation empilée, solaire à côté', + (t) async { + await t.pumpWidget(MaterialApp( + home: Scaffold( + body: ListView(children: [ + JourneeView(heures: heures, series: conso.series), + ])))); + final groupes = t.widget(find.byType(BarChart)).data.barGroups; + expect(groupes, hasLength(heures.length)); + final i10 = heures.indexWhere((h) => h.debut.hour == 10); + final g = groupes[i10].barRods; + expect(g, hasLength(2)); + // Colonne consommation : Maison 0,6 puis V2C 3,0. + expect(g[0].rodStackItems.map((s) => (s.fromY, s.toY)), + [(0.0, 0.6), (0.6, 3.6)]); + // Colonne solaire : 0,9 autoconsommé, prévision 0,5 en fond. + expect(g[1].toY, closeTo(0.9, 1e-9)); + expect(g[1].backDrawRodData.toY, closeTo(0.5, 1e-9)); + // Heure à venir prévue : la prévision elle-même, en contour. + final i11 = heures.indexWhere((h) => h.debut.hour == 11); + expect(groupes[i11].barRods[1].toY, closeTo(0.501, 1e-9)); + expect(groupes[i11].barRods[1].borderSide.width, 1); + expect(find.text('V2C Trydan'), findsOneWidget); + expect(find.text('Maison'), findsOneWidget); + }); +} diff --git a/test/pv_forecast_test.dart b/test/pv_forecast_test.dart index d1473cf..3271a64 100644 --- a/test/pv_forecast_test.dart +++ b/test/pv_forecast_test.dart @@ -252,82 +252,6 @@ void main() { }); }); - group('production du jour (accueil)', () { - // Un jour pris dans le fuseau de l'APPAREIL : les attendus se dérivent de toLocal(), - // jamais d'une heure recopiée — sous TZ=UTC ces contrôles ne mordent pas tous. - final jour = DateTime(2026, 10, 14); - final h0 = DateTime(2026, 10, 14, 10).toUtc(); // 10:00 locale - final snap = PvForecastSnapshot.fromJson({ - 'forecastPoints': [ - for (var i = -1; i < 3; i++) - _pt(h0.add(Duration(hours: i)).toIso8601String(), 500.0 + i) - ], - }); - - test('un échantillon horaire horodaté T appartient à l\'heure qui COMMENCE à T − 1 h', - () { - final heures = heuresDuJour(jourLocal: jour, snapshot: snap, mesuresHoraires: [ - // horodaté 11:00 locale = moyenne de 10:00 à 11:00 - (t: DateTime(2026, 10, 14, 11), w: 420), - ]); - final dix = heures.firstWhere((h) => h.debut.hour == 10); - final onze = heures.firstWhere((h) => h.debut.hour == 11); - // Aurait attrapé une mesure posée sur l'heure de son horodatage, décalée d'une - // heure par rapport à la prévision qu'elle est censée contrôler. - expect(dix.mesureWh, 420); - expect(onze.mesureWh, isNull); - expect(dix.prevuWh, 500); - }); - - test('une heure non couverte n\'a pas de prévision — pas zéro', () { - final heures = - heuresDuJour(jourLocal: jour, snapshot: snap, mesuresHoraires: const []); - expect(heures.firstWhere((h) => h.debut.hour == 3).prevuWh, isNull); - }); - - test('une heure à moitié prévue n\'est pas sommée à moitié', () { - final quarts = PvForecastSnapshot.fromJson({ - 'forecastPoints': [ - for (final m in [0, 15, 30]) // 10:45 manque - _pt(h0.add(Duration(minutes: m)).toIso8601String(), 100) - ], - }); - final heures = - heuresDuJour(jourLocal: jour, snapshot: quarts, mesuresHoraires: const []); - expect(heures.firstWhere((h) => h.debut.hour == 10).prevuWh, isNull); - }); - - test('le jour du passage à l\'heure d\'hiver compte ses heures, pas 24', () { - final n = heuresDuJour( - jourLocal: DateTime(2026, 10, 25), snapshot: null, mesuresHoraires: const []) - .length; - final attendu = DateTime(2026, 10, 26) - .toUtc() - .difference(DateTime(2026, 10, 25).toUtc()) - .inHours; - expect(n, attendu); // 25 en Europe/Paris, 24 sous TZ=UTC - }); - - testWidgets('barres : prévision derrière, mesure devant, trou = rien', (t) async { - final heures = heuresDuJour(jourLocal: jour, snapshot: snap, mesuresHoraires: [ - (t: DateTime(2026, 10, 14, 11), w: 420), - ]); - await t.pumpWidget(MaterialApp( - home: Scaffold( - body: SizedBox( - height: 170, - child: PvForecastDayChart(heures: heures, maintenant: jour))))); - final groupes = t.widget(find.byType(BarChart)).data.barGroups; - final i10 = heures.indexWhere((h) => h.debut.hour == 10); - final rod = groupes[i10].barRods.single; - expect(rod.toY, closeTo(0.42, 1e-9)); - expect(rod.backDrawRodData.show, isTrue); - expect(rod.backDrawRodData.toY, closeTo(0.5, 1e-9)); - final i3 = heures.indexWhere((h) => h.debut.hour == 3); - expect(groupes[i3].barRods.single.backDrawRodData.show, isFalse); - }); - }); - 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 = (