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:
parent
fb5b0e7165
commit
ae454b703e
@ -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),
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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!),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -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' : ''}'),
|
||||
]),
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user