From c35c39cb8316a2aac14c6084fe234681f86aa550 Mon Sep 17 00:00:00 2001 From: Tallagrand Thierry Date: Tue, 25 Aug 2026 02:37:41 +0200 Subject: [PATCH] Chantier : le bilan lit la courbe horaire au lieu de l'enveloppe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01KKzkkVTG6KXnGXFFC7X9XJ --- docs/CHANTIER_BILAN_ENERGETIQUE.md | 17 ++-- .../HA_SNAPSHOT_TEMPLATE.json.tpl | 4 + src/energy-balance/RESOURCE_ESTIMATE.js | 78 +++++++++++++++---- tests/energy-balance/run.mjs | 1 + tests/energy-balance/scenarios-profile.mjs | 41 ++++++++++ workflows/evcs-energy-balance-v1.json | 4 +- 6 files changed, 124 insertions(+), 21 deletions(-) diff --git a/docs/CHANTIER_BILAN_ENERGETIQUE.md b/docs/CHANTIER_BILAN_ENERGETIQUE.md index 7a339e9..9d0d79a 100644 --- a/docs/CHANTIER_BILAN_ENERGETIQUE.md +++ b/docs/CHANTIER_BILAN_ENERGETIQUE.md @@ -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 surestimé l'ampleur en le mesurant contre une référence biaisée. -**Conséquence pour le code** : l'enveloppe doit disparaître de -`RESOURCE_ESTIMATE` au profit de la courbe horaire de l'API -(`estimate/watthours/period`), disponible sur 6 jours. Elle apporte à la fois la -forme et la magnitude, ce qui **simplifie** le nœud et supprime la référence -défectueuse. L'enveloppe reste utile pour une seule chose : le plafond physique -de vraisemblance. +**Conséquence appliquée le 25/08** : `RESOURCE_ESTIMATE` lit désormais la courbe +horaire de l'API, publiée par le workflow **EVCS Solar Forecast V1** +(`07P8I349ugna9FH4`, 15 min). Elle apporte forme et magnitude d'un seul tenant, +sur 6 jours, avec les trois plans réels, sans aucune censure — le nœud s'en +trouve simplifié : plus de répartition au prorata, plus de renormalisation. + +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 diff --git a/src/energy-balance/HA_SNAPSHOT_TEMPLATE.json.tpl b/src/energy-balance/HA_SNAPSHOT_TEMPLATE.json.tpl index 0191538..2a7746a 100644 --- a/src/energy-balance/HA_SNAPSHOT_TEMPLATE.json.tpl +++ b/src/energy-balance/HA_SNAPSHOT_TEMPLATE.json.tpl @@ -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"), diff --git a/src/energy-balance/RESOURCE_ESTIMATE.js b/src/energy-balance/RESOURCE_ESTIMATE.js index 5acd58b..3cfbe85 100644 --- a/src/energy-balance/RESOURCE_ESTIMATE.js +++ b/src/energy-balance/RESOURCE_ESTIMATE.js @@ -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) diff --git a/tests/energy-balance/run.mjs b/tests/energy-balance/run.mjs index 635bf75..1374a24 100644 --- a/tests/energy-balance/run.mjs +++ b/tests/energy-balance/run.mjs @@ -59,6 +59,7 @@ const SUMMARIZE = { }), RESOURCE_ESTIMATE: o => ({ usable: o.usable, + mode: o.mode, todayExpected: o.todayExpectedRemainingKwh, tomorrowExpected: o.tomorrowExpectedKwh, shapeConfidenceBelow: o.source.shapeConfidence != null && o.source.shapeConfidence < 0.5, diff --git a/tests/energy-balance/scenarios-profile.mjs b/tests/energy-balance/scenarios-profile.mjs index 45d78db..044888f 100644 --- a/tests/energy-balance/scenarios-profile.mjs +++ b/tests/energy-balance/scenarios-profile.mjs @@ -126,6 +126,47 @@ export const resourceScenarios = [ 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', id: 'ressource-sans-enveloppe', diff --git a/workflows/evcs-energy-balance-v1.json b/workflows/evcs-energy-balance-v1.json index a44bda7..b72c09f 100644 --- a/workflows/evcs-energy-balance-v1.json +++ b/workflows/evcs-energy-balance-v1.json @@ -51,7 +51,7 @@ "parameters": [ { "name": "template", - "value": "{{ {\n \"ts\": now().isoformat(),\n \"nowMs\": as_timestamp(now()) * 1000,\n\n \"balance_enabled\": states(\"input_boolean.evcs_balance_enabled\"),\n \"usable_capacity_kwh\": states(\"input_number.evcs_balance_usable_capacity_kwh\"),\n\n \"consumption_p50_csv\": states(\"input_text.evcs_balance_consumption_p50\"),\n \"consumption_p80_csv\": states(\"input_text.evcs_balance_consumption_p80\"),\n \"envelope_csv\": states(\"input_text.evcs_balance_envelope\"),\n \"censorship_rate_csv\": states(\"input_text.evcs_balance_censorship_rate\"),\n \"grid_soc_off\": states(\"input_number.victron_grid_soc_off\"),\n\n \"pv_power_w\": states(\"sensor.victron_pv_power_system\"),\n\n \"house_ac_power_w\": states(\"sensor.victron_consumption_power_l1\"),\n \"vebus_ac_out_l1_power_w\": states(\"sensor.victron_ve_bus_ac_out_l1_power\"),\n \"evcs_ac_power_w\": states(\"sensor.victron_evcs_ac_power\"),\n\n \"soc_percent\": states(\"sensor.victron_battery_soc\"),\n \"battery_current_a\": states(\"sensor.victron_battery_current\"),\n \"battery_voltage_v\": states(\"sensor.victron_battery_voltage\"),\n \"vebus_dc_power_w\": states(\"sensor.victron_ve_bus_dc_power_system\"),\n\n \"grid_switch\": states(\"switch.edf_general_ok_switch\"),\n \"acin1_available\": states(\"sensor.victron_ac_in1_available\"),\n \"grid_power_l1_w\": states(\"sensor.victron_grid_power_l1\"),\n\n \"multiplus_state\": states(\"sensor.airtria_mpii_v1_state\"),\n \"multiplus_overload_alarm\": states(\"sensor.airtria_mpii_v1_overload_alarm\"),\n \"system_state\": states(\"sensor.gx_device_system_state\"),\n \"ess_batterylife_state\": states(\"sensor.gx_device_ess_batterylife_state\"),\n \"dc_battery_state\": states(\"sensor.gx_device_dc_battery_state\"),\n\n \"mppt_1_state\": states(\"sensor.airtria_mppt1_state\"),\n \"mppt_2_state\": states(\"sensor.mppt_450_200_hq2449yr9pt_state\"),\n \"mppt_3_state\": states(\"sensor.v1_state\"),\n\n \"evcs_native_status\": states(\"sensor.evcs_hq2239dnt7p_status\"),\n\n \"forecast_today_remaining_kwh\": states(\"sensor.energy_production_today_remaining\"),\n \"forecast_today_kwh\": states(\"sensor.energy_production_today\"),\n \"forecast_tomorrow_kwh\": states(\"sensor.energy_production_tomorrow\"),\n \"forecast_next_hour_w\": states(\"sensor.power_production_next_hour\"),\n\n \"sun_elevation\": state_attr(\"sun.sun\", \"elevation\")\n} | tojson }}\n" + "value": "{{ {\n \"ts\": now().isoformat(),\n \"nowMs\": as_timestamp(now()) * 1000,\n\n \"balance_enabled\": states(\"input_boolean.evcs_balance_enabled\"),\n \"usable_capacity_kwh\": states(\"input_number.evcs_balance_usable_capacity_kwh\"),\n\n \"consumption_p50_csv\": states(\"input_text.evcs_balance_consumption_p50\"),\n \"consumption_p80_csv\": states(\"input_text.evcs_balance_consumption_p80\"),\n \"envelope_csv\": states(\"input_text.evcs_balance_envelope\"),\n \"censorship_rate_csv\": states(\"input_text.evcs_balance_censorship_rate\"),\n\n \"forecast_today_csv\": states(\"input_text.evcs_balance_forecast_today\"),\n \"forecast_tomorrow_csv\": states(\"input_text.evcs_balance_forecast_tomorrow\"),\n \"forecast_updated\": states(\"input_text.evcs_balance_forecast_updated\"),\n \"grid_soc_off\": states(\"input_number.victron_grid_soc_off\"),\n\n \"pv_power_w\": states(\"sensor.victron_pv_power_system\"),\n\n \"house_ac_power_w\": states(\"sensor.victron_consumption_power_l1\"),\n \"vebus_ac_out_l1_power_w\": states(\"sensor.victron_ve_bus_ac_out_l1_power\"),\n \"evcs_ac_power_w\": states(\"sensor.victron_evcs_ac_power\"),\n\n \"soc_percent\": states(\"sensor.victron_battery_soc\"),\n \"battery_current_a\": states(\"sensor.victron_battery_current\"),\n \"battery_voltage_v\": states(\"sensor.victron_battery_voltage\"),\n \"vebus_dc_power_w\": states(\"sensor.victron_ve_bus_dc_power_system\"),\n\n \"grid_switch\": states(\"switch.edf_general_ok_switch\"),\n \"acin1_available\": states(\"sensor.victron_ac_in1_available\"),\n \"grid_power_l1_w\": states(\"sensor.victron_grid_power_l1\"),\n\n \"multiplus_state\": states(\"sensor.airtria_mpii_v1_state\"),\n \"multiplus_overload_alarm\": states(\"sensor.airtria_mpii_v1_overload_alarm\"),\n \"system_state\": states(\"sensor.gx_device_system_state\"),\n \"ess_batterylife_state\": states(\"sensor.gx_device_ess_batterylife_state\"),\n \"dc_battery_state\": states(\"sensor.gx_device_dc_battery_state\"),\n\n \"mppt_1_state\": states(\"sensor.airtria_mppt1_state\"),\n \"mppt_2_state\": states(\"sensor.mppt_450_200_hq2449yr9pt_state\"),\n \"mppt_3_state\": states(\"sensor.v1_state\"),\n\n \"evcs_native_status\": states(\"sensor.evcs_hq2239dnt7p_status\"),\n\n \"forecast_today_remaining_kwh\": states(\"sensor.energy_production_today_remaining\"),\n \"forecast_today_kwh\": states(\"sensor.energy_production_today\"),\n \"forecast_tomorrow_kwh\": states(\"sensor.energy_production_tomorrow\"),\n \"forecast_next_hour_w\": states(\"sensor.power_production_next_hour\"),\n\n \"sun_elevation\": state_attr(\"sun.sun\", \"elevation\")\n} | tojson }}\n" } ] }, @@ -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. RETENU — FORME = enveloppe horaire réellement démontrée (production\n// maximale jamais observée à chaque heure). Elle encode le comportement\n// réel du champ, orientations multiples comprises, sans aucun modèle.\n// MAGNITUDE = prévision Forecast.Solar (énergie restante du jour), qui\n// porte la météo et ignore totalement l'état de notre batterie.\n//\n// LIMITE ASSUMÉE ET SIGNALÉE : l'enveloppe de l'après-midi est elle-même\n// censurée (77 à 96 % des heures après 13 h le sont), donc sous-estimée. Les\n// heures du matin sont propres (censure de 0 à 4 %). Le champ `envelopeQuality`\n// dit, heure par heure, si la forme est fiable ou minorée — à charge du\n// consommateur d'en tenir compte plutôt que de le découvrir trop tard.\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 HORIZON_HOURS: 30,\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// 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 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\nconst slots = [];\nfor (let i = 0; i < CFG.HORIZON_HOURS; i++) {\n const start = currentHourStart + i * 3600000;\n const hour = hourOfDay(start);\n slots.push({\n startMs: start,\n hour,\n day: dayKey(start),\n weight: envelope ? envelope[hour] : 0,\n quality: 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);\ndistribute(todaySlots, todayRemainingKwh);\ndistribute(tomorrowSlots, tomorrowKwh);\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: !!envelope,\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 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 HORIZON_HOURS: 30,\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');\nconst hourlyAvailable = !!hourlyToday && forecastFresh;\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\nconst slots = [];\nfor (let i = 0; i < CFG.HORIZON_HOURS; i++) {\n const start = currentHourStart + i * 3600000;\n const hour = hourOfDay(start);\n slots.push({\n startMs: start,\n hour,\n day: dayKey(start),\n weight: envelope ? envelope[hour] : 0,\n quality: 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, hourlyToday[slot.hour] ?? 0);\n const tom = hourlyTomorrow || hourlyToday;\n for (const slot of tomorrowSlots) slot.expectedKwh = Math.max(0, tom[slot.hour] ?? 0);\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 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" } }, {