feat(accueil): carte « Journée » — consommation par consommateur et solaire

Variante E de la maquette, retenue par Patrick le 2026-10-15 (« deux
colonnes à la Victron ») ; remplace la carte « Production du jour ».

- Par heure, deux colonnes : à gauche la consommation, Maison (bleu) puis
  chaque consommateur déclaré avec compteur (couleur de son domaine, même
  règle que l'écran Énergie) ; à droite le solaire, autoconsommé (doré)
  puis injecté (pâle), la prévision en fond pour comparer. Heures à venir :
  la prévision seule, en contour — aucun plan n'est publié par la box.
- Échantillon horaire horodaté T = heure qui commence à T − 1 h, pour le
  bilan comme pour les Things.
- Météo de maintenant lue sur le Thing site openmeteo, et seulement si
  updateTime a été écrit : les défauts du plugin (clear-day, 0 °C)
  afficheraient sinon un soleil à 0 °C. Les icônes horaires attendent
  openmeteo etm16 (consignes/CONSIGNE-openmeteo-meteo-horaire.md).
- Charges sans compteur déclaré nommées sous la carte (« comptée dans
  Maison »), jamais devinées.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Patrick Schurig 2026-10-15 07:53:24 +02:00
parent c1a4d5f46c
commit 28c4196b9b
8 changed files with 897 additions and 366 deletions

View File

@ -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<JourneeCard> createState() => _JourneeCardState();
}
class _JourneeCardState extends State<JourneeCard> {
List<PowerBalanceEntry> _bilan = const [];
ConsumerHistory _conso = ConsumerHistory.vide;
List<String> _sansCompteur = const [];
DateTime? _luePour;
bool _lue = false;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
context.read<PvForecastProvider>().assurer();
final cfg = context.read<LoadConfigProvider>();
if (cfg.entries.isEmpty) {
cfg
..attach(context.read<NymeaService>())
..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<PvForecastProvider>().snapshot?.capturedAt;
final n = context.watch<LoadConfigProvider>().entries.length;
final cle = lue?.add(Duration(microseconds: n));
if (cle != null && cle != _luePour) {
_luePour = cle;
_lire();
}
}
Future<void> _lire() async {
final service = context.read<NymeaService>();
final cfg = context.read<LoadConfigProvider>();
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<dynamic>([
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<PowerBalanceEntry>;
_conso = r[1] as ConsumerHistory;
_sansCompteur = sansCompteur;
_lue = true;
});
}
@override
Widget build(BuildContext context) {
final pv = context.watch<PvForecastProvider>();
final service = context.watch<NymeaService>();
final snap = pv.snapshot?.etat == PvForecastEtat.courbe ? pv.snapshot : null;
final plans = <String>[for (final f in snap?.perField ?? const []) f.id];
final fourchette = context.select<NymeaService, FourchetteMeteo?>((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<HeureJournee> heures;
final List<ConsumerSeries> series;
final MeteoActuelle? meteo;
final BandePrevision? bande;
final PvForecastEtat? etatPrevision;
final bool previsionNonLue;
final List<String> sansCompteur;
final List<String> 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<String, Color> couleurs(BuildContext c, List<ConsumerSeries> series) {
final rangs = <String, int>{};
final out = <String, Color>{};
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 = <String>[];
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<Widget> _notes(BuildContext context) {
final notes = <String>[
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<HeureJournee> heures;
final List<ConsumerSeries> series;
final Map<String, Color> 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<double>(
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 = <BarChartRodStackItem>[];
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))),
]);
}

View File

@ -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<PvDayCard> createState() => _PvDayCardState();
}
class _PvDayCardState extends State<PvDayCard> {
List<ProductionMesuree> _mesures = const [];
DateTime? _luePour;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) context.read<PvForecastProvider>().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<PvForecastProvider>().snapshot?.capturedAt;
if (lue != null && lue != _luePour) {
_luePour = lue;
_lireMesures();
}
}
Future<void> _lireMesures() async {
final service = context.read<NymeaService>();
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<PvForecastProvider>();
final service = context.watch<NymeaService>();
final maintenant = DateTime.now();
final heures = heuresDuJour(
jourLocal: maintenant, snapshot: pv.snapshot, mesuresHoraires: _mesures);
final snap = pv.snapshot;
final plans = <String>[for (final f in snap?.perField ?? const []) f.id];
final fourchette = context.select<NymeaService, FourchetteMeteo?>((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<HeureDuJour> heures,
PvForecastSnapshot? snap, BandePrevision? bande) {
String kwh(double wh) => '${(wh / 1000).toStringAsFixed(1)} kWh';
final lignes = <String>[];
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 <PvForecastPoint>[])
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;
}
}

181
lib/models/journee.dart Normal file
View File

@ -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<String, double> 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<HeureJournee> construireJournee({
required DateTime jourLocal,
required PvForecastSnapshot? snapshot,
required List<PowerBalanceEntry> 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 = <DateTime, double>{
for (final p in snapshot?.points ?? const <PvForecastPoint>[]) p.debutUtc: p.wh
};
final bilans = <DateTime, PowerBalanceEntry>{
for (final e in bilanHoraire) e.timestamp.toUtc().subtract(heure): e
};
final appareils = <String, Map<DateTime, double>>{
for (final s in consoHoraire.series)
s.thingId: {
for (final p in s.points) p.t.toUtc().subtract(heure): p.powerW,
}
};
final out = <HeureJournee>[];
// 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<String, Object?> 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,
};
}

View File

@ -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<DashboardScreen> {
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 ──────────
//

View File

@ -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<String, Object?>? 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;

View File

@ -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<HeureDuJour> heuresDuJour({
required DateTime jourLocal,
required PvForecastSnapshot? snapshot,
required List<ProductionMesuree> 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 = <DateTime, double>{
for (final p in snapshot?.points ?? const <PvForecastPoint>[]) p.debutUtc: p.wh
};
final mesures = <DateTime, double>{
for (final m in mesuresHoraires)
m.t.toUtc().subtract(const Duration(hours: 1)): m.w
};
final out = <HeureDuJour>[];
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<HeureDuJour> 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),
),
),
]),
],
),
);
}
}

178
test/journee_test.dart Normal file
View File

@ -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<BarChart>(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);
});
}

View File

@ -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<BarChart>(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 = (