Chantier : le bilan lit la courbe horaire au lieu de l'enveloppe

RESOURCE_ESTIMATE consomme desormais la courbe horaire publiee par le workflow
de prevision : forme et magnitude d'un seul tenant, sur 6 jours, avec les trois
plans reels et sans censure. Plus de repartition au prorata, plus de
renormalisation — le noeud se simplifie.

L'enveloppe est retrogradee en repli, utilisee seulement si la courbe est
absente ou vieille de plus d'une heure, le champ `mode` disant laquelle a
servi. Elle garde un role de repere de vraisemblance mais n'ecrete PLUS la
prevision : etant minoree l'apres-midi, elle tronquerait a tort une prevision
correcte.

Piege corrige au passage : le nouveau champ `source` entrait en collision avec
l'objet `source` deja present dans la sortie et etait silencieusement ecrase.
Renomme en `mode`.

Suite portee a 35 scenarios (courbe fraiche lue telle quelle, courbe perimee
basculant en repli).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KKzkkVTG6KXnGXFFC7X9XJ
This commit is contained in:
Tallagrand Thierry
2026-08-25 02:37:41 +02:00
parent 26fb9f72ea
commit c35c39cb83
6 changed files with 124 additions and 21 deletions

View File

@@ -303,12 +303,17 @@ journée comme preuve du défaut de configuration était lui-même gonflé. Le d
était réel — un seul plan plein sud, 15 kWp, plafonné à 8 kW — mais j'en ai était réel — un seul plan plein sud, 15 kWp, plafonné à 8 kW — mais j'en ai
surestimé l'ampleur en le mesurant contre une référence biaisée. surestimé l'ampleur en le mesurant contre une référence biaisée.
**Conséquence pour le code** : l'enveloppe doit disparaître de **Conséquence appliquée le 25/08** : `RESOURCE_ESTIMATE` lit désormais la courbe
`RESOURCE_ESTIMATE` au profit de la courbe horaire de l'API horaire de l'API, publiée par le workflow **EVCS Solar Forecast V1**
(`estimate/watthours/period`), disponible sur 6 jours. Elle apporte à la fois la (`07P8I349ugna9FH4`, 15 min). Elle apporte forme et magnitude d'un seul tenant,
forme et la magnitude, ce qui **simplifie** le nœud et supprime la référence sur 6 jours, avec les trois plans réels, sans aucune censure — le nœud s'en
défectueuse. L'enveloppe reste utile pour une seule chose : le plafond physique trouve simplifié : plus de répartition au prorata, plus de renormalisation.
de vraisemblance.
L'enveloppe est **rétrogradée en repli** : elle sert encore si la courbe est
absente ou vieille de plus d'une heure, et le champ `mode` dit laquelle des deux
a servi (`courbe_horaire_api` ou `repli_enveloppe`). Elle garde par ailleurs un
rôle de repère de vraisemblance, **sans écrêtage** : étant minorée l'après-midi,
elle tronquerait à tort une prévision correcte.
## Backlog ## Backlog

View File

@@ -9,6 +9,10 @@
"consumption_p80_csv": states("input_text.evcs_balance_consumption_p80"), "consumption_p80_csv": states("input_text.evcs_balance_consumption_p80"),
"envelope_csv": states("input_text.evcs_balance_envelope"), "envelope_csv": states("input_text.evcs_balance_envelope"),
"censorship_rate_csv": states("input_text.evcs_balance_censorship_rate"), "censorship_rate_csv": states("input_text.evcs_balance_censorship_rate"),
"forecast_today_csv": states("input_text.evcs_balance_forecast_today"),
"forecast_tomorrow_csv": states("input_text.evcs_balance_forecast_tomorrow"),
"forecast_updated": states("input_text.evcs_balance_forecast_updated"),
"grid_soc_off": states("input_number.victron_grid_soc_off"), "grid_soc_off": states("input_number.victron_grid_soc_off"),
"pv_power_w": states("sensor.victron_pv_power_system"), "pv_power_w": states("sensor.victron_pv_power_system"),

