Patrick Schurig ae454b703e feat(prévision): consommation de base prévue, bande apprise en Wh (p10Wh/p90Wh)
Consigne app §H.4 et §G (2026-10-16), contre INTERFACE.md local de la
branche moteur — etm70 pas encore sur le banc.

- baseload : lu sur la grille start + i·resolutionS, W moyens → Wh par
  créneau. Sous model « none » (forme réelle du banc etm69 : p50W tout
  null) rien n'est tracé ni légendé — une absence, jamais 0 ; une case
  null reste absente. Carte « Journée » : heures à venir, colonne
  consommation en contour bleu « Consommation de base prévue ». Premier
  profil attendu vers le 25 octobre.
- Bande apprise : forme fixée par la consigne G, p10Wh / p90Wh en Wh par
  point de forecastPoints (l'ancienne lecture p10W/p90W est retirée et
  testée comme telle). Libellé client « Fourchette apprise (8 cas sur
  10) ». Toujours inactive : aucune box ne la publie.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 21:31:11 +02:00

616 lines
24 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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;
String? _luePour;
bool _lue = false;
/// Le jour affiché (minuit local). Aujourd'hui par défaut.
DateTime _jour = _minuit(DateTime.now());
/// Prévision ENREGISTRÉE pour un jour passé (`GetPvForecast`, connue au début du jour).
/// `null` pour aujourd'hui et demain : c'est alors la prévision en vigueur du provider.
PvForecastSnapshot? _historique;
/// Numéro de la dernière lecture lancée : une réponse plus ancienne est jetée — sinon un
/// tap rapide sur ‹ ‹ ‹ afficherait le jour de la première réponse arrivée.
int _lecture = 0;
static DateTime _minuit(DateTime t) => DateTime(t.year, t.month, t.day);
static DateTime _decale(DateTime j, int jours) => DateTime(j.year, j.month, j.day + jours);
DateTime get _aujourdhui => _minuit(DateTime.now());
/// La prévision s'arrête à la fin de demain ; l'historique horaire remonte à un an.
DateTime get _dernier => _decale(_aujourdhui, 1);
DateTime get _premier => _decale(_aujourdhui, -365);
void _aller(DateTime jour) {
final j = _minuit(jour);
if (j.isBefore(_premier) || j.isAfter(_dernier) || j == _jour) return;
setState(() {
_jour = j;
_lue = false;
});
_lire();
}
@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();
// Sur le compteur de lectures, pas sur `capturedAt` : une box sans prévision lisible
// ne doit pas laisser la carte sans mesures (trouvé par le test de navigation).
final lectures = context.watch<PvForecastProvider>().lectures;
final n = context.watch<LoadConfigProvider>().entries.length;
final cle = '$lectures/$n';
if (lectures > 0 && cle != _luePour) {
_luePour = cle;
// Un jour passé ne change plus : seule la journée en cours se relit au rythme du
// provider (et toute journée pas encore lue).
if (_jour == _aujourdhui || !_lue) _lire();
}
}
Future<void> _lire() async {
final service = context.read<NymeaService>();
final cfg = context.read<LoadConfigProvider>();
final now = DateTime.now();
final lecture = ++_lecture;
final debut = _jour;
final fin = _decale(_jour, 1);
final jusqua = fin.isBefore(now) ? fin : now;
// Seules les charges qui déclarent un compteur ont une colonne : rien n'est deviné à
// partir d'un nom d'appareil. Les autres comptent dans « Maison ».
final appareils = [
for (final e in cfg.entries)
if (e.meterThingId.isNotEmpty)
(id: e.meterThingId, label: e.label, domaine: e.domain)
];
// Demain : rien à mesurer. Un jour passé : la prévision telle qu'elle était connue à son
// début — sans `knownAt`, la table rendrait la dernière révision, faite après coup.
final futur = !debut.isBefore(now);
final passe = !fin.isAfter(_aujourdhui);
final r = await Future.wait<dynamic>([
futur
? Future.value(const <PowerBalanceEntry>[])
: service.fetchPowerBalanceLogs(
from: debut, to: jusqua, sampleRate: 'SampleRate1Hour'),
futur
? Future.value(ConsumerHistory.vide)
: service.fetchThingPowerLogs(
appareils: appareils, from: debut, to: jusqua, sampleRate: 'SampleRate1Hour'),
passe
? service.fetchPvForecast(de: debut, a: fin, connueA: debut)
: Future<PvForecastSnapshot?>.value(null),
]);
if (!mounted || lecture != _lecture) return;
setState(() {
_bilan = r[0] as List<PowerBalanceEntry>;
_conso = r[1] as ConsumerHistory;
_historique = r[2] as PvForecastSnapshot?;
_lue = true;
});
}
static const _jours = ['lun.', 'mar.', 'mer.', 'jeu.', 'ven.', 'sam.', 'dim.'];
static const _mois = [
'janv.', 'févr.', 'mars', 'avr.', 'mai', 'juin',
'juil.', 'août', 'sept.', 'oct.', 'nov.', 'déc.'
];
String _libelle(DateTime j) =>
'${_jours[j.weekday - 1]} ${j.day} ${_mois[j.month - 1]}';
Future<void> _calendrier() async {
final choisi = await showDatePicker(
context: context,
initialDate: _jour,
firstDate: _premier,
lastDate: _dernier,
);
if (choisi != null) _aller(choisi);
}
/// **Le choix du jour** : un menu Hier / Aujourd'hui / Demain et un calendrier à côté
/// (Patrick, 2026-10-16 : « fonctionnel mais visuellement trop »). Un jour choisi au
/// calendrier s'ajoute au menu, sous sa date, pour que la sélection reste visible.
Widget _navigation(BuildContext context) {
final muted = EtmTokens.mutedOf(context);
final ecart = _jour.difference(_aujourdhui).inHours.round() ~/ 24;
DropdownMenuItem<int> item(int v, String t) => DropdownMenuItem(
value: v, child: Text(t, style: EtmTokens.sans(size: 12.5, color: EtmTokens.inkOf(context))));
return Row(mainAxisSize: MainAxisSize.min, children: [
DropdownButton<int>(
value: ecart,
isDense: true,
underline: const SizedBox.shrink(),
borderRadius: BorderRadius.circular(10),
icon: Icon(Icons.expand_more_rounded, size: 18, color: muted),
items: [
item(-1, 'Hier'),
item(0, 'Aujourd\'hui'),
item(1, 'Demain'),
if (ecart < -1 || ecart > 1) item(ecart, _libelle(_jour)),
],
onChanged: (v) {
if (v != null) _aller(_decale(_aujourdhui, v));
},
),
IconButton(
visualDensity: VisualDensity.compact,
tooltip: 'Choisir une date',
icon: Icon(Icons.calendar_today_rounded, size: 16, color: muted),
onPressed: _calendrier,
),
]);
}
@override
Widget build(BuildContext context) {
final pv = context.watch<PvForecastProvider>();
final service = context.watch<NymeaService>();
final passe = !_decale(_jour, 1).isAfter(_aujourdhui);
final source = passe ? _historique : pv.snapshot;
final snap = source?.etat == PvForecastEtat.courbe ? source : null;
final heures = construireJournee(
jourLocal: _jour,
snapshot: snap,
bilanHoraire: _bilan,
consoHoraire: _conso);
// Le site est le PARENT des plans sommés par la prévision ; à défaut, le premier site.
final plan = pv.snapshot?.perField.firstOrNull?.id;
final site = plan == null ? null : service.parentDe(plan);
final etats = service.etatsParNom('openMeteoSite',
preferer: site == null || site.isEmpty ? null : site);
return JourneeView(
heures: heures,
series: _conso.series,
// La météo de MAINTENANT n'a de sens que sur aujourd'hui.
meteo: etats == null || _jour != _aujourdhui
? null
: MeteoActuelle.depuisEtats(etats),
meteoAttendue: _jour == _aujourdhui,
navigation: _navigation(context),
meteoHoraire: meteoHoraireDepuis(etats?['forecastHourlyWeather']),
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;
/// Météo par début d'heure (UTC), ou `null` si la box ne la publie pas (openmeteo < etm16).
final Map<DateTime, MeteoHeure>? meteoHoraire;
final bool chargement;
/// Faux sur un autre jour qu'aujourd'hui : la ligne « météo de maintenant » disparaît au
/// lieu de dire « non publiée » — elle n'est pas absente, elle est hors sujet.
final bool meteoAttendue;
/// Le choix du jour (menu + calendrier), posé à droite du titre.
final Widget? navigation;
const JourneeView({
super.key,
required this.heures,
this.series = const [],
this.meteo,
this.meteoHoraire,
this.chargement = false,
this.meteoAttendue = true,
this.navigation,
});
/// 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(children: [
Expanded(
child: Text('Journée',
style: EtmTokens.sans(size: 16, weight: FontWeight.w600, color: EtmTokens.inkOf(context)))),
?navigation,
]),
if (meteoAttendue) ...[const SizedBox(height: 8), _meteo(context)],
const SizedBox(height: 10),
// La rangée horaire ne s'affiche que si la série couvre ce jour (aujourd'hui, demain) :
// la box ne garde pas la météo des jours passés.
if (meteoHoraire != null &&
heures.any((h) => meteoHoraire!.containsKey(h.debut.toUtc()))) ...[
_rangeeMeteo(context),
const SizedBox(height: 2),
],
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),
// Seulement quand la box publie un profil (`model != "none"`) : une légende ne
// promet pas une série qui n'existe pas.
if (heures.any((h) => h.baseWh != null))
_Puce(EtmTokens.brand, 'Consommation de base prévue', contour: true),
]),
// Aucun texte sous le graphe (Patrick, 2026-10-15).
]),
);
}
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, color: EtmTokens.inkOf(context))),
]),
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 et température, une heure sur trois**, alignées sur les colonnes du graphe.
///
/// Le graphe réserve 36 px à gauche pour son axe et répartit les heures à parts égales
/// (`spaceAround`) : la rangée reprend exactement ce découpage. Une heure sur trois, parce
/// que 24 icônes ne tiennent pas lisiblement sur la largeur d'un téléphone.
Widget _rangeeMeteo(BuildContext context) {
final muted = EtmTokens.mutedOf(context);
return Padding(
padding: const EdgeInsets.only(left: 36),
child: Row(children: [
for (final h in heures)
Expanded(
child: h.debut.hour % 3 != 0
? const SizedBox(height: 30)
: Builder(builder: (context) {
final m = meteoHoraire![h.debut.toUtc()];
final icone = m?.code == null ? null : iconeWmo(m!.code!, m.jour);
return SizedBox(
height: 30,
child: OverflowBox(
maxWidth: 40,
child: Column(mainAxisSize: MainAxisSize.min, children: [
if (icone != null)
Icon(icone,
size: 14,
color: m!.code == 0 && m.jour != false
? EtmTokens.solarOf(context)
: muted)
else
const SizedBox(height: 14),
Text(
m?.tempC == null ? '' : '${m!.tempC!.round()}°',
style: EtmTokens.mono(size: 9, color: muted)),
]),
),
);
}),
),
]),
);
}
/// **Code WMO → icône** (table de Patrick du 2026-10-15, inspirée de Weather Icons).
/// `jour == false` donne la variante de nuit. Un code inconnu n'a pas d'icône.
static IconData? iconeWmo(int code, bool? jour) {
final nuit = jour == false;
return switch (code) {
0 => nuit ? Icons.nightlight_round : Icons.wb_sunny_rounded,
1 || 2 => nuit ? Icons.nights_stay_rounded : Icons.wb_cloudy_rounded,
3 => Icons.cloud_rounded,
45 || 48 => Icons.foggy,
51 || 53 || 55 => Icons.grain_rounded,
56 || 57 || 66 || 67 => Icons.cloudy_snowing,
61 || 63 || 65 || 80 || 81 || 82 => Icons.water_drop_rounded,
71 || 73 || 75 || 77 || 85 || 86 => Icons.ac_unit_rounded,
95 || 96 || 99 => Icons.thunderstorm_rounded,
_ => null,
};
}
/// 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,
};
}
/// 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,
h.baseWh ?? 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é. Heure à
/// venir : la consommation de base PRÉVUE en contour, si la box publie un profil.
BarChartRodData _conso(BuildContext context, HeureJournee h, double w) {
if (!h.mesuree) {
final base = h.baseWh;
if (base == null || base <= 0) {
return BarChartRodData(toY: 0, width: w, color: Colors.transparent);
}
return BarChartRodData(
toY: base / 1000,
width: w,
color: EtmTokens.brand.withValues(alpha: 0.25),
borderRadius: BorderRadius.zero,
borderSide: const BorderSide(color: EtmTokens.brand, width: 1),
);
}
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))),
]);
}