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