View File

@@ -17,17 +17,25 @@
// mesurés. L'installation a manifestement plusieurs orientations // mesurés. L'installation a manifestement plusieurs orientations
// (trois trackers), qu'un plan unique ne peut pas représenter. // (trois trackers), qu'un plan unique ne peut pas représenter.
// //
// 3. RETENU — FORME = enveloppe horaire réellement démontrée (production // 3. Forme = enveloppe démontrée, magnitude = total prévu — RETENU du 24 au
// maximale jamais observée à chaque heure). Elle encode le comportement // 25/08, puis RÉTROGRADÉ EN REPLI. L'enveloppe étant un maximum horaire
// réel du champ, orientations multiples comprises, sans aucun modèle. // pris sur des jours différents, elle surpondère le matin, dont la
// MAGNITUDE = prévision Forecast.Solar (énergie restante du jour), qui // variabilité jour à jour est plus forte qu'à midi. Deux modèles
// porte la météo et ignore totalement l'état de notre batterie. // indépendants (géométrique local et ciel clair de Forecast.Solar) la
// contredisent de la même façon : c'est elle qui a tort.
// //
// LIMITE ASSUMÉE ET SIGNALÉE : l'enveloppe de l'après-midi est elle-même // 4. RETENU (25/08) — COURBE HORAIRE de l'API Forecast.Solar, récupérée par
// censurée (77 à 96 % des heures après 13 h le sont), donc sous-estimée. Les // le workflow "EVCS Solar Forecast V1" et publiée en CSV. Elle apporte
// heures du matin sont propres (censure de 0 à 4 %). Le champ `envelopeQuality` // forme ET magnitude d'un seul tenant, sur 6 jours, avec les trois plans
// dit, heure par heure, si la forme est fiable ou minorée — à charge du // réels de l'installation, et n'est censurée par rien. Le nœud s'en
// consommateur d'en tenir compte plutôt que de le découvrir trop tard. // trouve simplifié : plus de répartition au prorata, plus de
// renormalisation.
//
// REPLI : si la courbe est absente ou périmée, on retombe sur la méthode 3
// (total prévu réparti sur l'enveloppe), en le signalant par `source`.
//
// L'enveloppe garde un seul rôle : repère de vraisemblance. Elle n'écrête PAS
// la prévision — étant minorée l'après-midi, elle tronquerait à tort.
// //
// Ce nœud ne décide rien. // Ce nœud ne décide rien.
// ===================================================================== // =====================================================================
@@ -64,6 +72,25 @@ const nowMs = num(input.nowMs, Date.now());
const todayRemainingKwh = Math.max(0, num(input.forecast_today_remaining_kwh, 0)); const todayRemainingKwh = Math.max(0, num(input.forecast_today_remaining_kwh, 0));
const tomorrowKwh = Math.max(0, num(input.forecast_tomorrow_kwh, 0)); const tomorrowKwh = Math.max(0, num(input.forecast_tomorrow_kwh, 0));
// Fraîcheur de la courbe : le workflow de prévision tourne au quart d'heure.
// Au-delà d'une heure sans mise à jour, on considère la courbe périmée et on
// repasse au repli plutôt que de projeter sur une météo dépassée.
const FRESHNESS_LIMIT_MIN = 60;
function parseStampParis(text) {
const m = /^(\d{2})\/(\d{2})\s+(\d{2}):(\d{2})$/.exec((text || '').toString().trim());
if (!m) return NaN;
const now = new Date(nowMs);
const guess = new Date(Date.UTC(now.getUTCFullYear(), +m[2] - 1, +m[1], +m[3] - 2, +m[4]));
// Passage d'année : une date future de plus d'un mois appartient à l'an dernier.
if (guess.getTime() - nowMs > 31 * 86400000) guess.setUTCFullYear(guess.getUTCFullYear() - 1);
return guess.getTime();
}
const forecastUpdatedMs = parseStampParis(input.forecast_updated);
const forecastAgeMin = Number.isFinite(forecastUpdatedMs)
? (nowMs - forecastUpdatedMs) / 60000
: Infinity;
const forecastFresh = forecastAgeMin <= FRESHNESS_LIMIT_MIN;
// Enveloppe et taux de censure par heure : produits par le calcul quotidien et // Enveloppe et taux de censure par heure : produits par le calcul quotidien et
// stockés dans des helpers input_text sous forme de CSV compact. On accepte // stockés dans des helpers input_text sous forme de CSV compact. On accepte
// aussi le tableau déjà décodé, ce qui rend le nœud testable hors n8n. // aussi le tableau déjà décodé, ce qui rend le nœud testable hors n8n.
@@ -78,6 +105,10 @@ function read24(arrayKey, csvKey) {
return parts; return parts;
} }
const hourlyToday = read24('forecast_today_kwh', 'forecast_today_csv');
const hourlyTomorrow = read24('forecast_tomorrow_kwh', 'forecast_tomorrow_csv');
const hourlyAvailable = !!hourlyToday && forecastFresh;
const envelopeRaw = read24('envelope_kwh', 'envelope_csv'); const envelopeRaw = read24('envelope_kwh', 'envelope_csv');
const envelope = envelopeRaw ? envelopeRaw.map(v => Math.max(0, num(v, 0))) : null; const envelope = envelopeRaw ? envelopeRaw.map(v => Math.max(0, num(v, 0))) : null;
@@ -125,8 +156,22 @@ function distribute(daySlots, energyKwh) {
const todaySlots = slots.filter(s => s.day === today); const todaySlots = slots.filter(s => s.day === today);
const tomorrowSlots = slots.filter(s => s.day !== today); const tomorrowSlots = slots.filter(s => s.day !== today);
if (hourlyAvailable) {
// Lecture directe : chaque heure porte déjà son énergie prévue.
for (const slot of todaySlots) slot.expectedKwh = Math.max(0, hourlyToday[slot.hour] ?? 0);
const tom = hourlyTomorrow || hourlyToday;
for (const slot of tomorrowSlots) slot.expectedKwh = Math.max(0, tom[slot.hour] ?? 0);
} else {
distribute(todaySlots, todayRemainingKwh); distribute(todaySlots, todayRemainingKwh);
distribute(tomorrowSlots, tomorrowKwh); distribute(tomorrowSlots, tomorrowKwh);
}
// Repère de vraisemblance, sans écrêtage : l'enveloppe est minorée l'après-midi.
for (const slot of slots) {
slot.aboveEnvelope = !!envelope && envelope[slot.hour] > 0
&& slot.expectedKwh > envelope[slot.hour] * 1.5;
}
// Part de l'énergie projetée qui tombe sur des heures dont la forme est minorée : // Part de l'énergie projetée qui tombe sur des heures dont la forme est minorée :
// c'est la mesure de confiance de cette projection. // c'est la mesure de confiance de cette projection.
@@ -138,7 +183,10 @@ const projectedOnWeakShape = slots
return [{ return [{
json: { json: {
generatedAt: new Date(nowMs).toISOString(), generatedAt: new Date(nowMs).toISOString(),
usable: !!envelope, usable: hourlyAvailable || !!envelope,
// Quelle source a réellement fourni la courbe ? (`source` ci-dessous est
// le détail des entrées ; `mode` dit laquelle a gagné.)
mode: hourlyAvailable ? 'courbe_horaire_api' : 'repli_enveloppe',
horizon: slots.map(s => ({ horizon: slots.map(s => ({
startMs: s.startMs, startMs: s.startMs,
@@ -155,6 +203,10 @@ return [{
todayRemainingKwh: round2(todayRemainingKwh), todayRemainingKwh: round2(todayRemainingKwh),
tomorrowKwh: round2(tomorrowKwh), tomorrowKwh: round2(tomorrowKwh),
envelopeProvided: !!envelope, envelopeProvided: !!envelope,
hourlyCurveAvailable: !!hourlyToday,
forecastAgeMin: Number.isFinite(forecastAgeMin) ? Math.round(forecastAgeMin) : null,
forecastFresh,
hoursAboveEnvelope: slots.filter(s => s.aboveEnvelope).length,
shapeConfidence: projected > 0 ? round2(1 - projectedOnWeakShape / projected) : null, shapeConfidence: projected > 0 ? round2(1 - projectedOnWeakShape / projected) : null,
weakShapeHours: slots weakShapeHours: slots
.filter(x => x.quality === 'minoree' && x.expectedKwh > 0) .filter(x => x.quality === 'minoree' && x.expectedKwh > 0)

View File

@@ -59,6 +59,7 @@ const SUMMARIZE = {
}), }),
RESOURCE_ESTIMATE: o => ({ RESOURCE_ESTIMATE: o => ({
usable: o.usable, usable: o.usable,
mode: o.mode,
todayExpected: o.todayExpectedRemainingKwh, todayExpected: o.todayExpectedRemainingKwh,
tomorrowExpected: o.tomorrowExpectedKwh, tomorrowExpected: o.tomorrowExpectedKwh,
shapeConfidenceBelow: o.source.shapeConfidence != null && o.source.shapeConfidence < 0.5, shapeConfidenceBelow: o.source.shapeConfidence != null && o.source.shapeConfidence < 0.5,

View File

@@ -126,6 +126,47 @@ export const resourceScenarios = [
todayExpected: 30, todayExpected: 30,
}, },
}, },
{
node: 'RESOURCE_ESTIMATE',
id: 'ressource-courbe-horaire',
label: 'Courbe horaire fraîche : lue telle quelle, sans répartition ni renormalisation',
snapshot: {
nowMs: Date.parse('2026-08-25T09:30:00+02:00'),
forecast_today_remaining_kwh: '999',
forecast_tomorrow_kwh: '999',
forecast_updated: '25/08 09:30',
// Relevé réel du 25/08 : 43,6 kWh sur la journée.
forecast_today_csv: '0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.07,0.63,1.60,3.50,6.50,7.90,7.00,4.60,3.30,2.70,2.20,1.70,1.10,0.60,0.10,0.00,0.00',
forecast_tomorrow_csv: '0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.09,0.77,1.60,2.27,2.81,3.63,4.24,3.52,2.95,2.59,2.21,1.69,1.08,0.47,0.05,0.00,0.00',
envelope_kwh: envelope.envelope_kwh,
censorship_rate: envelope.censorship_rate,
},
expect: {
usable: true,
mode: 'courbe_horaire_api',
// Reste de la journée à partir de 09 h : lu dans la courbe, pas recalculé.
todayExpected: 42.8,
},
},
{
node: 'RESOURCE_ESTIMATE',
id: 'ressource-courbe-perimee',
label: 'Courbe vieille de plus d\'une heure : repli sur l\'enveloppe, signalé',
snapshot: {
nowMs: Date.parse('2026-08-25T12:30:00+02:00'),
forecast_today_remaining_kwh: '15.9',
forecast_tomorrow_kwh: '29.1',
forecast_updated: '25/08 09:30',
forecast_today_csv: '0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.07,0.63,1.60,3.50,6.50,7.90,7.00,4.60,3.30,2.70,2.20,1.70,1.10,0.60,0.10,0.00,0.00',
envelope_kwh: envelope.envelope_kwh,
censorship_rate: envelope.censorship_rate,
},
expect: {
usable: true,
mode: 'repli_enveloppe',
todayExpected: 15.9,
},
},
{ {
node: 'RESOURCE_ESTIMATE', node: 'RESOURCE_ESTIMATE',
id: 'ressource-sans-enveloppe', id: 'ressource-sans-enveloppe',

File diff suppressed because one or more lines are too long