From 8ad34f8c930d87a7266a0eddbc16dea5dc4a2435 Mon Sep 17 00:00:00 2001 From: Tallagrand Thierry Date: Wed, 26 Aug 2026 12:14:35 +0200 Subject: [PATCH] Bilan : une heure de nuit n'a aucune forme a minorer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le drapeau « degrade » introduit la veille s'allumait de 20 h a 07 h toutes les nuits. La qualite de forme d'une heure vient du taux de censure ; les heures sans soleil portent un taux de 1 faute d'avoir quoi que ce soit a observer, pas parce que la mesure serait douteuse. Comme les heures de deficit sont justement les heures sans soleil, le drapeau se declenchait exactement quand le bilan sert. Une heure dont l'enveloppe demontree est nulle recoit desormais la qualite 'nuit'. Scenario epingle sur l'enveloppe et le taux de censure releves en production, pas sur le fixture — dont la nuit valait 0 et qui ne reproduisait donc pas le defaut. Preuve par mutation : avant 'minoree', apres 'nuit'. Suite 37 -> 38. Documente la nuit du 25-26/08 : profil corrige verifie a 0,2 kWh/h pres, bilan qui predit le point bas a un point pres, et non-linearite du SOC mesuree (huit points en dix-sept minutes pour 0,16 kWh). --- docs/CHANTIER_BILAN_ENERGETIQUE.md | 145 +++++++++++++++++++++ src/energy-balance/RESOURCE_ESTIMATE.js | 13 +- tests/energy-balance/run.mjs | 5 + tests/energy-balance/scenarios-profile.mjs | 26 ++++ workflows/evcs-energy-balance-v1.json | 2 +- 5 files changed, 189 insertions(+), 2 deletions(-) diff --git a/docs/CHANTIER_BILAN_ENERGETIQUE.md b/docs/CHANTIER_BILAN_ENERGETIQUE.md index fef7145..473e832 100644 --- a/docs/CHANTIER_BILAN_ENERGETIQUE.md +++ b/docs/CHANTIER_BILAN_ENERGETIQUE.md @@ -425,6 +425,151 @@ l'utilisateur. C'est voulu pour un câblage réel en période d'essai, mais il f le savoir — et le retirer le jour où le câblage sera adopté. +## 26/08/2026 — La nuit qui valide le bilan, et le défaut du mètre + +### Déroulé (véhicule débranché à 03:01, donc hors mécanique de nuit EVCS) + +| Heure | Événement | +|---|---| +| 01:24:03 | Fenêtre HC → EDF ON, charge du véhicule dès 01:25:30 | +| 02:52:30 | Fin de charge détectée → `night_recovery_done_idle` | +| 02:53:03 | EDF coupé **3 secondes** plus tard | +| 03:01 | Débranchement | +| 05:10 | SOC affiché 20 % → EDF ON (branche « SOC bas » de l'hystérésis) | +| 06:18 | SOC affiché 30 % → EDF OFF | +| 09:06 | Point bas de la nuit : 23 % affichés, puis le PV prend le relais | + +Réseau importé : 5,70 kWh pour le véhicule (01 h et 02 h, mesurés sur +`energy_from_ac_in_1_to_ac_out`) et 1,71 kWh pour la batterie (05:10-06:19, +intégration de `battery_power`). **Aucun yoyo** : une seule session, 69 minutes, +20 → 30 %. L'hystérésis a fait exactement ce pour quoi elle est écrite. + +### Le profil de consommation corrigé colle à la mesure + +Le correctif d'attribution horaire de la veille se vérifie sur une nuit +complète — profil publié contre consommation réellement mesurée : + +| Heure | p50 publié | Mesuré | Heure | p50 publié | Mesuré | +|---|---:|---:|---|---:|---:| +| 21 h | 1,60 | 1,50 | 03 h | 0,70 | 0,80 | +| 22 h | 1,40 | 1,40 | 04 h | 0,70 | 0,70 | +| 23 h | 1,30 | 1,20 | 07 h | 0,70 | 0,70 | +| 00 h | 1,10 | 1,00 | 08 h | 0,80 | 0,70 | + +Écart maximal 0,2 kWh/h. Le matin ne porte plus l'import réseau de la nuit +suivante. + +### Le bilan a prédit le point bas de la nuit à un point près + +`required_soc` publié heure par heure, contre le SOC réellement observé : + +| Heure | Requis | Réel | Marge | +|---|---:|---:|---:| +| 21 h | 90,5 | 69 | −21,5 | +| 00 h | 62,7 | 45 | −17,7 | +| 03 h | 46,2 | 36 | −10,2 | +| 06 h | 32,1 | 29 | −3,1 | +| 07 h | 27,5 | 27 | −0,5 | +| 08 h | 23,3 | 24 | **+0,7** | +| 09 h | 22,0 | 23 | **+1,0** | + +Le bilan annonçait le croisement à 09 h : le point bas est tombé à 09:06. Il +annonçait 22 % nécessaires à 09 h : la batterie a touché 23 %. C'est la +première validation quantitative du modèle sur une nuit entière. + +Deux conséquences immédiates : + +1. **Le seuil fixe a coupé une fois de trop tôt.** À 06:00 le bilan demandait + 32,1 %, `grid_soc_off` a coupé à 30 %. La batterie est ensuite descendue à + 23 %, soit **un point au-dessus du plancher dur**. Le seuil fixe n'était pas + grossièrement faux — il était court de 2 points, et c'est le lever du jour + qui a payé la différence. Une matinée couverte aurait traversé le plancher. +2. **La cible SOC du matin n'a joué aucun rôle cette nuit.** Le véhicule est + parti à 03:01, donc `nightSessionActive` est retombé à faux et toute la + mécanique de nuit EVCS avec lui. Ce sont `grid_soc_on`/`grid_soc_off` + (20/30, dans le workflow d'hystérésis) qui ont gouverné — et **le conseiller + IA ne peut pas les toucher**, même toggle activé. + +### Le défaut du mètre : le SOC n'est pas linéaire dans la bande qui décide + +Énergie réellement échangée par point de SOC, mesurée par intégration de +`sensor.victron_battery_power` : + +| Fenêtre | ΔSOC | Énergie | kWh / point | Capacité implicite | +|---|---:|---:|---:|---:| +| 69 → 39 % (décharge) | 30 | 5,11 kWh | 0,170 | 17,0 kWh | +| **35 → 20 % (décharge)** | **15** | **1,07 kWh** | **0,071** | **7,1 kWh** | +| 20 → 30 % (charge) | 10 | 1,71 kWh | 0,171 | 17,1 kWh | +| 30 → 23 % (décharge) | 7 | 1,19 kWh | 0,170 | 17,0 kWh | +| 23 → 80 % (charge) | 57 | 9,06 kWh | 0,159 | 15,9 kWh | + +Tout vaut 0,16-0,17 kWh par point — sauf la descente 35 → 20 %, qui n'en vaut +que 0,071. En regardant de plus près, la chute est concentrée : **de 04:17 à +04:34, le SOC affiché passe de 32 % à 24 %, huit points en dix-sept minutes**, +sous une décharge parfaitement stable de 560 W. Huit points « coûtent » 0,16 kWh +là où ils devraient en coûter 1,3. + +La tension tranche : 48,81 V à 04:17, 48,75 V à 04:34, soit 60 mV pour huit +points, quand les six points précédents en avaient consommé 200. **Le pack n'a +pas perdu cette énergie ; c'est l'estimateur qui a décroché.** Comportement +classique d'un BMS Pylontech dont un module est plus faible que les autres — et +le parc en compte un mort isolé et un second qui faiblit (§0 du manuel). + +Conséquence directe : la session réseau de 05:10 a été déclenchée par un « 20 % » +qui valait physiquement 26 à 28 %. Elle restait justifiée au fond — le bilan +demandait 36,9 % à 05:00 — mais elle a été **déclenchée par le mauvais +instrument, pour la bonne raison, par hasard**. + +### Le drapeau « dégradé » criait au loup toutes les nuits + +Introduit la veille, il a affiché `degrade` de 20:00 à 07:00. Cause : dans +`RESOURCE_ESTIMATE.js`, la qualité de forme d'une heure vient du taux de +censure, et les heures de nuit portent un taux de 1 — non pas parce que la +mesure serait douteuse, mais parce qu'il n'y a jamais eu de soleil à observer. +Or les heures de déficit *sont* les heures sans soleil : le drapeau se +déclenchait donc **toutes les nuits, c'est-à-dire exactement quand le bilan +sert**. Le commentaire du code disait déjà la bonne règle ; le code ne +l'appliquait pas. + +Correctif : une heure dont l'enveloppe démontrée est nulle reçoit la qualité +`'nuit'` et non `'minoree'` — elle n'a aucune forme à minorer. Scénario +`ressource-nuit-forme-non-minoree`, épinglé sur l'enveloppe et le taux de +censure **relevés en production**, pas sur le fixture (dont la nuit valait 0 et +qui ne reproduisait donc pas le défaut — le piège du 24/08, à nouveau). Preuve +par mutation : code d'avant → `minoree`, après → `nuit`. Suite 37 → 38. + +### Conclusion sur la cible SOC du matin + +Trois arguments convergent, et ils déplacent la cible du chantier : + +1. **La bonne grandeur n'est pas un SOC, c'est une énergie.** Le bilan calcule + déjà l'énergie à couvrir jusqu'au croisement, et il vient de le faire à un + point près sur douze heures. La cible de 35 % est une valeur de table ; la + valeur physique de cette nuit-là était 32,1 % à 06:00. +2. **Convertir une énergie en points de SOC réimporte le défaut du mètre.** + `usable_capacity_kwh = 15` appliqué linéairement suppose que tous les points + se valent ; les mesures ci-dessus montrent que ceux de la bande 24-32 %, + celle où se prennent toutes les décisions de nuit, peuvent ne rien valoir. + Tant que la conversion reste linéaire, câbler l'advisor sur la cible ne fait + que déplacer un nombre arbitraire vers un nombre calculé sur une règle + graduée fausse. +3. **La cible du matin n'est pas le seuil qui gouverne le plus souvent.** Elle + ne s'applique qu'avec un véhicule branché pendant la fenêtre HC. Les nuits + sans véhicule — celle-ci — sont gouvernées par `grid_soc_off`, que personne + n'arbitre et que le conseiller ne voit même pas. + +Ordre de travail qui en découle, avant tout câblage : + +- **a.** Rendre la conversion énergie ↔ SOC honnête : soit une courbe de + capacité par bande de SOC construite sur l'historique (la donnée existe, elle + vient d'être mesurée), soit un drapeau qui signale que le SOC vient de + décrocher et qu'il ne faut pas décider dessus pendant N minutes. +- **b.** Donner au bilan le seuil qui gouverne réellement : `grid_soc_off`, + côté hystérésis. C'est le chiffre qui a manqué de 2 points cette nuit, et + c'est le seul que le conseiller ne peut pas atteindre aujourd'hui. +- **c.** Ensuite seulement, la cible SOC du matin — qui deviendra un + sous-produit du bilan plutôt qu'un réglage indépendant. + ## Backlog - [ ] **Doublon `unique_id: victron_vebus_dc_power`** — déclaré deux fois, avec diff --git a/src/energy-balance/RESOURCE_ESTIMATE.js b/src/energy-balance/RESOURCE_ESTIMATE.js index a868673..323b1c3 100644 --- a/src/energy-balance/RESOURCE_ESTIMATE.js +++ b/src/energy-balance/RESOURCE_ESTIMATE.js @@ -137,16 +137,27 @@ const dayKey = (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: censorshipRate[hour] <= CFG.CENSORSHIP_QUALITY_LIMIT ? 'fiable' : 'minoree', + quality: nightHour + ? 'nuit' + : censorshipRate[hour] <= CFG.CENSORSHIP_QUALITY_LIMIT ? 'fiable' : 'minoree', }); } diff --git a/tests/energy-balance/run.mjs b/tests/energy-balance/run.mjs index 06f9538..921da24 100644 --- a/tests/energy-balance/run.mjs +++ b/tests/energy-balance/run.mjs @@ -68,6 +68,11 @@ const SUMMARIZE = { todayExpected: o.todayExpectedRemainingKwh, tomorrowExpected: o.tomorrowExpectedKwh, shapeConfidenceBelow: o.source.shapeConfidence != null && o.source.shapeConfidence < 0.5, + // Qualite de forme d'une heure de nuit (03 h) et d'une heure de plein + // apres-midi (14 h) : sert au scenario de nuit, ou une heure sans soleil + // ne doit pas etre declaree « minoree ». + qualityAtHour3: (o.horizon.find(h => h.hour === 3) || {}).shapeQuality, + qualityAtHour14: (o.horizon.find(h => h.hour === 14) || {}).shapeQuality, }), BALANCE: o => o.usable ? ({ usable: true, diff --git a/tests/energy-balance/scenarios-profile.mjs b/tests/energy-balance/scenarios-profile.mjs index 084107f..6b5e9c0 100644 --- a/tests/energy-balance/scenarios-profile.mjs +++ b/tests/energy-balance/scenarios-profile.mjs @@ -159,6 +159,32 @@ export const resourceScenarios = [ shapeConfidenceBelow: true, }, }, + { + node: 'RESOURCE_ESTIMATE', + id: 'ressource-nuit-forme-non-minoree', + label: 'Projection lancee en pleine nuit : une heure sans soleil n\'a aucune forme a minorer, elle ne doit pas degrader la confiance', + snapshot: { + // 22:30 : l'horizon couvre les heures 0 a 6, dont le taux de censure + // vaut 1 faute de soleil a observer, pas faute de mesure fiable. + nowMs: Date.parse('2026-08-25T22:30:00+02:00'), + forecast_today_remaining_kwh: '0', + forecast_tomorrow_kwh: '32', + // Enveloppe et taux de censure RELEVES EN PRODUCTION le 26/08/2026, pas + // ceux du fixture (capture plus ancienne ou la nuit valait 0). En + // production la nuit vaut 1 : c'est cette valeur-la qui declenchait le + // faux « degrade », et c'est donc elle qu'il faut tester. + envelope_kwh: [0,0,0,0,0,0,0,0.17,1.03,3.86,6.55,7.17,9.33,8.38, + 7.78,6.37,3.77,4.78,2.55,2.72,0.91,0.16,0,0], + censorship_rate: [1,1,1,1,1,1,1,0,0,0,0,0,0.25,0.36, + 0.44,0.63,0.71,0.88,0.80,0.75,0.08,0,1,1], + }, + expect: { + usable: true, + qualityAtHour3: 'nuit', + // L'apres-midi, lui, reste bien signale comme minore. + qualityAtHour14: 'minoree', + }, + }, { node: 'RESOURCE_ESTIMATE', id: 'ressource-matin-forme-fiable', diff --git a/workflows/evcs-energy-balance-v1.json b/workflows/evcs-energy-balance-v1.json index 2ffe17a..ede9295 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');\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" + "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');\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\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, 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" } }, {