From 8d40e0d446a07721b6196f695924ad03bfa4b7a9 Mon Sep 17 00:00:00 2001 From: Tallagrand Thierry Date: Tue, 8 Sep 2026 09:30:35 +0200 Subject: [PATCH] La prevision se juge sur la date qu elle decrit, plus sur son age MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nuit du 07 au 08/09 : 32 calculs du bilan sur 120 en statut degrade, tous entre 00 h et 03 h, avec « aucun croisement PV/conso sur l'horizon, 47,6 kWh a couvrir ». Le controleur, qui exige statut=ok, retombait sur le palier de 35 % — d'ou une cible du matin oscillant entre 35, 78,8 et 85 % au lieu de decroitre. Cause immediate : plus aucune publication de prevision de 23:45 a 04:30. Le noeud d'appel porte neverError, donc les executions restaient en success ; la reponse etait « socket hang up », et le noeud d'ecriture vers Home Assistant rendait 0 item la nuit contre 1 en journee — le .17 etait lui aussi injoignable, si bien que le message d'echec ne pouvait meme pas etre publie. Mais le defaut qu'on controle est ailleurs : les courbes sont publiees sans leur date, et PARSE_FORECAST prend days[0] pour « aujourd'hui ». Vrai a l'instant de la publication, faux des qu'on passe minuit sans rafraichir. A 00:45 le bilan a donc lu comme journee en cours celle du 7, dont toutes les heures etaient passees. Une courbe est desormais utilisable si elle DECRIT le jour dont on a besoin, pas si elle est recente : une prevision publiee a 23:45 pour le 8 reste exacte a 03:00, la nuit il n'y a rien a reactualiser. La date part dans evcs_balance_forecast_day0, et RESOURCE_ESTIMATE distingue trois cas — a jour, decalee d'un jour (la courbe du lendemain devient celle du jour, l'enveloppe mesuree reprend la main pour demain), perimee. Helper absent : ancienne garde d'age, comportement inchange. Le rapport de nuit signale un trou de plus de deux heures dans les publications. Il lit la base sur l'autre NAS, seul temoin qui survit quand le .17 est injoignable. Verifie sur la nuit ecoulee : 285 minutes. Tests 44 -> 48. Les quatre scenarios partagent la meme heure — 00:45, l'instant du decrochage — et les memes courbes : seule la date change. Quatre mutations, chacune sur un jeu different, chaque scenario ayant la sienne. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01GwpFpy2aQ2rJok6tNQSbVj --- HANDOFF.md | 92 +++++++++++++++++ src/energy-balance/BUILD_HA_CALLS_FORECAST.js | 9 ++ .../HA_SNAPSHOT_TEMPLATE.json.tpl | 1 + src/energy-balance/RESOURCE_ESTIMATE.js | 60 +++++++++++- tests/energy-balance/run.mjs | 2 + tests/energy-balance/scenarios-profile.mjs | 98 +++++++++++++++++++ tests/energy-balance/scenarios-shadow.mjs | 4 +- tools/rapport-nuit.mjs | 22 +++++ workflows/evcs-energy-balance-v1.json | 4 +- workflows/evcs-solar-forecast-v1.json | 2 +- 10 files changed, 286 insertions(+), 8 deletions(-) diff --git a/HANDOFF.md b/HANDOFF.md index 8ce8c5b..1a452ef 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -5593,3 +5593,95 @@ n'est jamais parti dans le depot. --- *Document mis a jour le : 08/09/2026 — acces API Grafana retabli, rectification sur la cause des 401* + + +## 61. Session du 08/09/2026 — La prevision se juge sur la date qu'elle decrit + +### Le symptome +Nuit du 07 au 08/09, **32 calculs du bilan sur 120 en statut « degrade »**, +tous entre 00 h et 03 h, avec ce motif : + +> « Aucun croisement PV/conso sur l'horizon : 47,6 kWh a couvrir → SOC requis +> 100 %. ⚠ chiffre indicatif, pas une exigence datee. » + +Le controleur exige `statut = ok` pour suivre le bilan (§39) : a ces moments-la +il retombait sur le palier de 35 %, d'ou une cible du matin qui oscillait entre +35, 78,8 et 85 % au lieu de decroitre proprement. + +### La panne, et ce qu'elle a revele +`input_text.evcs_balance_forecast_updated` s'arrete a **23:45** et ne reprend +qu'a **04:30** — 4 h 45. Pendant ce temps le workflow s'executait bien quatre +fois par heure **en « success »**, parce que le noeud d'appel porte +`neverError: true` et `onError: continueRegularOutput`. + +Reponse reelle de l'API pendant la fenetre : `socket hang up`. Et ce n'etait pas +que Forecast.Solar — le noeud `Execute HA Service Forecast` rendait **0 item** +la nuit contre 1 en journee : **Home Assistant lui aussi etait injoignable** +depuis n8n, si bien que le message d'echec ne pouvait meme pas etre publie. +Deux autres workflows ont eu des erreurs dans la meme fenetre. Cause racine de +l'indisponibilite reseau **non etablie** ; ce n'est pas la sauvegarde de 399 Go, +qui s'est terminee le 07 a 12:04. Une seule occurrence en quatre jours. + +⚠️ **Ce que l'on controle, c'est la REACTION a cette panne, et elle etait +fautive.** Les courbes sont publiees dans `evcs_balance_forecast_today` et +`_tomorrow` **sans leur date**. `PARSE_FORECAST` prend `days[0]` comme +« aujourd'hui » : vrai a l'instant de la publication, **faux des qu'on passe +minuit sans rafraichir**. A 23:45 « today » etait la courbe du 7 ; a 00:45 le +bilan l'a lue comme celle du 8, journee dont toutes les heures etaient passees +— d'ou « aucune production sur l'horizon ». En temps normal le rafraichissement +de minuit corrige en quinze minutes et personne ne voit rien. + +### Le correctif +**Une courbe est utilisable si elle DECRIT le jour dont on a besoin, pas si +elle est recente.** Une prevision publiee a 23:45 pour le 8 reste exacte a +03:00 : la nuit, il n'y a rien a reactualiser. + +- `BUILD_HA_CALLS_FORECAST` publie `input_text.evcs_balance_forecast_day0` + (la date de la courbe « today ») ; +- le gabarit du bilan la lit ; +- `RESOURCE_ESTIMATE` remplace la garde d'age de 60 min par une garde de date, + avec trois cas : `a_jour` (lecture directe), `decalee_d_un_jour` + (publication d'avant minuit : la courbe « tomorrow » EST celle du jour, et + l'enveloppe mesuree reprend la main pour le lendemain), `perimee` + (au-dela : repli enveloppe). + +⚠️ **REPLI** : `forecast_day0` absent ou illisible → ancienne garde d'age. +Tant que le helper n'existe pas cote Home Assistant, le comportement est +strictement inchange. C'est ce qui permet de deployer le code avant le helper. + +### Visibilite de la panne +Le rapport de nuit signale desormais un trou de plus de deux heures dans les +publications de prevision. Il lit la table `prevision` sur **l'autre NAS** : +c'est le seul temoin qui survit quand le .17 est injoignable. Verifie sur la +nuit ecoulee : la requete rend bien 285 minutes. + +### Tests 44 → 48 +Les quatre scenarios partagent la meme heure — **00:45, l'instant exact du +decrochage** — et les memes courbes : seule la date publiee change, ce qui les +rend probants. +`prevision-decalee-apres-minuit` (le cas reel : day0 = la veille → la courbe du +lendemain devient celle du jour, 30 kWh redeviennent visibles) · +`prevision-a-jour-lecture-directe` (contrefactuel) · +`prevision-vieille-mais-du-bon-jour` (publiee cinq heures plus tot mais datee du +jour : utilisable — **l'ancienne garde d'age la refusait**) · +`prevision-perimee-de-deux-jours` (repli enveloppe). + +Un attendu existant passe de 4 a 5 ecritures (`forecast-perimetre-respecte`) : +la date est publiee a cote des courbes, et le perimetre `evcs_balance_*` reste +respecte. + +Preuve par mutation, quatre directions, **chacune tombant sur un jeu different** +et chaque scenario ayant la sienne : decalage non corrige · cas decale non +reconnu · retour a la garde d'age (3 scenarios) · courbe perimee utilisee quand +meme. + +### A FAIRE AVANT QUE LE CORRECTIF AGISSE +1. Creer le helper `input_text.evcs_balance_forecast_day0` dans + `/mnt/ARCH3/docker/HA/packages/evcs_energy_balance.yaml`, a cote de + `evcs_balance_forecast_today`, puis `input_text.reload` — aucun redemarrage. +2. Pousser les deux workflows : `evcs-solar-forecast-v1.json` (publie la date) + et `evcs-energy-balance-v1.json` (la lit). +L'ordre n'a pas d'importance grace au repli. + +--- +*Document mis a jour le : 08/09/2026 — prevision jugee sur sa date et non son age, tests 48/48, en attente de deploiement* diff --git a/src/energy-balance/BUILD_HA_CALLS_FORECAST.js b/src/energy-balance/BUILD_HA_CALLS_FORECAST.js index 22bac70..0759ce5 100644 --- a/src/energy-balance/BUILD_HA_CALLS_FORECAST.js +++ b/src/energy-balance/BUILD_HA_CALLS_FORECAST.js @@ -31,6 +31,15 @@ if (!parsed.ok) { return calls; } +// La DATE que decrit la courbe, et non l'heure a laquelle on l'a recue. +// Sans elle, « today » veut dire « le premier jour renvoye par l'API au moment +// de la publication » — ce qui devient faux des qu'on passe minuit sans +// rafraichir. Nuit du 07 au 08/09 : plus aucune publication de 23:45 a 04:30, +// et le bilan a lu comme « aujourd'hui » la courbe du 7, dont toutes les heures +// etaient passees. Il en a conclu « aucun croisement, 47,6 kWh a couvrir, SOC +// requis 100 % », s'est declare degrade, et le controleur est retombe sur le +// palier de 35 % pendant quatre heures. +setText('input_text.evcs_balance_forecast_day0', parsed.today.date); setText('input_text.evcs_balance_forecast_today', parsed.today.csv); if (parsed.tomorrow) { setText('input_text.evcs_balance_forecast_tomorrow', parsed.tomorrow.csv); diff --git a/src/energy-balance/HA_SNAPSHOT_TEMPLATE.json.tpl b/src/energy-balance/HA_SNAPSHOT_TEMPLATE.json.tpl index b4917eb..658370a 100644 --- a/src/energy-balance/HA_SNAPSHOT_TEMPLATE.json.tpl +++ b/src/energy-balance/HA_SNAPSHOT_TEMPLATE.json.tpl @@ -11,6 +11,7 @@ "envelope_csv": states("input_text.evcs_balance_envelope"), "censorship_rate_csv": states("input_text.evcs_balance_censorship_rate"), + "forecast_day0": states("input_text.evcs_balance_forecast_day0"), "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"), diff --git a/src/energy-balance/RESOURCE_ESTIMATE.js b/src/energy-balance/RESOURCE_ESTIMATE.js index 323b1c3..3502ce1 100644 --- a/src/energy-balance/RESOURCE_ESTIMATE.js +++ b/src/energy-balance/RESOURCE_ESTIMATE.js @@ -111,7 +111,6 @@ function read24(arrayKey, csvKey) { 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; @@ -137,6 +136,49 @@ const dayKey = (ms) => const currentHourStart = Math.floor(nowMs / 3600000) * 3600000; const today = dayKey(nowMs); +// Une courbe est utilisable si elle DECRIT le jour dont on a besoin, pas si +// elle est recente. Une prevision publiee a 23:45 pour le 8 septembre reste +// exacte a 03:00 : la nuit, il n'y a rien a reactualiser. Inversement, une +// courbe publiee il y a dix minutes mais qui decrit la veille ne vaut rien. +// +// `forecast_day0` porte la date de la courbe « today » telle qu'elle etait au +// moment de la publication. Deux cas seulement sont exploitables : +// day0 = aujourd'hui -> lecture directe, cas normal +// day0 = hier -> la publication date d'avant minuit : la courbe +// « tomorrow » est celle d'aujourd'hui, et on n'a +// rien pour demain (repli sur l'enveloppe). +// Au-dela, les courbes sont trop vieilles pour dire quoi que ce soit. +// +// ⚠️ REPLI : `forecast_day0` absent ou illisible => on retombe sur l'ancienne +// garde d'age. Tant que le helper n'existe pas cote Home Assistant, le +// comportement est donc strictement inchange. +const forecastDay0 = (input.forecast_day0 || '').toString().trim(); +const day0Lisible = /^\d{4}-\d{2}-\d{2}$/.test(forecastDay0); +const veille = dayKey(nowMs - 86400000); + +let hourlyForToday = null; +let hourlyForTomorrow = null; +let forecastAlignment; +if (!day0Lisible) { + // Comportement historique, tant que la date n'est pas publiee. + forecastAlignment = 'date_absente'; + if (forecastFresh) { + hourlyForToday = hourlyToday; + hourlyForTomorrow = hourlyTomorrow; + } +} else if (forecastDay0 === today) { + forecastAlignment = 'a_jour'; + hourlyForToday = hourlyToday; + hourlyForTomorrow = hourlyTomorrow; +} else if (forecastDay0 === veille) { + forecastAlignment = 'decalee_d_un_jour'; + hourlyForToday = hourlyTomorrow; + hourlyForTomorrow = null; +} else { + forecastAlignment = 'perimee'; +} +const hourlyAvailable = !!hourlyForToday; + // 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 @@ -174,9 +216,17 @@ const tomorrowSlots = slots.filter(s => s.day !== today); if (hourlyAvailable) { // Lecture directe : chaque heure porte déjà son énergie prévue. - for (const slot of todaySlots) slot.expectedKwh = Math.max(0, hourlyToday[slot.hour] ?? 0); - const tom = hourlyTomorrow || hourlyToday; - for (const slot of tomorrowSlots) slot.expectedKwh = Math.max(0, tom[slot.hour] ?? 0); + for (const slot of todaySlots) slot.expectedKwh = Math.max(0, hourlyForToday[slot.hour] ?? 0); + if (hourlyForTomorrow) { + for (const slot of tomorrowSlots) { + slot.expectedKwh = Math.max(0, hourlyForTomorrow[slot.hour] ?? 0); + } + } else { + // Courbe decalee : on connait aujourd'hui, pas demain. L'enveloppe mesuree + // reprend la main pour la suite de l'horizon plutot que de recopier la + // courbe du jour, qui serait une invention. + distribute(tomorrowSlots, tomorrowKwh); + } } else { distribute(todaySlots, todayRemainingKwh); distribute(tomorrowSlots, tomorrowKwh); @@ -221,6 +271,8 @@ return [{ hourlyCurveAvailable: !!hourlyToday, forecastAgeMin: Number.isFinite(forecastAgeMin) ? Math.round(forecastAgeMin) : null, forecastFresh, + forecastDay0: forecastDay0 || null, + forecastAlignment, hoursAboveEnvelope: slots.filter(s => s.aboveEnvelope).length, shapeConfidence: projected > 0 ? round2(1 - projectedOnWeakShape / projected) : null, weakShapeHours: slots diff --git a/tests/energy-balance/run.mjs b/tests/energy-balance/run.mjs index 987ac7e..4b013a8 100644 --- a/tests/energy-balance/run.mjs +++ b/tests/energy-balance/run.mjs @@ -65,6 +65,8 @@ const SUMMARIZE = { RESOURCE_ESTIMATE: o => ({ usable: o.usable, mode: o.mode, + // Quelle courbe a ete retenue, et pour quel jour (correctif du 08/09). + alignment: o.source.forecastAlignment, 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 6b5e9c0..172b80b 100644 --- a/tests/energy-balance/scenarios-profile.mjs +++ b/tests/energy-balance/scenarios-profile.mjs @@ -137,8 +137,106 @@ export const profileScenarios = [ // --- RESOURCE_ESTIMATE ------------------------------------------------- const envelope = JSON.parse(fs.readFileSync(path.join(HERE, 'fixture-envelope.json'), 'utf8')); const MIDDAY = Date.parse('2026-08-24T12:30:00+02:00'); +// 00:45, l'heure exacte ou le bilan a decroche la nuit du 07 au 08/09. +const APRES_MINUIT = Date.parse('2026-09-08T00:45:00+02:00'); +// Deux courbes horaires de 24 valeurs : une journee sans rien, et une journee +// a 30 kWh repartis de 8 h a 17 h (3 kWh par heure). +const JOURNEE_VIDE = Array.from({ length: 24 }, () => 0); +const JOURNEE_PLEINE = Array.from({ length: 24 }, (_, h) => (h >= 8 && h <= 17 ? 3 : 0)); export const resourceScenarios = [ + // --- Correctif du 08/09/2026 : la courbe se juge sur la DATE qu'elle decrit, + // plus sur son age. Les quatre scenarios ci-dessous partagent la meme heure + // (00:45, juste apres minuit) et les memes courbes : seule la date publiee + // change. C'est ce qui les rend probants. + // + // Releve reel : la nuit du 07 au 08/09, plus aucune publication de 23:45 a + // 04:30. La derniere courbe decrivait le 7, dont toutes les heures etaient + // passees — le bilan a conclu « aucune production sur l'horizon ». + { + node: 'RESOURCE_ESTIMATE', + id: 'prevision-decalee-apres-minuit', + label: 'Publication d\'avant minuit : la courbe « lendemain » EST celle du jour, la production redevient visible', + snapshot: { + nowMs: APRES_MINUIT, + forecast_day0: '2026-09-07', + forecast_today_kwh: JOURNEE_VIDE, + forecast_tomorrow_kwh: JOURNEE_PLEINE, + forecast_today_remaining_kwh: '0', + forecast_tomorrow_kwh_total: '0', + envelope_kwh: envelope.envelope_kwh, + censorship_rate: envelope.censorship_rate, + }, + expect: { + usable: true, + mode: 'courbe_horaire_api', + alignment: 'decalee_d_un_jour', + todayExpected: 30, + }, + }, + { + node: 'RESOURCE_ESTIMATE', + id: 'prevision-a-jour-lecture-directe', + label: 'CONTREFACTUEL : meme instant, courbe datee du jour → lecture directe', + snapshot: { + nowMs: APRES_MINUIT, + forecast_day0: '2026-09-08', + forecast_today_kwh: JOURNEE_PLEINE, + forecast_tomorrow_kwh: JOURNEE_VIDE, + forecast_today_remaining_kwh: '0', + envelope_kwh: envelope.envelope_kwh, + censorship_rate: envelope.censorship_rate, + }, + expect: { + usable: true, + mode: 'courbe_horaire_api', + alignment: 'a_jour', + todayExpected: 30, + }, + }, + { + node: 'RESOURCE_ESTIMATE', + id: 'prevision-vieille-mais-du-bon-jour', + label: 'Publiee il y a cinq heures mais datee du jour : utilisable — l\'ancienne garde d\'age la refusait', + snapshot: { + nowMs: APRES_MINUIT, + forecast_day0: '2026-09-08', + forecast_updated: '07/09 19:45', + forecast_today_kwh: JOURNEE_PLEINE, + forecast_tomorrow_kwh: JOURNEE_VIDE, + forecast_today_remaining_kwh: '0', + envelope_kwh: envelope.envelope_kwh, + censorship_rate: envelope.censorship_rate, + }, + expect: { + usable: true, + mode: 'courbe_horaire_api', + alignment: 'a_jour', + todayExpected: 30, + }, + }, + { + node: 'RESOURCE_ESTIMATE', + id: 'prevision-perimee-de-deux-jours', + label: 'CONTREFACTUEL : courbe datee de l\'avant-veille → inexploitable, repli sur l\'enveloppe', + snapshot: { + nowMs: APRES_MINUIT, + forecast_day0: '2026-09-06', + forecast_today_kwh: JOURNEE_PLEINE, + forecast_tomorrow_kwh: JOURNEE_PLEINE, + forecast_today_remaining_kwh: '0', + forecast_tomorrow_kwh_total: '0', + envelope_kwh: envelope.envelope_kwh, + censorship_rate: envelope.censorship_rate, + }, + expect: { + usable: true, + mode: 'repli_enveloppe', + alignment: 'perimee', + todayExpected: 0, + }, + }, + { node: 'RESOURCE_ESTIMATE', id: 'ressource-repartition-prevision', diff --git a/tests/energy-balance/scenarios-shadow.mjs b/tests/energy-balance/scenarios-shadow.mjs index 9e93c42..e097d77 100644 --- a/tests/energy-balance/scenarios-shadow.mjs +++ b/tests/energy-balance/scenarios-shadow.mjs @@ -191,7 +191,9 @@ export const forecastScenarios = [ id: 'forecast-perimetre-respecte', label: 'Publication de la prévision : périmètre evcs_balance_* respecté', snapshot: FS_OK, - expect: { outOfScope: '', callCount: 4, hasLogbook: true }, + // 5 et non 4 depuis le 08/09 : la date que decrit la courbe est publiee a + // cote des courbes elles-memes (`forecast_day0`). + expect: { outOfScope: '', callCount: 5, hasLogbook: true }, }, { node: 'BUILD_HA_CALLS_FORECAST', diff --git a/tools/rapport-nuit.mjs b/tools/rapport-nuit.mjs index 50e1a47..0d13b0a 100644 --- a/tools/rapport-nuit.mjs +++ b/tools/rapport-nuit.mjs @@ -167,6 +167,28 @@ export async function texteRapport(client, jour = new Date()) { : 'C\'est du bruit de regulation, sans consequence.'); } + // ------------------------------------------------ prevision solaire + // Une panne de prevision est invisible autrement : les nodes n8n portent + // `onError: continueRegularOutput`, donc les executions restent « success » + // meme quand l'appel echoue, et quand Home Assistant est lui aussi + // injoignable le message d'echec ne peut meme pas etre publie. Le service + // d'historisation, lui, tourne sur l'autre NAS : c'est le seul temoin qui + // survit. Releve de la nuit du 07 au 08/09 : 4 h 45 sans une seule + // publication, et personne ne l'a su avant le lendemain. + const { rows: [prev] } = await q( + `SELECT round(EXTRACT(EPOCH FROM max(ecart)) / 60) AS trou + FROM (SELECT publie_a - lag(publie_a) OVER (ORDER BY publie_a) AS ecart + FROM prevision WHERE publie_a >= $1 AND publie_a < $2) t`, + [debut, fin]); + const trouMin = Number(prev?.trou ?? 0); + if (trouMin >= 120) { + const h = Math.floor(trouMin / 60); + const m = trouMin % 60; + dire(`Attention, la prevision solaire n'a pas ete rafraichie pendant ${h} heure${h > 1 ? 's' : ''}` + + `${m ? ` et ${m} minutes` : ''}. Le bilan a donc travaille sur une courbe` + + ' vieillissante pendant ce temps, et la cible de la nuit a pu retomber sur la regle des paliers.'); + } + // ------------------------------------------------ anomalies const { rows: soucis } = await q( `SELECT resume FROM evenement WHERE ts >= $1 AND ts < $2 diff --git a/workflows/evcs-energy-balance-v1.json b/workflows/evcs-energy-balance-v1.json index 5e8ce4f..d749b0d 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 \"modules_en_service\": states(\"input_number.evcs_balance_modules_en_service\"),\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\n \"storm_alert_level\": (states.sensor\n | selectattr('entity_id', 'search', '_weather_alert')\n | map(attribute='state') | list | first) | default('indisponible', true),\n \"storm_alert_detail\": (states.sensor\n | selectattr('entity_id', 'search', '_weather_alert')\n | map(attribute='attributes') | list | first) | default({}, true)\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 \"modules_en_service\": states(\"input_number.evcs_balance_modules_en_service\"),\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_day0\": states(\"input_text.evcs_balance_forecast_day0\"),\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\n \"storm_alert_level\": (states.sensor\n | selectattr('entity_id', 'search', '_weather_alert')\n | map(attribute='state') | list | first) | default('indisponible', true),\n \"storm_alert_detail\": (states.sensor\n | selectattr('entity_id', 'search', '_weather_alert')\n | map(attribute='attributes') | list | first) | default({}, true)\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. 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" + "jsCode": "// EVCS Energy Balance — RESOURCE_ESTIMATE\n// =====================================================================\n// Rôle : estimer la production PV attendue heure par heure, en RESSOURCE\n// (ce que l'installation peut produire) et non en mesure censurée.\n//\n// Trois sources ont été évaluées le 24/08/2026, deux ont été écartées :\n//\n// 1. Recalage de la prévision sur les heures non censurées — ÉCARTÉ.\n// La censure est corrélée au beau temps : les heures exploitables de\n// l'après-midi ne le sont que les jours médiocres. Le facteur mesuré va\n// de 4,29 à 9 h (27 échantillons) à 0,48 à 15 h (3 échantillons).\n// Données manquantes non aléatoires, aucun facteur transportable.\n//\n// 2. Forme issue de la géométrie solaire — ÉCARTÉ. Un modèle mono-plan\n// ajusté sur les heures propres converge sur « plein est, 20° » et\n// prédit alors 2,0 % de la journée à 18 h contre 5,8 % réellement\n// mesurés. L'installation a manifestement plusieurs orientations\n// (trois trackers), qu'un plan unique ne peut pas représenter.\n//\n// 3. Forme = enveloppe démontrée, magnitude = total prévu — RETENU du 24 au\n// 25/08, puis RÉTROGRADÉ EN REPLI. L'enveloppe étant un maximum horaire\n// pris sur des jours différents, elle surpondère le matin, dont la\n// variabilité jour à jour est plus forte qu'à midi. Deux modèles\n// indépendants (géométrique local et ciel clair de Forecast.Solar) la\n// contredisent de la même façon : c'est elle qui a tort.\n//\n// 4. RETENU (25/08) — COURBE HORAIRE de l'API Forecast.Solar, récupérée par\n// le workflow \"EVCS Solar Forecast V1\" et publiée en CSV. Elle apporte\n// forme ET magnitude d'un seul tenant, sur 6 jours, avec les trois plans\n// réels de l'installation, et n'est censurée par rien. Le nœud s'en\n// trouve simplifié : plus de répartition au prorata, plus de\n// renormalisation.\n//\n// REPLI : si la courbe est absente ou périmée, on retombe sur la méthode 3\n// (total prévu réparti sur l'enveloppe), en le signalant par `source`.\n//\n// L'enveloppe garde un seul rôle : repère de vraisemblance. Elle n'écrête PAS\n// la prévision — étant minorée l'après-midi, elle tronquerait à tort.\n//\n// Ce nœud ne décide rien.\n// =====================================================================\n\nfunction num(v, fallback = NaN) {\n const n = Number.parseFloat(v);\n return Number.isFinite(n) ? n : fallback;\n}\n\nfunction round2(v) {\n return Number.isFinite(v) ? Math.round(v * 100) / 100 : null;\n}\n\n// Le nœud HTTP de n8n renvoie le corps du template dans `data`, sous forme de\n// chaîne. Hors n8n (tests), l'objet arrive déjà décodé — on accepte les deux.\nfunction unwrapSnapshot(raw) {\n if (raw && typeof raw.data === 'string') {\n try { return JSON.parse(raw.data); } catch { return raw; }\n }\n return (raw && raw.data) || raw;\n}\n\nconst input = unwrapSnapshot($input.first().json);\n\nconst CFG = {\n TIMEZONE: 'Europe/Paris',\n // 40 h : assez pour qu'une traversée déficitaire commençant en fin\n // d'après-midi se referme au croisement du lendemain matin. Avec 30 h, la\n // fenêtre de la nuit restait ouverte et son heure de fin s'affichait « ? ».\n // La courbe couvre aujourd'hui et demain, soit 48 h au plus.\n HORIZON_HOURS: 40,\n // Au-delà de cette part d'heures censurées, la forme de l'enveloppe est\n // considérée comme minorée et signalée comme telle.\n CENSORSHIP_QUALITY_LIMIT: 0.25,\n};\n\nconst nowMs = num(input.nowMs, Date.now());\nconst todayRemainingKwh = Math.max(0, num(input.forecast_today_remaining_kwh, 0));\nconst tomorrowKwh = Math.max(0, num(input.forecast_tomorrow_kwh, 0));\n\n// Fraîcheur de la courbe : le workflow de prévision tourne au quart d'heure.\n// Au-delà d'une heure sans mise à jour, on considère la courbe périmée et on\n// repasse au repli plutôt que de projeter sur une météo dépassée.\nconst FRESHNESS_LIMIT_MIN = 60;\nfunction parseStampParis(text) {\n const m = /^(\\d{2})\\/(\\d{2})\\s+(\\d{2}):(\\d{2})$/.exec((text || '').toString().trim());\n if (!m) return NaN;\n const now = new Date(nowMs);\n const guess = new Date(Date.UTC(now.getUTCFullYear(), +m[2] - 1, +m[1], +m[3] - 2, +m[4]));\n // Passage d'année : une date future de plus d'un mois appartient à l'an dernier.\n if (guess.getTime() - nowMs > 31 * 86400000) guess.setUTCFullYear(guess.getUTCFullYear() - 1);\n return guess.getTime();\n}\nconst forecastUpdatedMs = parseStampParis(input.forecast_updated);\nconst forecastAgeMin = Number.isFinite(forecastUpdatedMs)\n ? (nowMs - forecastUpdatedMs) / 60000\n : Infinity;\nconst forecastFresh = forecastAgeMin <= FRESHNESS_LIMIT_MIN;\n\n// Enveloppe et taux de censure par heure : produits par le calcul quotidien et\n// stockés dans des helpers input_text sous forme de CSV compact. On accepte\n// aussi le tableau déjà décodé, ce qui rend le nœud testable hors n8n.\n// Absents au premier démarrage → on le dit, on ne fabrique pas de forme.\nfunction read24(arrayKey, csvKey) {\n const direct = input[arrayKey];\n if (Array.isArray(direct) && direct.length === 24) return direct.map(v => num(v, NaN));\n const csv = input[csvKey];\n if (typeof csv !== 'string') return null;\n const parts = csv.split(',').map(v => Number.parseFloat(v.trim()));\n if (parts.length !== 24 || parts.some(v => !Number.isFinite(v))) return null;\n return parts;\n}\n\nconst hourlyToday = read24('forecast_today_kwh', 'forecast_today_csv');\nconst hourlyTomorrow = read24('forecast_tomorrow_kwh', 'forecast_tomorrow_csv');\n\nconst envelopeRaw = read24('envelope_kwh', 'envelope_csv');\nconst envelope = envelopeRaw ? envelopeRaw.map(v => Math.max(0, num(v, 0))) : null;\n\nconst censorshipRaw = read24('censorship_rate', 'censorship_rate_csv');\nconst censorshipRate = censorshipRaw\n ? censorshipRaw.map(v => Math.max(0, Math.min(1, num(v, 1))))\n : Array.from({ length: 24 }, () => 1);\n\nconst hourOfDay = (ms) =>\n Number.parseInt(\n new Intl.DateTimeFormat('fr-FR', {\n timeZone: CFG.TIMEZONE, hour: '2-digit', hour12: false,\n }).format(new Date(ms)),\n 10\n );\n\nconst dayKey = (ms) =>\n new Intl.DateTimeFormat('fr-CA', {\n timeZone: CFG.TIMEZONE, year: 'numeric', month: '2-digit', day: '2-digit',\n }).format(new Date(ms));\n\nconst currentHourStart = Math.floor(nowMs / 3600000) * 3600000;\nconst today = dayKey(nowMs);\n\n// Une courbe est utilisable si elle DECRIT le jour dont on a besoin, pas si\n// elle est recente. Une prevision publiee a 23:45 pour le 8 septembre reste\n// exacte a 03:00 : la nuit, il n'y a rien a reactualiser. Inversement, une\n// courbe publiee il y a dix minutes mais qui decrit la veille ne vaut rien.\n//\n// `forecast_day0` porte la date de la courbe « today » telle qu'elle etait au\n// moment de la publication. Deux cas seulement sont exploitables :\n// day0 = aujourd'hui -> lecture directe, cas normal\n// day0 = hier -> la publication date d'avant minuit : la courbe\n// « tomorrow » est celle d'aujourd'hui, et on n'a\n// rien pour demain (repli sur l'enveloppe).\n// Au-dela, les courbes sont trop vieilles pour dire quoi que ce soit.\n//\n// ⚠️ REPLI : `forecast_day0` absent ou illisible => on retombe sur l'ancienne\n// garde d'age. Tant que le helper n'existe pas cote Home Assistant, le\n// comportement est donc strictement inchange.\nconst forecastDay0 = (input.forecast_day0 || '').toString().trim();\nconst day0Lisible = /^\\d{4}-\\d{2}-\\d{2}$/.test(forecastDay0);\nconst veille = dayKey(nowMs - 86400000);\n\nlet hourlyForToday = null;\nlet hourlyForTomorrow = null;\nlet forecastAlignment;\nif (!day0Lisible) {\n // Comportement historique, tant que la date n'est pas publiee.\n forecastAlignment = 'date_absente';\n if (forecastFresh) {\n hourlyForToday = hourlyToday;\n hourlyForTomorrow = hourlyTomorrow;\n }\n} else if (forecastDay0 === today) {\n forecastAlignment = 'a_jour';\n hourlyForToday = hourlyToday;\n hourlyForTomorrow = hourlyTomorrow;\n} else if (forecastDay0 === veille) {\n forecastAlignment = 'decalee_d_un_jour';\n hourlyForToday = hourlyTomorrow;\n hourlyForTomorrow = null;\n} else {\n forecastAlignment = 'perimee';\n}\nconst hourlyAvailable = !!hourlyForToday;\n\n// Une heure ou l'enveloppe demontree est nulle est une heure de nuit : il n'y\n// a jamais eu de soleil a y observer. Son taux de censure vaut 1 pour cette\n// raison-la, pas parce que la mesure serait douteuse, et il n'existe aucune\n// forme a minorer. La classer « minoree » faisait crier au loup TOUTES les\n// nuits (les heures de deficit sont justement les heures sans soleil), donc\n// exactement quand le bilan sert. Constate sur la nuit du 25-26/08 : statut\n// « degrade » de 20 h a 07 h alors que le bilan a predit le point bas a un\n// point pres.\nconst slots = [];\nfor (let i = 0; i < CFG.HORIZON_HOURS; i++) {\n const start = currentHourStart + i * 3600000;\n const hour = hourOfDay(start);\n const nightHour = !!envelope && !(envelope[hour] > 0);\n slots.push({\n startMs: start,\n hour,\n day: dayKey(start),\n weight: envelope ? envelope[hour] : 0,\n quality: nightHour\n ? 'nuit'\n : censorshipRate[hour] <= CFG.CENSORSHIP_QUALITY_LIMIT ? 'fiable' : 'minoree',\n });\n}\n\n// Répartition de l'énergie prévue au prorata de la forme démontrée.\nfunction distribute(daySlots, energyKwh) {\n const totalWeight = daySlots.reduce((s, x) => s + x.weight, 0);\n for (const slot of daySlots) {\n slot.expectedKwh = totalWeight > 0 ? (slot.weight / totalWeight) * energyKwh : 0;\n }\n}\n\nconst todaySlots = slots.filter(s => s.day === today);\nconst tomorrowSlots = slots.filter(s => s.day !== today);\n\nif (hourlyAvailable) {\n // Lecture directe : chaque heure porte déjà son énergie prévue.\n for (const slot of todaySlots) slot.expectedKwh = Math.max(0, hourlyForToday[slot.hour] ?? 0);\n if (hourlyForTomorrow) {\n for (const slot of tomorrowSlots) {\n slot.expectedKwh = Math.max(0, hourlyForTomorrow[slot.hour] ?? 0);\n }\n } else {\n // Courbe decalee : on connait aujourd'hui, pas demain. L'enveloppe mesuree\n // reprend la main pour la suite de l'horizon plutot que de recopier la\n // courbe du jour, qui serait une invention.\n distribute(tomorrowSlots, tomorrowKwh);\n }\n} else {\n distribute(todaySlots, todayRemainingKwh);\n distribute(tomorrowSlots, tomorrowKwh);\n}\n\n// Repère de vraisemblance, sans écrêtage : l'enveloppe est minorée l'après-midi.\nfor (const slot of slots) {\n slot.aboveEnvelope = !!envelope && envelope[slot.hour] > 0\n && slot.expectedKwh > envelope[slot.hour] * 1.5;\n}\n\n// Part de l'énergie projetée qui tombe sur des heures dont la forme est minorée :\n// c'est la mesure de confiance de cette projection.\nconst projected = slots.reduce((s, x) => s + x.expectedKwh, 0);\nconst projectedOnWeakShape = slots\n .filter(x => x.quality === 'minoree')\n .reduce((s, x) => s + x.expectedKwh, 0);\n\nreturn [{\n json: {\n generatedAt: new Date(nowMs).toISOString(),\n usable: hourlyAvailable || !!envelope,\n // Quelle source a réellement fourni la courbe ? (`source` ci-dessous est\n // le détail des entrées ; `mode` dit laquelle a gagné.)\n mode: hourlyAvailable ? 'courbe_horaire_api' : 'repli_enveloppe',\n\n horizon: slots.map(s => ({\n startMs: s.startMs,\n hour: s.hour,\n day: s.day,\n expectedKwh: round2(s.expectedKwh),\n shapeQuality: s.quality,\n })),\n\n todayExpectedRemainingKwh: round2(todaySlots.reduce((s, x) => s + x.expectedKwh, 0)),\n tomorrowExpectedKwh: round2(tomorrowSlots.reduce((s, x) => s + x.expectedKwh, 0)),\n\n source: {\n todayRemainingKwh: round2(todayRemainingKwh),\n tomorrowKwh: round2(tomorrowKwh),\n envelopeProvided: !!envelope,\n hourlyCurveAvailable: !!hourlyToday,\n forecastAgeMin: Number.isFinite(forecastAgeMin) ? Math.round(forecastAgeMin) : null,\n forecastFresh,\n forecastDay0: forecastDay0 || null,\n forecastAlignment,\n hoursAboveEnvelope: slots.filter(s => s.aboveEnvelope).length,\n shapeConfidence: projected > 0 ? round2(1 - projectedOnWeakShape / projected) : null,\n weakShapeHours: slots\n .filter(x => x.quality === 'minoree' && x.expectedKwh > 0)\n .map(x => x.hour)\n .filter((h, i, a) => a.indexOf(h) === i),\n },\n\n cfg: CFG,\n },\n}];\n" } }, { diff --git a/workflows/evcs-solar-forecast-v1.json b/workflows/evcs-solar-forecast-v1.json index 01c775e..45e983a 100644 --- a/workflows/evcs-solar-forecast-v1.json +++ b/workflows/evcs-solar-forecast-v1.json @@ -122,7 +122,7 @@ 0 ], "parameters": { - "jsCode": "// EVCS Energy Balance — BUILD_HA_CALLS_FORECAST\n// =====================================================================\n// Publie la prévision horaire dans ses helpers.\n// MÊME GARANTIE DE PÉRIMÈTRE : uniquement des entités `evcs_balance_*`.\n// En cas d'échec, on ne publie rien plutôt que d'écraser une prévision valide\n// par du vide — une prévision d'il y a quinze minutes vaut mieux que rien.\n// =====================================================================\n\nconst parsed = $input.first().json;\n\nfunction stampParis() {\n const p = new Intl.DateTimeFormat('fr-FR', {\n timeZone: 'Europe/Paris', day: '2-digit', month: '2-digit',\n hour: '2-digit', minute: '2-digit', hour12: false,\n }).formatToParts(new Date());\n const g = t => p.find(x => x.type === t)?.value || '';\n return `${g('day')}/${g('month')} ${g('hour')}:${g('minute')}`;\n}\n\nconst calls = [];\nfunction setText(entity, value) {\n calls.push({ json: {\n domain: 'input_text', service: 'set_value',\n data: { entity_id: entity, value: (value ?? '').toString().slice(0, 250) },\n } });\n}\n\nif (!parsed.ok) {\n setText('input_text.evcs_balance_forecast_updated',\n `échec : ${(parsed.problems || []).join(', ')}`);\n return calls;\n}\n\nsetText('input_text.evcs_balance_forecast_today', parsed.today.csv);\nif (parsed.tomorrow) {\n setText('input_text.evcs_balance_forecast_tomorrow', parsed.tomorrow.csv);\n}\nsetText('input_text.evcs_balance_forecast_updated', stampParis());\n\ncalls.push({ json: {\n domain: 'logbook', service: 'log',\n data: {\n name: 'EVCS Bilan — prévision solaire',\n message: `${parsed.today.date} : ${parsed.today.totalKwh} kWh attendus`\n + (parsed.tomorrow ? `, lendemain ${parsed.tomorrow.totalKwh} kWh` : '')\n + ` (${parsed.days} jours reçus, quota restant ${parsed.rateLimitRemaining ?? '?'})`,\n entity_id: 'input_text.evcs_balance_forecast_today',\n },\n} });\n\nreturn calls;\n" + "jsCode": "// EVCS Energy Balance — BUILD_HA_CALLS_FORECAST\n// =====================================================================\n// Publie la prévision horaire dans ses helpers.\n// MÊME GARANTIE DE PÉRIMÈTRE : uniquement des entités `evcs_balance_*`.\n// En cas d'échec, on ne publie rien plutôt que d'écraser une prévision valide\n// par du vide — une prévision d'il y a quinze minutes vaut mieux que rien.\n// =====================================================================\n\nconst parsed = $input.first().json;\n\nfunction stampParis() {\n const p = new Intl.DateTimeFormat('fr-FR', {\n timeZone: 'Europe/Paris', day: '2-digit', month: '2-digit',\n hour: '2-digit', minute: '2-digit', hour12: false,\n }).formatToParts(new Date());\n const g = t => p.find(x => x.type === t)?.value || '';\n return `${g('day')}/${g('month')} ${g('hour')}:${g('minute')}`;\n}\n\nconst calls = [];\nfunction setText(entity, value) {\n calls.push({ json: {\n domain: 'input_text', service: 'set_value',\n data: { entity_id: entity, value: (value ?? '').toString().slice(0, 250) },\n } });\n}\n\nif (!parsed.ok) {\n setText('input_text.evcs_balance_forecast_updated',\n `échec : ${(parsed.problems || []).join(', ')}`);\n return calls;\n}\n\n// La DATE que decrit la courbe, et non l'heure a laquelle on l'a recue.\n// Sans elle, « today » veut dire « le premier jour renvoye par l'API au moment\n// de la publication » — ce qui devient faux des qu'on passe minuit sans\n// rafraichir. Nuit du 07 au 08/09 : plus aucune publication de 23:45 a 04:30,\n// et le bilan a lu comme « aujourd'hui » la courbe du 7, dont toutes les heures\n// etaient passees. Il en a conclu « aucun croisement, 47,6 kWh a couvrir, SOC\n// requis 100 % », s'est declare degrade, et le controleur est retombe sur le\n// palier de 35 % pendant quatre heures.\nsetText('input_text.evcs_balance_forecast_day0', parsed.today.date);\nsetText('input_text.evcs_balance_forecast_today', parsed.today.csv);\nif (parsed.tomorrow) {\n setText('input_text.evcs_balance_forecast_tomorrow', parsed.tomorrow.csv);\n}\nsetText('input_text.evcs_balance_forecast_updated', stampParis());\n\ncalls.push({ json: {\n domain: 'logbook', service: 'log',\n data: {\n name: 'EVCS Bilan — prévision solaire',\n message: `${parsed.today.date} : ${parsed.today.totalKwh} kWh attendus`\n + (parsed.tomorrow ? `, lendemain ${parsed.tomorrow.totalKwh} kWh` : '')\n + ` (${parsed.days} jours reçus, quota restant ${parsed.rateLimitRemaining ?? '?'})`,\n entity_id: 'input_text.evcs_balance_forecast_today',\n },\n} });\n\nreturn calls;\n" } }, {