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>
This commit is contained in:
Patrick Schurig 2026-10-16 21:31:11 +02:00
parent fb5b0e7165
commit ae454b703e
6 changed files with 190 additions and 18 deletions

View File

@ -320,6 +320,10 @@ class JourneeView extends StatelessWidget {
_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).
]),
@ -449,6 +453,7 @@ class JourneeChart extends StatelessWidget {
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);
@ -512,10 +517,21 @@ class JourneeChart extends StatelessWidget {
});
}
/// Colonne de gauche : la Maison en bas, puis chaque consommateur déclaré.
/// 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) {
return BarChartRodData(toY: 0, width: w, color: Colors.transparent);
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),

View File

@ -35,6 +35,11 @@ class HeureJournee {
/// Consommation par appareil déclaré (clé = thingId), mesurée.
final Map<String, double> parConsommateur;
/// **Consommation de base PRÉVUE** (hors charges pilotées), seulement si l'heure est
/// entièrement couverte par le profil. `null` sous `baseload.model == "none"` : une
/// absence, jamais une consommation nulle.
final double? baseWh;
const HeureJournee({
required this.debut,
this.prevuWh,
@ -43,6 +48,7 @@ class HeureJournee {
this.importWh,
this.exportWh,
this.parConsommateur = const {},
this.baseWh,
});
bool get mesuree => consWh != null;
@ -101,8 +107,26 @@ List<HeureJournee> construireJournee({
}
if (complet) prevu = somme;
}
// Même règle que le prévu : une heure à moitié couverte n'a pas de valeur.
double? base;
final pasB = snapshot?.pasGrille;
final bases = snapshot?.baseloadWh ?? const <DateTime, double>{};
if (pasB != null && pasB.inSeconds > 0 && 3600 % pasB.inSeconds == 0 && bases.isNotEmpty) {
var somme = 0.0;
var complet = true;
for (var t = h; t.isBefore(h.add(heure)); t = t.add(pasB)) {
final v = bases[t];
if (v == null) {
complet = false;
break;
}
somme += v;
}
if (complet) base = somme;
}
final b = bilans[h];
out.add(HeureJournee(
baseWh: base,
debut: h.toLocal(),
prevuWh: prevu,
prodWh: b == null ? null : -b.productionW,

View File

@ -30,12 +30,13 @@ class PvForecastPoint {
/// Énergie prévue sur le créneau, en Wh. Jamais une puissance.
final double wh;
/// Bornes apprises (`p10W` / `p90W`, puissances moyennes sur le créneau), quand la box
/// les publie un jour. Absentes aujourd'hui : le code qui les lit est prêt, inactif.
final double? p10W;
final double? p90W;
/// Bornes apprises, en **Wh** sur le créneau (`p10Wh` / `p90Wh`, consigne G du
/// 2026-10-16 — remplace la forme `p10W`/`p90W` d'abord supposée). Absentes si inconnues :
/// le code qui les lit est prêt, inactif tant que la box ne les publie pas.
final double? p10Wh;
final double? p90Wh;
const PvForecastPoint(this.debutUtc, this.wh, {this.p10W, this.p90W});
const PvForecastPoint(this.debutUtc, this.wh, {this.p10Wh, this.p90Wh});
}
/// Pourquoi il n'y a pas de courbe — ou qu'il y en a une.
@ -89,6 +90,18 @@ class PvForecastSnapshot {
/// Un plan sommé par entrée : son Thing et ses corrections déclarées.
final List<({String id, List<String> corrections})> perField;
/// **Consommation de base prévue** (`baseload`, hors charges pilotées), par début de
/// créneau UTC, en **Wh** (W moyens × durée du créneau). VIDE sous `model: "none"` — c'est
/// une absence, pas une consommation nulle — et une case `null` n'y figure pas.
/// Premier profil attendu sur le banc vers le 25 octobre (≥ 7 jours couverts).
final Map<DateTime, double> baseloadWh;
/// `profile`, `none`, ou `null` si la box ne publie pas le champ (antérieure à etm69).
final String? baseloadModel;
/// Pas de la grille `baseload` / `price` (`resolutionS`), `null` si non publié.
final Duration? pasGrille;
const PvForecastSnapshot({
required this.points,
this.fault,
@ -101,6 +114,9 @@ class PvForecastSnapshot {
this.corrections,
this.acLimitW,
this.perField = const [],
this.baseloadWh = const {},
this.baseloadModel,
this.pasGrille,
});
factory PvForecastSnapshot.fromJson(Map<String, dynamic> m) {
@ -114,8 +130,8 @@ class PvForecastSnapshot {
// Un point illisible est un trou, pas un zéro : on le saute.
if (t == null || wh is! num) continue;
pts.add(PvForecastPoint(_enUtc(t), wh.toDouble(),
p10W: (e['p10W'] as num?)?.toDouble(),
p90W: (e['p90W'] as num?)?.toDouble()));
p10Wh: (e['p10Wh'] as num?)?.toDouble(),
p90Wh: (e['p90Wh'] as num?)?.toDouble()));
}
}
pts.sort((a, b) => a.debutUtc.compareTo(b.debutUtc));
@ -125,6 +141,29 @@ class PvForecastSnapshot {
final fp = m['faultParams'];
final champs = m['perField'];
// La grille de `baseload` / `price` : `start` + i·`resolutionS`, alignée index par index.
final base = m['baseload'];
final debutGrille = DateTime.tryParse(m['start'] as String? ?? '');
final res = (m['resolutionS'] as num?)?.toInt();
final baseloadWh = <DateTime, double>{};
final baseModel = base is Map ? base['model'] as String? : null;
if (base is Map &&
baseModel != null &&
baseModel != 'none' &&
debutGrille != null &&
res != null &&
res > 0) {
final p50 = base['p50W'];
if (p50 is List) {
for (var i = 0; i < p50.length; i++) {
final w = p50[i];
if (w is! num) continue; // null = inconnu, jamais 0
baseloadWh[_enUtc(debutGrille).add(Duration(seconds: i * res))] =
w.toDouble() * res / 3600;
}
}
}
final fault = m['fault'];
return PvForecastSnapshot(
points: pts,
@ -137,6 +176,9 @@ class PvForecastSnapshot {
conversion: m['conversion'] as String?,
corrections: chaines(m['corrections']),
acLimitW: (m['acLimitW'] as num?)?.toInt(),
baseloadWh: baseloadWh,
baseloadModel: baseModel,
pasGrille: res != null && res > 0 ? Duration(seconds: res) : null,
perField: [
if (champs is List)
for (final c in champs)
@ -378,7 +420,7 @@ enum SourceBande {
/// ±dispersion journalière des modèles — affichage seulement, jamais renvoyée à la box.
fourchetteMeteo,
/// `p10W` / `p90W` publiés par la box : ils REMPLACENT la fourchette météo.
/// `p10Wh` / `p90Wh` publiés par la box : ils REMPLACENT la fourchette météo.
p10p90,
}
@ -398,7 +440,7 @@ class BandePrevision {
/// **La bande autour de la prévision** — `null` quand il n'y a rien à tracer.
///
/// - `p10W`/`p90W` présents → leur bande, et elle seule.
/// - `p10Wh`/`p90Wh` présents → leur bande, et elle seule.
/// - sinon, fourchette météo : `bas = prévu × (1 − s_J)`, `haut = prévu × (1 + s_J)`, avec
/// `s_J` la dispersion du jour (Paris) du créneau. `bas` ≥ 0 ; `haut` ≤ plafond AC s'il
/// est configuré, sinon ≤ puissance crête des plans.
@ -412,11 +454,11 @@ BandePrevision? bandePrevision(
if (pas == null) return null;
final h = pas.inSeconds / 3600.0;
if (snap.points.any((p) => p.p10W != null && p.p90W != null)) {
if (snap.points.any((p) => p.p10Wh != null && p.p90Wh != null)) {
return BandePrevision(SourceBande.p10p90, {
for (final p in snap.points)
if (p.p10W != null && p.p90W != null)
p.debutUtc: (basWh: p.p10W! * h, hautWh: p.p90W! * h),
if (p.p10Wh != null && p.p90Wh != null)
p.debutUtc: (basWh: p.p10Wh!, hautWh: p.p90Wh!),
});
}

View File

@ -235,7 +235,9 @@ class PvForecastView extends StatelessWidget {
EtmTokens.solarOf(context).withValues(alpha: 0.30),
plein: true,
bande!.source == SourceBande.p10p90
? 'Fourchette p10–p90 (publiée par la box)'
// Libellé client fixé par la consigne G ; « p10–p90 » réservé à
// l'installateur.
? 'Fourchette apprise (8 cas sur 10)'
: 'Fourchette météo (dispersion des modèles)'
'${bande!.attenuee ? ' · non datée' : ''}'),
]),

View File

@ -317,5 +317,50 @@ void main() {
final titre = t.widget<Text>(find.text('Journée'));
expect(titre.style!.color, EtmTokens.inkDark);
});
group('consommation de base prévue (baseload)', () {
final debutJour = DateTime(2026, 10, 14).toUtc();
PvForecastSnapshot snapBase(String model) => PvForecastSnapshot.fromJson({
'start': debutJour.toIso8601String(),
'resolutionS': 3600,
'steps': 24,
'baseload': {
'model': model,
'p50W': [for (var i = 0; i < 24; i++) model == 'none' ? null : 400.0],
},
'forecastPoints': const [],
});
testWidgets('profil publié : contour bleu sur les heures à venir, avec sa légende',
(t) async {
final hs = construireJournee(
jourLocal: DateTime(2026, 10, 14),
snapshot: snapBase('profile'),
bilanHoraire: const [],
consoHoraire: ConsumerHistory.vide);
expect(hs.first.baseWh, 400);
await t.pumpWidget(MaterialApp(home: Scaffold(body: ListView(children: [
JourneeView(heures: hs),
]))));
final rod = t.widget<BarChart>(find.byType(BarChart)).data.barGroups.first.barRods[0];
expect(rod.toY, closeTo(0.4, 1e-9));
expect(rod.borderSide.width, 1);
expect(find.text('Consommation de base prévue'), findsOneWidget);
});
testWidgets('model "none" : rien de tracé, pas de légende — une absence, pas 0',
(t) async {
final hs = construireJournee(
jourLocal: DateTime(2026, 10, 14),
snapshot: snapBase('none'),
bilanHoraire: const [],
consoHoraire: ConsumerHistory.vide);
expect(hs.every((h) => h.baseWh == null), isTrue);
await t.pumpWidget(MaterialApp(home: Scaffold(body: ListView(children: [
JourneeView(heures: hs),
]))));
expect(find.text('Consommation de base prévue'), findsNothing);
});
});
}

View File

@ -391,11 +391,11 @@ void main() {
expect(find.textContaining('confiance'), findsNothing);
});
test('p10W / p90W présents → ils REMPLACENT la fourchette météo', () {
test('p10Wh / p90Wh présents (Wh, consigne G) → ils REMPLACENT la fourchette météo', () {
final appris = PvForecastSnapshot.fromJson({
'forecastPoints': [
{'targetAt': '2026-10-14T08:00:00Z', 'forecastWh': 500, 'p10W': 200, 'p90W': 600},
{'targetAt': '2026-10-14T09:00:00Z', 'forecastWh': 900, 'p10W': 400, 'p90W': 1000},
{'targetAt': '2026-10-14T08:00:00Z', 'forecastWh': 500, 'p10Wh': 200, 'p90Wh': 600},
{'targetAt': '2026-10-14T09:00:00Z', 'forecastWh': 900, 'p10Wh': 400, 'p90Wh': 1000},
],
});
final b = bandePrevision(appris, f, maintenant: matin)!;
@ -444,4 +444,47 @@ void main() {
expect(f.aujourdhui, 0);
});
});
test('l\'ancienne forme p10W / p90W n\'est plus lue', () {
final s = PvForecastSnapshot.fromJson({
'forecastPoints': [
{'targetAt': '2026-10-14T08:00:00Z', 'forecastWh': 500, 'p10W': 200, 'p90W': 600},
{'targetAt': '2026-10-14T09:00:00Z', 'forecastWh': 500, 'p10W': 200, 'p90W': 600},
],
});
expect(bandePrevision(s, null, maintenant: DateTime.utc(2026, 10, 14, 8)), isNull);
});
group('consommation de base prévue (baseload, consigne H)', () {
Map<String, dynamic> snap(Map<String, dynamic> base) => {
'start': '2026-10-14T22:00:00Z',
'resolutionS': 3600,
'steps': 3,
'baseload': base,
'forecastPoints': const [],
};
test('model "none" + p50W tout null : une ABSENCE, rien à tracer', () {
// La forme réelle du banc en etm69 (2026-10-15).
final s = PvForecastSnapshot.fromJson(
snap({'model': 'none', 'p50W': [null, null, null]}));
expect(s.baseloadModel, 'none');
expect(s.baseloadWh, isEmpty);
});
test('model "profile" : W moyens → Wh par créneau, une case null reste absente', () {
final s = PvForecastSnapshot.fromJson(
snap({'model': 'profile', 'p50W': [310.0, null, 295.0]}));
expect(s.baseloadWh[DateTime.utc(2026, 10, 14, 22)], 310);
expect(s.baseloadWh.containsKey(DateTime.utc(2026, 10, 14, 23)), isFalse);
expect(s.baseloadWh[DateTime.utc(2026, 10, 15, 0)], 295);
});
test('même sous "profile", un model "none" par box ne trace rien d\'autre', () {
final s = PvForecastSnapshot.fromJson({'forecastPoints': const []});
expect(s.baseloadModel, isNull);
expect(s.baseloadWh, isEmpty);
});
});
}