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:
parent
c1a4d5f46c
commit
28c4196b9b
504
lib/features/dashboard/widgets/journee_card.dart
Normal file
504
lib/features/dashboard/widgets/journee_card.dart
Normal 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))),
|
||||
]);
|
||||
}
|
||||
@ -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
181
lib/models/journee.dart
Normal 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,
|
||||
};
|
||||
}
|
||||
@ -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 ──────────
|
||||
//
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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
178
test/journee_test.dart
Normal 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);
|
||||
});
|
||||
}
|
||||
@ -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 = (
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user