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" } }, {