// EVCS Energy Balance — RESOURCE_ESTIMATE // ===================================================================== // Rôle : estimer la production PV attendue heure par heure, en RESSOURCE // (ce que l'installation peut produire) et non en mesure censurée. // // Trois sources ont été évaluées le 24/08/2026, deux ont été écartées : // // 1. Recalage de la prévision sur les heures non censurées — ÉCARTÉ. // La censure est corrélée au beau temps : les heures exploitables de // l'après-midi ne le sont que les jours médiocres. Le facteur mesuré va // de 4,29 à 9 h (27 échantillons) à 0,48 à 15 h (3 échantillons). // Données manquantes non aléatoires, aucun facteur transportable. // // 2. Forme issue de la géométrie solaire — ÉCARTÉ. Un modèle mono-plan // ajusté sur les heures propres converge sur « plein est, 20° » et // prédit alors 2,0 % de la journée à 18 h contre 5,8 % réellement // mesurés. L'installation a manifestement plusieurs orientations // (trois trackers), qu'un plan unique ne peut pas représenter. // // 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. // // 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. // ===================================================================== function num(v, fallback = NaN) { const n = Number.parseFloat(v); return Number.isFinite(n) ? n : fallback; } function round2(v) { return Number.isFinite(v) ? Math.round(v * 100) / 100 : null; } // Le nœud HTTP de n8n renvoie le corps du template dans `data`, sous forme de // chaîne. Hors n8n (tests), l'objet arrive déjà décodé — on accepte les deux. function unwrapSnapshot(raw) { if (raw && typeof raw.data === 'string') { try { return JSON.parse(raw.data); } catch { return raw; } } return (raw && raw.data) || raw; } const input = unwrapSnapshot($input.first().json); const CFG = { TIMEZONE: 'Europe/Paris', // 40 h : assez pour qu'une traversée déficitaire commençant en fin // d'après-midi se referme au croisement du lendemain matin. Avec 30 h, la // fenêtre de la nuit restait ouverte et son heure de fin s'affichait « ? ». // La courbe couvre aujourd'hui et demain, soit 48 h au plus. HORIZON_HOURS: 40, // Au-delà de cette part d'heures censurées, la forme de l'enveloppe est // considérée comme minorée et signalée comme telle. CENSORSHIP_QUALITY_LIMIT: 0.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. // Absents au premier démarrage → on le dit, on ne fabrique pas de forme. function read24(arrayKey, csvKey) { const direct = input[arrayKey]; if (Array.isArray(direct) && direct.length === 24) return direct.map(v => num(v, NaN)); const csv = input[csvKey]; if (typeof csv !== 'string') return null; const parts = csv.split(',').map(v => Number.parseFloat(v.trim())); if (parts.length !== 24 || parts.some(v => !Number.isFinite(v))) return null; 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; const censorshipRaw = read24('censorship_rate', 'censorship_rate_csv'); const censorshipRate = censorshipRaw ? censorshipRaw.map(v => Math.max(0, Math.min(1, num(v, 1)))) : Array.from({ length: 24 }, () => 1); const hourOfDay = (ms) => Number.parseInt( new Intl.DateTimeFormat('fr-FR', { timeZone: CFG.TIMEZONE, hour: '2-digit', hour12: false, }).format(new Date(ms)), 10 ); const dayKey = (ms) => new Intl.DateTimeFormat('fr-CA', { timeZone: CFG.TIMEZONE, year: 'numeric', month: '2-digit', day: '2-digit', }).format(new Date(ms)); const currentHourStart = Math.floor(nowMs / 3600000) * 3600000; const today = dayKey(nowMs); // Une heure ou l'enveloppe demontree est nulle est une heure de nuit : il n'y // a jamais eu de soleil a y observer. Son taux de censure vaut 1 pour cette // raison-la, pas parce que la mesure serait douteuse, et il n'existe aucune // forme a minorer. La classer « minoree » faisait crier au loup TOUTES les // nuits (les heures de deficit sont justement les heures sans soleil), donc // exactement quand le bilan sert. Constate sur la nuit du 25-26/08 : statut // « degrade » de 20 h a 07 h alors que le bilan a predit le point bas a un // point pres. const slots = []; for (let i = 0; i < CFG.HORIZON_HOURS; i++) { const start = currentHourStart + i * 3600000; const hour = hourOfDay(start); const nightHour = !!envelope && !(envelope[hour] > 0); slots.push({ startMs: start, hour, day: dayKey(start), weight: envelope ? envelope[hour] : 0, quality: nightHour ? 'nuit' : censorshipRate[hour] <= CFG.CENSORSHIP_QUALITY_LIMIT ? 'fiable' : 'minoree', }); } // Répartition de l'énergie prévue au prorata de la forme démontrée. function distribute(daySlots, energyKwh) { const totalWeight = daySlots.reduce((s, x) => s + x.weight, 0); for (const slot of daySlots) { slot.expectedKwh = totalWeight > 0 ? (slot.weight / totalWeight) * energyKwh : 0; } } const todaySlots = 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(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. const projected = slots.reduce((s, x) => s + x.expectedKwh, 0); const projectedOnWeakShape = slots .filter(x => x.quality === 'minoree') .reduce((s, x) => s + x.expectedKwh, 0); return [{ json: { generatedAt: new Date(nowMs).toISOString(), 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, hour: s.hour, day: s.day, expectedKwh: round2(s.expectedKwh), shapeQuality: s.quality, })), todayExpectedRemainingKwh: round2(todaySlots.reduce((s, x) => s + x.expectedKwh, 0)), tomorrowExpectedKwh: round2(tomorrowSlots.reduce((s, x) => s + x.expectedKwh, 0)), source: { 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) .map(x => x.hour) .filter((h, i, a) => a.indexOf(h) === i), }, cfg: CFG, }, }];