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

@@ -9,6 +9,10 @@
"consumption_p80_csv": states("input_text.evcs_balance_consumption_p80"),
"envelope_csv": states("input_text.evcs_balance_envelope"),
"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"),
"pv_power_w": states("sensor.victron_pv_power_system"),

View File

@@ -17,17 +17,25 @@
// mesurés. L'installation a manifestement plusieurs orientations
// (trois trackers), qu'un plan unique ne peut pas représenter.
//
// 3. RETENU — FORME = enveloppe horaire réellement démontrée (production
// maximale jamais observée à chaque heure). Elle encode le comportement
// réel du champ, orientations multiples comprises, sans aucun modèle.
// MAGNITUDE = prévision Forecast.Solar (énergie restante du jour), qui
// porte la météo et ignore totalement l'état de notre batterie.
// 3. Forme = enveloppe démontrée, magnitude = total prévu — RETENU du 24 au
// 25/08, puis RÉTROGRADÉ EN REPLI. L'enveloppe étant un maximum horaire
// pris sur des jours différents, elle surpondère le matin, dont la
// variabilité jour à jour est plus forte qu'à midi. Deux modèles
// 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
// censurée (77 à 96 % des heures après 13 h le sont), donc sous-estimée. Les
// heures du matin sont propres (censure de 0 à 4 %). Le champ `envelopeQuality`
// dit, heure par heure, si la forme est fiable ou minorée — à charge du
// consommateur d'en tenir compte plutôt que de le découvrir trop tard.
// 4. RETENU (25/08) — COURBE HORAIRE de l'API Forecast.Solar, récupérée par
// le workflow "EVCS Solar Forecast V1" et publiée en CSV. Elle apporte
// forme ET magnitude d'un seul tenant, sur 6 jours, avec les trois plans
// réels de l'installation, et n'est censurée par rien. Le nœud s'en
// 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.
// =====================================================================
@@ -64,6 +72,25 @@ const nowMs = num(input.nowMs, Date.now());
const todayRemainingKwh = Math.max(0, num(input.forecast_today_remaining_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
// 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.
@@ -78,6 +105,10 @@ function read24(arrayKey, csvKey) {
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 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 tomorrowSlots = slots.filter(s => s.day !== today);
distribute(todaySlots, todayRemainingKwh);
distribute(tomorrowSlots, tomorrowKwh);
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(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 :
// c'est la mesure de confiance de cette projection.
@@ -138,7 +183,10 @@ const projectedOnWeakShape = slots
return [{
json: {
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 => ({
startMs: s.startMs,
@@ -155,6 +203,10 @@ return [{
todayRemainingKwh: round2(todayRemainingKwh),
tomorrowKwh: round2(tomorrowKwh),
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,
weakShapeHours: slots
.filter(x => x.quality === 'minoree' && x.expectedKwh > 0)