diff --git a/HANDOFF.md b/HANDOFF.md index fe878f3..e636f1f 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -1179,11 +1179,13 @@ affiner ; demarrage solaire de l'apres-midi toujours non couvert par le besoin d l'historique de production de ces heures de fin de journee est incomplet … la decision reelle est reprise a 01:24 » ; `indisponible` seulement sans bilan, bilan perime ou statut inconnu, avec la raison dans `explication`. Diagnostics 90/90, mutation qui mord. - ⚠️ **OUVERT, decision du proprietaire** : pendant cette heure, le controleur ignore - AUSSI le bilan (`balanceStatusOk`) -> plancher du soir retombe sur 85 % fixe, cible et - session sur les regles historiques. A 01:24 le statut est `ok` (pas d'effet sur la - session de nuit constate). Piste : ne pas degrader sur une forme minoree quand la - production horaire vient de la courbe de prevision (et non de l'enveloppe). + Pendant cette heure, le controleur ignorait AUSSI le bilan (`balanceStatusOk`) : + plancher du soir retombe sur 85 % fixe. **Corrige a la source (accord du proprietaire, + 18:20)** : `RESOURCE_ESTIMATE` classe `courbe` toute heure dont l'energie est lue dans la + courbe horaire ; `minoree` ne qualifie plus que les heures reparties sur l'enveloppe + (repli). Verifie en reel : 18:15 `degrade`, 18:20 `ok`, chiffres IDENTIQUES (67,2 / + 81,5 / 13,56 kWh) — l'etiquette ne changeait rien au calcul. Bilan 72/72, mutation qui + mord. Le statut `sous_reserve` de la prevision ne doit plus apparaitre qu'en repli. - SPARK : les transcripts SQLite (`openclaw-agent.sqlite`, droits 600) remontent au 18/07, AUCUNE rotation ; un evenement contient le jeton HA (l'agent a lu `.ha-token`). Signale au proprietaire par SPARK, base non modifiee. diff --git a/src/energy-balance/RESOURCE_ESTIMATE.js b/src/energy-balance/RESOURCE_ESTIMATE.js index 3502ce1..b9bd565 100644 --- a/src/energy-balance/RESOURCE_ESTIMATE.js +++ b/src/energy-balance/RESOURCE_ESTIMATE.js @@ -216,10 +216,14 @@ 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, hourlyForToday[slot.hour] ?? 0); + for (const slot of todaySlots) { + slot.expectedKwh = Math.max(0, hourlyForToday[slot.hour] ?? 0); + slot.fromCurve = true; + } if (hourlyForTomorrow) { for (const slot of tomorrowSlots) { slot.expectedKwh = Math.max(0, hourlyForTomorrow[slot.hour] ?? 0); + slot.fromCurve = true; } } else { // Courbe decalee : on connait aujourd'hui, pas demain. L'enveloppe mesuree @@ -232,6 +236,19 @@ if (hourlyAvailable) { distribute(tomorrowSlots, tomorrowKwh); } +// Une heure lue dans la COURBE HORAIRE ne doit rien a l'enveloppe (29/09/2026). +// La classer « minoree » d'apres le taux de censure de l'enveloppe faisait +// passer le bilan `degrade` chaque soir pendant une heure (19 h du 23 au 28/09, +// 18 h le 29/09) : l'heure du soir encore ensoleillee est une heure de deficit, +// et son historique est censure parce que la batterie est souvent pleine a +// cette heure. Or sa production venait de VRM / Forecast.Solar, pas de cet +// historique. Pendant cette heure le controleur ignorait le bilan (plancher du +// soir retombe a 85 % fixe). « minoree » ne qualifie plus que les heures dont +// l'energie a ete repartie sur l'enveloppe (repli). +for (const slot of slots) { + if (slot.fromCurve && slot.quality !== 'nuit') slot.quality = 'courbe'; +} + // 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 diff --git a/tests/energy-balance/scenarios-profile.mjs b/tests/energy-balance/scenarios-profile.mjs index 172b80b..85b8376 100644 --- a/tests/energy-balance/scenarios-profile.mjs +++ b/tests/energy-balance/scenarios-profile.mjs @@ -319,6 +319,12 @@ export const resourceScenarios = [ mode: 'courbe_horaire_api', // Reste de la journée à partir de 09 h : lu dans la courbe, pas recalculé. todayExpected: 42.8, + // 29/09 : une heure lue dans la courbe ne doit rien a l'enveloppe, meme + // censuree (14 h : taux 0,63) — plus de faux « minoree », plus de faux + // `degrade` du bilan chaque soir. La nuit reste la nuit. + qualityAtHour14: 'courbe', + qualityAtHour3: 'nuit', + shapeConfidenceBelow: false, }, }, { @@ -338,6 +344,8 @@ export const resourceScenarios = [ usable: true, mode: 'repli_enveloppe', todayExpected: 15.9, + // Repli sur l'enveloppe : la forme minoree reste signalee. + qualityAtHour14: 'minoree', }, }, { diff --git a/workflows/evcs-energy-balance-v1.json b/workflows/evcs-energy-balance-v1.json index 908aee5..2adc4d9 100644 --- a/workflows/evcs-energy-balance-v1.json +++ b/workflows/evcs-energy-balance-v1.json @@ -87,7 +87,7 @@ 120 ], "parameters": { - "jsCode": "// EVCS Energy Balance — RESOURCE_ESTIMATE\n// =====================================================================\n// Rôle : estimer la production PV attendue heure par heure, en RESSOURCE\n// (ce que l'installation peut produire) et non en mesure censurée.\n//\n// Trois sources ont été évaluées le 24/08/2026, deux ont été écartées :\n//\n// 1. Recalage de la prévision sur les heures non censurées — ÉCARTÉ.\n// La censure est corrélée au beau temps : les heures exploitables de\n// l'après-midi ne le sont que les jours médiocres. Le facteur mesuré va\n// de 4,29 à 9 h (27 échantillons) à 0,48 à 15 h (3 échantillons).\n// Données manquantes non aléatoires, aucun facteur transportable.\n//\n// 2. Forme issue de la géométrie solaire — ÉCARTÉ. Un modèle mono-plan\n// ajusté sur les heures propres converge sur « plein est, 20° » et\n// prédit alors 2,0 % de la journée à 18 h contre 5,8 % réellement\n// mesurés. L'installation a manifestement plusieurs orientations\n// (trois trackers), qu'un plan unique ne peut pas représenter.\n//\n// 3. Forme = enveloppe démontrée, magnitude = total prévu — RETENU du 24 au\n// 25/08, puis RÉTROGRADÉ EN REPLI. L'enveloppe étant un maximum horaire\n// pris sur des jours différents, elle surpondère le matin, dont la\n// variabilité jour à jour est plus forte qu'à midi. Deux modèles\n// indépendants (géométrique local et ciel clair de Forecast.Solar) la\n// contredisent de la même façon : c'est elle qui a tort.\n//\n// 4. RETENU (25/08) — COURBE HORAIRE de l'API Forecast.Solar, récupérée par\n// le workflow \"EVCS Solar Forecast V1\" et publiée en CSV. Elle apporte\n// forme ET magnitude d'un seul tenant, sur 6 jours, avec les trois plans\n// réels de l'installation, et n'est censurée par rien. Le nœud s'en\n// trouve simplifié : plus de répartition au prorata, plus de\n// renormalisation.\n//\n// REPLI : si la courbe est absente ou périmée, on retombe sur la méthode 3\n// (total prévu réparti sur l'enveloppe), en le signalant par `source`.\n//\n// L'enveloppe garde un seul rôle : repère de vraisemblance. Elle n'écrête PAS\n// la prévision — étant minorée l'après-midi, elle tronquerait à tort.\n//\n// Ce nœud ne décide rien.\n// =====================================================================\n\nfunction num(v, fallback = NaN) {\n const n = Number.parseFloat(v);\n return Number.isFinite(n) ? n : fallback;\n}\n\nfunction round2(v) {\n return Number.isFinite(v) ? Math.round(v * 100) / 100 : null;\n}\n\n// Le nœud HTTP de n8n renvoie le corps du template dans `data`, sous forme de\n// chaîne. Hors n8n (tests), l'objet arrive déjà décodé — on accepte les deux.\nfunction unwrapSnapshot(raw) {\n if (raw && typeof raw.data === 'string') {\n try { return JSON.parse(raw.data); } catch { return raw; }\n }\n return (raw && raw.data) || raw;\n}\n\nconst input = unwrapSnapshot($input.first().json);\n\nconst CFG = {\n TIMEZONE: 'Europe/Paris',\n // 40 h : assez pour qu'une traversée déficitaire commençant en fin\n // d'après-midi se referme au croisement du lendemain matin. Avec 30 h, la\n // fenêtre de la nuit restait ouverte et son heure de fin s'affichait « ? ».\n // La courbe couvre aujourd'hui et demain, soit 48 h au plus.\n HORIZON_HOURS: 40,\n // Au-delà de cette part d'heures censurées, la forme de l'enveloppe est\n // considérée comme minorée et signalée comme telle.\n CENSORSHIP_QUALITY_LIMIT: 0.25,\n};\n\nconst nowMs = num(input.nowMs, Date.now());\nconst todayRemainingKwh = Math.max(0, num(input.forecast_today_remaining_kwh, 0));\nconst tomorrowKwh = Math.max(0, num(input.forecast_tomorrow_kwh, 0));\n\n// Fraîcheur de la courbe : le workflow de prévision tourne au quart d'heure.\n// Au-delà d'une heure sans mise à jour, on considère la courbe périmée et on\n// repasse au repli plutôt que de projeter sur une météo dépassée.\nconst FRESHNESS_LIMIT_MIN = 60;\nfunction parseStampParis(text) {\n const m = /^(\\d{2})\\/(\\d{2})\\s+(\\d{2}):(\\d{2})$/.exec((text || '').toString().trim());\n if (!m) return NaN;\n const now = new Date(nowMs);\n const guess = new Date(Date.UTC(now.getUTCFullYear(), +m[2] - 1, +m[1], +m[3] - 2, +m[4]));\n // Passage d'année : une date future de plus d'un mois appartient à l'an dernier.\n if (guess.getTime() - nowMs > 31 * 86400000) guess.setUTCFullYear(guess.getUTCFullYear() - 1);\n return guess.getTime();\n}\nconst forecastUpdatedMs = parseStampParis(input.forecast_updated);\nconst forecastAgeMin = Number.isFinite(forecastUpdatedMs)\n ? (nowMs - forecastUpdatedMs) / 60000\n : Infinity;\nconst forecastFresh = forecastAgeMin <= FRESHNESS_LIMIT_MIN;\n\n// Enveloppe et taux de censure par heure : produits par le calcul quotidien et\n// stockés dans des helpers input_text sous forme de CSV compact. On accepte\n// aussi le tableau déjà décodé, ce qui rend le nœud testable hors n8n.\n// Absents au premier démarrage → on le dit, on ne fabrique pas de forme.\nfunction read24(arrayKey, csvKey) {\n const direct = input[arrayKey];\n if (Array.isArray(direct) && direct.length === 24) return direct.map(v => num(v, NaN));\n const csv = input[csvKey];\n if (typeof csv !== 'string') return null;\n const parts = csv.split(',').map(v => Number.parseFloat(v.trim()));\n if (parts.length !== 24 || parts.some(v => !Number.isFinite(v))) return null;\n return parts;\n}\n\nconst hourlyToday = read24('forecast_today_kwh', 'forecast_today_csv');\nconst hourlyTomorrow = read24('forecast_tomorrow_kwh', 'forecast_tomorrow_csv');\n\nconst envelopeRaw = read24('envelope_kwh', 'envelope_csv');\nconst envelope = envelopeRaw ? envelopeRaw.map(v => Math.max(0, num(v, 0))) : null;\n\nconst censorshipRaw = read24('censorship_rate', 'censorship_rate_csv');\nconst censorshipRate = censorshipRaw\n ? censorshipRaw.map(v => Math.max(0, Math.min(1, num(v, 1))))\n : Array.from({ length: 24 }, () => 1);\n\nconst hourOfDay = (ms) =>\n Number.parseInt(\n new Intl.DateTimeFormat('fr-FR', {\n timeZone: CFG.TIMEZONE, hour: '2-digit', hour12: false,\n }).format(new Date(ms)),\n 10\n );\n\nconst dayKey = (ms) =>\n new Intl.DateTimeFormat('fr-CA', {\n timeZone: CFG.TIMEZONE, year: 'numeric', month: '2-digit', day: '2-digit',\n }).format(new Date(ms));\n\nconst currentHourStart = Math.floor(nowMs / 3600000) * 3600000;\nconst today = dayKey(nowMs);\n\n// Une courbe est utilisable si elle DECRIT le jour dont on a besoin, pas si\n// elle est recente. Une prevision publiee a 23:45 pour le 8 septembre reste\n// exacte a 03:00 : la nuit, il n'y a rien a reactualiser. Inversement, une\n// courbe publiee il y a dix minutes mais qui decrit la veille ne vaut rien.\n//\n// `forecast_day0` porte la date de la courbe « today » telle qu'elle etait au\n// moment de la publication. Deux cas seulement sont exploitables :\n// day0 = aujourd'hui -> lecture directe, cas normal\n// day0 = hier -> la publication date d'avant minuit : la courbe\n// « tomorrow » est celle d'aujourd'hui, et on n'a\n// rien pour demain (repli sur l'enveloppe).\n// Au-dela, les courbes sont trop vieilles pour dire quoi que ce soit.\n//\n// ⚠️ REPLI : `forecast_day0` absent ou illisible => on retombe sur l'ancienne\n// garde d'age. Tant que le helper n'existe pas cote Home Assistant, le\n// comportement est donc strictement inchange.\nconst forecastDay0 = (input.forecast_day0 || '').toString().trim();\nconst day0Lisible = /^\\d{4}-\\d{2}-\\d{2}$/.test(forecastDay0);\nconst veille = dayKey(nowMs - 86400000);\n\nlet hourlyForToday = null;\nlet hourlyForTomorrow = null;\nlet forecastAlignment;\nif (!day0Lisible) {\n // Comportement historique, tant que la date n'est pas publiee.\n forecastAlignment = 'date_absente';\n if (forecastFresh) {\n hourlyForToday = hourlyToday;\n hourlyForTomorrow = hourlyTomorrow;\n }\n} else if (forecastDay0 === today) {\n forecastAlignment = 'a_jour';\n hourlyForToday = hourlyToday;\n hourlyForTomorrow = hourlyTomorrow;\n} else if (forecastDay0 === veille) {\n forecastAlignment = 'decalee_d_un_jour';\n hourlyForToday = hourlyTomorrow;\n hourlyForTomorrow = null;\n} else {\n forecastAlignment = 'perimee';\n}\nconst hourlyAvailable = !!hourlyForToday;\n\n// Une heure ou l'enveloppe demontree est nulle est une heure de nuit : il n'y\n// a jamais eu de soleil a y observer. Son taux de censure vaut 1 pour cette\n// raison-la, pas parce que la mesure serait douteuse, et il n'existe aucune\n// forme a minorer. La classer « minoree » faisait crier au loup TOUTES les\n// nuits (les heures de deficit sont justement les heures sans soleil), donc\n// exactement quand le bilan sert. Constate sur la nuit du 25-26/08 : statut\n// « degrade » de 20 h a 07 h alors que le bilan a predit le point bas a un\n// point pres.\nconst slots = [];\nfor (let i = 0; i < CFG.HORIZON_HOURS; i++) {\n const start = currentHourStart + i * 3600000;\n const hour = hourOfDay(start);\n const nightHour = !!envelope && !(envelope[hour] > 0);\n slots.push({\n startMs: start,\n hour,\n day: dayKey(start),\n weight: envelope ? envelope[hour] : 0,\n quality: nightHour\n ? 'nuit'\n : censorshipRate[hour] <= CFG.CENSORSHIP_QUALITY_LIMIT ? 'fiable' : 'minoree',\n });\n}\n\n// Répartition de l'énergie prévue au prorata de la forme démontrée.\nfunction distribute(daySlots, energyKwh) {\n const totalWeight = daySlots.reduce((s, x) => s + x.weight, 0);\n for (const slot of daySlots) {\n slot.expectedKwh = totalWeight > 0 ? (slot.weight / totalWeight) * energyKwh : 0;\n }\n}\n\nconst todaySlots = slots.filter(s => s.day === today);\nconst tomorrowSlots = slots.filter(s => s.day !== today);\n\nif (hourlyAvailable) {\n // Lecture directe : chaque heure porte déjà son énergie prévue.\n for (const slot of todaySlots) slot.expectedKwh = Math.max(0, hourlyForToday[slot.hour] ?? 0);\n if (hourlyForTomorrow) {\n for (const slot of tomorrowSlots) {\n slot.expectedKwh = Math.max(0, hourlyForTomorrow[slot.hour] ?? 0);\n }\n } else {\n // Courbe decalee : on connait aujourd'hui, pas demain. L'enveloppe mesuree\n // reprend la main pour la suite de l'horizon plutot que de recopier la\n // courbe du jour, qui serait une invention.\n distribute(tomorrowSlots, tomorrowKwh);\n }\n} else {\n distribute(todaySlots, todayRemainingKwh);\n distribute(tomorrowSlots, tomorrowKwh);\n}\n\n// Repère de vraisemblance, sans écrêtage : l'enveloppe est minorée l'après-midi.\nfor (const slot of slots) {\n slot.aboveEnvelope = !!envelope && envelope[slot.hour] > 0\n && slot.expectedKwh > envelope[slot.hour] * 1.5;\n}\n\n// Part de l'énergie projetée qui tombe sur des heures dont la forme est minorée :\n// c'est la mesure de confiance de cette projection.\nconst projected = slots.reduce((s, x) => s + x.expectedKwh, 0);\nconst projectedOnWeakShape = slots\n .filter(x => x.quality === 'minoree')\n .reduce((s, x) => s + x.expectedKwh, 0);\n\nreturn [{\n json: {\n generatedAt: new Date(nowMs).toISOString(),\n usable: hourlyAvailable || !!envelope,\n // Quelle source a réellement fourni la courbe ? (`source` ci-dessous est\n // le détail des entrées ; `mode` dit laquelle a gagné.)\n mode: hourlyAvailable ? 'courbe_horaire_api' : 'repli_enveloppe',\n\n horizon: slots.map(s => ({\n startMs: s.startMs,\n hour: s.hour,\n day: s.day,\n expectedKwh: round2(s.expectedKwh),\n shapeQuality: s.quality,\n })),\n\n todayExpectedRemainingKwh: round2(todaySlots.reduce((s, x) => s + x.expectedKwh, 0)),\n tomorrowExpectedKwh: round2(tomorrowSlots.reduce((s, x) => s + x.expectedKwh, 0)),\n\n source: {\n todayRemainingKwh: round2(todayRemainingKwh),\n tomorrowKwh: round2(tomorrowKwh),\n envelopeProvided: !!envelope,\n hourlyCurveAvailable: !!hourlyToday,\n forecastAgeMin: Number.isFinite(forecastAgeMin) ? Math.round(forecastAgeMin) : null,\n forecastFresh,\n forecastDay0: forecastDay0 || null,\n forecastAlignment,\n hoursAboveEnvelope: slots.filter(s => s.aboveEnvelope).length,\n shapeConfidence: projected > 0 ? round2(1 - projectedOnWeakShape / projected) : null,\n weakShapeHours: slots\n .filter(x => x.quality === 'minoree' && x.expectedKwh > 0)\n .map(x => x.hour)\n .filter((h, i, a) => a.indexOf(h) === i),\n },\n\n cfg: CFG,\n },\n}];\n" + "jsCode": "// EVCS Energy Balance — RESOURCE_ESTIMATE\n// =====================================================================\n// Rôle : estimer la production PV attendue heure par heure, en RESSOURCE\n// (ce que l'installation peut produire) et non en mesure censurée.\n//\n// Trois sources ont été évaluées le 24/08/2026, deux ont été écartées :\n//\n// 1. Recalage de la prévision sur les heures non censurées — ÉCARTÉ.\n// La censure est corrélée au beau temps : les heures exploitables de\n// l'après-midi ne le sont que les jours médiocres. Le facteur mesuré va\n// de 4,29 à 9 h (27 échantillons) à 0,48 à 15 h (3 échantillons).\n// Données manquantes non aléatoires, aucun facteur transportable.\n//\n// 2. Forme issue de la géométrie solaire — ÉCARTÉ. Un modèle mono-plan\n// ajusté sur les heures propres converge sur « plein est, 20° » et\n// prédit alors 2,0 % de la journée à 18 h contre 5,8 % réellement\n// mesurés. L'installation a manifestement plusieurs orientations\n// (trois trackers), qu'un plan unique ne peut pas représenter.\n//\n// 3. Forme = enveloppe démontrée, magnitude = total prévu — RETENU du 24 au\n// 25/08, puis RÉTROGRADÉ EN REPLI. L'enveloppe étant un maximum horaire\n// pris sur des jours différents, elle surpondère le matin, dont la\n// variabilité jour à jour est plus forte qu'à midi. Deux modèles\n// indépendants (géométrique local et ciel clair de Forecast.Solar) la\n// contredisent de la même façon : c'est elle qui a tort.\n//\n// 4. RETENU (25/08) — COURBE HORAIRE de l'API Forecast.Solar, récupérée par\n// le workflow \"EVCS Solar Forecast V1\" et publiée en CSV. Elle apporte\n// forme ET magnitude d'un seul tenant, sur 6 jours, avec les trois plans\n// réels de l'installation, et n'est censurée par rien. Le nœud s'en\n// trouve simplifié : plus de répartition au prorata, plus de\n// renormalisation.\n//\n// REPLI : si la courbe est absente ou périmée, on retombe sur la méthode 3\n// (total prévu réparti sur l'enveloppe), en le signalant par `source`.\n//\n// L'enveloppe garde un seul rôle : repère de vraisemblance. Elle n'écrête PAS\n// la prévision — étant minorée l'après-midi, elle tronquerait à tort.\n//\n// Ce nœud ne décide rien.\n// =====================================================================\n\nfunction num(v, fallback = NaN) {\n const n = Number.parseFloat(v);\n return Number.isFinite(n) ? n : fallback;\n}\n\nfunction round2(v) {\n return Number.isFinite(v) ? Math.round(v * 100) / 100 : null;\n}\n\n// Le nœud HTTP de n8n renvoie le corps du template dans `data`, sous forme de\n// chaîne. Hors n8n (tests), l'objet arrive déjà décodé — on accepte les deux.\nfunction unwrapSnapshot(raw) {\n if (raw && typeof raw.data === 'string') {\n try { return JSON.parse(raw.data); } catch { return raw; }\n }\n return (raw && raw.data) || raw;\n}\n\nconst input = unwrapSnapshot($input.first().json);\n\nconst CFG = {\n TIMEZONE: 'Europe/Paris',\n // 40 h : assez pour qu'une traversée déficitaire commençant en fin\n // d'après-midi se referme au croisement du lendemain matin. Avec 30 h, la\n // fenêtre de la nuit restait ouverte et son heure de fin s'affichait « ? ».\n // La courbe couvre aujourd'hui et demain, soit 48 h au plus.\n HORIZON_HOURS: 40,\n // Au-delà de cette part d'heures censurées, la forme de l'enveloppe est\n // considérée comme minorée et signalée comme telle.\n CENSORSHIP_QUALITY_LIMIT: 0.25,\n};\n\nconst nowMs = num(input.nowMs, Date.now());\nconst todayRemainingKwh = Math.max(0, num(input.forecast_today_remaining_kwh, 0));\nconst tomorrowKwh = Math.max(0, num(input.forecast_tomorrow_kwh, 0));\n\n// Fraîcheur de la courbe : le workflow de prévision tourne au quart d'heure.\n// Au-delà d'une heure sans mise à jour, on considère la courbe périmée et on\n// repasse au repli plutôt que de projeter sur une météo dépassée.\nconst FRESHNESS_LIMIT_MIN = 60;\nfunction parseStampParis(text) {\n const m = /^(\\d{2})\\/(\\d{2})\\s+(\\d{2}):(\\d{2})$/.exec((text || '').toString().trim());\n if (!m) return NaN;\n const now = new Date(nowMs);\n const guess = new Date(Date.UTC(now.getUTCFullYear(), +m[2] - 1, +m[1], +m[3] - 2, +m[4]));\n // Passage d'année : une date future de plus d'un mois appartient à l'an dernier.\n if (guess.getTime() - nowMs > 31 * 86400000) guess.setUTCFullYear(guess.getUTCFullYear() - 1);\n return guess.getTime();\n}\nconst forecastUpdatedMs = parseStampParis(input.forecast_updated);\nconst forecastAgeMin = Number.isFinite(forecastUpdatedMs)\n ? (nowMs - forecastUpdatedMs) / 60000\n : Infinity;\nconst forecastFresh = forecastAgeMin <= FRESHNESS_LIMIT_MIN;\n\n// Enveloppe et taux de censure par heure : produits par le calcul quotidien et\n// stockés dans des helpers input_text sous forme de CSV compact. On accepte\n// aussi le tableau déjà décodé, ce qui rend le nœud testable hors n8n.\n// Absents au premier démarrage → on le dit, on ne fabrique pas de forme.\nfunction read24(arrayKey, csvKey) {\n const direct = input[arrayKey];\n if (Array.isArray(direct) && direct.length === 24) return direct.map(v => num(v, NaN));\n const csv = input[csvKey];\n if (typeof csv !== 'string') return null;\n const parts = csv.split(',').map(v => Number.parseFloat(v.trim()));\n if (parts.length !== 24 || parts.some(v => !Number.isFinite(v))) return null;\n return parts;\n}\n\nconst hourlyToday = read24('forecast_today_kwh', 'forecast_today_csv');\nconst hourlyTomorrow = read24('forecast_tomorrow_kwh', 'forecast_tomorrow_csv');\n\nconst envelopeRaw = read24('envelope_kwh', 'envelope_csv');\nconst envelope = envelopeRaw ? envelopeRaw.map(v => Math.max(0, num(v, 0))) : null;\n\nconst censorshipRaw = read24('censorship_rate', 'censorship_rate_csv');\nconst censorshipRate = censorshipRaw\n ? censorshipRaw.map(v => Math.max(0, Math.min(1, num(v, 1))))\n : Array.from({ length: 24 }, () => 1);\n\nconst hourOfDay = (ms) =>\n Number.parseInt(\n new Intl.DateTimeFormat('fr-FR', {\n timeZone: CFG.TIMEZONE, hour: '2-digit', hour12: false,\n }).format(new Date(ms)),\n 10\n );\n\nconst dayKey = (ms) =>\n new Intl.DateTimeFormat('fr-CA', {\n timeZone: CFG.TIMEZONE, year: 'numeric', month: '2-digit', day: '2-digit',\n }).format(new Date(ms));\n\nconst currentHourStart = Math.floor(nowMs / 3600000) * 3600000;\nconst today = dayKey(nowMs);\n\n// Une courbe est utilisable si elle DECRIT le jour dont on a besoin, pas si\n// elle est recente. Une prevision publiee a 23:45 pour le 8 septembre reste\n// exacte a 03:00 : la nuit, il n'y a rien a reactualiser. Inversement, une\n// courbe publiee il y a dix minutes mais qui decrit la veille ne vaut rien.\n//\n// `forecast_day0` porte la date de la courbe « today » telle qu'elle etait au\n// moment de la publication. Deux cas seulement sont exploitables :\n// day0 = aujourd'hui -> lecture directe, cas normal\n// day0 = hier -> la publication date d'avant minuit : la courbe\n// « tomorrow » est celle d'aujourd'hui, et on n'a\n// rien pour demain (repli sur l'enveloppe).\n// Au-dela, les courbes sont trop vieilles pour dire quoi que ce soit.\n//\n// ⚠️ REPLI : `forecast_day0` absent ou illisible => on retombe sur l'ancienne\n// garde d'age. Tant que le helper n'existe pas cote Home Assistant, le\n// comportement est donc strictement inchange.\nconst forecastDay0 = (input.forecast_day0 || '').toString().trim();\nconst day0Lisible = /^\\d{4}-\\d{2}-\\d{2}$/.test(forecastDay0);\nconst veille = dayKey(nowMs - 86400000);\n\nlet hourlyForToday = null;\nlet hourlyForTomorrow = null;\nlet forecastAlignment;\nif (!day0Lisible) {\n // Comportement historique, tant que la date n'est pas publiee.\n forecastAlignment = 'date_absente';\n if (forecastFresh) {\n hourlyForToday = hourlyToday;\n hourlyForTomorrow = hourlyTomorrow;\n }\n} else if (forecastDay0 === today) {\n forecastAlignment = 'a_jour';\n hourlyForToday = hourlyToday;\n hourlyForTomorrow = hourlyTomorrow;\n} else if (forecastDay0 === veille) {\n forecastAlignment = 'decalee_d_un_jour';\n hourlyForToday = hourlyTomorrow;\n hourlyForTomorrow = null;\n} else {\n forecastAlignment = 'perimee';\n}\nconst hourlyAvailable = !!hourlyForToday;\n\n// Une heure ou l'enveloppe demontree est nulle est une heure de nuit : il n'y\n// a jamais eu de soleil a y observer. Son taux de censure vaut 1 pour cette\n// raison-la, pas parce que la mesure serait douteuse, et il n'existe aucune\n// forme a minorer. La classer « minoree » faisait crier au loup TOUTES les\n// nuits (les heures de deficit sont justement les heures sans soleil), donc\n// exactement quand le bilan sert. Constate sur la nuit du 25-26/08 : statut\n// « degrade » de 20 h a 07 h alors que le bilan a predit le point bas a un\n// point pres.\nconst slots = [];\nfor (let i = 0; i < CFG.HORIZON_HOURS; i++) {\n const start = currentHourStart + i * 3600000;\n const hour = hourOfDay(start);\n const nightHour = !!envelope && !(envelope[hour] > 0);\n slots.push({\n startMs: start,\n hour,\n day: dayKey(start),\n weight: envelope ? envelope[hour] : 0,\n quality: nightHour\n ? 'nuit'\n : censorshipRate[hour] <= CFG.CENSORSHIP_QUALITY_LIMIT ? 'fiable' : 'minoree',\n });\n}\n\n// Répartition de l'énergie prévue au prorata de la forme démontrée.\nfunction distribute(daySlots, energyKwh) {\n const totalWeight = daySlots.reduce((s, x) => s + x.weight, 0);\n for (const slot of daySlots) {\n slot.expectedKwh = totalWeight > 0 ? (slot.weight / totalWeight) * energyKwh : 0;\n }\n}\n\nconst todaySlots = slots.filter(s => s.day === today);\nconst tomorrowSlots = slots.filter(s => s.day !== today);\n\nif (hourlyAvailable) {\n // Lecture directe : chaque heure porte déjà son énergie prévue.\n for (const slot of todaySlots) {\n slot.expectedKwh = Math.max(0, hourlyForToday[slot.hour] ?? 0);\n slot.fromCurve = true;\n }\n if (hourlyForTomorrow) {\n for (const slot of tomorrowSlots) {\n slot.expectedKwh = Math.max(0, hourlyForTomorrow[slot.hour] ?? 0);\n slot.fromCurve = true;\n }\n } else {\n // Courbe decalee : on connait aujourd'hui, pas demain. L'enveloppe mesuree\n // reprend la main pour la suite de l'horizon plutot que de recopier la\n // courbe du jour, qui serait une invention.\n distribute(tomorrowSlots, tomorrowKwh);\n }\n} else {\n distribute(todaySlots, todayRemainingKwh);\n distribute(tomorrowSlots, tomorrowKwh);\n}\n\n// Une heure lue dans la COURBE HORAIRE ne doit rien a l'enveloppe (29/09/2026).\n// La classer « minoree » d'apres le taux de censure de l'enveloppe faisait\n// passer le bilan `degrade` chaque soir pendant une heure (19 h du 23 au 28/09,\n// 18 h le 29/09) : l'heure du soir encore ensoleillee est une heure de deficit,\n// et son historique est censure parce que la batterie est souvent pleine a\n// cette heure. Or sa production venait de VRM / Forecast.Solar, pas de cet\n// historique. Pendant cette heure le controleur ignorait le bilan (plancher du\n// soir retombe a 85 % fixe). « minoree » ne qualifie plus que les heures dont\n// l'energie a ete repartie sur l'enveloppe (repli).\nfor (const slot of slots) {\n if (slot.fromCurve && slot.quality !== 'nuit') slot.quality = 'courbe';\n}\n\n// Repère de vraisemblance, sans écrêtage : l'enveloppe est minorée l'après-midi.\nfor (const slot of slots) {\n slot.aboveEnvelope = !!envelope && envelope[slot.hour] > 0\n && slot.expectedKwh > envelope[slot.hour] * 1.5;\n}\n\n// Part de l'énergie projetée qui tombe sur des heures dont la forme est minorée :\n// c'est la mesure de confiance de cette projection.\nconst projected = slots.reduce((s, x) => s + x.expectedKwh, 0);\nconst projectedOnWeakShape = slots\n .filter(x => x.quality === 'minoree')\n .reduce((s, x) => s + x.expectedKwh, 0);\n\nreturn [{\n json: {\n generatedAt: new Date(nowMs).toISOString(),\n usable: hourlyAvailable || !!envelope,\n // Quelle source a réellement fourni la courbe ? (`source` ci-dessous est\n // le détail des entrées ; `mode` dit laquelle a gagné.)\n mode: hourlyAvailable ? 'courbe_horaire_api' : 'repli_enveloppe',\n\n horizon: slots.map(s => ({\n startMs: s.startMs,\n hour: s.hour,\n day: s.day,\n expectedKwh: round2(s.expectedKwh),\n shapeQuality: s.quality,\n })),\n\n todayExpectedRemainingKwh: round2(todaySlots.reduce((s, x) => s + x.expectedKwh, 0)),\n tomorrowExpectedKwh: round2(tomorrowSlots.reduce((s, x) => s + x.expectedKwh, 0)),\n\n source: {\n todayRemainingKwh: round2(todayRemainingKwh),\n tomorrowKwh: round2(tomorrowKwh),\n envelopeProvided: !!envelope,\n hourlyCurveAvailable: !!hourlyToday,\n forecastAgeMin: Number.isFinite(forecastAgeMin) ? Math.round(forecastAgeMin) : null,\n forecastFresh,\n forecastDay0: forecastDay0 || null,\n forecastAlignment,\n hoursAboveEnvelope: slots.filter(s => s.aboveEnvelope).length,\n shapeConfidence: projected > 0 ? round2(1 - projectedOnWeakShape / projected) : null,\n weakShapeHours: slots\n .filter(x => x.quality === 'minoree' && x.expectedKwh > 0)\n .map(x => x.hour)\n .filter((h, i, a) => a.indexOf(h) === i),\n },\n\n cfg: CFG,\n },\n}];\n" } }, {