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:
@@ -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"),
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user