From e18158bfd1e6d1a1db424546ca68e7116e67c426 Mon Sep 17 00:00:00 2001 From: Tallagrand Thierry Date: Mon, 28 Sep 2026 23:36:08 +0200 Subject: [PATCH] Bilan : la nuit prevue suit la soiree reelle (regression sur 24 nuits), nuage Grafana corrige Le service publie le facteur nuit prevue / nuit moyenne (sensor.evcs_nuit_maison_prevue_kwh), le bilan l'applique a son profil de 21:30 a 09:00. Diagnostics 81/81, bilan 68/68. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_011uJ47NxgfpGnuuMr3f2n7Z --- HANDOFF.md | 16 ++- .../grafana/dashboards/nuit.json | 118 +----------------- src/energy-balance/BALANCE.js | 60 ++++++++- src/energy-balance/BUILD_HA_CALLS.js | 6 +- .../HA_SNAPSHOT_TEMPLATE.json.tpl | 5 + src/energy-balance/PREPARE_BALANCE.js | 6 + tests/diagnostics-agent/run.mjs | 36 +++++- tests/energy-balance/run.mjs | 3 + tests/energy-balance/scenarios-balance.mjs | 45 +++++++ tools/diagnostics-agent.mjs | 67 ++++++++++ tools/gen-grafana-dashboards.py | 47 ++++--- tools/historise-evcs.mjs | 42 ++++++- tools/{grafana-nuits.sql => nuits-maison.sql} | 0 workflows/evcs-energy-balance-v1.json | 8 +- 14 files changed, 306 insertions(+), 153 deletions(-) rename tools/{grafana-nuits.sql => nuits-maison.sql} (100%) diff --git a/HANDOFF.md b/HANDOFF.md index 031e24e..2083c3c 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -1066,8 +1066,20 @@ Grafana « La nuit » : 3 panneaux (nuage soiree/nuit avec regression et CE SOIR tableau des nuits, profil horaire 20 nuits contre la nuit en cours). SQL dans `tools/grafana-nuits.sql` et `tools/grafana-profil-horaire.sql`, verifiees contre le calcul par script (identiques). ⚠️ Rendu du nuage (xychart Grafana 11.3) non vu de mes -yeux : le proprietaire confirme. Piste ouverte : faire utiliser la regression par le -bilan (la nuit prevue suit la soiree reelle). +yeux : le proprietaire a vu « Err » -> refait en UNE requete, mapping automatique. +**Le bilan utilise la regression (28/09 23:34, demande du proprietaire).** Le service +d'historisation publie `sensor.evcs_nuit_maison_prevue_kwh` (21:30 -> 09:00, toutes les +5 min ; `hors_fenetre` le jour, `indisponible` si < 15 nuits ou r2 < 0,2) avec le +FACTEUR nuit prevue / nuit moyenne des memes donnees (le profil vient des compteurs, +la regression des puissances : le rapport annule l'ecart de methode). Le bilan +multiplie son profil p50 par ce facteur sur 21:30-09:00 (prorata), le p80 ne descend +jamais (max(p80, p50 recale)), facteur borne [0,75 ; 1,45] = extremes des 24 nuits, +perime > 30 min -> ignore. Phrase « Nuit recalee sur la soiree : conso x1,092 » dans +la raison. Tests : diagnostics 81/81 (valeurs verifiees par un calcul Python +independant), bilan 68/68 (verifie a la main : +0,71 kWh, +2,4 points), 2 mutations +qui mordent. Premier calcul reel 23:35 : SOC 47, requis 50,5 (~48 sans facteur) -> +1,05 kWh EDF a 01:24. ⚠️ Ma premiere annonce d'un facteur 1,12 etait fausse (moyenne +calculee de tete) : 1,09. --- *Document mis a jour le : 28/09/2026 (~02:15) — VRM remplace Forecast.Solar (comparaison le 05/10), tenir jusqu'a la prochaine HC, prevision de nuit pour l'agent* diff --git a/deploy/historisation/grafana/dashboards/nuit.json b/deploy/historisation/grafana/dashboards/nuit.json index d43496c..ec9db35 100644 --- a/deploy/historisation/grafana/dashboards/nuit.json +++ b/deploy/historisation/grafana/dashboards/nuit.json @@ -667,40 +667,7 @@ "format": "table", "rawQuery": true, "editorMode": "code", - "rawSql": "WITH pts AS (\n SELECT cle, ts, valeur,\n least(coalesce(lead(ts) OVER (PARTITION BY cle ORDER BY ts), now()), ts + interval '1 hour') AS fin\n FROM mesure\n WHERE cle IN ('sensor.victron_consumption_power_l1', 'sensor.victron_evcs_ac_power',\n 'sensor.victron_grid_power_l1', 'sensor.victron_pv_power_system')\n AND ts > now() - interval '40 days'\n),\njours AS (\n SELECT d::date AS j,\n (d::date + time '18:00') AT TIME ZONE 'Europe/Paris' AS s0,\n (d::date + time '21:30') AT TIME ZONE 'Europe/Paris' AS n0,\n (d::date + 1 + time '01:24') AT TIME ZONE 'Europe/Paris' AS hc,\n (d::date + 1 + time '09:00') AT TIME ZONE 'Europe/Paris' AS n1\n FROM generate_series(current_date - 35, current_date, interval '1 day') d\n),\ne AS (\n SELECT j.j,\n sum(p.valeur * extract(epoch FROM least(p.fin, j.n0) - greatest(p.ts, j.s0))) FILTER (WHERE p.cle = 'sensor.victron_consumption_power_l1' AND p.fin > j.s0 AND p.ts < j.n0) AS c_soir,\n sum(p.valeur * extract(epoch FROM least(p.fin, j.n0) - greatest(p.ts, j.s0))) FILTER (WHERE p.cle = 'sensor.victron_evcs_ac_power' AND p.fin > j.s0 AND p.ts < j.n0) AS v_soir,\n sum(p.valeur * extract(epoch FROM least(p.fin, j.hc) - greatest(p.ts, j.n0))) FILTER (WHERE p.cle = 'sensor.victron_consumption_power_l1' AND p.fin > j.n0 AND p.ts < j.hc) AS c_hc,\n sum(p.valeur * extract(epoch FROM least(p.fin, j.hc) - greatest(p.ts, j.n0))) FILTER (WHERE p.cle = 'sensor.victron_evcs_ac_power' AND p.fin > j.n0 AND p.ts < j.hc) AS v_hc,\n sum(p.valeur * extract(epoch FROM least(p.fin, j.n1) - greatest(p.ts, j.n0))) FILTER (WHERE p.cle = 'sensor.victron_consumption_power_l1' AND p.fin > j.n0 AND p.ts < j.n1) AS c_nuit,\n sum(p.valeur * extract(epoch FROM least(p.fin, j.n1) - greatest(p.ts, j.n0))) FILTER (WHERE p.cle = 'sensor.victron_evcs_ac_power' AND p.fin > j.n0 AND p.ts < j.n1) AS v_nuit,\n sum(p.valeur * extract(epoch FROM least(p.fin, j.n1) - greatest(p.ts, j.n0))) FILTER (WHERE p.cle = 'sensor.victron_grid_power_l1' AND p.fin > j.n0 AND p.ts < j.n1) AS r_nuit,\n sum(p.valeur * extract(epoch FROM least(p.fin, j.n1) - greatest(p.ts, j.n0))) FILTER (WHERE p.cle = 'sensor.victron_pv_power_system' AND p.fin > j.n0 AND p.ts < j.n1) AS pv_nuit\n FROM jours j JOIN pts p ON p.fin > j.s0 AND p.ts < j.n1\n GROUP BY j.j\n),\nnuits AS (\n SELECT j.j, j.n0, j.n1,\n round(((coalesce(c_soir,0) - coalesce(v_soir,0)) / 3.6e6)::numeric, 2) AS soiree_kwh,\n round(((coalesce(c_hc,0) - coalesce(v_hc,0)) / 3.6e6)::numeric, 2) AS maison_hc_kwh,\n round(((coalesce(c_nuit,0) - coalesce(v_nuit,0)) / 3.6e6)::numeric, 2) AS maison_nuit_kwh,\n round((coalesce(v_nuit,0) / 3.6e6)::numeric, 2) AS voiture_kwh,\n round((coalesce(r_nuit,0) / 3.6e6)::numeric, 2) AS reseau_kwh,\n round((coalesce(pv_nuit,0) / 3.6e6)::numeric, 2) AS pv_avant_9h_kwh,\n (SELECT valeur FROM mesure WHERE cle = 'sensor.victron_battery_soc' AND ts <= j.n0 AND valeur > 0 ORDER BY ts DESC LIMIT 1) AS soc_2130,\n (SELECT valeur FROM mesure WHERE cle = 'sensor.victron_battery_soc' AND ts <= j.hc AND valeur > 0 ORDER BY ts DESC LIMIT 1) AS soc_0124,\n (SELECT valeur FROM mesure WHERE cle = 'sensor.victron_battery_soc' AND ts <= j.n1 AND valeur > 0 ORDER BY ts DESC LIMIT 1) AS soc_9h\n FROM jours j JOIN e ON e.j = j.j\n WHERE j.n1 <= now() AND j.j >= date '2026-09-04'\n)\nSELECT soiree_kwh AS \"Soiree\", maison_nuit_kwh AS \"Nuit sans voiture\" FROM nuits WHERE voiture_kwh < 0.5 ORDER BY 1" - }, - { - "refId": "B", - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "evcs-pg" - }, - "format": "table", - "rawQuery": true, - "editorMode": "code", - "rawSql": "WITH pts AS (\n SELECT cle, ts, valeur,\n least(coalesce(lead(ts) OVER (PARTITION BY cle ORDER BY ts), now()), ts + interval '1 hour') AS fin\n FROM mesure\n WHERE cle IN ('sensor.victron_consumption_power_l1', 'sensor.victron_evcs_ac_power',\n 'sensor.victron_grid_power_l1', 'sensor.victron_pv_power_system')\n AND ts > now() - interval '40 days'\n),\njours AS (\n SELECT d::date AS j,\n (d::date + time '18:00') AT TIME ZONE 'Europe/Paris' AS s0,\n (d::date + time '21:30') AT TIME ZONE 'Europe/Paris' AS n0,\n (d::date + 1 + time '01:24') AT TIME ZONE 'Europe/Paris' AS hc,\n (d::date + 1 + time '09:00') AT TIME ZONE 'Europe/Paris' AS n1\n FROM generate_series(current_date - 35, current_date, interval '1 day') d\n),\ne AS (\n SELECT j.j,\n sum(p.valeur * extract(epoch FROM least(p.fin, j.n0) - greatest(p.ts, j.s0))) FILTER (WHERE p.cle = 'sensor.victron_consumption_power_l1' AND p.fin > j.s0 AND p.ts < j.n0) AS c_soir,\n sum(p.valeur * extract(epoch FROM least(p.fin, j.n0) - greatest(p.ts, j.s0))) FILTER (WHERE p.cle = 'sensor.victron_evcs_ac_power' AND p.fin > j.s0 AND p.ts < j.n0) AS v_soir,\n sum(p.valeur * extract(epoch FROM least(p.fin, j.hc) - greatest(p.ts, j.n0))) FILTER (WHERE p.cle = 'sensor.victron_consumption_power_l1' AND p.fin > j.n0 AND p.ts < j.hc) AS c_hc,\n sum(p.valeur * extract(epoch FROM least(p.fin, j.hc) - greatest(p.ts, j.n0))) FILTER (WHERE p.cle = 'sensor.victron_evcs_ac_power' AND p.fin > j.n0 AND p.ts < j.hc) AS v_hc,\n sum(p.valeur * extract(epoch FROM least(p.fin, j.n1) - greatest(p.ts, j.n0))) FILTER (WHERE p.cle = 'sensor.victron_consumption_power_l1' AND p.fin > j.n0 AND p.ts < j.n1) AS c_nuit,\n sum(p.valeur * extract(epoch FROM least(p.fin, j.n1) - greatest(p.ts, j.n0))) FILTER (WHERE p.cle = 'sensor.victron_evcs_ac_power' AND p.fin > j.n0 AND p.ts < j.n1) AS v_nuit,\n sum(p.valeur * extract(epoch FROM least(p.fin, j.n1) - greatest(p.ts, j.n0))) FILTER (WHERE p.cle = 'sensor.victron_grid_power_l1' AND p.fin > j.n0 AND p.ts < j.n1) AS r_nuit,\n sum(p.valeur * extract(epoch FROM least(p.fin, j.n1) - greatest(p.ts, j.n0))) FILTER (WHERE p.cle = 'sensor.victron_pv_power_system' AND p.fin > j.n0 AND p.ts < j.n1) AS pv_nuit\n FROM jours j JOIN pts p ON p.fin > j.s0 AND p.ts < j.n1\n GROUP BY j.j\n),\nnuits AS (\n SELECT j.j, j.n0, j.n1,\n round(((coalesce(c_soir,0) - coalesce(v_soir,0)) / 3.6e6)::numeric, 2) AS soiree_kwh,\n round(((coalesce(c_hc,0) - coalesce(v_hc,0)) / 3.6e6)::numeric, 2) AS maison_hc_kwh,\n round(((coalesce(c_nuit,0) - coalesce(v_nuit,0)) / 3.6e6)::numeric, 2) AS maison_nuit_kwh,\n round((coalesce(v_nuit,0) / 3.6e6)::numeric, 2) AS voiture_kwh,\n round((coalesce(r_nuit,0) / 3.6e6)::numeric, 2) AS reseau_kwh,\n round((coalesce(pv_nuit,0) / 3.6e6)::numeric, 2) AS pv_avant_9h_kwh,\n (SELECT valeur FROM mesure WHERE cle = 'sensor.victron_battery_soc' AND ts <= j.n0 AND valeur > 0 ORDER BY ts DESC LIMIT 1) AS soc_2130,\n (SELECT valeur FROM mesure WHERE cle = 'sensor.victron_battery_soc' AND ts <= j.hc AND valeur > 0 ORDER BY ts DESC LIMIT 1) AS soc_0124,\n (SELECT valeur FROM mesure WHERE cle = 'sensor.victron_battery_soc' AND ts <= j.n1 AND valeur > 0 ORDER BY ts DESC LIMIT 1) AS soc_9h\n FROM jours j JOIN e ON e.j = j.j\n WHERE j.n1 <= now() AND j.j >= date '2026-09-04'\n)\nSELECT soiree_kwh AS \"Soiree\", maison_nuit_kwh AS \"Nuit avec voiture\" FROM nuits WHERE voiture_kwh >= 0.5 ORDER BY 1" - }, - { - "refId": "C", - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "evcs-pg" - }, - "format": "table", - "rawQuery": true, - "editorMode": "code", - "rawSql": "WITH pts AS (\n SELECT cle, ts, valeur,\n least(coalesce(lead(ts) OVER (PARTITION BY cle ORDER BY ts), now()), ts + interval '1 hour') AS fin\n FROM mesure\n WHERE cle IN ('sensor.victron_consumption_power_l1', 'sensor.victron_evcs_ac_power',\n 'sensor.victron_grid_power_l1', 'sensor.victron_pv_power_system')\n AND ts > now() - interval '40 days'\n),\njours AS (\n SELECT d::date AS j,\n (d::date + time '18:00') AT TIME ZONE 'Europe/Paris' AS s0,\n (d::date + time '21:30') AT TIME ZONE 'Europe/Paris' AS n0,\n (d::date + 1 + time '01:24') AT TIME ZONE 'Europe/Paris' AS hc,\n (d::date + 1 + time '09:00') AT TIME ZONE 'Europe/Paris' AS n1\n FROM generate_series(current_date - 35, current_date, interval '1 day') d\n),\ne AS (\n SELECT j.j,\n sum(p.valeur * extract(epoch FROM least(p.fin, j.n0) - greatest(p.ts, j.s0))) FILTER (WHERE p.cle = 'sensor.victron_consumption_power_l1' AND p.fin > j.s0 AND p.ts < j.n0) AS c_soir,\n sum(p.valeur * extract(epoch FROM least(p.fin, j.n0) - greatest(p.ts, j.s0))) FILTER (WHERE p.cle = 'sensor.victron_evcs_ac_power' AND p.fin > j.s0 AND p.ts < j.n0) AS v_soir,\n sum(p.valeur * extract(epoch FROM least(p.fin, j.hc) - greatest(p.ts, j.n0))) FILTER (WHERE p.cle = 'sensor.victron_consumption_power_l1' AND p.fin > j.n0 AND p.ts < j.hc) AS c_hc,\n sum(p.valeur * extract(epoch FROM least(p.fin, j.hc) - greatest(p.ts, j.n0))) FILTER (WHERE p.cle = 'sensor.victron_evcs_ac_power' AND p.fin > j.n0 AND p.ts < j.hc) AS v_hc,\n sum(p.valeur * extract(epoch FROM least(p.fin, j.n1) - greatest(p.ts, j.n0))) FILTER (WHERE p.cle = 'sensor.victron_consumption_power_l1' AND p.fin > j.n0 AND p.ts < j.n1) AS c_nuit,\n sum(p.valeur * extract(epoch FROM least(p.fin, j.n1) - greatest(p.ts, j.n0))) FILTER (WHERE p.cle = 'sensor.victron_evcs_ac_power' AND p.fin > j.n0 AND p.ts < j.n1) AS v_nuit,\n sum(p.valeur * extract(epoch FROM least(p.fin, j.n1) - greatest(p.ts, j.n0))) FILTER (WHERE p.cle = 'sensor.victron_grid_power_l1' AND p.fin > j.n0 AND p.ts < j.n1) AS r_nuit,\n sum(p.valeur * extract(epoch FROM least(p.fin, j.n1) - greatest(p.ts, j.n0))) FILTER (WHERE p.cle = 'sensor.victron_pv_power_system' AND p.fin > j.n0 AND p.ts < j.n1) AS pv_nuit\n FROM jours j JOIN pts p ON p.fin > j.s0 AND p.ts < j.n1\n GROUP BY j.j\n),\nnuits AS (\n SELECT j.j, j.n0, j.n1,\n round(((coalesce(c_soir,0) - coalesce(v_soir,0)) / 3.6e6)::numeric, 2) AS soiree_kwh,\n round(((coalesce(c_hc,0) - coalesce(v_hc,0)) / 3.6e6)::numeric, 2) AS maison_hc_kwh,\n round(((coalesce(c_nuit,0) - coalesce(v_nuit,0)) / 3.6e6)::numeric, 2) AS maison_nuit_kwh,\n round((coalesce(v_nuit,0) / 3.6e6)::numeric, 2) AS voiture_kwh,\n round((coalesce(r_nuit,0) / 3.6e6)::numeric, 2) AS reseau_kwh,\n round((coalesce(pv_nuit,0) / 3.6e6)::numeric, 2) AS pv_avant_9h_kwh,\n (SELECT valeur FROM mesure WHERE cle = 'sensor.victron_battery_soc' AND ts <= j.n0 AND valeur > 0 ORDER BY ts DESC LIMIT 1) AS soc_2130,\n (SELECT valeur FROM mesure WHERE cle = 'sensor.victron_battery_soc' AND ts <= j.hc AND valeur > 0 ORDER BY ts DESC LIMIT 1) AS soc_0124,\n (SELECT valeur FROM mesure WHERE cle = 'sensor.victron_battery_soc' AND ts <= j.n1 AND valeur > 0 ORDER BY ts DESC LIMIT 1) AS soc_9h\n FROM jours j JOIN e ON e.j = j.j\n WHERE j.n1 <= now() AND j.j >= date '2026-09-04'\n)\n\nSELECT v.x AS \"Soiree\", round((r.a + r.b * v.x)::numeric, 2) AS \"Regression\"\n FROM (SELECT regr_intercept(maison_nuit_kwh, soiree_kwh) AS a, regr_slope(maison_nuit_kwh, soiree_kwh) AS b,\n min(soiree_kwh) AS lo, max(soiree_kwh) AS hi FROM nuits) r,\n LATERAL (VALUES (r.lo), (r.hi)) v(x)\n ORDER BY 1" - }, - { - "refId": "D", - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "evcs-pg" - }, - "format": "table", - "rawQuery": true, - "editorMode": "code", - "rawSql": "WITH pts AS (\n SELECT cle, ts, valeur,\n least(coalesce(lead(ts) OVER (PARTITION BY cle ORDER BY ts), now()), ts + interval '1 hour') AS fin\n FROM mesure\n WHERE cle IN ('sensor.victron_consumption_power_l1', 'sensor.victron_evcs_ac_power',\n 'sensor.victron_grid_power_l1', 'sensor.victron_pv_power_system')\n AND ts > now() - interval '40 days'\n),\njours AS (\n SELECT d::date AS j,\n (d::date + time '18:00') AT TIME ZONE 'Europe/Paris' AS s0,\n (d::date + time '21:30') AT TIME ZONE 'Europe/Paris' AS n0,\n (d::date + 1 + time '01:24') AT TIME ZONE 'Europe/Paris' AS hc,\n (d::date + 1 + time '09:00') AT TIME ZONE 'Europe/Paris' AS n1\n FROM generate_series(current_date - 35, current_date, interval '1 day') d\n),\ne AS (\n SELECT j.j,\n sum(p.valeur * extract(epoch FROM least(p.fin, j.n0) - greatest(p.ts, j.s0))) FILTER (WHERE p.cle = 'sensor.victron_consumption_power_l1' AND p.fin > j.s0 AND p.ts < j.n0) AS c_soir,\n sum(p.valeur * extract(epoch FROM least(p.fin, j.n0) - greatest(p.ts, j.s0))) FILTER (WHERE p.cle = 'sensor.victron_evcs_ac_power' AND p.fin > j.s0 AND p.ts < j.n0) AS v_soir,\n sum(p.valeur * extract(epoch FROM least(p.fin, j.hc) - greatest(p.ts, j.n0))) FILTER (WHERE p.cle = 'sensor.victron_consumption_power_l1' AND p.fin > j.n0 AND p.ts < j.hc) AS c_hc,\n sum(p.valeur * extract(epoch FROM least(p.fin, j.hc) - greatest(p.ts, j.n0))) FILTER (WHERE p.cle = 'sensor.victron_evcs_ac_power' AND p.fin > j.n0 AND p.ts < j.hc) AS v_hc,\n sum(p.valeur * extract(epoch FROM least(p.fin, j.n1) - greatest(p.ts, j.n0))) FILTER (WHERE p.cle = 'sensor.victron_consumption_power_l1' AND p.fin > j.n0 AND p.ts < j.n1) AS c_nuit,\n sum(p.valeur * extract(epoch FROM least(p.fin, j.n1) - greatest(p.ts, j.n0))) FILTER (WHERE p.cle = 'sensor.victron_evcs_ac_power' AND p.fin > j.n0 AND p.ts < j.n1) AS v_nuit,\n sum(p.valeur * extract(epoch FROM least(p.fin, j.n1) - greatest(p.ts, j.n0))) FILTER (WHERE p.cle = 'sensor.victron_grid_power_l1' AND p.fin > j.n0 AND p.ts < j.n1) AS r_nuit,\n sum(p.valeur * extract(epoch FROM least(p.fin, j.n1) - greatest(p.ts, j.n0))) FILTER (WHERE p.cle = 'sensor.victron_pv_power_system' AND p.fin > j.n0 AND p.ts < j.n1) AS pv_nuit\n FROM jours j JOIN pts p ON p.fin > j.s0 AND p.ts < j.n1\n GROUP BY j.j\n),\nnuits AS (\n SELECT j.j, j.n0, j.n1,\n round(((coalesce(c_soir,0) - coalesce(v_soir,0)) / 3.6e6)::numeric, 2) AS soiree_kwh,\n round(((coalesce(c_hc,0) - coalesce(v_hc,0)) / 3.6e6)::numeric, 2) AS maison_hc_kwh,\n round(((coalesce(c_nuit,0) - coalesce(v_nuit,0)) / 3.6e6)::numeric, 2) AS maison_nuit_kwh,\n round((coalesce(v_nuit,0) / 3.6e6)::numeric, 2) AS voiture_kwh,\n round((coalesce(r_nuit,0) / 3.6e6)::numeric, 2) AS reseau_kwh,\n round((coalesce(pv_nuit,0) / 3.6e6)::numeric, 2) AS pv_avant_9h_kwh,\n (SELECT valeur FROM mesure WHERE cle = 'sensor.victron_battery_soc' AND ts <= j.n0 AND valeur > 0 ORDER BY ts DESC LIMIT 1) AS soc_2130,\n (SELECT valeur FROM mesure WHERE cle = 'sensor.victron_battery_soc' AND ts <= j.hc AND valeur > 0 ORDER BY ts DESC LIMIT 1) AS soc_0124,\n (SELECT valeur FROM mesure WHERE cle = 'sensor.victron_battery_soc' AND ts <= j.n1 AND valeur > 0 ORDER BY ts DESC LIMIT 1) AS soc_9h\n FROM jours j JOIN e ON e.j = j.j\n WHERE j.n1 <= now() AND j.j >= date '2026-09-04'\n),\nf AS (\n SELECT ((CASE WHEN (now() AT TIME ZONE 'Europe/Paris')::time >= time '18:00'\n THEN (now() AT TIME ZONE 'Europe/Paris')::date\n ELSE (now() AT TIME ZONE 'Europe/Paris')::date - 1 END) + time '18:00')\n AT TIME ZONE 'Europe/Paris' AS s0\n),\nsoir AS (\n SELECT sum(p.valeur * extract(epoch FROM least(p.fin, f.s0 + interval '3 hours 30 minutes', now()) - greatest(p.ts, f.s0))\n * CASE WHEN p.cle = 'sensor.victron_evcs_ac_power' THEN -1\n WHEN p.cle = 'sensor.victron_consumption_power_l1' THEN 1 ELSE 0 END) / 3.6e6 AS kwh\n FROM pts p, f\n WHERE p.fin > f.s0 AND p.ts < least(f.s0 + interval '3 hours 30 minutes', now())\n)\nSELECT round(s.kwh::numeric, 2) AS \"Soiree\",\n round((regr_intercept(maison_nuit_kwh, soiree_kwh) + regr_slope(maison_nuit_kwh, soiree_kwh) * s.kwh)::numeric, 2) AS \"Ce soir (prevu)\"\n FROM nuits, soir s GROUP BY s.kwh" + "rawSql": "WITH pts AS (\n SELECT cle, ts, valeur,\n least(coalesce(lead(ts) OVER (PARTITION BY cle ORDER BY ts), now()), ts + interval '1 hour') AS fin\n FROM mesure\n WHERE cle IN ('sensor.victron_consumption_power_l1', 'sensor.victron_evcs_ac_power',\n 'sensor.victron_grid_power_l1', 'sensor.victron_pv_power_system')\n AND ts > now() - interval '40 days'\n),\njours AS (\n SELECT d::date AS j,\n (d::date + time '18:00') AT TIME ZONE 'Europe/Paris' AS s0,\n (d::date + time '21:30') AT TIME ZONE 'Europe/Paris' AS n0,\n (d::date + 1 + time '01:24') AT TIME ZONE 'Europe/Paris' AS hc,\n (d::date + 1 + time '09:00') AT TIME ZONE 'Europe/Paris' AS n1\n FROM generate_series(current_date - 35, current_date, interval '1 day') d\n),\ne AS (\n SELECT j.j,\n sum(p.valeur * extract(epoch FROM least(p.fin, j.n0) - greatest(p.ts, j.s0))) FILTER (WHERE p.cle = 'sensor.victron_consumption_power_l1' AND p.fin > j.s0 AND p.ts < j.n0) AS c_soir,\n sum(p.valeur * extract(epoch FROM least(p.fin, j.n0) - greatest(p.ts, j.s0))) FILTER (WHERE p.cle = 'sensor.victron_evcs_ac_power' AND p.fin > j.s0 AND p.ts < j.n0) AS v_soir,\n sum(p.valeur * extract(epoch FROM least(p.fin, j.hc) - greatest(p.ts, j.n0))) FILTER (WHERE p.cle = 'sensor.victron_consumption_power_l1' AND p.fin > j.n0 AND p.ts < j.hc) AS c_hc,\n sum(p.valeur * extract(epoch FROM least(p.fin, j.hc) - greatest(p.ts, j.n0))) FILTER (WHERE p.cle = 'sensor.victron_evcs_ac_power' AND p.fin > j.n0 AND p.ts < j.hc) AS v_hc,\n sum(p.valeur * extract(epoch FROM least(p.fin, j.n1) - greatest(p.ts, j.n0))) FILTER (WHERE p.cle = 'sensor.victron_consumption_power_l1' AND p.fin > j.n0 AND p.ts < j.n1) AS c_nuit,\n sum(p.valeur * extract(epoch FROM least(p.fin, j.n1) - greatest(p.ts, j.n0))) FILTER (WHERE p.cle = 'sensor.victron_evcs_ac_power' AND p.fin > j.n0 AND p.ts < j.n1) AS v_nuit,\n sum(p.valeur * extract(epoch FROM least(p.fin, j.n1) - greatest(p.ts, j.n0))) FILTER (WHERE p.cle = 'sensor.victron_grid_power_l1' AND p.fin > j.n0 AND p.ts < j.n1) AS r_nuit,\n sum(p.valeur * extract(epoch FROM least(p.fin, j.n1) - greatest(p.ts, j.n0))) FILTER (WHERE p.cle = 'sensor.victron_pv_power_system' AND p.fin > j.n0 AND p.ts < j.n1) AS pv_nuit\n FROM jours j JOIN pts p ON p.fin > j.s0 AND p.ts < j.n1\n GROUP BY j.j\n),\nnuits AS (\n SELECT j.j, j.n0, j.n1,\n round(((coalesce(c_soir,0) - coalesce(v_soir,0)) / 3.6e6)::numeric, 2) AS soiree_kwh,\n round(((coalesce(c_hc,0) - coalesce(v_hc,0)) / 3.6e6)::numeric, 2) AS maison_hc_kwh,\n round(((coalesce(c_nuit,0) - coalesce(v_nuit,0)) / 3.6e6)::numeric, 2) AS maison_nuit_kwh,\n round((coalesce(v_nuit,0) / 3.6e6)::numeric, 2) AS voiture_kwh,\n round((coalesce(r_nuit,0) / 3.6e6)::numeric, 2) AS reseau_kwh,\n round((coalesce(pv_nuit,0) / 3.6e6)::numeric, 2) AS pv_avant_9h_kwh,\n (SELECT valeur FROM mesure WHERE cle = 'sensor.victron_battery_soc' AND ts <= j.n0 AND valeur > 0 ORDER BY ts DESC LIMIT 1) AS soc_2130,\n (SELECT valeur FROM mesure WHERE cle = 'sensor.victron_battery_soc' AND ts <= j.hc AND valeur > 0 ORDER BY ts DESC LIMIT 1) AS soc_0124,\n (SELECT valeur FROM mesure WHERE cle = 'sensor.victron_battery_soc' AND ts <= j.n1 AND valeur > 0 ORDER BY ts DESC LIMIT 1) AS soc_9h\n FROM jours j JOIN e ON e.j = j.j\n WHERE j.n1 <= now() AND j.j >= date '2026-09-04'\n),\nf AS (\n SELECT ((CASE WHEN (now() AT TIME ZONE 'Europe/Paris')::time >= time '18:00'\n THEN (now() AT TIME ZONE 'Europe/Paris')::date\n ELSE (now() AT TIME ZONE 'Europe/Paris')::date - 1 END) + time '18:00')\n AT TIME ZONE 'Europe/Paris' AS s0\n),\nsoir AS (\n SELECT sum(p.valeur * extract(epoch FROM least(p.fin, f.s0 + interval '3 hours 30 minutes', now()) - greatest(p.ts, f.s0))\n * CASE WHEN p.cle = 'sensor.victron_evcs_ac_power' THEN -1\n WHEN p.cle = 'sensor.victron_consumption_power_l1' THEN 1 ELSE 0 END) / 3.6e6 AS kwh\n FROM pts p, f\n WHERE p.fin > f.s0 AND p.ts < least(f.s0 + interval '3 hours 30 minutes', now())\n),\nr AS (SELECT regr_intercept(maison_nuit_kwh, soiree_kwh) AS a, regr_slope(maison_nuit_kwh, soiree_kwh) AS b FROM nuits)\nSELECT soiree_kwh::float8 AS \"Soiree\",\n CASE WHEN voiture_kwh < 0.5 THEN maison_nuit_kwh END::float8 AS \"Nuit sans voiture\",\n CASE WHEN voiture_kwh >= 0.5 THEN maison_nuit_kwh END::float8 AS \"Nuit avec voiture\",\n round((r.a + r.b * soiree_kwh)::numeric, 2)::float8 AS \"Regression\",\n NULL::float8 AS \"Ce soir (prevu)\"\n FROM nuits, r\nUNION ALL\nSELECT round(s.kwh::numeric, 2)::float8, NULL, NULL, round((r.a + r.b * s.kwh)::numeric, 2)::float8,\n round((r.a + r.b * s.kwh)::numeric, 2)::float8\n FROM soir s, r\nORDER BY 1" } ], "fieldConfig": { @@ -793,88 +760,9 @@ ] }, "options": { - "mapping": "manual", + "mapping": "auto", "series": [ - { - "frame": { - "matcher": { - "id": "byIndex", - "options": 0 - } - }, - "x": { - "matcher": { - "id": "byName", - "options": "Soiree" - } - }, - "y": { - "matcher": { - "id": "byName", - "options": "Nuit sans voiture" - } - } - }, - { - "frame": { - "matcher": { - "id": "byIndex", - "options": 1 - } - }, - "x": { - "matcher": { - "id": "byName", - "options": "Soiree" - } - }, - "y": { - "matcher": { - "id": "byName", - "options": "Nuit avec voiture" - } - } - }, - { - "frame": { - "matcher": { - "id": "byIndex", - "options": 2 - } - }, - "x": { - "matcher": { - "id": "byName", - "options": "Soiree" - } - }, - "y": { - "matcher": { - "id": "byName", - "options": "Regression" - } - } - }, - { - "frame": { - "matcher": { - "id": "byIndex", - "options": 3 - } - }, - "x": { - "matcher": { - "id": "byName", - "options": "Soiree" - } - }, - "y": { - "matcher": { - "id": "byName", - "options": "Ce soir (prevu)" - } - } - } + {} ], "legend": { "showLegend": true, diff --git a/src/energy-balance/BALANCE.js b/src/energy-balance/BALANCE.js index e250509..bd8e93a 100644 --- a/src/energy-balance/BALANCE.js +++ b/src/energy-balance/BALANCE.js @@ -140,11 +140,57 @@ if (missing.length) { // - profil PRUDENT (p80) → planification : cible de nuit, dimensionnement // du stockage, choix qui engagent plusieurs heures. // --------------------------------------------------------------------- -function buildSlots(consumption) { +// --------------------------------------------------------------------- +// LA NUIT PREVUE PAR LA SOIREE (28/09/2026, demande du proprietaire) +// +// Le profil p50 est une mediane HORAIRE : il ignore la soiree qui vient de se +// passer. Or sur 24 nuits, la maison consomme d'autant plus de 21:30 a 09:00 +// que la soiree 18:00-21:30 a ete chargee (four) : nuit = 6,98 + 0,74 x soiree, +// r2 = 0,40. Le service d'historisation publie le FACTEUR nuit prevue / nuit +// moyenne (sensor.evcs_nuit_maison_prevue_kwh) ; on multiplie le profil par ce +// facteur sur la seule fenetre 21:30 -> 09:00, au prorata des heures a cheval. +// Le 28/09 : soiree 6,27 kWh -> facteur 1,09. +// +// Un FACTEUR et non des kWh : le profil vient des compteurs d'energie, la +// regression des puissances integrees ; le rapport annule l'ecart de methode. +// Dans les deux sens pour le profil TYPIQUE (c'est un estimateur, une soiree +// legere allege la nuit) ; le PRUDENT ne descend jamais : max(p80, p50 recale). +// Filet : facteur borne a [0,75 ; 1,45], les extremes des 24 nuits observees +// (0,77 et 1,41 fois la moyenne) — hors de la, c'est une mesure fausse, pas une +// nuit. Facteur absent, perime (> 30 min) ou hors fenetre : profil inchange. +const NIGHT_FACTOR_MIN = 0.75; +const NIGHT_FACTOR_MAX = 1.45; +const nowMsNight = num(input.nowMs, Date.now()); +const nightStartMs = num(input.night_house_start_ms, NaN); +const nightEndMs = num(input.night_house_end_ms, NaN); +const nightFactorRaw = num(input.night_house_factor, NaN); +const nightComputedMs = num(input.night_house_computed_ms, NaN); +const nightModelActive = + Number.isFinite(nightFactorRaw) && nightFactorRaw > 0 && + Number.isFinite(nightStartMs) && Number.isFinite(nightEndMs) && nightEndMs > nightStartMs && + nowMsNight >= nightStartMs && nowMsNight < nightEndMs && + Number.isFinite(nightComputedMs) && nowMsNight - nightComputedMs <= 30 * 60000; +const nightFactor = nightModelActive + ? Math.min(NIGHT_FACTOR_MAX, Math.max(NIGHT_FACTOR_MIN, nightFactorRaw)) + : 1; +function nightShare(startMs) { + const debut = num(startMs, NaN); + if (!nightModelActive || !Number.isFinite(debut)) return 0; + const a = Math.max(debut, nightStartMs), b = Math.min(debut + 3600000, nightEndMs); + return b > a ? (b - a) / 3600000 : 0; +} +function nightScaled(profile, slot) { + const h = num(slot.hour, 0); + return (profile[h] ?? 0) * (1 + (nightFactor - 1) * nightShare(slot.startMs)); +} + +function buildSlots(consumption, prudent = false) { return horizon.slice(0, CFG.SEARCH_HOURS).map(slot => { const hour = num(slot.hour, 0); const productionKwh = Math.max(0, num(slot.expectedKwh, 0)); - const consumptionKwh = consumption[hour] ?? 0; + const consumptionKwh = prudent + ? Math.max(consumption[hour] ?? 0, nightScaled(consumptionP50, slot)) + : nightScaled(consumption, slot); return { startMs: slot.startMs, hour, @@ -341,7 +387,7 @@ function coverage(slots, hcFloorPct = 0) { } const slotsP50 = buildSlots(consumptionP50); -const slotsP80 = buildSlots(consumptionP80); +const slotsP80 = buildSlots(consumptionP80, true); const hcFloorTypicalPct = hcReachFloorPct(slotsP50, nextHcStartMs); const hcFloorNightPrudentPct = hcNightReachActive ? hcReachFloorPct(slotsP80, nextNightHcStartMs) : 0; const hcFloorPrudentPct = Math.max(hcReachFloorPct(slotsP80, nextHcStartMs), hcFloorNightPrudentPct); @@ -613,6 +659,14 @@ return [{ surplusAvailableKwh: round2(surplusAfterCrossingKwh), }, + // Nuit prevue par la soiree (28/09) : facteur applique au profil. + nightModel: { + active: nightModelActive, + factor: Math.round(nightFactor * 1000) / 1000, + rawFactor: Number.isFinite(nightFactorRaw) ? nightFactorRaw : null, + forecastKwh: num(input.night_house_forecast_kwh, NaN) || null, + }, + // Voiture branchee en HC, depart avant la prochaine HC (28/09). vehicleMidday: { active: vehicleRuleActive, diff --git a/src/energy-balance/BUILD_HA_CALLS.js b/src/energy-balance/BUILD_HA_CALLS.js index da4a780..c44ebca 100644 --- a/src/energy-balance/BUILD_HA_CALLS.js +++ b/src/energy-balance/BUILD_HA_CALLS.js @@ -154,7 +154,11 @@ const hhmm = ms => new Date(ms).toLocaleTimeString('fr-FR', { timeZone: 'Europe/ const vehText = vm.active ? ` Voiture (départ ${hhmm(vm.departureMs)}) : ${vm.reachableKwh} kWh à donner, solaire ${vm.solarKwh} kWh → ${vm.gridNeeded ? 'réseau justifié' : 'solaire suffisant'}.` : ''; -setText('input_text.evcs_balance_reason', reason + hcText + vehText + degradedNote + ndText); +const nm = balance.nightModel || {}; +const nightText = nm.active && Math.abs(nm.factor - 1) >= 0.005 + ? ` Nuit recalée sur la soirée : conso ×${String(nm.factor).replace('.', ',')}${nm.forecastKwh ? ` (${String(nm.forecastKwh).replace('.', ',')} kWh prévus 21:30-09:00)` : ''}.` + : ''; +setText('input_text.evcs_balance_reason', reason + nightText + hcText + vehText + degradedNote + ndText); // Trace longue durée, pour comparer jour après jour le seuil calculé au seuil // fixe encore en vigueur — c'est cette comparaison qui décidera du câblage. diff --git a/src/energy-balance/HA_SNAPSHOT_TEMPLATE.json.tpl b/src/energy-balance/HA_SNAPSHOT_TEMPLATE.json.tpl index d6e505d..63add81 100644 --- a/src/energy-balance/HA_SNAPSHOT_TEMPLATE.json.tpl +++ b/src/energy-balance/HA_SNAPSHOT_TEMPLATE.json.tpl @@ -59,6 +59,11 @@ {%- endif -%} {{ { "ts": now().isoformat(), + "night_house_factor": state_attr("sensor.evcs_nuit_maison_prevue_kwh", "facteur"), + "night_house_start_ms": state_attr("sensor.evcs_nuit_maison_prevue_kwh", "debut_ms"), + "night_house_end_ms": state_attr("sensor.evcs_nuit_maison_prevue_kwh", "fin_ms"), + "night_house_computed_ms": (as_timestamp(state_attr("sensor.evcs_nuit_maison_prevue_kwh", "calcule_a"), 0) * 1000), + "night_house_forecast_kwh": states("sensor.evcs_nuit_maison_prevue_kwh"), "vehicle_connected": is_state("binary_sensor.victron_evcs_vehicle_connected", "on"), "vehicle_departure_ms": (as_timestamp(vh.dep) * 1000) if vh.dep != '' else none, "vehicle_level_pct": states("input_number.victron_evcs_vehicle_level_pct"), diff --git a/src/energy-balance/PREPARE_BALANCE.js b/src/energy-balance/PREPARE_BALANCE.js index 5a9ad1a..097890b 100644 --- a/src/energy-balance/PREPARE_BALANCE.js +++ b/src/energy-balance/PREPARE_BALANCE.js @@ -60,6 +60,12 @@ return [{ // Plancher de charge HC manuelle (18/09) : oublie ici jusqu'au 28/09, le // bouton n'avait donc jamais atteint le bilan. manual_hc_floor_percent: snapshot.manual_hc_floor_percent, + // Nuit de la maison prevue par la soiree (28/09) : facteur sur le profil. + night_house_factor: snapshot.night_house_factor, + night_house_start_ms: snapshot.night_house_start_ms, + night_house_end_ms: snapshot.night_house_end_ms, + night_house_computed_ms: snapshot.night_house_computed_ms, + night_house_forecast_kwh: snapshot.night_house_forecast_kwh, // Vehicule branche a midi (28/09, JEEP/VW/ZERO) : depart du jour et besoin saisi. vehicle_connected: snapshot.vehicle_connected, vehicle_departure_ms: snapshot.vehicle_departure_ms, diff --git a/tests/diagnostics-agent/run.mjs b/tests/diagnostics-agent/run.mjs index bf91a13..1ce2288 100644 --- a/tests/diagnostics-agent/run.mjs +++ b/tests/diagnostics-agent/run.mjs @@ -2,7 +2,7 @@ // Les cas PV reprennent les gammes du banc OpenClaw (HANDOFF §77) : G3, G4, G5, G7. // node tests/diagnostics-agent/run.mjs import fs from 'node:fs'; -import { diagnosticPv, etatCellules, productionJour, chargeSolaire, bilanNuit, etatReseau, importJour, COMPTEURS_IMPORT, COMPTEUR_EDF, MPPT_MODES, ORIGINE_PAR_SOURCE, consoJour, COMPTEURS_CONSO, projectionSoir, previsionNuit, vrmVersCourbes } from '../../tools/diagnostics-agent.mjs'; +import { diagnosticPv, etatCellules, productionJour, chargeSolaire, bilanNuit, etatReseau, importJour, COMPTEURS_IMPORT, COMPTEUR_EDF, MPPT_MODES, ORIGINE_PAR_SOURCE, consoJour, COMPTEURS_CONSO, projectionSoir, previsionNuit, vrmVersCourbes, nuitMaisonPrevue } from '../../tools/diagnostics-agent.mjs'; const LIM = 'voltage_current_limited', ACT = 'mppt_active'; function st({ pv, conso, reseau, soc = 80, courant = 0, edf = 'off', modes }) { @@ -287,6 +287,40 @@ for (const [nom, r, test] of imp) { globalThis.__nConso = (globalThis.__nConso || 0) + pj.length; } + +// --- Nuit de la maison prevue par la soiree : les 24 nuits REELLES (04->27/09) --- +{ + const N = [[4.11,10.29],[6.52,9.81],[4.65,9.9],[3.76,8.66],[3.53,8.55],[3.61,9.48],[3.73,8.7],[7.04,11.88], + [2.96,10.36],[5.73,13.63],[5.39,8.93],[3.12,8.14],[4.35,12.44],[5.08,12.64],[3.74,10],[4.24,10.75], + [4.96,12.18],[5.47,11.43],[6.68,13.85],[9.48,14.95],[7.48,9.56],[4.41,9.72],[3.67,10.04],[4.81,8.87]] + .map(([soiree, nuit]) => ({ soiree, nuit })); + const soir = new Date('2026-09-28T21:00:00Z'); // 23:00 a Paris + const r = nuitMaisonPrevue(N, 6.27, soir); + const cas = [ + // Verifie par un calcul independant (Python) : a 6,979, b 0,7362, r2 0,388, + // moyenne 10,615, prevue 11,595, facteur 1,0924. + ['soir du 28/09 : soiree 6,27 -> nuit prevue 11,6 kWh (a 6,98, b 0,736, r2 0,39)', r, + (x) => x.etat === 11.6 && x.attributs.a === 6.98 && x.attributs.b === 0.736 && x.attributs.r2 === 0.39], + ['facteur = prevue / moyenne des memes nuits (10,62) -> 1,092', r, (x) => x.attributs.facteur === 1.092 && x.attributs.nuit_moyenne_kwh === 10.62], + ['fenetre 21:30 -> 09:00 de la bonne nuit', r, + (x) => x.attributs.debut_ms === Date.parse('2026-09-28T19:30:00Z') && x.attributs.fin_ms === Date.parse('2026-09-29T07:00:00Z')], + ['03:00 : c est la nuit commencee la veille', nuitMaisonPrevue(N, 6.27, new Date('2026-09-29T01:00:00Z')), + (x) => x.etat === 11.6 && x.attributs.debut_ms === Date.parse('2026-09-28T19:30:00Z')], + ['14:00 -> hors_fenetre (pas de soiree complete)', nuitMaisonPrevue(N, 6.27, new Date('2026-09-28T12:00:00Z')), + (x) => x.etat === 'hors_fenetre' && x.attributs.facteur === null], + ['10 nuits seulement -> indisponible', nuitMaisonPrevue(N.slice(0, 10), 6.27, soir), + (x) => x.etat === 'indisponible' && x.attributs.facteur === null], + ['nuage sans relation (r2 < 0,2) -> indisponible', nuitMaisonPrevue(N.map((p, i) => ({ soiree: p.soiree, nuit: 10 + (i % 2) })), 6.27, soir), + (x) => x.etat === 'indisponible'], + ['soiree illisible -> indisponible', nuitMaisonPrevue(N, NaN, soir), (x) => x.etat === 'indisponible'], + ]; + for (const [nom, x, test] of cas) { + const ok = test(x); if (!ok) { ko++; console.log(JSON.stringify(x)); } + console.log(`${ok ? 'OK ' : 'KO '} ${nom}`); + } + globalThis.__nConso = (globalThis.__nConso || 0) + cas.length; +} + // --- Prevision de la nuit : releve reel du 28/09 01:25 ---------------------- { const base = () => new Map(Object.entries({ diff --git a/tests/energy-balance/run.mjs b/tests/energy-balance/run.mjs index 0221adc..2987087 100644 --- a/tests/energy-balance/run.mjs +++ b/tests/energy-balance/run.mjs @@ -106,6 +106,9 @@ const SUMMARIZE = { hcReachActive: o.hcReach ? o.hcReach.active : false, hcFloorPct: o.hcReach ? o.hcReach.floorPct : null, hcNightFloorPrudentPct: o.hcReach ? o.hcReach.nightFloorPrudentPct : null, + // Nuit prevue par la soiree (28/09). + nightModelActive: o.nightModel ? o.nightModel.active : false, + nightFactor: o.nightModel ? o.nightModel.factor : null, // Voiture branchee en HC, depart avant la prochaine HC (28/09). vehicleActive: o.vehicleMidday ? o.vehicleMidday.active : false, vehicleNeedKwh: o.vehicleMidday ? o.vehicleMidday.needKwh : null, diff --git a/tests/energy-balance/scenarios-balance.mjs b/tests/energy-balance/scenarios-balance.mjs index 28a5e3f..7f9c76f 100644 --- a/tests/energy-balance/scenarios-balance.mjs +++ b/tests/energy-balance/scenarios-balance.mjs @@ -512,4 +512,49 @@ export const balanceScenarios = [ snapshot: jeep(snap2709('2026-09-27T12:30:00+02:00', 30, MIDI_2709, SOMBRE), 0, { vehicle_capacity_kwh: 15, vehicle_charger_kw: 0 }), expect: { usable: true, vehicleActive: true, vehicleReachableKwh: 15, vehicleGridNeeded: true }, }, + // ======== LA NUIT PREVUE PAR LA SOIREE (28/09/2026) ======== + // Soir du 28/09 : soiree 6,27 kWh -> facteur 1,092 sur le profil de 21:30 a + // 09:00. Meme snapshot avec et sans facteur : seule la consommation de nuit + // bouge, donc le SOC requis monte. + { + node: 'BALANCE', + id: 'bilan-nuit-soiree-reference-sans-facteur', + label: 'Reference : 23:00, SOC 52, sans facteur de nuit', + snapshot: snap2709('2026-09-27T23:00:00+02:00', 52, null), + expect: { usable: true, nightModelActive: false, nightFactor: 1, energyToCoverKwh: 7.77, requiredSocPct: 47.9 }, + }, + { + node: 'BALANCE', + id: 'bilan-nuit-soiree-chargee-releve-le-requis', + label: 'Soiree chargee (facteur 1,092) : la nuit est plus lourde, le SOC requis monte', + snapshot: Object.assign(snap2709('2026-09-27T23:00:00+02:00', 52, null), { + night_house_factor: 1.092, night_house_forecast_kwh: '11.59', + night_house_start_ms: Date.parse('2026-09-27T21:30:00+02:00'), night_house_end_ms: Date.parse('2026-09-28T09:00:00+02:00'), + night_house_computed_ms: Date.parse('2026-09-27T22:57:00+02:00') }), + // Verifie a la main : profil 23:00->09:00 = 8,0 kWh, x 0,092 = +0,74 (0,71 + // apres l'heure ou le soleil revient) ; +2,4 points = 0,71 / 0,30. Le + // prudent (p80) est deja au-dessus du p50 recale : il ne bouge pas. + expect: { usable: true, nightModelActive: true, nightFactor: 1.092, energyToCoverKwh: 8.48, + requiredSocPct: 50.3, prudentRequiredSocPct: 56.7 }, + }, + { + node: 'BALANCE', + id: 'bilan-nuit-soiree-facteur-perime', + label: 'Facteur calcule il y a 2 h : on ne l applique pas', + snapshot: Object.assign(snap2709('2026-09-27T23:00:00+02:00', 52, null), { + night_house_factor: 1.092, + night_house_start_ms: Date.parse('2026-09-27T21:30:00+02:00'), night_house_end_ms: Date.parse('2026-09-28T09:00:00+02:00'), + night_house_computed_ms: Date.parse('2026-09-27T21:00:00+02:00') }), + expect: { usable: true, nightModelActive: false, nightFactor: 1 }, + }, + { + node: 'BALANCE', + id: 'bilan-nuit-soiree-facteur-aberrant-borne', + label: 'Facteur 3 (mesure fausse) : borne a 1,45, l extreme observe', + snapshot: Object.assign(snap2709('2026-09-27T23:00:00+02:00', 52, null), { + night_house_factor: 3, + night_house_start_ms: Date.parse('2026-09-27T21:30:00+02:00'), night_house_end_ms: Date.parse('2026-09-28T09:00:00+02:00'), + night_house_computed_ms: Date.parse('2026-09-27T22:57:00+02:00') }), + expect: { usable: true, nightModelActive: true, nightFactor: 1.45, requiredSocPct: 59.8, prudentRequiredSocPct: 62.2 }, + }, ]; diff --git a/tools/diagnostics-agent.mjs b/tools/diagnostics-agent.mjs index fd0b3c0..ecb552a 100644 --- a/tools/diagnostics-agent.mjs +++ b/tools/diagnostics-agent.mjs @@ -677,3 +677,70 @@ export function vrmVersCourbes(paires, maintenant = new Date()) { updated: `${p2(d.getDate())}/${p2(d.getMonth() + 1)} ${p2(d.getHours())}:${p2(d.getMinutes())}`, }; } + +// --------------------------------------------------------------------- +// La nuit de la maison, prevue par la soiree (28/09/2026) +// --------------------------------------------------------------------- +// Demande du proprietaire : « si tu superposes 20 nuits tu dois trouver un +// nuage de points ». Sur 24 nuits (04/09 -> 27/09), l'energie consommee par la +// maison de 21:30 a 09:00 (voiture exclue) suit celle de la soiree 18:00-21:30 : +// nuit = 6,98 + 0,74 x soiree, r2 = 0,40. Une soiree chargee (four) annonce une +// nuit chargee ; le profil horaire MOYEN du bilan ne le voit pas. +// +// Ce qui est publie n'est PAS un kWh a substituer au bilan : c'est un FACTEUR, +// nuit prevue / nuit moyenne des MEMES donnees. Le profil du bilan vient des +// compteurs d'energie, la regression des puissances integrees : le rapport +// annule l'ecart de methode, que la difference ne ferait qu'ajouter. +// +// Garde-fous : au moins 15 nuits et r2 >= 0,2, sinon le modele ne vaut rien et +// on le dit (`indisponible`). Hors 21:30 -> 09:00, pas de soiree complete a +// lire : `hors_fenetre`. +export const NUIT_MIN_NUITS = 15; +export const NUIT_MIN_R2 = 0.2; + +function heureLocaleMs(jour, hhmm, reference) { + // Meme calcul de decalage que minuitLocal, pour une heure de Paris donnee. + const decalage = new Date(reference.toLocaleString('en-US', { timeZone: 'Europe/Paris' })) - + new Date(reference.toLocaleString('en-US', { timeZone: 'UTC' })); + return Date.parse(`${jour}T${hhmm}:00Z`) - decalage; +} + +export function nuitMaisonPrevue(nuits, soireeKwh, maintenant = new Date()) { + const jourDe = (d) => new Intl.DateTimeFormat('en-CA', { timeZone: 'Europe/Paris' }).format(d); + const hm = Number(new Intl.DateTimeFormat('en-GB', { timeZone: 'Europe/Paris', hour: '2-digit', minute: '2-digit', hour12: false }) + .format(maintenant).replace(':', '')); + const aujourdhui = jourDe(maintenant); + const hier = jourDe(new Date(maintenant.getTime() - 86400000)); + const demain = jourDe(new Date(maintenant.getTime() + 86400000)); + const fenetre = hm >= 2130 ? { soir: aujourdhui, matin: demain } : hm < 900 ? { soir: hier, matin: aujourdhui } : null; + const pts = (nuits || []).map((x) => [Number(x.soiree), Number(x.nuit)]).filter(([a, b]) => Number.isFinite(a) && Number.isFinite(b)); + const n = pts.length; + let modele = null; + if (n >= 2) { + const mx = pts.reduce((s, p) => s + p[0], 0) / n, my = pts.reduce((s, p) => s + p[1], 0) / n; + let sxy = 0, sxx = 0, syy = 0; + for (const [x, y] of pts) { sxy += (x - mx) * (y - my); sxx += (x - mx) ** 2; syy += (y - my) ** 2; } + if (sxx > 0 && syy > 0) { + const b = sxy / sxx; + modele = { a: my - b * mx, b, r2: (sxy * sxy) / (sxx * syy), moyenne: my }; + } + } + const r2 = (v) => Math.round(v * 100) / 100; + const attributs = { + nuits: n, soiree_kwh: Number.isFinite(soireeKwh) ? r2(soireeKwh) : null, + a: modele ? r2(modele.a) : null, b: modele ? Math.round(modele.b * 1000) / 1000 : null, + r2: modele ? r2(modele.r2) : null, nuit_moyenne_kwh: modele ? r2(modele.moyenne) : null, + debut_ms: fenetre ? heureLocaleMs(fenetre.soir, '21:30', maintenant) : null, + fin_ms: fenetre ? heureLocaleMs(fenetre.matin, '09:00', maintenant) : null, + }; + if (!fenetre) return { etat: 'hors_fenetre', attributs: { ...attributs, facteur: null, statut: 'hors_fenetre' } }; + if (!modele || n < NUIT_MIN_NUITS || modele.r2 < NUIT_MIN_R2 || !Number.isFinite(soireeKwh)) { + return { etat: 'indisponible', attributs: { ...attributs, facteur: null, statut: 'indisponible', + explication: !Number.isFinite(soireeKwh) ? 'soiree illisible' + : n < NUIT_MIN_NUITS ? `seulement ${n} nuits (${NUIT_MIN_NUITS} requises)` + : `modele trop faible (r2 ${modele ? r2(modele.r2) : '?'} < ${NUIT_MIN_R2})` } }; + } + const prevue = modele.a + modele.b * soireeKwh; + return { etat: r2(prevue), attributs: { ...attributs, facteur: Math.round((prevue / modele.moyenne) * 1000) / 1000, + statut: 'ok', explication: `soiree ${r2(soireeKwh)} kWh -> nuit prevue ${r2(prevue)} kWh (moyenne ${r2(modele.moyenne)}, ${n} nuits, r2 ${r2(modele.r2)})` } }; +} diff --git a/tools/gen-grafana-dashboards.py b/tools/gen-grafana-dashboards.py index b308ba8..820fdd1 100644 --- a/tools/gen-grafana-dashboards.py +++ b/tools/gen-grafana-dashboards.py @@ -217,13 +217,17 @@ PREVISION_A_VENIR = """ # changement), plafond 1 h pour qu'un trou n'extrapole rien — meme methode que # le rapport de nuit (§76). « Maison » = consommation AC moins la voiture. # Chiffres verifies contre un calcul independant (script) le 28/09 : identiques. -NUITS_CTE = pathlib.Path(__file__).with_name('grafana-nuits.sql').read_text() +NUITS_CTE = pathlib.Path(__file__).with_name('nuits-maison.sql').read_text() PROFIL_SQL = pathlib.Path(__file__).with_name('grafana-profil-horaire.sql').read_text() def nuits_sql(select): return NUITS_CTE.rstrip() + "\n" + select -CE_SOIR = """, +# UNE seule trame, colonnes X puis Y (mapping automatique : premiere colonne +# numerique = X). La premiere version (4 requetes, mapping manuel par trame) +# affichait « Err » dans Grafana 11.3. La droite est calculee pour CHAQUE nuit : +# elle passe par toutes les abscisses, donc aucun trou ne la coupe. +NUAGE = """, f AS ( SELECT ((CASE WHEN (now() AT TIME ZONE 'Europe/Paris')::time >= time '18:00' THEN (now() AT TIME ZONE 'Europe/Paris')::date @@ -236,17 +240,19 @@ soir AS ( WHEN p.cle = 'sensor.victron_consumption_power_l1' THEN 1 ELSE 0 END) / 3.6e6 AS kwh FROM pts p, f WHERE p.fin > f.s0 AND p.ts < least(f.s0 + interval '3 hours 30 minutes', now()) -) -SELECT round(s.kwh::numeric, 2) AS "Soiree", - round((regr_intercept(maison_nuit_kwh, soiree_kwh) + regr_slope(maison_nuit_kwh, soiree_kwh) * s.kwh)::numeric, 2) AS "Ce soir (prevu)" - FROM nuits, soir s GROUP BY s.kwh""" - -DROITE = """ -SELECT v.x AS "Soiree", round((r.a + r.b * v.x)::numeric, 2) AS "Regression" - FROM (SELECT regr_intercept(maison_nuit_kwh, soiree_kwh) AS a, regr_slope(maison_nuit_kwh, soiree_kwh) AS b, - min(soiree_kwh) AS lo, max(soiree_kwh) AS hi FROM nuits) r, - LATERAL (VALUES (r.lo), (r.hi)) v(x) - ORDER BY 1""" +), +r AS (SELECT regr_intercept(maison_nuit_kwh, soiree_kwh) AS a, regr_slope(maison_nuit_kwh, soiree_kwh) AS b FROM nuits) +SELECT soiree_kwh::float8 AS "Soiree", + CASE WHEN voiture_kwh < 0.5 THEN maison_nuit_kwh END::float8 AS "Nuit sans voiture", + CASE WHEN voiture_kwh >= 0.5 THEN maison_nuit_kwh END::float8 AS "Nuit avec voiture", + round((r.a + r.b * soiree_kwh)::numeric, 2)::float8 AS "Regression", + NULL::float8 AS "Ce soir (prevu)" + FROM nuits, r +UNION ALL +SELECT round(s.kwh::numeric, 2)::float8, NULL, NULL, round((r.a + r.b * s.kwh)::numeric, 2)::float8, + round((r.a + r.b * s.kwh)::numeric, 2)::float8 + FROM soir s, r +ORDER BY 1""" NUIT_XY = {"type": "xychart", "title": "Soiree contre nuit : le nuage des nuits", "description": ("Un point = une nuit. En abscisse ce que la maison a consomme de 18:00 a " @@ -255,13 +261,7 @@ NUIT_XY = {"type": "xychart", "title": "Soiree contre nuit : le nuage des nuits" "est CE SOIR, place sur la droite : une prevision, pas une mesure. " "1 point de SOC = 0,30 kWh sur le parc actuel."), "datasource": PG, "gridPos": {"x": 0, "y": 32, "w": 14, "h": 11}, - "targets": [ - sql("A", nuits_sql('SELECT soiree_kwh AS "Soiree", maison_nuit_kwh AS "Nuit sans voiture" ' - 'FROM nuits WHERE voiture_kwh < 0.5 ORDER BY 1'), fmt="table"), - sql("B", nuits_sql('SELECT soiree_kwh AS "Soiree", maison_nuit_kwh AS "Nuit avec voiture" ' - 'FROM nuits WHERE voiture_kwh >= 0.5 ORDER BY 1'), fmt="table"), - sql("C", nuits_sql(DROITE), fmt="table"), - sql("D", NUITS_CTE.rstrip() + CE_SOIR, fmt="table")], + "targets": [sql("A", NUITS_CTE.rstrip() + NUAGE, fmt="table")], "fieldConfig": {"defaults": {"unit": "kwatth", "color": {"mode": "palette-classic"}, "custom": {"show": "points", "pointSize": {"fixed": 9}, "pointShape": "circle", "lineWidth": 2, "fillOpacity": 60}}, @@ -276,12 +276,7 @@ NUIT_XY = {"type": "xychart", "title": "Soiree contre nuit : le nuage des nuits" {"matcher": {"id": "byName", "options": "Ce soir (prevu)"}, "properties": [{"id": "color", "value": {"mode": "fixed", "fixedColor": "red"}}, {"id": "custom.pointSize", "value": {"fixed": 18}}]}]}, - "options": {"mapping": "manual", - "series": [{"frame": {"matcher": {"id": "byIndex", "options": i}}, - "x": {"matcher": {"id": "byName", "options": "Soiree"}}, - "y": {"matcher": {"id": "byName", "options": n}}} - for i, n in enumerate(["Nuit sans voiture", "Nuit avec voiture", - "Regression", "Ce soir (prevu)"])], + "options": {"mapping": "auto", "series": [{}], "legend": {"showLegend": True, "displayMode": "list", "placement": "bottom"}, "tooltip": {"mode": "single", "sort": "none"}}} diff --git a/tools/historise-evcs.mjs b/tools/historise-evcs.mjs index 23cba7b..d1cfce0 100644 --- a/tools/historise-evcs.mjs +++ b/tools/historise-evcs.mjs @@ -30,7 +30,7 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; import pg from 'pg'; import { texteRapport, envoyer, donneesNuit, texteDepuis } from './rapport-nuit.mjs'; -import { diagnosticPv, etatCellules, productionJour, chargeSolaire, bilanNuit, etatReseau, importJour, minuitLocal, COMPTEURS_IMPORT, COMPTEUR_EDF, consoJour, COMPTEURS_CONSO, projectionSoir, previsionNuit, vrmVersCourbes } from './diagnostics-agent.mjs'; +import { diagnosticPv, etatCellules, productionJour, chargeSolaire, bilanNuit, etatReseau, importJour, minuitLocal, COMPTEURS_IMPORT, COMPTEUR_EDF, consoJour, COMPTEURS_CONSO, projectionSoir, previsionNuit, vrmVersCourbes, nuitMaisonPrevue } from './diagnostics-agent.mjs'; const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const ONCE = process.argv.includes('--once'); @@ -1994,6 +1994,43 @@ async function publierPrevisionVrm(sql) { return n; } +// La nuit de la maison prevue par la soiree (28/09/2026). Les nuits closes +// viennent de la MEME requete que le tableau Grafana « La nuit » +// (tools/nuits-maison.sql) : un seul calcul, deux lecteurs. Le bilan lit le +// FACTEUR (nuit prevue / nuit moyenne) pour recaler son profil de 21:30 a 09:00. +const NUITS_SQL = fs.readFileSync(path.join(path.dirname(fileURLToPath(import.meta.url)), 'nuits-maison.sql'), 'utf8'); +const SOIREE_SQL = ` +WITH pts AS ( + SELECT cle, ts, valeur, + least(coalesce(lead(ts) OVER (PARTITION BY cle ORDER BY ts), now()), ts + interval '1 hour') AS fin + FROM mesure WHERE cle IN ('sensor.victron_consumption_power_l1', 'sensor.victron_evcs_ac_power') + AND ts > to_timestamp($1 / 1000.0) - interval '2 hours' AND ts < to_timestamp($2 / 1000.0) +) +SELECT sum(valeur * extract(epoch FROM least(fin, to_timestamp($2 / 1000.0)) - greatest(ts, to_timestamp($1 / 1000.0))) + * CASE WHEN cle = 'sensor.victron_evcs_ac_power' THEN -1 ELSE 1 END) / 3.6e6 AS kwh, + count(*) FILTER (WHERE cle = 'sensor.victron_consumption_power_l1') AS points + FROM pts WHERE fin > to_timestamp($1 / 1000.0)`; +let nuitMaisonPubliee = false; +async function publierNuitMaison(client) { + if (DRY || !client) return 0; + const maintenant = new Date(); + const cadre = nuitMaisonPrevue([], NaN, maintenant); + let res = cadre; + if (cadre.etat !== 'hors_fenetre') { + const nuits = (await client.query(NUITS_SQL + '\nSELECT soiree_kwh AS soiree, maison_nuit_kwh AS nuit FROM nuits')).rows; + const debut = cadre.attributs.debut_ms; + const soir = (await client.query(SOIREE_SQL, [debut - 3.5 * 3600000, debut])).rows[0]; + // Une soiree sans mesure (service arrete) ne vaut pas zero kWh. + const soiree = soir && Number(soir.points) > 0 ? Number(soir.kwh) : NaN; + res = nuitMaisonPrevue(nuits, soiree, maintenant); + } + await publierCapteur('sensor.evcs_nuit_maison_prevue_kwh', res.etat, { + friendly_name: 'EVCS — nuit de la maison prevue par la soiree', icon: 'mdi:chart-scatter-plot', + unit_of_measurement: typeof res.etat === 'number' ? 'kWh' : undefined, ...res.attributs, + calcule_a: maintenant.toISOString(), + }); + return 1; +} let bilanNuitPublie = false; // publie des le premier cycle, puis toutes les 5 min async function publierBilanNuit(client) { if (DRY || !client) return 0; @@ -2095,6 +2132,9 @@ async function cycle(client) { vrm: (DRY || (vrmPublie && new Date().getMinutes() % 15 !== 7)) ? 0 : await publierPrevisionVrm(sql).then((n) => { vrmPublie = true; return n; }).catch((e) => { console.error(`prevision VRM : ${e.message}`); return 0; }), + nuitMaison: (DRY || (nuitMaisonPubliee && new Date().getMinutes() % 5 !== 2)) ? 0 : await publierNuitMaison(client).then((n) => { nuitMaisonPubliee = true; return n; }).catch((e) => { + console.error(`nuit de la maison : ${e.message}`); return 0; + }), bilanNuit: (DRY || (bilanNuitPublie && new Date().getMinutes() % 5 !== 1)) ? 0 : await publierBilanNuit(client).then((n) => { bilanNuitPublie = true; return n; }).catch((e) => { console.error(`bilan de nuit : ${e.message}`); return 0; }), diff --git a/tools/grafana-nuits.sql b/tools/nuits-maison.sql similarity index 100% rename from tools/grafana-nuits.sql rename to tools/nuits-maison.sql diff --git a/workflows/evcs-energy-balance-v1.json b/workflows/evcs-energy-balance-v1.json index b0d3f72..d3490dd 100644 --- a/workflows/evcs-energy-balance-v1.json +++ b/workflows/evcs-energy-balance-v1.json @@ -51,7 +51,7 @@ "parameters": [ { "name": "template", - "value": "{#- Fenetres d'heures creuses (28/09/2026). Le calendrier tarifaire reste dans\n Home Assistant (helpers victron_evcs_hc_*) ; le bilan ne recoit que deux\n instants : la fin de la fenetre HC en cours (vide hors fenetre) et le debut\n de la fenetre HC suivante. Strategie du proprietaire : la batterie doit\n tenir jusqu'a la prochaine fenetre HC. -#}\n{%- set ns = namespace(fin='', suivant='', nuit='', fen=[]) -%}\n{%- for j in [0, 1, 2] -%}\n {%- for a, b, t in [('victron_evcs_hc_night_start', 'victron_evcs_hc_night_stop', 'nuit'), ('victron_evcs_hc_midday_start', 'victron_evcs_hc_midday_stop', 'midi')] -%}\n {%- set d = today_at(states('input_datetime.' ~ a)) + timedelta(days=j) -%}\n {%- set f = today_at(states('input_datetime.' ~ b)) + timedelta(days=j) -%}\n {%- set f = f + timedelta(days=1) if f <= d else f -%}\n {%- set ns.fen = ns.fen + [[d, f, t]] -%}\n {%- endfor -%}\n{%- endfor -%}\n{%- for w in ns.fen | sort(attribute='0') -%}\n {%- if w[0] <= now() < w[1] and ns.fin == '' -%}{%- set ns.fin = w[1] -%}{%- endif -%}\n{%- endfor -%}\n{%- set repere = ns.fin if ns.fin != '' else now() -%}\n{%- for w in ns.fen | sort(attribute='0') -%}\n {%- if w[0] >= repere and ns.suivant == '' -%}{%- set ns.suivant = w[0] -%}{%- endif -%}\n {%- if w[0] >= repere and w[2] == 'nuit' and ns.nuit == '' -%}{%- set ns.nuit = w[0] -%}{%- endif -%}\n{%- endfor -%}\n{#- Vehicule branche a midi (28/09/2026) — JEEP, VW ou ZERO, selon le\n selecteur. Pour chacun, HA porte le calendrier (jours et heure de depart\n apres le dejeuner), la capacite et la puissance de charge MESUREES ; le\n bilan recoit un instant de depart, vide si le vehicule ne repart pas\n aujourd'hui. Le niveau saisi n'est retenu que s'il date de ce branchement\n (tolerance 1 h avant). Capacite ou puissance a 0 = inconnue. -#}\n{%- set vh = namespace(dep='', valide=false, cap=none, kw=none) -%}\n{%- set jours = ['lun', 'mar', 'mer', 'jeu', 'ven', 'sam', 'dim'] -%}\n{%- set cle = {'JEEP': 'jeep', 'VW': 'vw', 'ZERO': 'zero'}.get(states('input_select.victron_evcs_vehicle')) -%}\n{%- if cle -%}\n {%- set vh.cap = states('input_number.victron_evcs_capacity_' ~ cle ~ '_kwh') -%}\n {%- set vh.kw = states('input_number.victron_evcs_charger_kw_' ~ cle) -%}\n {%- set heure = states('input_datetime.victron_evcs_' ~ cle ~ '_midday_departure') -%}\n {%- if jours[now().weekday()] in states('input_text.victron_evcs_' ~ cle ~ '_midday_departure_days') | lower\n and heure not in ['unknown', 'unavailable', ''] -%}\n {%- set d = today_at(heure) -%}\n {%- if d > now() -%}{%- set vh.dep = d -%}{%- endif -%}\n {%- endif -%}\n{%- endif -%}\n{%- set branche = states.binary_sensor.victron_evcs_vehicle_connected -%}\n{%- set niveau = states.input_number.victron_evcs_vehicle_level_pct -%}\n{#- Depart ponctuel (VW, ZERO : pas de jours fixes) : pour aujourd'hui, a venir,\n saisi depuis ce branchement. Le plus proche des deux departs l'emporte. -#}\n{%- set ponctuel = states.input_datetime.victron_evcs_departure_ponctuel -%}\n{%- if branche is not none and ponctuel is not none and branche.state == 'on'\n and ponctuel.state not in ['unknown', 'unavailable', ''] -%}\n {%- set p = as_datetime(ponctuel.attributes.timestamp) | as_local -%}\n {%- if p.date() == now().date() and p > now()\n and as_timestamp(ponctuel.last_changed) >= as_timestamp(branche.last_changed) - 3600 -%}\n {%- set vh.dep = p if vh.dep == '' or p < vh.dep else vh.dep -%}\n {%- endif -%}\n{%- endif -%}\n{%- if branche is not none and niveau is not none and branche.state == 'on' -%}\n {#- Date de SAISIE ecrite par l'automatisation, pas last_changed : une saisie\n identique ne change pas l'etat du helper mais compte comme une saisie. -#}\n {%- set vh.valide = (as_timestamp(states('input_datetime.victron_evcs_vehicle_level_at'), 0)) >= as_timestamp(branche.last_changed) - 3600 -%}\n{%- endif -%}\n{{ {\n \"ts\": now().isoformat(),\n \"vehicle_connected\": is_state(\"binary_sensor.victron_evcs_vehicle_connected\", \"on\"),\n \"vehicle_departure_ms\": (as_timestamp(vh.dep) * 1000) if vh.dep != '' else none,\n \"vehicle_level_pct\": states(\"input_number.victron_evcs_vehicle_level_pct\"),\n \"vehicle_level_valid\": vh.valide,\n \"vehicle_capacity_kwh\": vh.cap,\n \"vehicle_charger_kw\": vh.kw,\n \"vehicle_delivered_since_level_kwh\": ((states(\"sensor.evcs_hq2239dnt7p_total_energy\") | float(0)) - (states(\"input_number.victron_evcs_vehicle_level_ref_kwh\") | float(0))) if states(\"sensor.evcs_hq2239dnt7p_total_energy\") | is_number else none,\n \"hc_window_end_ms\": (as_timestamp(ns.fin) * 1000) if ns.fin != '' else none,\n \"next_hc_start_ms\": (as_timestamp(ns.suivant) * 1000) if ns.suivant != '' else none,\n \"next_night_hc_start_ms\": (as_timestamp(ns.nuit) * 1000) if ns.nuit != '' else none,\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_source\": states(\"input_select.evcs_balance_forecast_source\"),\n \"forecast_day0\": states(\"input_text.evcs_balance_vrm_day0\") if is_state(\"input_select.evcs_balance_forecast_source\", \"vrm\") else states(\"input_text.evcs_balance_forecast_day0\"),\n \"forecast_today_csv\": states(\"input_text.evcs_balance_vrm_today\") if is_state(\"input_select.evcs_balance_forecast_source\", \"vrm\") else states(\"input_text.evcs_balance_forecast_today\"),\n \"forecast_tomorrow_csv\": states(\"input_text.evcs_balance_vrm_tomorrow\") if is_state(\"input_select.evcs_balance_forecast_source\", \"vrm\") else states(\"input_text.evcs_balance_forecast_tomorrow\"),\n \"forecast_updated\": states(\"input_text.evcs_balance_vrm_updated\") if is_state(\"input_select.evcs_balance_forecast_source\", \"vrm\") else states(\"input_text.evcs_balance_forecast_updated\"),\n \"grid_soc_off\": states(\"input_number.victron_grid_soc_off\"),\n\n \"manual_hc_floor_percent\": states(\"input_number.evcs_battery_manual_hc_floor\"),\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": "{#- Fenetres d'heures creuses (28/09/2026). Le calendrier tarifaire reste dans\n Home Assistant (helpers victron_evcs_hc_*) ; le bilan ne recoit que deux\n instants : la fin de la fenetre HC en cours (vide hors fenetre) et le debut\n de la fenetre HC suivante. Strategie du proprietaire : la batterie doit\n tenir jusqu'a la prochaine fenetre HC. -#}\n{%- set ns = namespace(fin='', suivant='', nuit='', fen=[]) -%}\n{%- for j in [0, 1, 2] -%}\n {%- for a, b, t in [('victron_evcs_hc_night_start', 'victron_evcs_hc_night_stop', 'nuit'), ('victron_evcs_hc_midday_start', 'victron_evcs_hc_midday_stop', 'midi')] -%}\n {%- set d = today_at(states('input_datetime.' ~ a)) + timedelta(days=j) -%}\n {%- set f = today_at(states('input_datetime.' ~ b)) + timedelta(days=j) -%}\n {%- set f = f + timedelta(days=1) if f <= d else f -%}\n {%- set ns.fen = ns.fen + [[d, f, t]] -%}\n {%- endfor -%}\n{%- endfor -%}\n{%- for w in ns.fen | sort(attribute='0') -%}\n {%- if w[0] <= now() < w[1] and ns.fin == '' -%}{%- set ns.fin = w[1] -%}{%- endif -%}\n{%- endfor -%}\n{%- set repere = ns.fin if ns.fin != '' else now() -%}\n{%- for w in ns.fen | sort(attribute='0') -%}\n {%- if w[0] >= repere and ns.suivant == '' -%}{%- set ns.suivant = w[0] -%}{%- endif -%}\n {%- if w[0] >= repere and w[2] == 'nuit' and ns.nuit == '' -%}{%- set ns.nuit = w[0] -%}{%- endif -%}\n{%- endfor -%}\n{#- Vehicule branche a midi (28/09/2026) — JEEP, VW ou ZERO, selon le\n selecteur. Pour chacun, HA porte le calendrier (jours et heure de depart\n apres le dejeuner), la capacite et la puissance de charge MESUREES ; le\n bilan recoit un instant de depart, vide si le vehicule ne repart pas\n aujourd'hui. Le niveau saisi n'est retenu que s'il date de ce branchement\n (tolerance 1 h avant). Capacite ou puissance a 0 = inconnue. -#}\n{%- set vh = namespace(dep='', valide=false, cap=none, kw=none) -%}\n{%- set jours = ['lun', 'mar', 'mer', 'jeu', 'ven', 'sam', 'dim'] -%}\n{%- set cle = {'JEEP': 'jeep', 'VW': 'vw', 'ZERO': 'zero'}.get(states('input_select.victron_evcs_vehicle')) -%}\n{%- if cle -%}\n {%- set vh.cap = states('input_number.victron_evcs_capacity_' ~ cle ~ '_kwh') -%}\n {%- set vh.kw = states('input_number.victron_evcs_charger_kw_' ~ cle) -%}\n {%- set heure = states('input_datetime.victron_evcs_' ~ cle ~ '_midday_departure') -%}\n {%- if jours[now().weekday()] in states('input_text.victron_evcs_' ~ cle ~ '_midday_departure_days') | lower\n and heure not in ['unknown', 'unavailable', ''] -%}\n {%- set d = today_at(heure) -%}\n {%- if d > now() -%}{%- set vh.dep = d -%}{%- endif -%}\n {%- endif -%}\n{%- endif -%}\n{%- set branche = states.binary_sensor.victron_evcs_vehicle_connected -%}\n{%- set niveau = states.input_number.victron_evcs_vehicle_level_pct -%}\n{#- Depart ponctuel (VW, ZERO : pas de jours fixes) : pour aujourd'hui, a venir,\n saisi depuis ce branchement. Le plus proche des deux departs l'emporte. -#}\n{%- set ponctuel = states.input_datetime.victron_evcs_departure_ponctuel -%}\n{%- if branche is not none and ponctuel is not none and branche.state == 'on'\n and ponctuel.state not in ['unknown', 'unavailable', ''] -%}\n {%- set p = as_datetime(ponctuel.attributes.timestamp) | as_local -%}\n {%- if p.date() == now().date() and p > now()\n and as_timestamp(ponctuel.last_changed) >= as_timestamp(branche.last_changed) - 3600 -%}\n {%- set vh.dep = p if vh.dep == '' or p < vh.dep else vh.dep -%}\n {%- endif -%}\n{%- endif -%}\n{%- if branche is not none and niveau is not none and branche.state == 'on' -%}\n {#- Date de SAISIE ecrite par l'automatisation, pas last_changed : une saisie\n identique ne change pas l'etat du helper mais compte comme une saisie. -#}\n {%- set vh.valide = (as_timestamp(states('input_datetime.victron_evcs_vehicle_level_at'), 0)) >= as_timestamp(branche.last_changed) - 3600 -%}\n{%- endif -%}\n{{ {\n \"ts\": now().isoformat(),\n \"night_house_factor\": state_attr(\"sensor.evcs_nuit_maison_prevue_kwh\", \"facteur\"),\n \"night_house_start_ms\": state_attr(\"sensor.evcs_nuit_maison_prevue_kwh\", \"debut_ms\"),\n \"night_house_end_ms\": state_attr(\"sensor.evcs_nuit_maison_prevue_kwh\", \"fin_ms\"),\n \"night_house_computed_ms\": (as_timestamp(state_attr(\"sensor.evcs_nuit_maison_prevue_kwh\", \"calcule_a\"), 0) * 1000),\n \"night_house_forecast_kwh\": states(\"sensor.evcs_nuit_maison_prevue_kwh\"),\n \"vehicle_connected\": is_state(\"binary_sensor.victron_evcs_vehicle_connected\", \"on\"),\n \"vehicle_departure_ms\": (as_timestamp(vh.dep) * 1000) if vh.dep != '' else none,\n \"vehicle_level_pct\": states(\"input_number.victron_evcs_vehicle_level_pct\"),\n \"vehicle_level_valid\": vh.valide,\n \"vehicle_capacity_kwh\": vh.cap,\n \"vehicle_charger_kw\": vh.kw,\n \"vehicle_delivered_since_level_kwh\": ((states(\"sensor.evcs_hq2239dnt7p_total_energy\") | float(0)) - (states(\"input_number.victron_evcs_vehicle_level_ref_kwh\") | float(0))) if states(\"sensor.evcs_hq2239dnt7p_total_energy\") | is_number else none,\n \"hc_window_end_ms\": (as_timestamp(ns.fin) * 1000) if ns.fin != '' else none,\n \"next_hc_start_ms\": (as_timestamp(ns.suivant) * 1000) if ns.suivant != '' else none,\n \"next_night_hc_start_ms\": (as_timestamp(ns.nuit) * 1000) if ns.nuit != '' else none,\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_source\": states(\"input_select.evcs_balance_forecast_source\"),\n \"forecast_day0\": states(\"input_text.evcs_balance_vrm_day0\") if is_state(\"input_select.evcs_balance_forecast_source\", \"vrm\") else states(\"input_text.evcs_balance_forecast_day0\"),\n \"forecast_today_csv\": states(\"input_text.evcs_balance_vrm_today\") if is_state(\"input_select.evcs_balance_forecast_source\", \"vrm\") else states(\"input_text.evcs_balance_forecast_today\"),\n \"forecast_tomorrow_csv\": states(\"input_text.evcs_balance_vrm_tomorrow\") if is_state(\"input_select.evcs_balance_forecast_source\", \"vrm\") else states(\"input_text.evcs_balance_forecast_tomorrow\"),\n \"forecast_updated\": states(\"input_text.evcs_balance_vrm_updated\") if is_state(\"input_select.evcs_balance_forecast_source\", \"vrm\") else states(\"input_text.evcs_balance_forecast_updated\"),\n \"grid_soc_off\": states(\"input_number.victron_grid_soc_off\"),\n\n \"manual_hc_floor_percent\": states(\"input_number.evcs_battery_manual_hc_floor\"),\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" } ] }, @@ -99,7 +99,7 @@ 120 ], "parameters": { - "jsCode": "// EVCS Energy Balance — PREPARE_BALANCE\n// =====================================================================\n// Colle de câblage n8n, et rien d'autre. Les nœuds de calcul (CENSORSHIP,\n// RESOURCE_ESTIMATE, BALANCE) sont volontairement écrits pour ne lire que\n// `$input`, afin de rester testables hors de n8n. Ce nœud rassemble donc ce\n// que BALANCE attend, en puisant dans les nœuds précédents.\n//\n// Il fait aussi la seule conversion nécessaire : les profils horaires vivent\n// dans des helpers `input_text` sous forme de CSV compact (24 valeurs, moins\n// de 255 caractères), il faut les redonner sous forme de tableaux.\n// =====================================================================\n\nfunction parseCsv24(raw) {\n if (typeof raw !== 'string') return null;\n const parts = raw.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\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 snapshot = unwrapSnapshot($('HA Snapshot Balance').first().json);\nconst resource = $input.first().json;\n\nreturn [{\n json: {\n nowMs: Number.parseFloat(snapshot.nowMs),\n\n consumption_p50: parseCsv24(snapshot.consumption_p50_csv),\n consumption_p80: parseCsv24(snapshot.consumption_p80_csv),\n\n horizon: resource.horizon || [],\n\n soc_percent: snapshot.soc_percent,\n usable_capacity_kwh: snapshot.usable_capacity_kwh,\n modules_en_service: snapshot.modules_en_service,\n // Plancher dur : même valeur de repli que INPUT_PARSE.js\n // (evcs_night_battery_hard_floor_pct n'est aujourd'hui exposé par AUCUN\n // helper — la valeur « 22 % » documentée partout est en réalité un défaut\n // de code, cf. backlog du chantier).\n soc_floor_percent: 22,\n\n // Vigilance Météo-France, lue sans connaître la commune (l'entité\n // s'appelle sensor._weather_alert). Absente tant que\n // l'intégration n'est pas installée : le bilan publie alors\n // 'indisponible' et ne change rien.\n storm_alert_level: snapshot.storm_alert_level,\n storm_alert_detail: snapshot.storm_alert_detail,\n\n // Fenetres HC (28/09, « tenir jusqu'a la prochaine HC ») : horodatages\n // calcules par le gabarit depuis les helpers victron_evcs_hc_*. Sans cette\n // recopie, BALANCE.js ne les voit pas et la regle reste muette.\n // Plancher de charge HC manuelle (18/09) : oublie ici jusqu'au 28/09, le\n // bouton n'avait donc jamais atteint le bilan.\n manual_hc_floor_percent: snapshot.manual_hc_floor_percent,\n // Vehicule branche a midi (28/09, JEEP/VW/ZERO) : depart du jour et besoin saisi.\n vehicle_connected: snapshot.vehicle_connected,\n vehicle_departure_ms: snapshot.vehicle_departure_ms,\n vehicle_level_pct: snapshot.vehicle_level_pct,\n vehicle_level_valid: snapshot.vehicle_level_valid,\n vehicle_capacity_kwh: snapshot.vehicle_capacity_kwh,\n vehicle_charger_kw: snapshot.vehicle_charger_kw,\n vehicle_delivered_since_level_kwh: snapshot.vehicle_delivered_since_level_kwh,\n hc_window_end_ms: snapshot.hc_window_end_ms,\n next_hc_start_ms: snapshot.next_hc_start_ms,\n next_night_hc_start_ms: snapshot.next_night_hc_start_ms,\n\n // Repris pour l'écriture des helpers, sans influencer le calcul.\n resourceUsable: resource.usable,\n shapeConfidence: resource.source ? resource.source.shapeConfidence : null,\n },\n}];\n" + "jsCode": "// EVCS Energy Balance — PREPARE_BALANCE\n// =====================================================================\n// Colle de câblage n8n, et rien d'autre. Les nœuds de calcul (CENSORSHIP,\n// RESOURCE_ESTIMATE, BALANCE) sont volontairement écrits pour ne lire que\n// `$input`, afin de rester testables hors de n8n. Ce nœud rassemble donc ce\n// que BALANCE attend, en puisant dans les nœuds précédents.\n//\n// Il fait aussi la seule conversion nécessaire : les profils horaires vivent\n// dans des helpers `input_text` sous forme de CSV compact (24 valeurs, moins\n// de 255 caractères), il faut les redonner sous forme de tableaux.\n// =====================================================================\n\nfunction parseCsv24(raw) {\n if (typeof raw !== 'string') return null;\n const parts = raw.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\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 snapshot = unwrapSnapshot($('HA Snapshot Balance').first().json);\nconst resource = $input.first().json;\n\nreturn [{\n json: {\n nowMs: Number.parseFloat(snapshot.nowMs),\n\n consumption_p50: parseCsv24(snapshot.consumption_p50_csv),\n consumption_p80: parseCsv24(snapshot.consumption_p80_csv),\n\n horizon: resource.horizon || [],\n\n soc_percent: snapshot.soc_percent,\n usable_capacity_kwh: snapshot.usable_capacity_kwh,\n modules_en_service: snapshot.modules_en_service,\n // Plancher dur : même valeur de repli que INPUT_PARSE.js\n // (evcs_night_battery_hard_floor_pct n'est aujourd'hui exposé par AUCUN\n // helper — la valeur « 22 % » documentée partout est en réalité un défaut\n // de code, cf. backlog du chantier).\n soc_floor_percent: 22,\n\n // Vigilance Météo-France, lue sans connaître la commune (l'entité\n // s'appelle sensor._weather_alert). Absente tant que\n // l'intégration n'est pas installée : le bilan publie alors\n // 'indisponible' et ne change rien.\n storm_alert_level: snapshot.storm_alert_level,\n storm_alert_detail: snapshot.storm_alert_detail,\n\n // Fenetres HC (28/09, « tenir jusqu'a la prochaine HC ») : horodatages\n // calcules par le gabarit depuis les helpers victron_evcs_hc_*. Sans cette\n // recopie, BALANCE.js ne les voit pas et la regle reste muette.\n // Plancher de charge HC manuelle (18/09) : oublie ici jusqu'au 28/09, le\n // bouton n'avait donc jamais atteint le bilan.\n manual_hc_floor_percent: snapshot.manual_hc_floor_percent,\n // Nuit de la maison prevue par la soiree (28/09) : facteur sur le profil.\n night_house_factor: snapshot.night_house_factor,\n night_house_start_ms: snapshot.night_house_start_ms,\n night_house_end_ms: snapshot.night_house_end_ms,\n night_house_computed_ms: snapshot.night_house_computed_ms,\n night_house_forecast_kwh: snapshot.night_house_forecast_kwh,\n // Vehicule branche a midi (28/09, JEEP/VW/ZERO) : depart du jour et besoin saisi.\n vehicle_connected: snapshot.vehicle_connected,\n vehicle_departure_ms: snapshot.vehicle_departure_ms,\n vehicle_level_pct: snapshot.vehicle_level_pct,\n vehicle_level_valid: snapshot.vehicle_level_valid,\n vehicle_capacity_kwh: snapshot.vehicle_capacity_kwh,\n vehicle_charger_kw: snapshot.vehicle_charger_kw,\n vehicle_delivered_since_level_kwh: snapshot.vehicle_delivered_since_level_kwh,\n hc_window_end_ms: snapshot.hc_window_end_ms,\n next_hc_start_ms: snapshot.next_hc_start_ms,\n next_night_hc_start_ms: snapshot.next_night_hc_start_ms,\n\n // Repris pour l'écriture des helpers, sans influencer le calcul.\n resourceUsable: resource.usable,\n shapeConfidence: resource.source ? resource.source.shapeConfidence : null,\n },\n}];\n" } }, { @@ -111,7 +111,7 @@ 120 ], "parameters": { - "jsCode": "// EVCS Energy Balance — BALANCE\n// =====================================================================\n// Rôle : produire les FAITS qui remplacent les seuils fixes. Rien d'autre.\n// Ce nœud ne pilote ni EDF, ni la borne : il publie des grandeurs que les deux\n// contrôleurs liront à la place de leurs constantes.\n//\n// La question à laquelle il répond n'est pas « le SOC est-il au-dessus de\n// 30 % ? » mais « la batterie contient-elle de quoi tenir jusqu'à ce que le\n// solaire prenne le relais ? ». Le 24/08/2026 à 06:43, la réponse du seuil fixe\n// et celle du bilan diffèrent : la production ne couvrait la maison qu'à partir\n// de 09 h, il fallait donc environ 0,8 kWh de réserve, soit ~5 points de SOC —\n// et non un palier arbitraire à 30 %.\n//\n// Entrées (toutes fournies, aucune constante physique en dur) :\n// consumption_p50[24] profil horaire mesuré, valeur typique (CONSUMPTION_PROFILE)\n// consumption_p80[24] idem, valeur prudente\n// horizon[] production attendue heure par heure (RESOURCE_ESTIMATE)\n// soc_percent SOC lissé pour le pilotage\n// usable_capacity_kwh capacité réellement disponible du parc batterie\n// soc_floor_percent plancher dur de sécurité\n//\n// Sorties : heure de croisement · énergie à couvrir · SOC requis · marge\n// disponible pour le véhicule · niveau de confiance.\n// =====================================================================\n\nfunction num(v, fallback = NaN) {\n const n = Number.parseFloat(v);\n return Number.isFinite(n) ? n : fallback;\n}\n\nfunction round1(v) {\n return Number.isFinite(v) ? Math.round(v * 10) / 10 : null;\n}\n\nfunction round2(v) {\n return Number.isFinite(v) ? Math.round(v * 100) / 100 : null;\n}\n\nconst input = $input.first().json;\n\nconst CFG = {\n TIMEZONE: 'Europe/Paris',\n // Au-delà de cet horizon sans croisement trouvé, on cesse de chercher : la\n // réponse n'aurait plus de sens opérationnel.\n SEARCH_HOURS: 40,\n // PAS de marge de sécurité supplémentaire. La prudence est déjà portée,\n // entièrement, par le choix du percentile du profil de consommation (p80),\n // qui est mesuré. Vérifié le 24/08/2026 : sur toutes les fenêtres testées,\n // p80(somme des heures) est égal a somme(p80 par heure) — les heures de\n // consommation sont fortement corrélées, une matinée chargée l'étant sur\n // plusieurs heures d'affilée. Sommer les p80 ne cumule donc PAS deux\n // pessimismes, et ajouter un coefficient par-dessus ne serait qu'une\n // constante arbitraire de plus.\n};\n\nfunction profileOf(key) {\n const a = input[key];\n return Array.isArray(a) && a.length === 24 ? a.map(v => Math.max(0, num(v, 0))) : null;\n}\nconst consumptionP50 = profileOf('consumption_p50');\nconst consumptionP80 = profileOf('consumption_p80');\n\nconst horizon = Array.isArray(input.horizon) ? input.horizon : null;\n\nconst socPct = num(input.soc_percent, NaN);\nconst usableCapacityKwh = num(input.usable_capacity_kwh, NaN);\nconst socFloorPct = num(input.soc_floor_percent, 22);\n\n// =====================================================\n// CHARGE HC MANUELLE — UN PLANCHER, PAS UNE CIBLE (18/09/2026)\n// =====================================================\n// Le proprietaire veut pouvoir demander une recharge batterie en heures creuses\n// quand le bilan ne l'a pas jugee necessaire — journee d'hiver courte, ciel\n// couvert. La decision qu'il a tranchee : CONTRAINDRE le bilan plutot que de\n// l'ignorer, pour garder un seul arbitre. Ce projet a deja paye cher les\n// automates concurrents (charges programmees de l'ESS §26, consigne a 4200 W\n// §27, plancher SOC du GX §27).\n//\n// ⚠️ LE FORCAGE DOIT ETRE ICI, EN AMONT, ET NULLE PART AILLEURS. Mesure du\n// 18/09 sur banc : ecrire directement dans `input_number.evcs_balance_required_soc`\n// engage bien la session — contacteur ferme, takeover ON — mais laisse la\n// consigne reseau a 700 W, donc la batterie recoit une centaine de watts. C'est\n// le defaut du 01/09 et du 04/09 a l'identique, et c'est le seul resultat des\n// trois qui soit INDIAGNOSTICABLE depuis le tableau de bord : il a l'air de\n// marcher. La raison : `required_soc` ENGAGE la session, `grid_energy_needed_kwh`\n// la FINANCE, et les deux sont publiees separement. Pose ici, dans `coverage()`,\n// le plancher fait bouger les deux ENSEMBLE par construction — c'est une\n// consequence de la forme du code et non une discipline a tenir.\n//\n// ⚠️ ET CE PLANCHER EST DEJA FILTRE PAR L'HEURE QUAND IL ARRIVE ICI. Le bilan\n// ignore totalement les fenetres d'heures creuses (aucune occurrence de\n// `hc_night` / `hc_midday` dans ce sous-systeme) et doit le rester : il repond a\n// « de combien la batterie a-t-elle besoin », pas a « quand est-ce bon marche ».\n// C'est Home Assistant qui detient le calendrier tarifaire et qui ne publie une\n// valeur non nulle que pendant une fenetre creuse. Sans ce filtrage en amont, un\n// plancher a 85 % fuirait dans la journee et mordrait sur\n// `BATTERY_CLOUD_LOCKS.js` — `eveningSocStartFloorPct = max(eveningSocStartMin,\n// balanceRequiredSocPct)` — au point de bloquer la charge du vehicule le soir.\nconst manualHcFloorPct = (() => {\n const v = num(input.manual_hc_floor_percent, 0);\n return Number.isFinite(v) && v > socFloorPct && v <= 100 ? v : 0;\n})();\n\n// Sans l'une de ces entrées, le bilan ne peut rien affirmer. Il le dit, et les\n// contrôleurs retombent sur leurs règles historiques.\nconst missing = [];\nif (!consumptionP50) missing.push('consumption_p50');\nif (!consumptionP80) missing.push('consumption_p80');\nif (!horizon || !horizon.length) missing.push('horizon');\nif (!Number.isFinite(socPct)) missing.push('soc_percent');\nif (!Number.isFinite(usableCapacityKwh) || usableCapacityKwh <= 0) missing.push('usable_capacity_kwh');\n\nif (missing.length) {\n return [{\n json: {\n usable: false,\n missing,\n reason: `Bilan indisponible : ${missing.join(', ')}. Les contrôleurs doivent conserver leurs règles.`,\n cfg: CFG,\n },\n }];\n}\n\n// ---------------------------------------------------------------------\n// Bilan heure par heure, calculé DEUX FOIS.\n//\n// Pourquoi deux niveaux de prudence et pas un seul : la décision « faut-il du\n// réseau maintenant ? » est réévaluée à chaque cycle et reste réversible. Se\n// tromper par optimisme ne coûte qu'un démarrage réseau une heure plus tard,\n// le plancher dur garantissant la batterie ; se tromper par excès de prudence\n// coûte un import réseau bien réel, immédiatement.\n//\n// Vérifié sur la matinée du 24/08/2026 : avec le profil prudent, le bilan\n// exigerait 48 % de SOC pour franchir la matinée, alors que la consommation\n// réelle de ce jour-là n'a demandé que 0,8 kWh — le seuil fixe de 30 % aurait\n// été MOINS coûteux. Un bilan « intelligent » plus gourmand que le seuil bête\n// qu'il remplace n'a aucun intérêt.\n//\n// - profil TYPIQUE (p50) → décision réseau immédiate, réévaluée en continu.\n// - profil PRUDENT (p80) → planification : cible de nuit, dimensionnement\n// du stockage, choix qui engagent plusieurs heures.\n// ---------------------------------------------------------------------\nfunction buildSlots(consumption) {\n return horizon.slice(0, CFG.SEARCH_HOURS).map(slot => {\n const hour = num(slot.hour, 0);\n const productionKwh = Math.max(0, num(slot.expectedKwh, 0));\n const consumptionKwh = consumption[hour] ?? 0;\n return {\n startMs: slot.startMs,\n hour,\n productionKwh: round2(productionKwh),\n consumptionKwh: round2(consumptionKwh),\n netKwh: round2(productionKwh - consumptionKwh),\n shapeQuality: slot.shapeQuality || 'inconnue',\n };\n });\n}\n\n// =====================================================================\n// COURBE D'ENERGIE DE LA BATTERIE — MESUREE, PAS SUPPOSEE (03/09/2026)\n// =====================================================================\n// La conversion energie <-> points de SOC etait une CONSTANTE\n// (`usable_capacity_kwh`, 15 kWh) la ou la realite est une COURBE.\n//\n// Mesure : 576 paliers de SOC sur 8 jours (`tools/mesure-courbe-batterie.mjs`).\n// Pour chaque palier ou le SOC reste a une valeur puis DESCEND d'un point, on\n// integre la puissance batterie — en ecartant les paliers ou la batterie a\n// charge ou ou le reseau a apporte quoi que ce soit.\n//\n// tranche paliers kWh/point\n// 21-31 88 0,097 <- effondrement pres du genou\n// 35-44 78 0,171\n// 45-54 76 0,169\n// 55-64 85 0,163\n// 65-74 80 0,171\n// 75-84 81 0,170\n// 85-94 62 0,169\n//\n// Plate a 0,169 de 33 a 93, genou net a 32-33, puis 0,097 : sous le genou, un\n// point de SOC vaut 57 % de ce qu'il vaut au-dessus. Coherent avec §0 (un\n// module en panne sur huit, un autre qui faiblit) et avec l'effondrement de la\n// jauge deja signale au §28 — ceci lui donne sa courbe.\n//\n// CE QUE LA CONSTANTE FAISAIT : 15 kWh se situe ENTRE les deux pentes, donc\n// l'erreur CHANGE DE SIGNE selon l'energie a couvrir —\n// 1 kWh a couvrir : SOC requis 28,7 % annonce contre 32,3 % reel (-3,6)\n// 4,3 kWh : 50,7 contre 52,1 (-1,4)\n// 8 kWh : 75,3 contre 74,0 (+1,3)\n// 11,7 kWh : 99,7 contre 95,7 (+4,1)\n// Trop BAS en fin de nuit (la direction dangereuse), trop HAUT en debut.\n//\n// Energie utile totale de 100 a 22 % : 12,39 kWh mesures, contre 11,70 vus par\n// le bilan et 13,65 par INPUT_PARSE. Les deux constantes existantes etaient\n// fausses, dans des sens opposes.\n//\n// ⚠️ A RE-MESURER apres toute evolution du parc (extension prevue cet hiver) :\n// `node tools/mesure-courbe-batterie.mjs 8`, puis mettre a jour ces trois\n// nombres. Ce ne sont pas des reglages, ce sont des resultats de mesure.\n//\n// ⚠️ CES PENTES ONT ETE MESUREES SUR SEPT MODULES. Retirer ou ajouter un module\n// divise ou multiplie la capacite sans changer la FORME de la courbe : on les\n// exprime donc par module, et on les multiplie par le nombre reellement en\n// service. Ce nombre se lit dans un helper — `input_number\n// .evcs_balance_modules_en_service` — et non dans le code : le parc a change\n// deux fois en une semaine et changera encore cet hiver. Helper absent ou\n// illisible => 7, la valeur sur laquelle la mesure a ete faite, donc aucun\n// changement de comportement tant que personne ne l'ecrit.\nconst MODULES_MESURE = 7;\nconst modulesEnService = (() => {\n const n = num(input.modules_en_service, MODULES_MESURE);\n return Number.isFinite(n) && n >= 1 && n <= 24 ? n : MODULES_MESURE;\n})();\nconst echelleParc = modulesEnService / MODULES_MESURE;\n\n// 20/09/2026 — RE-MESURE sur le parc ASSAINI (13 equivalents US2000 : 4 US5000\n// + 5 US2000C, cellules a 3-6 mV). `tools/mesure-courbe-batterie.mjs 8` :\n// 0,30 kWh/point PLAT de 25 a 97 %, PLUS DE GENOU. Le genou a 33 % etait bien\n// la signature de la cellule 12 morte du pack ancien (§50) — il a disparu avec\n// elle, exactement comme le predisait la note de prudence du 03/09. La reserve\n// reelle a quasiment double (12,4 -> ~23 kWh utiles de 100 a 22 %).\n// Base normalisee a 7 modules (MODULES_MESURE) : 0,30 x 7/13 = 0,1615 par point.\n// x echelleParc(13/7) = 0,30 au parc actuel. Pente unique => plus de genou.\n// A REMESURER apres toute evolution du parc (2e cable console en commande pour\n// surveiller aussi les cellules du pack 1 US5000).\nconst BATTERY_CURVE = {\n kneePct: 0,\n kwhPerPointAbove: 0.1615 * echelleParc,\n kwhPerPointBelow: 0.1615 * echelleParc,\n};\n\n// Energie que la batterie peut delivrer en descendant de socHi a socLo.\nfunction energyBetweenSoc(socLo, socHi) {\n if (!Number.isFinite(socLo) || !Number.isFinite(socHi) || socHi <= socLo) return 0;\n const knee = BATTERY_CURVE.kneePct;\n const lowSpan = Math.max(0, Math.min(socHi, knee) - Math.min(socLo, knee));\n const highSpan = Math.max(0, Math.max(socHi, knee) - Math.max(socLo, knee));\n return lowSpan * BATTERY_CURVE.kwhPerPointBelow\n + highSpan * BATTERY_CURVE.kwhPerPointAbove;\n}\n\n// SOC necessaire pour delivrer energyKwh en descendant jusqu'a floorPct.\n// NON ecrete : au-dela de 100 la valeur reste le signal de sous-dimensionnement\n// (cf. §26). L'extrapolation au-dessus de 100 utilise la pente haute.\nfunction socForEnergy(energyKwh, floorPct) {\n if (!Number.isFinite(energyKwh) || energyKwh <= 0) return floorPct;\n const knee = Math.max(floorPct, BATTERY_CURVE.kneePct);\n const belowKwh = (knee - floorPct) * BATTERY_CURVE.kwhPerPointBelow;\n if (energyKwh <= belowKwh) {\n return floorPct + energyKwh / BATTERY_CURVE.kwhPerPointBelow;\n }\n return knee + (energyKwh - belowKwh) / BATTERY_CURVE.kwhPerPointAbove;\n}\n\n// SOC atteint en ajoutant addKwh a partir de socPct.\nfunction socAfterEnergy(socPct, addKwh) {\n if (!Number.isFinite(addKwh) || addKwh <= 0) return socPct;\n return socForEnergy(energyBetweenSoc(0, socPct) + addKwh, 0);\n}\n\n// =====================================================\n// TENIR JUSQU'A LA PROCHAINE FENETRE HC (28/09/2026)\n// =====================================================\n// Strategie du proprietaire, apres la nuit du 26-27/09 : P12 avait charge la\n// JEEP sur la batterie (72 -> 44 %) parce qu'il restait assez pour atteindre le\n// solaire du matin — alors que ce meme bilan annoncait deja « prochaine\n// traversee 17,35 kWh des 18 h -> SOC requis 79,8 % ». La journee (22 kWh reels)\n// n'a pas pu reconstituer la reserve. Sa regle : une voiture branchee se charge\n// en heures creuses sur le reseau, et la batterie doit TENIR JUSQU'A LA\n// PROCHAINE FENETRE HC ; si elle ne le peut pas, la fenetre HC de midi la\n// complete.\n//\n// Traduction : PENDANT une fenetre HC, l'exigence vaut au moins le SOC qu'il\n// faut avoir A LA FIN de cette fenetre pour atteindre le DEBUT de la suivante\n// sans passer sous le plancher, production prevue comprise (tranches horaires\n// ci-dessus, heure par heure, profil de conso du profil calcule).\n//\n// ⚠️ LE CALENDRIER RESTE DANS HOME ASSISTANT (§73) : le bilan ne recoit que deux\n// instants, la fin de la fenetre en cours (absente hors fenetre) et le debut de\n// la suivante. Hors fenetre HC, rien ne change.\n//\n// ⚠️ INSERE DANS coverage(), COMME LE PLANCHER MANUEL : la cible (required_soc,\n// qui ENGAGE la session et releve le plancher de P12) et le manque en kWh (qui\n// FINANCE la consigne) bougent ensemble par construction — jamais de session\n// muette (§73). Sens unique : il ne peut que RELEVER l'exigence.\nconst hcWindowEndMs = num(input.hc_window_end_ms, NaN);\nconst nextHcStartMs = num(input.next_hc_start_ms, NaN);\nconst nextNightHcStartMs = num(input.next_night_hc_start_ms, NaN);\nconst hcReachActive = Number.isFinite(hcWindowEndMs) && Number.isFinite(nextHcStartMs)\n && nextHcStartMs > hcWindowEndMs;\n// DEUX HORIZONS, DEUX USAGES (decision du proprietaire, 28/09) :\n// - recharger la batterie au reseau : tenir jusqu'a la PROCHAINE fenetre HC\n// (la nuit : midi ; a midi : 01:24). Midi complete si besoin, et la matinee\n// garde de la place pour le solaire. -> required_soc (engage ET finance).\n// - laisser la voiture puiser dans la batterie la nuit (P12) : seulement si la\n// batterie tient ensuite jusqu'a la NUIT SUIVANTE (01:24), sans compter sur\n// midi. Sinon la voiture se charge sur le reseau en HC. -> required_soc_prudent,\n// qui n'est lu que comme plancher de P12 (et par le conseiller, pour info).\nconst hcNightReachActive = Number.isFinite(hcWindowEndMs) && Number.isFinite(nextNightHcStartMs)\n && nextNightHcStartMs > hcWindowEndMs;\n\n// Energie a avoir en reserve a la fin de la fenetre : parcours A REBOURS des\n// tranches comprises entre les deux instants (au prorata de leur recouvrement).\n// Un surplus ne rembourse qu'un deficit POSTERIEUR — d'ou le sens du parcours.\nfunction hcReachFloorPct(slots, untilMs = nextHcStartMs, fromMs = hcWindowEndMs) {\n if (!Number.isFinite(fromMs) || !Number.isFinite(untilMs) || untilMs <= fromMs) return 0;\n let needKwh = 0;\n for (let i = slots.length - 1; i >= 0; i--) {\n const s = slots[i];\n const debut = num(s.startMs, NaN);\n if (!Number.isFinite(debut)) continue;\n const a = Math.max(debut, fromMs);\n const b = Math.min(debut + 3600000, untilMs);\n if (b <= a) continue;\n needKwh = Math.max(0, needKwh - s.netKwh * (b - a) / 3600000);\n }\n return needKwh > 0 ? socForEnergy(needKwh, socFloorPct) : 0;\n}\n\nfunction coverage(slots, hcFloorPct = 0) {\n const crossingIndex = slots.findIndex(s => s.netKwh > 0);\n const found = crossingIndex >= 0;\n const deficitSlots = found ? slots.slice(0, crossingIndex) : slots;\n const energyToCoverKwh = deficitSlots.reduce((sum, s) => sum + Math.max(0, -s.netKwh), 0);\n // Le plancher manuel ne peut que RELEVER l'exigence, jamais l'abaisser — meme\n // sens unique que les cablages de §38, §39 et §40 : le bilan ne pousse jamais\n // dans la direction « couvre moins bien ».\n const requiredSocRawPct = Math.max(\n socForEnergy(energyToCoverKwh, socFloorPct),\n manualHcFloorPct,\n hcFloorPct\n );\n const socNeededPct = requiredSocRawPct - socFloorPct;\n const requiredSocPct = Math.min(100, requiredSocRawPct);\n const socMarginPct = socPct - requiredSocPct;\n return {\n crossingIndex, found, deficitSlots, slots,\n energyToCoverKwh, socNeededPct, requiredSocPct, socMarginPct,\n gridNeeded: socMarginPct < 0,\n gridEnergyNeededKwh: socMarginPct < 0 ? energyBetweenSoc(socPct, requiredSocPct) : 0,\n };\n}\n\nconst slotsP50 = buildSlots(consumptionP50);\nconst slotsP80 = buildSlots(consumptionP80);\nconst hcFloorTypicalPct = hcReachFloorPct(slotsP50, nextHcStartMs);\nconst hcFloorNightPrudentPct = hcNightReachActive ? hcReachFloorPct(slotsP80, nextNightHcStartMs) : 0;\nconst hcFloorPrudentPct = Math.max(hcReachFloorPct(slotsP80, nextHcStartMs), hcFloorNightPrudentPct);\nconst typical = coverage(slotsP50, hcFloorTypicalPct);\nconst prudent = coverage(slotsP80, hcFloorPrudentPct);\n\n// ---------------------------------------------------------------------\n// VEHICULE BRANCHE EN HC AVEC UN DEPART AVANT LA PROCHAINE HC (28/09/2026)\n//\n// Regle du proprietaire : « JEEP branchee engage le reseau en HC midi si\n// l'analyse de production le justifie ». La JEEP repart a 15:30 certains jours\n// (calendrier dans HA) : ce qu'elle n'a pas pris avant, elle ne le prendra pas\n// en HC la nuit. Ailleurs, elle reste branchee et la HC de 01:24 la finit au\n// meme tarif — fermer le contacteur a midi n'y gagnerait rien et etoufferait le\n// solaire (installation sans injection).\n//\n// Ce que le solaire peut donner a la voiture avant son depart SANS toucher a\n// la reserve de la nuit : l'energie de la batterie au-dessus du plancher\n// « tenir jusqu'a la prochaine HC » evalue AU DEPART, plus le bilan net prevu\n// (production - maison) d'ici le depart. Profil p50, comme required_soc.\n// Le reseau est justifie quand le besoin de la voiture, borne par ce que son\n// chargeur peut absorber avant le depart, depasse ce solaire.\n//\n// Besoin = (100 - niveau saisi) % x capacite MESUREE au compteur de la borne,\n// moins ce que la borne a deja delivre depuis la saisie. Aucun capteur ne donne\n// le niveau de la voiture : sans saisie valable, la regle se tait.\n//\n// Vaut pour les TROIS vehicules (JEEP, VW, moto ZERO) : capacite, puissance de\n// charge et calendrier viennent de HA pour le vehicule selectionne.\n// La puissance du chargeur EMBARQUE (plateau mesure : JEEP 3,66 kW, VW\n// 3,68 kW) borne ce que le vehicule peut prendre avant son depart ; inconnue\n// (0), on ne borne pas — l'erreur va alors vers « reseau justifie », c'est-a-\n// dire vers le vehicule charge, jamais vers la batterie videe.\n// Seuil de 0,5 kWh : en dessous une session ne se justifie pas — une fermeture\n// du contacteur coute 50 a 150 Wh de transitoire (§57), on exige un gain d'au\n// moins trois fois ce cout.\nconst VEHICLE_GRID_MIN_SHORTFALL_KWH = 0.5;\nconst vehicleChargerKw = num(input.vehicle_charger_kw, NaN);\nconst nowMsBalance = num(input.nowMs, Date.now());\nconst vehicleConnected = input.vehicle_connected === true || input.vehicle_connected === 'true';\nconst vehicleDepartureMs = num(input.vehicle_departure_ms, NaN);\nconst vehicleLevelPct = num(input.vehicle_level_pct, NaN);\nconst vehicleLevelValid = input.vehicle_level_valid === true || input.vehicle_level_valid === 'true';\nconst vehicleCapacityKwh = num(input.vehicle_capacity_kwh, NaN);\nconst vehicleDeliveredKwh = Math.max(0, num(input.vehicle_delivered_since_level_kwh, 0));\nconst vehicleNeedKwh =\n vehicleLevelValid && Number.isFinite(vehicleLevelPct) && vehicleLevelPct >= 0 && vehicleLevelPct <= 100\n && Number.isFinite(vehicleCapacityKwh) && vehicleCapacityKwh > 0\n ? Math.max(0, (100 - vehicleLevelPct) / 100 * vehicleCapacityKwh - vehicleDeliveredKwh)\n : NaN;\nconst vehicleRuleActive =\n hcReachActive && vehicleConnected && Number.isFinite(vehicleNeedKwh)\n && Number.isFinite(vehicleDepartureMs) && vehicleDepartureMs > nowMsBalance\n && vehicleDepartureMs < nextHcStartMs;\nlet vehicleSolarKwh = null;\nlet vehicleReachableKwh = null;\nlet vehicleShortfallKwh = null;\nlet vehicleFloorAtDeparturePct = null;\nif (vehicleRuleActive) {\n vehicleFloorAtDeparturePct = hcReachFloorPct(slotsP50, nextHcStartMs, vehicleDepartureMs);\n let netKwh = 0;\n for (const sl of slotsP50) {\n const debut = num(sl.startMs, NaN);\n if (!Number.isFinite(debut)) continue;\n const a = Math.max(debut, nowMsBalance);\n const b = Math.min(debut + 3600000, vehicleDepartureMs);\n if (b > a) netKwh += sl.netKwh * (b - a) / 3600000;\n }\n // Difference SIGNEE : une batterie SOUS son plancher doit d'abord etre\n // remontee par ce meme surplus avant que la voiture n'en voie un kWh.\n const plancherDepart = Math.max(socFloorPct, vehicleFloorAtDeparturePct);\n const batterieAuDessusKwh = socPct >= plancherDepart\n ? energyBetweenSoc(plancherDepart, socPct)\n : -energyBetweenSoc(socPct, plancherDepart);\n vehicleSolarKwh = Math.max(0, batterieAuDessusKwh + netKwh);\n vehicleReachableKwh = Number.isFinite(vehicleChargerKw) && vehicleChargerKw > 0\n ? Math.min(vehicleNeedKwh, vehicleChargerKw * (vehicleDepartureMs - nowMsBalance) / 3600000)\n : vehicleNeedKwh;\n vehicleShortfallKwh = Math.max(0, vehicleReachableKwh - vehicleSolarKwh);\n}\nconst vehicleGridNeeded = vehicleRuleActive && vehicleShortfallKwh > VEHICLE_GRID_MIN_SHORTFALL_KWH;\n\n// ---------------------------------------------------------------------\n// PROCHAINE FENÊTRE DE DÉFICIT (ajout du 24/08/2026)\n//\n// Le calcul ci-dessus répond à « combien d'énergie jusqu'à ce que le solaire\n// prenne le relais ». À 15 h, le solaire a déjà pris le relais : la réponse est\n// zéro et le SOC requis retombe sur le plancher. C'est exact mais inutile à\n// cette heure-là, car le déficit qui approche est celui de la nuit.\n//\n// On cherche donc AUSSI la prochaine fenêtre de déficit après le surplus en\n// cours : son heure de début, son énergie, et le SOC qu'il faudra avoir atteint\n// à ce moment-là. C'est cette grandeur qui remplace la cible de nuit fixe, et\n// qui dit combien de surplus il faut avoir stocké avant le coucher du soleil.\n//\n// Profil PRUDENT : contrairement à la décision réseau immédiate, cette\n// projection engage plusieurs heures et n'est pas révisable à volonté — la nuit\n// tombée, il n'y a plus de solaire pour rattraper une sous-estimation.\n// ---------------------------------------------------------------------\nfunction nextDeficitWindow(slots) {\n const firstSurplus = slots.findIndex(s => s.netKwh > 0);\n if (firstSurplus < 0) return { found: false, reason: 'deficit_en_cours' };\n\n const start = slots.findIndex((s, i) => i > firstSurplus && s.netKwh < 0);\n if (start < 0) return { found: false, reason: 'aucun_deficit_sur_horizon' };\n\n let end = slots.findIndex((s, i) => i > start && s.netKwh > 0);\n if (end < 0) end = slots.length;\n\n const energyKwh = slots.slice(start, end)\n .reduce((sum, s) => sum + Math.max(0, -s.netKwh), 0);\n\n // Surplus encore récupérable d'ici là : de quoi remplir, ou pas.\n const surplusBeforeKwh = slots.slice(0, start)\n .reduce((sum, s) => sum + Math.max(0, s.netKwh), 0);\n\n return {\n found: true,\n startHour: slots[start].hour,\n startMs: slots[start].startMs,\n hoursFromNow: start,\n endHour: end < slots.length ? slots[end].hour : null,\n complete: end < slots.length,\n energyKwh,\n // NON écrêté à 100 volontairement : une valeur au-dessus de 100 signifie\n // que même une batterie pleine ne suffit pas à traverser la fenêtre. C'est\n // exactement le signal de sous-dimensionnement du stockage — l'écrêter le\n // masquerait.\n requiredSocAtStartPct: socForEnergy(energyKwh, socFloorPct),\n surplusBeforeKwh,\n };\n}\n\nconst nextDeficit = nextDeficitWindow(prudent.slots);\n\n// ---------------------------------------------------------------------\n// RÉSERVE D'ORAGE (27/08/2026)\n//\n// Les coupures réseau sont fréquentes ici lors des orages. Point de départ\n// souvent mal posé : une coupure n'arrête PAS l'installation. Le solaire et\n// la batterie continuent de fonctionner, seul le filet de secours disparaît.\n// La question n'est donc pas « combien stocker pour survivre » mais « la\n// batterie traverse-t-elle la prochaine fenêtre de déficit SANS le réseau ».\n//\n// C'est exactement la couverture PRUDENTE (p80) déjà calculée plus haut :\n// rien à inventer, aucune constante de plus. Le profil prudent est le bon\n// choix ici parce qu'une coupure retire précisément la possibilité de\n// rattraper une sous-estimation.\n//\n// Le niveau vient de la vigilance Météo-France, dont l'échelle officielle est\n// vert/jaune/orange/rouge. « Actif » à partir d'orange : c'est le seuil de\n// l'émetteur, pas un nombre choisi ici.\n// ---------------------------------------------------------------------\nconst STORM_SCALE = ['vert', 'jaune', 'orange', 'rouge'];\nconst STORM_ACTIVE_FROM = STORM_SCALE.indexOf('orange');\n\nconst stormRaw = typeof input.storm_alert_level === 'string'\n ? input.storm_alert_level.trim().toLowerCase()\n : '';\nconst stormIndex = STORM_SCALE.indexOf(stormRaw);\nconst stormKnown = stormIndex >= 0;\nconst stormActive = stormIndex >= STORM_ACTIVE_FROM;\n\n// Phénomènes atteignant le niveau d'alerte, s'ils sont exposés en attributs\n// (« Orages », « Vent violent »…). Purement informatif.\nconst stormDetail = (input.storm_alert_detail && typeof input.storm_alert_detail === 'object')\n ? input.storm_alert_detail\n : {};\nconst stormPhenomena = Object.keys(stormDetail)\n .filter(k => typeof stormDetail[k] === 'string'\n && STORM_SCALE.indexOf(stormDetail[k].trim().toLowerCase()) >= STORM_ACTIVE_FROM)\n .sort();\n\n// En surplus, la fenêtre qui compte est la prochaine (la nuit qui vient) ;\n// en déficit, c'est celle qu'on est en train de traverser.\nconst inSurplusNow = prudent.found && prudent.crossingIndex === 0;\nconst stormWindow = (inSurplusNow && nextDeficit.found)\n ? {\n kwh: nextDeficit.energyKwh,\n socPct: nextDeficit.requiredSocAtStartPct,\n startHour: nextDeficit.startHour,\n }\n : {\n kwh: prudent.energyToCoverKwh,\n socPct: prudent.requiredSocPct,\n startHour: null,\n };\nconst stormMarginPct = socPct - stormWindow.socPct;\n\nconst slots = typical.slots;\nconst crossingFound = typical.found;\nconst crossingIndex = typical.crossingIndex;\nconst crossing = crossingFound ? slots[crossingIndex] : null;\nconst deficitSlots = typical.deficitSlots;\n\n// ---------------------------------------------------------------------\n// Marge disponible pour le véhicule : surplus attendu après le croisement,\n// une fois la maison servie. C'est ce qui peut être chargé sans toucher au\n// réseau ni entamer la réserve.\n// ---------------------------------------------------------------------\nconst surplusAfterCrossingKwh = crossingFound\n ? slots.slice(crossingIndex).reduce((sum, s) => sum + Math.max(0, s.netKwh), 0)\n : 0;\n\n// ---------------------------------------------------------------------\n// Confiance : la part du calcul qui repose sur une forme de production minorée\n// (après-midi censuré). Elle ne dégrade PAS la réponse « faut-il du réseau »,\n// qui se joue sur les heures de déficit — celles-ci ne sont jamais censurées,\n// une heure censurée étant par construction une heure de surplus.\n// ---------------------------------------------------------------------\nconst deficitOnWeakShape = deficitSlots.filter(s => s.shapeQuality === 'minoree').length;\nconst confidence = {\n deficitHoursOnWeakShape: deficitOnWeakShape,\n deficitTrustworthy: deficitOnWeakShape === 0,\n surplusTrustworthy: !slots.slice(crossingFound ? crossingIndex : 0)\n .some(s => s.shapeQuality === 'minoree'),\n};\n\nreturn [{\n json: {\n usable: true,\n generatedAt: new Date(num(input.nowMs, Date.now())).toISOString(),\n\n crossing: {\n found: crossingFound,\n hour: crossing ? crossing.hour : null,\n startMs: crossing ? crossing.startMs : null,\n hoursFromNow: crossingFound ? crossingIndex : null,\n },\n\n coverage: {\n // Décision réseau immédiate — remplace input_number.victron_grid_soc_off.\n energyToCoverKwh: round2(typical.energyToCoverKwh),\n requiredSocPct: round1(typical.requiredSocPct),\n currentSocPct: round1(socPct),\n socMarginPct: round1(typical.socMarginPct),\n gridNeeded: typical.gridNeeded,\n gridEnergyNeededKwh: round2(typical.gridEnergyNeededKwh),\n\n // Planification (cible de nuit, dimensionnement) — ne pilote PAS le réseau.\n prudent: {\n energyToCoverKwh: round2(prudent.energyToCoverKwh),\n requiredSocPct: round1(prudent.requiredSocPct),\n socMarginPct: round1(prudent.socMarginPct),\n crossingHour: prudent.found ? prudent.slots[prudent.crossingIndex].hour : null,\n },\n },\n\n // Ce qu'il faudra avoir stocké avant la prochaine traversée déficitaire.\n nextDeficit: nextDeficit.found ? {\n found: true,\n startHour: nextDeficit.startHour,\n hoursFromNow: nextDeficit.hoursFromNow,\n endHour: nextDeficit.endHour,\n complete: nextDeficit.complete,\n energyKwh: round2(nextDeficit.energyKwh),\n requiredSocAtStartPct: round1(nextDeficit.requiredSocAtStartPct),\n surplusBeforeKwh: round2(nextDeficit.surplusBeforeKwh),\n // Franchissable avec le stockage actuel, batterie pleine ?\n feasibleWithCurrentStorage: nextDeficit.requiredSocAtStartPct <= 100,\n // Si non : capacité utile manquante. C'est le chiffre du dimensionnement.\n storageShortfallKwh: energyBetweenSoc(100, nextDeficit.requiredSocAtStartPct),\n reachable: nextDeficit.surplusBeforeKwh >= 0\n && socAfterEnergy(socPct, nextDeficit.surplusBeforeKwh)\n >= nextDeficit.requiredSocAtStartPct,\n } : { found: false, reason: nextDeficit.reason },\n\n vehicle: {\n surplusAvailableKwh: round2(surplusAfterCrossingKwh),\n },\n\n // Voiture branchee en HC, depart avant la prochaine HC (28/09).\n vehicleMidday: {\n active: vehicleRuleActive,\n needKwh: Number.isFinite(vehicleNeedKwh) ? round2(vehicleNeedKwh) : null,\n departureMs: Number.isFinite(vehicleDepartureMs) ? vehicleDepartureMs : null,\n solarKwh: vehicleSolarKwh === null ? null : round2(vehicleSolarKwh),\n reachableKwh: vehicleReachableKwh === null ? null : round2(vehicleReachableKwh),\n shortfallKwh: vehicleShortfallKwh === null ? null : round2(vehicleShortfallKwh),\n floorAtDeparturePct: vehicleFloorAtDeparturePct === null ? null : round1(vehicleFloorAtDeparturePct),\n gridNeeded: vehicleGridNeeded,\n },\n\n // Tenir jusqu'a la prochaine fenetre HC (28/09) : SOC a avoir en fin de\n // fenetre HC en cours. 0 hors fenetre ou si la production suffit.\n hcReach: {\n active: hcReachActive,\n windowEndMs: hcReachActive ? hcWindowEndMs : null,\n nextHcStartMs: hcReachActive ? nextHcStartMs : null,\n floorPct: round1(Math.min(100, hcFloorTypicalPct)),\n floorPrudentPct: round1(Math.min(100, hcFloorPrudentPct)),\n nextNightHcStartMs: hcNightReachActive ? nextNightHcStartMs : null,\n nightFloorPrudentPct: round1(Math.min(100, hcFloorNightPrudentPct)),\n raisesRequirement: hcReachActive && hcFloorTypicalPct >= typical.requiredSocPct - 0.05\n && hcFloorTypicalPct > socForEnergy(typical.energyToCoverKwh, socFloorPct),\n },\n\n // Réserve à tenir pour traverser SANS réseau la fenêtre de déficit qui\n // compte. Publiée en permanence — elle documente le risque même par temps\n // calme ; `active` dit seulement si la vigilance la rend d'actualité.\n storm: {\n level: stormKnown ? stormRaw : 'indisponible',\n known: stormKnown,\n active: stormActive,\n phenomena: stormPhenomena,\n windowStartHour: stormWindow.startHour,\n reserveKwh: round2(stormWindow.kwh),\n reserveSocPct: round1(stormWindow.socPct),\n socMarginPct: round1(stormMarginPct),\n covered: stormMarginPct >= 0,\n shortfallKwh: stormMarginPct < 0\n ? round2(energyBetweenSoc(socPct, stormWindow.socPct))\n : 0,\n },\n\n confidence,\n slots,\n\n inputs: {\n usableCapacityKwh: round2(usableCapacityKwh),\n socFloorPct: round1(socFloorPct),\n },\n\n cfg: CFG,\n },\n}];\n" + "jsCode": "// EVCS Energy Balance — BALANCE\n// =====================================================================\n// Rôle : produire les FAITS qui remplacent les seuils fixes. Rien d'autre.\n// Ce nœud ne pilote ni EDF, ni la borne : il publie des grandeurs que les deux\n// contrôleurs liront à la place de leurs constantes.\n//\n// La question à laquelle il répond n'est pas « le SOC est-il au-dessus de\n// 30 % ? » mais « la batterie contient-elle de quoi tenir jusqu'à ce que le\n// solaire prenne le relais ? ». Le 24/08/2026 à 06:43, la réponse du seuil fixe\n// et celle du bilan diffèrent : la production ne couvrait la maison qu'à partir\n// de 09 h, il fallait donc environ 0,8 kWh de réserve, soit ~5 points de SOC —\n// et non un palier arbitraire à 30 %.\n//\n// Entrées (toutes fournies, aucune constante physique en dur) :\n// consumption_p50[24] profil horaire mesuré, valeur typique (CONSUMPTION_PROFILE)\n// consumption_p80[24] idem, valeur prudente\n// horizon[] production attendue heure par heure (RESOURCE_ESTIMATE)\n// soc_percent SOC lissé pour le pilotage\n// usable_capacity_kwh capacité réellement disponible du parc batterie\n// soc_floor_percent plancher dur de sécurité\n//\n// Sorties : heure de croisement · énergie à couvrir · SOC requis · marge\n// disponible pour le véhicule · niveau de confiance.\n// =====================================================================\n\nfunction num(v, fallback = NaN) {\n const n = Number.parseFloat(v);\n return Number.isFinite(n) ? n : fallback;\n}\n\nfunction round1(v) {\n return Number.isFinite(v) ? Math.round(v * 10) / 10 : null;\n}\n\nfunction round2(v) {\n return Number.isFinite(v) ? Math.round(v * 100) / 100 : null;\n}\n\nconst input = $input.first().json;\n\nconst CFG = {\n TIMEZONE: 'Europe/Paris',\n // Au-delà de cet horizon sans croisement trouvé, on cesse de chercher : la\n // réponse n'aurait plus de sens opérationnel.\n SEARCH_HOURS: 40,\n // PAS de marge de sécurité supplémentaire. La prudence est déjà portée,\n // entièrement, par le choix du percentile du profil de consommation (p80),\n // qui est mesuré. Vérifié le 24/08/2026 : sur toutes les fenêtres testées,\n // p80(somme des heures) est égal a somme(p80 par heure) — les heures de\n // consommation sont fortement corrélées, une matinée chargée l'étant sur\n // plusieurs heures d'affilée. Sommer les p80 ne cumule donc PAS deux\n // pessimismes, et ajouter un coefficient par-dessus ne serait qu'une\n // constante arbitraire de plus.\n};\n\nfunction profileOf(key) {\n const a = input[key];\n return Array.isArray(a) && a.length === 24 ? a.map(v => Math.max(0, num(v, 0))) : null;\n}\nconst consumptionP50 = profileOf('consumption_p50');\nconst consumptionP80 = profileOf('consumption_p80');\n\nconst horizon = Array.isArray(input.horizon) ? input.horizon : null;\n\nconst socPct = num(input.soc_percent, NaN);\nconst usableCapacityKwh = num(input.usable_capacity_kwh, NaN);\nconst socFloorPct = num(input.soc_floor_percent, 22);\n\n// =====================================================\n// CHARGE HC MANUELLE — UN PLANCHER, PAS UNE CIBLE (18/09/2026)\n// =====================================================\n// Le proprietaire veut pouvoir demander une recharge batterie en heures creuses\n// quand le bilan ne l'a pas jugee necessaire — journee d'hiver courte, ciel\n// couvert. La decision qu'il a tranchee : CONTRAINDRE le bilan plutot que de\n// l'ignorer, pour garder un seul arbitre. Ce projet a deja paye cher les\n// automates concurrents (charges programmees de l'ESS §26, consigne a 4200 W\n// §27, plancher SOC du GX §27).\n//\n// ⚠️ LE FORCAGE DOIT ETRE ICI, EN AMONT, ET NULLE PART AILLEURS. Mesure du\n// 18/09 sur banc : ecrire directement dans `input_number.evcs_balance_required_soc`\n// engage bien la session — contacteur ferme, takeover ON — mais laisse la\n// consigne reseau a 700 W, donc la batterie recoit une centaine de watts. C'est\n// le defaut du 01/09 et du 04/09 a l'identique, et c'est le seul resultat des\n// trois qui soit INDIAGNOSTICABLE depuis le tableau de bord : il a l'air de\n// marcher. La raison : `required_soc` ENGAGE la session, `grid_energy_needed_kwh`\n// la FINANCE, et les deux sont publiees separement. Pose ici, dans `coverage()`,\n// le plancher fait bouger les deux ENSEMBLE par construction — c'est une\n// consequence de la forme du code et non une discipline a tenir.\n//\n// ⚠️ ET CE PLANCHER EST DEJA FILTRE PAR L'HEURE QUAND IL ARRIVE ICI. Le bilan\n// ignore totalement les fenetres d'heures creuses (aucune occurrence de\n// `hc_night` / `hc_midday` dans ce sous-systeme) et doit le rester : il repond a\n// « de combien la batterie a-t-elle besoin », pas a « quand est-ce bon marche ».\n// C'est Home Assistant qui detient le calendrier tarifaire et qui ne publie une\n// valeur non nulle que pendant une fenetre creuse. Sans ce filtrage en amont, un\n// plancher a 85 % fuirait dans la journee et mordrait sur\n// `BATTERY_CLOUD_LOCKS.js` — `eveningSocStartFloorPct = max(eveningSocStartMin,\n// balanceRequiredSocPct)` — au point de bloquer la charge du vehicule le soir.\nconst manualHcFloorPct = (() => {\n const v = num(input.manual_hc_floor_percent, 0);\n return Number.isFinite(v) && v > socFloorPct && v <= 100 ? v : 0;\n})();\n\n// Sans l'une de ces entrées, le bilan ne peut rien affirmer. Il le dit, et les\n// contrôleurs retombent sur leurs règles historiques.\nconst missing = [];\nif (!consumptionP50) missing.push('consumption_p50');\nif (!consumptionP80) missing.push('consumption_p80');\nif (!horizon || !horizon.length) missing.push('horizon');\nif (!Number.isFinite(socPct)) missing.push('soc_percent');\nif (!Number.isFinite(usableCapacityKwh) || usableCapacityKwh <= 0) missing.push('usable_capacity_kwh');\n\nif (missing.length) {\n return [{\n json: {\n usable: false,\n missing,\n reason: `Bilan indisponible : ${missing.join(', ')}. Les contrôleurs doivent conserver leurs règles.`,\n cfg: CFG,\n },\n }];\n}\n\n// ---------------------------------------------------------------------\n// Bilan heure par heure, calculé DEUX FOIS.\n//\n// Pourquoi deux niveaux de prudence et pas un seul : la décision « faut-il du\n// réseau maintenant ? » est réévaluée à chaque cycle et reste réversible. Se\n// tromper par optimisme ne coûte qu'un démarrage réseau une heure plus tard,\n// le plancher dur garantissant la batterie ; se tromper par excès de prudence\n// coûte un import réseau bien réel, immédiatement.\n//\n// Vérifié sur la matinée du 24/08/2026 : avec le profil prudent, le bilan\n// exigerait 48 % de SOC pour franchir la matinée, alors que la consommation\n// réelle de ce jour-là n'a demandé que 0,8 kWh — le seuil fixe de 30 % aurait\n// été MOINS coûteux. Un bilan « intelligent » plus gourmand que le seuil bête\n// qu'il remplace n'a aucun intérêt.\n//\n// - profil TYPIQUE (p50) → décision réseau immédiate, réévaluée en continu.\n// - profil PRUDENT (p80) → planification : cible de nuit, dimensionnement\n// du stockage, choix qui engagent plusieurs heures.\n// ---------------------------------------------------------------------\n// ---------------------------------------------------------------------\n// LA NUIT PREVUE PAR LA SOIREE (28/09/2026, demande du proprietaire)\n//\n// Le profil p50 est une mediane HORAIRE : il ignore la soiree qui vient de se\n// passer. Or sur 24 nuits, la maison consomme d'autant plus de 21:30 a 09:00\n// que la soiree 18:00-21:30 a ete chargee (four) : nuit = 6,98 + 0,74 x soiree,\n// r2 = 0,40. Le service d'historisation publie le FACTEUR nuit prevue / nuit\n// moyenne (sensor.evcs_nuit_maison_prevue_kwh) ; on multiplie le profil par ce\n// facteur sur la seule fenetre 21:30 -> 09:00, au prorata des heures a cheval.\n// Le 28/09 : soiree 6,27 kWh -> facteur 1,09.\n//\n// Un FACTEUR et non des kWh : le profil vient des compteurs d'energie, la\n// regression des puissances integrees ; le rapport annule l'ecart de methode.\n// Dans les deux sens pour le profil TYPIQUE (c'est un estimateur, une soiree\n// legere allege la nuit) ; le PRUDENT ne descend jamais : max(p80, p50 recale).\n// Filet : facteur borne a [0,75 ; 1,45], les extremes des 24 nuits observees\n// (0,77 et 1,41 fois la moyenne) — hors de la, c'est une mesure fausse, pas une\n// nuit. Facteur absent, perime (> 30 min) ou hors fenetre : profil inchange.\nconst NIGHT_FACTOR_MIN = 0.75;\nconst NIGHT_FACTOR_MAX = 1.45;\nconst nowMsNight = num(input.nowMs, Date.now());\nconst nightStartMs = num(input.night_house_start_ms, NaN);\nconst nightEndMs = num(input.night_house_end_ms, NaN);\nconst nightFactorRaw = num(input.night_house_factor, NaN);\nconst nightComputedMs = num(input.night_house_computed_ms, NaN);\nconst nightModelActive =\n Number.isFinite(nightFactorRaw) && nightFactorRaw > 0 &&\n Number.isFinite(nightStartMs) && Number.isFinite(nightEndMs) && nightEndMs > nightStartMs &&\n nowMsNight >= nightStartMs && nowMsNight < nightEndMs &&\n Number.isFinite(nightComputedMs) && nowMsNight - nightComputedMs <= 30 * 60000;\nconst nightFactor = nightModelActive\n ? Math.min(NIGHT_FACTOR_MAX, Math.max(NIGHT_FACTOR_MIN, nightFactorRaw))\n : 1;\nfunction nightShare(startMs) {\n const debut = num(startMs, NaN);\n if (!nightModelActive || !Number.isFinite(debut)) return 0;\n const a = Math.max(debut, nightStartMs), b = Math.min(debut + 3600000, nightEndMs);\n return b > a ? (b - a) / 3600000 : 0;\n}\nfunction nightScaled(profile, slot) {\n const h = num(slot.hour, 0);\n return (profile[h] ?? 0) * (1 + (nightFactor - 1) * nightShare(slot.startMs));\n}\n\nfunction buildSlots(consumption, prudent = false) {\n return horizon.slice(0, CFG.SEARCH_HOURS).map(slot => {\n const hour = num(slot.hour, 0);\n const productionKwh = Math.max(0, num(slot.expectedKwh, 0));\n const consumptionKwh = prudent\n ? Math.max(consumption[hour] ?? 0, nightScaled(consumptionP50, slot))\n : nightScaled(consumption, slot);\n return {\n startMs: slot.startMs,\n hour,\n productionKwh: round2(productionKwh),\n consumptionKwh: round2(consumptionKwh),\n netKwh: round2(productionKwh - consumptionKwh),\n shapeQuality: slot.shapeQuality || 'inconnue',\n };\n });\n}\n\n// =====================================================================\n// COURBE D'ENERGIE DE LA BATTERIE — MESUREE, PAS SUPPOSEE (03/09/2026)\n// =====================================================================\n// La conversion energie <-> points de SOC etait une CONSTANTE\n// (`usable_capacity_kwh`, 15 kWh) la ou la realite est une COURBE.\n//\n// Mesure : 576 paliers de SOC sur 8 jours (`tools/mesure-courbe-batterie.mjs`).\n// Pour chaque palier ou le SOC reste a une valeur puis DESCEND d'un point, on\n// integre la puissance batterie — en ecartant les paliers ou la batterie a\n// charge ou ou le reseau a apporte quoi que ce soit.\n//\n// tranche paliers kWh/point\n// 21-31 88 0,097 <- effondrement pres du genou\n// 35-44 78 0,171\n// 45-54 76 0,169\n// 55-64 85 0,163\n// 65-74 80 0,171\n// 75-84 81 0,170\n// 85-94 62 0,169\n//\n// Plate a 0,169 de 33 a 93, genou net a 32-33, puis 0,097 : sous le genou, un\n// point de SOC vaut 57 % de ce qu'il vaut au-dessus. Coherent avec §0 (un\n// module en panne sur huit, un autre qui faiblit) et avec l'effondrement de la\n// jauge deja signale au §28 — ceci lui donne sa courbe.\n//\n// CE QUE LA CONSTANTE FAISAIT : 15 kWh se situe ENTRE les deux pentes, donc\n// l'erreur CHANGE DE SIGNE selon l'energie a couvrir —\n// 1 kWh a couvrir : SOC requis 28,7 % annonce contre 32,3 % reel (-3,6)\n// 4,3 kWh : 50,7 contre 52,1 (-1,4)\n// 8 kWh : 75,3 contre 74,0 (+1,3)\n// 11,7 kWh : 99,7 contre 95,7 (+4,1)\n// Trop BAS en fin de nuit (la direction dangereuse), trop HAUT en debut.\n//\n// Energie utile totale de 100 a 22 % : 12,39 kWh mesures, contre 11,70 vus par\n// le bilan et 13,65 par INPUT_PARSE. Les deux constantes existantes etaient\n// fausses, dans des sens opposes.\n//\n// ⚠️ A RE-MESURER apres toute evolution du parc (extension prevue cet hiver) :\n// `node tools/mesure-courbe-batterie.mjs 8`, puis mettre a jour ces trois\n// nombres. Ce ne sont pas des reglages, ce sont des resultats de mesure.\n//\n// ⚠️ CES PENTES ONT ETE MESUREES SUR SEPT MODULES. Retirer ou ajouter un module\n// divise ou multiplie la capacite sans changer la FORME de la courbe : on les\n// exprime donc par module, et on les multiplie par le nombre reellement en\n// service. Ce nombre se lit dans un helper — `input_number\n// .evcs_balance_modules_en_service` — et non dans le code : le parc a change\n// deux fois en une semaine et changera encore cet hiver. Helper absent ou\n// illisible => 7, la valeur sur laquelle la mesure a ete faite, donc aucun\n// changement de comportement tant que personne ne l'ecrit.\nconst MODULES_MESURE = 7;\nconst modulesEnService = (() => {\n const n = num(input.modules_en_service, MODULES_MESURE);\n return Number.isFinite(n) && n >= 1 && n <= 24 ? n : MODULES_MESURE;\n})();\nconst echelleParc = modulesEnService / MODULES_MESURE;\n\n// 20/09/2026 — RE-MESURE sur le parc ASSAINI (13 equivalents US2000 : 4 US5000\n// + 5 US2000C, cellules a 3-6 mV). `tools/mesure-courbe-batterie.mjs 8` :\n// 0,30 kWh/point PLAT de 25 a 97 %, PLUS DE GENOU. Le genou a 33 % etait bien\n// la signature de la cellule 12 morte du pack ancien (§50) — il a disparu avec\n// elle, exactement comme le predisait la note de prudence du 03/09. La reserve\n// reelle a quasiment double (12,4 -> ~23 kWh utiles de 100 a 22 %).\n// Base normalisee a 7 modules (MODULES_MESURE) : 0,30 x 7/13 = 0,1615 par point.\n// x echelleParc(13/7) = 0,30 au parc actuel. Pente unique => plus de genou.\n// A REMESURER apres toute evolution du parc (2e cable console en commande pour\n// surveiller aussi les cellules du pack 1 US5000).\nconst BATTERY_CURVE = {\n kneePct: 0,\n kwhPerPointAbove: 0.1615 * echelleParc,\n kwhPerPointBelow: 0.1615 * echelleParc,\n};\n\n// Energie que la batterie peut delivrer en descendant de socHi a socLo.\nfunction energyBetweenSoc(socLo, socHi) {\n if (!Number.isFinite(socLo) || !Number.isFinite(socHi) || socHi <= socLo) return 0;\n const knee = BATTERY_CURVE.kneePct;\n const lowSpan = Math.max(0, Math.min(socHi, knee) - Math.min(socLo, knee));\n const highSpan = Math.max(0, Math.max(socHi, knee) - Math.max(socLo, knee));\n return lowSpan * BATTERY_CURVE.kwhPerPointBelow\n + highSpan * BATTERY_CURVE.kwhPerPointAbove;\n}\n\n// SOC necessaire pour delivrer energyKwh en descendant jusqu'a floorPct.\n// NON ecrete : au-dela de 100 la valeur reste le signal de sous-dimensionnement\n// (cf. §26). L'extrapolation au-dessus de 100 utilise la pente haute.\nfunction socForEnergy(energyKwh, floorPct) {\n if (!Number.isFinite(energyKwh) || energyKwh <= 0) return floorPct;\n const knee = Math.max(floorPct, BATTERY_CURVE.kneePct);\n const belowKwh = (knee - floorPct) * BATTERY_CURVE.kwhPerPointBelow;\n if (energyKwh <= belowKwh) {\n return floorPct + energyKwh / BATTERY_CURVE.kwhPerPointBelow;\n }\n return knee + (energyKwh - belowKwh) / BATTERY_CURVE.kwhPerPointAbove;\n}\n\n// SOC atteint en ajoutant addKwh a partir de socPct.\nfunction socAfterEnergy(socPct, addKwh) {\n if (!Number.isFinite(addKwh) || addKwh <= 0) return socPct;\n return socForEnergy(energyBetweenSoc(0, socPct) + addKwh, 0);\n}\n\n// =====================================================\n// TENIR JUSQU'A LA PROCHAINE FENETRE HC (28/09/2026)\n// =====================================================\n// Strategie du proprietaire, apres la nuit du 26-27/09 : P12 avait charge la\n// JEEP sur la batterie (72 -> 44 %) parce qu'il restait assez pour atteindre le\n// solaire du matin — alors que ce meme bilan annoncait deja « prochaine\n// traversee 17,35 kWh des 18 h -> SOC requis 79,8 % ». La journee (22 kWh reels)\n// n'a pas pu reconstituer la reserve. Sa regle : une voiture branchee se charge\n// en heures creuses sur le reseau, et la batterie doit TENIR JUSQU'A LA\n// PROCHAINE FENETRE HC ; si elle ne le peut pas, la fenetre HC de midi la\n// complete.\n//\n// Traduction : PENDANT une fenetre HC, l'exigence vaut au moins le SOC qu'il\n// faut avoir A LA FIN de cette fenetre pour atteindre le DEBUT de la suivante\n// sans passer sous le plancher, production prevue comprise (tranches horaires\n// ci-dessus, heure par heure, profil de conso du profil calcule).\n//\n// ⚠️ LE CALENDRIER RESTE DANS HOME ASSISTANT (§73) : le bilan ne recoit que deux\n// instants, la fin de la fenetre en cours (absente hors fenetre) et le debut de\n// la suivante. Hors fenetre HC, rien ne change.\n//\n// ⚠️ INSERE DANS coverage(), COMME LE PLANCHER MANUEL : la cible (required_soc,\n// qui ENGAGE la session et releve le plancher de P12) et le manque en kWh (qui\n// FINANCE la consigne) bougent ensemble par construction — jamais de session\n// muette (§73). Sens unique : il ne peut que RELEVER l'exigence.\nconst hcWindowEndMs = num(input.hc_window_end_ms, NaN);\nconst nextHcStartMs = num(input.next_hc_start_ms, NaN);\nconst nextNightHcStartMs = num(input.next_night_hc_start_ms, NaN);\nconst hcReachActive = Number.isFinite(hcWindowEndMs) && Number.isFinite(nextHcStartMs)\n && nextHcStartMs > hcWindowEndMs;\n// DEUX HORIZONS, DEUX USAGES (decision du proprietaire, 28/09) :\n// - recharger la batterie au reseau : tenir jusqu'a la PROCHAINE fenetre HC\n// (la nuit : midi ; a midi : 01:24). Midi complete si besoin, et la matinee\n// garde de la place pour le solaire. -> required_soc (engage ET finance).\n// - laisser la voiture puiser dans la batterie la nuit (P12) : seulement si la\n// batterie tient ensuite jusqu'a la NUIT SUIVANTE (01:24), sans compter sur\n// midi. Sinon la voiture se charge sur le reseau en HC. -> required_soc_prudent,\n// qui n'est lu que comme plancher de P12 (et par le conseiller, pour info).\nconst hcNightReachActive = Number.isFinite(hcWindowEndMs) && Number.isFinite(nextNightHcStartMs)\n && nextNightHcStartMs > hcWindowEndMs;\n\n// Energie a avoir en reserve a la fin de la fenetre : parcours A REBOURS des\n// tranches comprises entre les deux instants (au prorata de leur recouvrement).\n// Un surplus ne rembourse qu'un deficit POSTERIEUR — d'ou le sens du parcours.\nfunction hcReachFloorPct(slots, untilMs = nextHcStartMs, fromMs = hcWindowEndMs) {\n if (!Number.isFinite(fromMs) || !Number.isFinite(untilMs) || untilMs <= fromMs) return 0;\n let needKwh = 0;\n for (let i = slots.length - 1; i >= 0; i--) {\n const s = slots[i];\n const debut = num(s.startMs, NaN);\n if (!Number.isFinite(debut)) continue;\n const a = Math.max(debut, fromMs);\n const b = Math.min(debut + 3600000, untilMs);\n if (b <= a) continue;\n needKwh = Math.max(0, needKwh - s.netKwh * (b - a) / 3600000);\n }\n return needKwh > 0 ? socForEnergy(needKwh, socFloorPct) : 0;\n}\n\nfunction coverage(slots, hcFloorPct = 0) {\n const crossingIndex = slots.findIndex(s => s.netKwh > 0);\n const found = crossingIndex >= 0;\n const deficitSlots = found ? slots.slice(0, crossingIndex) : slots;\n const energyToCoverKwh = deficitSlots.reduce((sum, s) => sum + Math.max(0, -s.netKwh), 0);\n // Le plancher manuel ne peut que RELEVER l'exigence, jamais l'abaisser — meme\n // sens unique que les cablages de §38, §39 et §40 : le bilan ne pousse jamais\n // dans la direction « couvre moins bien ».\n const requiredSocRawPct = Math.max(\n socForEnergy(energyToCoverKwh, socFloorPct),\n manualHcFloorPct,\n hcFloorPct\n );\n const socNeededPct = requiredSocRawPct - socFloorPct;\n const requiredSocPct = Math.min(100, requiredSocRawPct);\n const socMarginPct = socPct - requiredSocPct;\n return {\n crossingIndex, found, deficitSlots, slots,\n energyToCoverKwh, socNeededPct, requiredSocPct, socMarginPct,\n gridNeeded: socMarginPct < 0,\n gridEnergyNeededKwh: socMarginPct < 0 ? energyBetweenSoc(socPct, requiredSocPct) : 0,\n };\n}\n\nconst slotsP50 = buildSlots(consumptionP50);\nconst slotsP80 = buildSlots(consumptionP80, true);\nconst hcFloorTypicalPct = hcReachFloorPct(slotsP50, nextHcStartMs);\nconst hcFloorNightPrudentPct = hcNightReachActive ? hcReachFloorPct(slotsP80, nextNightHcStartMs) : 0;\nconst hcFloorPrudentPct = Math.max(hcReachFloorPct(slotsP80, nextHcStartMs), hcFloorNightPrudentPct);\nconst typical = coverage(slotsP50, hcFloorTypicalPct);\nconst prudent = coverage(slotsP80, hcFloorPrudentPct);\n\n// ---------------------------------------------------------------------\n// VEHICULE BRANCHE EN HC AVEC UN DEPART AVANT LA PROCHAINE HC (28/09/2026)\n//\n// Regle du proprietaire : « JEEP branchee engage le reseau en HC midi si\n// l'analyse de production le justifie ». La JEEP repart a 15:30 certains jours\n// (calendrier dans HA) : ce qu'elle n'a pas pris avant, elle ne le prendra pas\n// en HC la nuit. Ailleurs, elle reste branchee et la HC de 01:24 la finit au\n// meme tarif — fermer le contacteur a midi n'y gagnerait rien et etoufferait le\n// solaire (installation sans injection).\n//\n// Ce que le solaire peut donner a la voiture avant son depart SANS toucher a\n// la reserve de la nuit : l'energie de la batterie au-dessus du plancher\n// « tenir jusqu'a la prochaine HC » evalue AU DEPART, plus le bilan net prevu\n// (production - maison) d'ici le depart. Profil p50, comme required_soc.\n// Le reseau est justifie quand le besoin de la voiture, borne par ce que son\n// chargeur peut absorber avant le depart, depasse ce solaire.\n//\n// Besoin = (100 - niveau saisi) % x capacite MESUREE au compteur de la borne,\n// moins ce que la borne a deja delivre depuis la saisie. Aucun capteur ne donne\n// le niveau de la voiture : sans saisie valable, la regle se tait.\n//\n// Vaut pour les TROIS vehicules (JEEP, VW, moto ZERO) : capacite, puissance de\n// charge et calendrier viennent de HA pour le vehicule selectionne.\n// La puissance du chargeur EMBARQUE (plateau mesure : JEEP 3,66 kW, VW\n// 3,68 kW) borne ce que le vehicule peut prendre avant son depart ; inconnue\n// (0), on ne borne pas — l'erreur va alors vers « reseau justifie », c'est-a-\n// dire vers le vehicule charge, jamais vers la batterie videe.\n// Seuil de 0,5 kWh : en dessous une session ne se justifie pas — une fermeture\n// du contacteur coute 50 a 150 Wh de transitoire (§57), on exige un gain d'au\n// moins trois fois ce cout.\nconst VEHICLE_GRID_MIN_SHORTFALL_KWH = 0.5;\nconst vehicleChargerKw = num(input.vehicle_charger_kw, NaN);\nconst nowMsBalance = num(input.nowMs, Date.now());\nconst vehicleConnected = input.vehicle_connected === true || input.vehicle_connected === 'true';\nconst vehicleDepartureMs = num(input.vehicle_departure_ms, NaN);\nconst vehicleLevelPct = num(input.vehicle_level_pct, NaN);\nconst vehicleLevelValid = input.vehicle_level_valid === true || input.vehicle_level_valid === 'true';\nconst vehicleCapacityKwh = num(input.vehicle_capacity_kwh, NaN);\nconst vehicleDeliveredKwh = Math.max(0, num(input.vehicle_delivered_since_level_kwh, 0));\nconst vehicleNeedKwh =\n vehicleLevelValid && Number.isFinite(vehicleLevelPct) && vehicleLevelPct >= 0 && vehicleLevelPct <= 100\n && Number.isFinite(vehicleCapacityKwh) && vehicleCapacityKwh > 0\n ? Math.max(0, (100 - vehicleLevelPct) / 100 * vehicleCapacityKwh - vehicleDeliveredKwh)\n : NaN;\nconst vehicleRuleActive =\n hcReachActive && vehicleConnected && Number.isFinite(vehicleNeedKwh)\n && Number.isFinite(vehicleDepartureMs) && vehicleDepartureMs > nowMsBalance\n && vehicleDepartureMs < nextHcStartMs;\nlet vehicleSolarKwh = null;\nlet vehicleReachableKwh = null;\nlet vehicleShortfallKwh = null;\nlet vehicleFloorAtDeparturePct = null;\nif (vehicleRuleActive) {\n vehicleFloorAtDeparturePct = hcReachFloorPct(slotsP50, nextHcStartMs, vehicleDepartureMs);\n let netKwh = 0;\n for (const sl of slotsP50) {\n const debut = num(sl.startMs, NaN);\n if (!Number.isFinite(debut)) continue;\n const a = Math.max(debut, nowMsBalance);\n const b = Math.min(debut + 3600000, vehicleDepartureMs);\n if (b > a) netKwh += sl.netKwh * (b - a) / 3600000;\n }\n // Difference SIGNEE : une batterie SOUS son plancher doit d'abord etre\n // remontee par ce meme surplus avant que la voiture n'en voie un kWh.\n const plancherDepart = Math.max(socFloorPct, vehicleFloorAtDeparturePct);\n const batterieAuDessusKwh = socPct >= plancherDepart\n ? energyBetweenSoc(plancherDepart, socPct)\n : -energyBetweenSoc(socPct, plancherDepart);\n vehicleSolarKwh = Math.max(0, batterieAuDessusKwh + netKwh);\n vehicleReachableKwh = Number.isFinite(vehicleChargerKw) && vehicleChargerKw > 0\n ? Math.min(vehicleNeedKwh, vehicleChargerKw * (vehicleDepartureMs - nowMsBalance) / 3600000)\n : vehicleNeedKwh;\n vehicleShortfallKwh = Math.max(0, vehicleReachableKwh - vehicleSolarKwh);\n}\nconst vehicleGridNeeded = vehicleRuleActive && vehicleShortfallKwh > VEHICLE_GRID_MIN_SHORTFALL_KWH;\n\n// ---------------------------------------------------------------------\n// PROCHAINE FENÊTRE DE DÉFICIT (ajout du 24/08/2026)\n//\n// Le calcul ci-dessus répond à « combien d'énergie jusqu'à ce que le solaire\n// prenne le relais ». À 15 h, le solaire a déjà pris le relais : la réponse est\n// zéro et le SOC requis retombe sur le plancher. C'est exact mais inutile à\n// cette heure-là, car le déficit qui approche est celui de la nuit.\n//\n// On cherche donc AUSSI la prochaine fenêtre de déficit après le surplus en\n// cours : son heure de début, son énergie, et le SOC qu'il faudra avoir atteint\n// à ce moment-là. C'est cette grandeur qui remplace la cible de nuit fixe, et\n// qui dit combien de surplus il faut avoir stocké avant le coucher du soleil.\n//\n// Profil PRUDENT : contrairement à la décision réseau immédiate, cette\n// projection engage plusieurs heures et n'est pas révisable à volonté — la nuit\n// tombée, il n'y a plus de solaire pour rattraper une sous-estimation.\n// ---------------------------------------------------------------------\nfunction nextDeficitWindow(slots) {\n const firstSurplus = slots.findIndex(s => s.netKwh > 0);\n if (firstSurplus < 0) return { found: false, reason: 'deficit_en_cours' };\n\n const start = slots.findIndex((s, i) => i > firstSurplus && s.netKwh < 0);\n if (start < 0) return { found: false, reason: 'aucun_deficit_sur_horizon' };\n\n let end = slots.findIndex((s, i) => i > start && s.netKwh > 0);\n if (end < 0) end = slots.length;\n\n const energyKwh = slots.slice(start, end)\n .reduce((sum, s) => sum + Math.max(0, -s.netKwh), 0);\n\n // Surplus encore récupérable d'ici là : de quoi remplir, ou pas.\n const surplusBeforeKwh = slots.slice(0, start)\n .reduce((sum, s) => sum + Math.max(0, s.netKwh), 0);\n\n return {\n found: true,\n startHour: slots[start].hour,\n startMs: slots[start].startMs,\n hoursFromNow: start,\n endHour: end < slots.length ? slots[end].hour : null,\n complete: end < slots.length,\n energyKwh,\n // NON écrêté à 100 volontairement : une valeur au-dessus de 100 signifie\n // que même une batterie pleine ne suffit pas à traverser la fenêtre. C'est\n // exactement le signal de sous-dimensionnement du stockage — l'écrêter le\n // masquerait.\n requiredSocAtStartPct: socForEnergy(energyKwh, socFloorPct),\n surplusBeforeKwh,\n };\n}\n\nconst nextDeficit = nextDeficitWindow(prudent.slots);\n\n// ---------------------------------------------------------------------\n// RÉSERVE D'ORAGE (27/08/2026)\n//\n// Les coupures réseau sont fréquentes ici lors des orages. Point de départ\n// souvent mal posé : une coupure n'arrête PAS l'installation. Le solaire et\n// la batterie continuent de fonctionner, seul le filet de secours disparaît.\n// La question n'est donc pas « combien stocker pour survivre » mais « la\n// batterie traverse-t-elle la prochaine fenêtre de déficit SANS le réseau ».\n//\n// C'est exactement la couverture PRUDENTE (p80) déjà calculée plus haut :\n// rien à inventer, aucune constante de plus. Le profil prudent est le bon\n// choix ici parce qu'une coupure retire précisément la possibilité de\n// rattraper une sous-estimation.\n//\n// Le niveau vient de la vigilance Météo-France, dont l'échelle officielle est\n// vert/jaune/orange/rouge. « Actif » à partir d'orange : c'est le seuil de\n// l'émetteur, pas un nombre choisi ici.\n// ---------------------------------------------------------------------\nconst STORM_SCALE = ['vert', 'jaune', 'orange', 'rouge'];\nconst STORM_ACTIVE_FROM = STORM_SCALE.indexOf('orange');\n\nconst stormRaw = typeof input.storm_alert_level === 'string'\n ? input.storm_alert_level.trim().toLowerCase()\n : '';\nconst stormIndex = STORM_SCALE.indexOf(stormRaw);\nconst stormKnown = stormIndex >= 0;\nconst stormActive = stormIndex >= STORM_ACTIVE_FROM;\n\n// Phénomènes atteignant le niveau d'alerte, s'ils sont exposés en attributs\n// (« Orages », « Vent violent »…). Purement informatif.\nconst stormDetail = (input.storm_alert_detail && typeof input.storm_alert_detail === 'object')\n ? input.storm_alert_detail\n : {};\nconst stormPhenomena = Object.keys(stormDetail)\n .filter(k => typeof stormDetail[k] === 'string'\n && STORM_SCALE.indexOf(stormDetail[k].trim().toLowerCase()) >= STORM_ACTIVE_FROM)\n .sort();\n\n// En surplus, la fenêtre qui compte est la prochaine (la nuit qui vient) ;\n// en déficit, c'est celle qu'on est en train de traverser.\nconst inSurplusNow = prudent.found && prudent.crossingIndex === 0;\nconst stormWindow = (inSurplusNow && nextDeficit.found)\n ? {\n kwh: nextDeficit.energyKwh,\n socPct: nextDeficit.requiredSocAtStartPct,\n startHour: nextDeficit.startHour,\n }\n : {\n kwh: prudent.energyToCoverKwh,\n socPct: prudent.requiredSocPct,\n startHour: null,\n };\nconst stormMarginPct = socPct - stormWindow.socPct;\n\nconst slots = typical.slots;\nconst crossingFound = typical.found;\nconst crossingIndex = typical.crossingIndex;\nconst crossing = crossingFound ? slots[crossingIndex] : null;\nconst deficitSlots = typical.deficitSlots;\n\n// ---------------------------------------------------------------------\n// Marge disponible pour le véhicule : surplus attendu après le croisement,\n// une fois la maison servie. C'est ce qui peut être chargé sans toucher au\n// réseau ni entamer la réserve.\n// ---------------------------------------------------------------------\nconst surplusAfterCrossingKwh = crossingFound\n ? slots.slice(crossingIndex).reduce((sum, s) => sum + Math.max(0, s.netKwh), 0)\n : 0;\n\n// ---------------------------------------------------------------------\n// Confiance : la part du calcul qui repose sur une forme de production minorée\n// (après-midi censuré). Elle ne dégrade PAS la réponse « faut-il du réseau »,\n// qui se joue sur les heures de déficit — celles-ci ne sont jamais censurées,\n// une heure censurée étant par construction une heure de surplus.\n// ---------------------------------------------------------------------\nconst deficitOnWeakShape = deficitSlots.filter(s => s.shapeQuality === 'minoree').length;\nconst confidence = {\n deficitHoursOnWeakShape: deficitOnWeakShape,\n deficitTrustworthy: deficitOnWeakShape === 0,\n surplusTrustworthy: !slots.slice(crossingFound ? crossingIndex : 0)\n .some(s => s.shapeQuality === 'minoree'),\n};\n\nreturn [{\n json: {\n usable: true,\n generatedAt: new Date(num(input.nowMs, Date.now())).toISOString(),\n\n crossing: {\n found: crossingFound,\n hour: crossing ? crossing.hour : null,\n startMs: crossing ? crossing.startMs : null,\n hoursFromNow: crossingFound ? crossingIndex : null,\n },\n\n coverage: {\n // Décision réseau immédiate — remplace input_number.victron_grid_soc_off.\n energyToCoverKwh: round2(typical.energyToCoverKwh),\n requiredSocPct: round1(typical.requiredSocPct),\n currentSocPct: round1(socPct),\n socMarginPct: round1(typical.socMarginPct),\n gridNeeded: typical.gridNeeded,\n gridEnergyNeededKwh: round2(typical.gridEnergyNeededKwh),\n\n // Planification (cible de nuit, dimensionnement) — ne pilote PAS le réseau.\n prudent: {\n energyToCoverKwh: round2(prudent.energyToCoverKwh),\n requiredSocPct: round1(prudent.requiredSocPct),\n socMarginPct: round1(prudent.socMarginPct),\n crossingHour: prudent.found ? prudent.slots[prudent.crossingIndex].hour : null,\n },\n },\n\n // Ce qu'il faudra avoir stocké avant la prochaine traversée déficitaire.\n nextDeficit: nextDeficit.found ? {\n found: true,\n startHour: nextDeficit.startHour,\n hoursFromNow: nextDeficit.hoursFromNow,\n endHour: nextDeficit.endHour,\n complete: nextDeficit.complete,\n energyKwh: round2(nextDeficit.energyKwh),\n requiredSocAtStartPct: round1(nextDeficit.requiredSocAtStartPct),\n surplusBeforeKwh: round2(nextDeficit.surplusBeforeKwh),\n // Franchissable avec le stockage actuel, batterie pleine ?\n feasibleWithCurrentStorage: nextDeficit.requiredSocAtStartPct <= 100,\n // Si non : capacité utile manquante. C'est le chiffre du dimensionnement.\n storageShortfallKwh: energyBetweenSoc(100, nextDeficit.requiredSocAtStartPct),\n reachable: nextDeficit.surplusBeforeKwh >= 0\n && socAfterEnergy(socPct, nextDeficit.surplusBeforeKwh)\n >= nextDeficit.requiredSocAtStartPct,\n } : { found: false, reason: nextDeficit.reason },\n\n vehicle: {\n surplusAvailableKwh: round2(surplusAfterCrossingKwh),\n },\n\n // Nuit prevue par la soiree (28/09) : facteur applique au profil.\n nightModel: {\n active: nightModelActive,\n factor: Math.round(nightFactor * 1000) / 1000,\n rawFactor: Number.isFinite(nightFactorRaw) ? nightFactorRaw : null,\n forecastKwh: num(input.night_house_forecast_kwh, NaN) || null,\n },\n\n // Voiture branchee en HC, depart avant la prochaine HC (28/09).\n vehicleMidday: {\n active: vehicleRuleActive,\n needKwh: Number.isFinite(vehicleNeedKwh) ? round2(vehicleNeedKwh) : null,\n departureMs: Number.isFinite(vehicleDepartureMs) ? vehicleDepartureMs : null,\n solarKwh: vehicleSolarKwh === null ? null : round2(vehicleSolarKwh),\n reachableKwh: vehicleReachableKwh === null ? null : round2(vehicleReachableKwh),\n shortfallKwh: vehicleShortfallKwh === null ? null : round2(vehicleShortfallKwh),\n floorAtDeparturePct: vehicleFloorAtDeparturePct === null ? null : round1(vehicleFloorAtDeparturePct),\n gridNeeded: vehicleGridNeeded,\n },\n\n // Tenir jusqu'a la prochaine fenetre HC (28/09) : SOC a avoir en fin de\n // fenetre HC en cours. 0 hors fenetre ou si la production suffit.\n hcReach: {\n active: hcReachActive,\n windowEndMs: hcReachActive ? hcWindowEndMs : null,\n nextHcStartMs: hcReachActive ? nextHcStartMs : null,\n floorPct: round1(Math.min(100, hcFloorTypicalPct)),\n floorPrudentPct: round1(Math.min(100, hcFloorPrudentPct)),\n nextNightHcStartMs: hcNightReachActive ? nextNightHcStartMs : null,\n nightFloorPrudentPct: round1(Math.min(100, hcFloorNightPrudentPct)),\n raisesRequirement: hcReachActive && hcFloorTypicalPct >= typical.requiredSocPct - 0.05\n && hcFloorTypicalPct > socForEnergy(typical.energyToCoverKwh, socFloorPct),\n },\n\n // Réserve à tenir pour traverser SANS réseau la fenêtre de déficit qui\n // compte. Publiée en permanence — elle documente le risque même par temps\n // calme ; `active` dit seulement si la vigilance la rend d'actualité.\n storm: {\n level: stormKnown ? stormRaw : 'indisponible',\n known: stormKnown,\n active: stormActive,\n phenomena: stormPhenomena,\n windowStartHour: stormWindow.startHour,\n reserveKwh: round2(stormWindow.kwh),\n reserveSocPct: round1(stormWindow.socPct),\n socMarginPct: round1(stormMarginPct),\n covered: stormMarginPct >= 0,\n shortfallKwh: stormMarginPct < 0\n ? round2(energyBetweenSoc(socPct, stormWindow.socPct))\n : 0,\n },\n\n confidence,\n slots,\n\n inputs: {\n usableCapacityKwh: round2(usableCapacityKwh),\n socFloorPct: round1(socFloorPct),\n },\n\n cfg: CFG,\n },\n}];\n" } }, { @@ -123,7 +123,7 @@ 120 ], "parameters": { - "jsCode": "// EVCS Energy Balance — BUILD_HA_CALLS\n// =====================================================================\n// GARANTIE MODE OMBRE : ce nœud ne produit QUE des écritures vers des entités\n// dont le nom commence par `evcs_balance_`, plus une trace logbook. Aucun\n// switch, aucune consigne de borne, aucune stratégie, aucun helper de takeover.\n// Le bilan observe et publie ; il ne pilote rien tant que le câblage réel n'a\n// pas été décidé explicitement.\n//\n// Cette garantie est VÉRIFIÉE par tests/energy-balance/ : la suite échoue si\n// une écriture sort de ce périmètre. Ne pas ajouter d'autres entités ici sans\n// changer le test en connaissance de cause.\n// =====================================================================\n\nconst balance = $input.first().json;\nconst censorship = $('CENSORSHIP').first().json;\nconst rawSnapshot = $('HA Snapshot Balance').first().json;\nconst snapshot = (rawSnapshot && typeof rawSnapshot.data === 'string')\n ? (() => { try { return JSON.parse(rawSnapshot.data); } catch { return rawSnapshot; } })()\n : ((rawSnapshot && rawSnapshot.data) || rawSnapshot);\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 = [];\n\nfunction push(domain, service, data) {\n calls.push({ json: { domain, service, data } });\n}\nfunction setText(entity, value) {\n push('input_text', 'set_value', { entity_id: entity, value: (value ?? '').toString().slice(0, 250) });\n}\nfunction setNumber(entity, value) {\n if (!Number.isFinite(value)) return;\n push('input_number', 'set_value', { entity_id: entity, value });\n}\nfunction setBool(entity, on) {\n push('input_boolean', on ? 'turn_on' : 'turn_off', { entity_id: entity });\n}\n\n// Kill-switch : à OFF, on ne touche à rien du tout, pas même au statut.\nconst enabled = (snapshot.balance_enabled ?? 'on').toString().toLowerCase() === 'on';\nif (!enabled) return [];\n\n// --- Ce que le bilan constate de la production, dans tous les cas ---------\nsetBool('input_boolean.evcs_balance_pv_censored', !!censorship.censorship.pvCensored);\nsetText('input_text.evcs_balance_censor_reason',\n censorship.censorship.pvCensored\n ? censorship.censorship.reasons.join(', ')\n : 'production non bridée');\nsetText('input_text.evcs_balance_last_run', stampParis());\n\n// --- Le bilan lui-même ---------------------------------------------------\nif (!balance.usable) {\n setText('input_text.evcs_balance_status', 'indisponible');\n // Jamais de verdict voiture perime quand le bilan ne sait plus rien.\n setBool('input_boolean.evcs_balance_vehicle_grid_needed', false);\n setText('input_text.evcs_balance_reason',\n `Entrées manquantes : ${(balance.missing || []).join(', ')}. Les contrôleurs gardent leurs règles.`);\n return calls;\n}\n\nconst c = balance.coverage;\n\n// `ok` ne veut pas dire « le calcul a abouti » mais « on peut s'appuyer\n// dessus ». Deux cas dégradent la réponse sans l'empêcher :\n// · aucun croisement production/consommation sur l'horizon — le SOC requis\n// n'est alors plus une exigence datée mais la somme d'un horizon entier ;\n// · des heures de déficit reposant sur une forme de production minorée\n// (repli sur l'enveloppe faute de courbe horaire fraîche).\n// Dans les deux cas le bilan publie quand même ses chiffres — ils restent\n// informatifs — mais il le DIT, pour qu'aucun câblage futur ne s'y fie en\n// silence. Mesuré le 25/08 : sans cette distinction, la nuit précédant la mise\n// en service de la courbe horaire, le bilan a publié un SOC requis de 100 %\n// avec un statut `ok`.\nconst degraded =\n !balance.crossing.found ||\n !(balance.confidence && balance.confidence.deficitTrustworthy);\nsetText('input_text.evcs_balance_status', degraded ? 'degrade' : 'ok');\nsetNumber('input_number.evcs_balance_required_soc', c.requiredSocPct);\nsetNumber('input_number.evcs_balance_required_soc_prudent', c.prudent.requiredSocPct);\n// Regle HC (28/09) publiee pour etre verifiable : 0 = hors fenetre HC.\nsetNumber('input_number.evcs_balance_hc_floor_pct', balance.hcReach ? balance.hcReach.floorPct : 0);\nsetNumber('input_number.evcs_balance_hc_floor_prudent_pct', balance.hcReach ? balance.hcReach.floorPrudentPct : 0);\n// Voiture branchee en HC, depart avant la prochaine HC (28/09). OFF hors regle.\nconst vm = balance.vehicleMidday || {};\nsetBool('input_boolean.evcs_balance_vehicle_grid_needed', !!vm.gridNeeded);\nsetNumber('input_number.evcs_balance_vehicle_need_kwh', vm.active ? vm.reachableKwh : 0);\nsetNumber('input_number.evcs_balance_vehicle_solar_kwh', vm.active ? vm.solarKwh : 0);\nsetNumber('input_number.evcs_balance_energy_to_cover_kwh', c.energyToCoverKwh);\nsetNumber('input_number.evcs_balance_grid_energy_needed_kwh', c.gridEnergyNeededKwh);\nsetNumber('input_number.evcs_balance_vehicle_surplus_kwh', balance.vehicle.surplusAvailableKwh);\nsetBool('input_boolean.evcs_balance_grid_needed', !!c.gridNeeded);\n\n// Prochaine traversée déficitaire : ce que la décision immédiate ne montre pas.\nconst nd = balance.nextDeficit || { found: false };\nif (nd.found) {\n setNumber('input_number.evcs_balance_next_deficit_kwh', nd.energyKwh);\n setNumber('input_number.evcs_balance_next_deficit_required_soc', nd.requiredSocAtStartPct);\n setNumber('input_number.evcs_balance_storage_shortfall_kwh', nd.storageShortfallKwh);\n setText('input_text.evcs_balance_next_deficit_window',\n `${String(nd.startHour).padStart(2, '0')} h → ${nd.endHour == null ? '?' : String(nd.endHour).padStart(2, '0') + ' h'}`\n + (nd.feasibleWithCurrentStorage ? '' : ' (stockage insuffisant)'));\n} else {\n setText('input_text.evcs_balance_next_deficit_window',\n nd.reason === 'deficit_en_cours' ? 'déficit en cours' : 'aucune sur l\\'horizon');\n}\n// --- Réserve d'orage -----------------------------------------------------\n// Publiée même hors vigilance : le chiffre dit en permanence si l'installation\n// passerait la prochaine nuit sans réseau. Le helper SOC est borné à 100 pour\n// rester dans la plage de l'entrée ; le dépassement éventuel reste lisible\n// dans la raison et dans le manque en kWh.\nconst st = balance.storm;\nif (st) {\n setText('input_text.evcs_balance_storm_level', st.level);\n setBool('input_boolean.evcs_balance_storm_active', !!st.active);\n setNumber('input_number.evcs_balance_storm_reserve_kwh', st.reserveKwh);\n setNumber('input_number.evcs_balance_storm_reserve_soc',\n Number.isFinite(st.reserveSocPct) ? Math.min(100, Math.max(0, st.reserveSocPct)) : null);\n setText('input_text.evcs_balance_storm_reason',\n (st.known ? `Vigilance ${st.level}` : 'Vigilance indisponible')\n + (st.phenomena.length ? ` (${st.phenomena.join(', ')})` : '')\n + ` — sans réseau il faut ${st.reserveKwh} kWh`\n + (st.windowStartHour == null ? ' pour la fenêtre en cours' : ` dès ${st.windowStartHour} h`)\n + `, soit SOC ${st.reserveSocPct}% ; actuel ${c.currentSocPct}% → `\n + (st.covered ? 'couvert.' : `manque ${st.shortfallKwh} kWh.`));\n}\n\nsetText('input_text.evcs_balance_crossing_hour',\n balance.crossing.found ? `${String(balance.crossing.hour).padStart(2, '0')} h` : 'aucun sur l\\'horizon');\n\nconst ndText = nd.found\n ? ` | prochaine traversée ${nd.energyKwh} kWh dès ${nd.startHour} h → SOC requis ${nd.requiredSocAtStartPct}%${nd.feasibleWithCurrentStorage ? '' : ` (manque ${Math.round(nd.storageShortfallKwh * 100) / 100} kWh de stockage)`}`\n : '';\n\nconst degradedNote = !balance.crossing.found\n ? ' ⚠ aucun croisement sur l\\'horizon : chiffre indicatif, pas une exigence datée.'\n : (degraded ? ' ⚠ forme de production minorée sur les heures de déficit.' : '');\nconst reason = balance.crossing.found\n ? `Croisement à ${balance.crossing.hour} h, ${c.energyToCoverKwh} kWh à couvrir → SOC requis ${c.requiredSocPct}% (actuel ${c.currentSocPct}%). ${c.gridNeeded ? `Réseau : ${c.gridEnergyNeededKwh} kWh.` : 'Réseau non nécessaire.'}`\n : `Aucun croisement PV/conso sur l'horizon : ${c.energyToCoverKwh} kWh à couvrir → SOC requis ${c.requiredSocPct}%.`;\n// Fenetre HC (28/09) : dire quand c'est « tenir jusqu'a la prochaine HC » qui\n// fixe l'exigence, sinon la raison affiche un croisement qui ne l'explique pas.\nconst hc = balance.hcReach || {};\nconst hcText = hc.active && hc.raisesRequirement\n ? ` Tenir jusqu'à la prochaine HC (${new Date(hc.nextHcStartMs).toLocaleTimeString('fr-FR', { timeZone: 'Europe/Paris', hour: '2-digit', minute: '2-digit' })}) → ${hc.floorPct}% en fin de fenêtre.`\n : '';\nconst hhmm = ms => new Date(ms).toLocaleTimeString('fr-FR', { timeZone: 'Europe/Paris', hour: '2-digit', minute: '2-digit' });\nconst vehText = vm.active\n ? ` Voiture (départ ${hhmm(vm.departureMs)}) : ${vm.reachableKwh} kWh à donner, solaire ${vm.solarKwh} kWh → ${vm.gridNeeded ? 'réseau justifié' : 'solaire suffisant'}.`\n : '';\nsetText('input_text.evcs_balance_reason', reason + hcText + vehText + degradedNote + ndText);\n\n// Trace longue durée, pour comparer jour après jour le seuil calculé au seuil\n// fixe encore en vigueur — c'est cette comparaison qui décidera du câblage.\npush('logbook', 'log', {\n name: 'EVCS Bilan énergétique (ombre)',\n message: `${reason} | seuil fixe actuel grid_soc_off = ${snapshot.grid_soc_off ?? 'n/a'}% | production ${censorship.censorship.pvCensored ? 'BRIDÉE (' + censorship.censorship.reasons.join(', ') + ')' : 'libre'}`,\n entity_id: 'input_text.evcs_balance_status',\n});\n\nreturn calls;\n" + "jsCode": "// EVCS Energy Balance — BUILD_HA_CALLS\n// =====================================================================\n// GARANTIE MODE OMBRE : ce nœud ne produit QUE des écritures vers des entités\n// dont le nom commence par `evcs_balance_`, plus une trace logbook. Aucun\n// switch, aucune consigne de borne, aucune stratégie, aucun helper de takeover.\n// Le bilan observe et publie ; il ne pilote rien tant que le câblage réel n'a\n// pas été décidé explicitement.\n//\n// Cette garantie est VÉRIFIÉE par tests/energy-balance/ : la suite échoue si\n// une écriture sort de ce périmètre. Ne pas ajouter d'autres entités ici sans\n// changer le test en connaissance de cause.\n// =====================================================================\n\nconst balance = $input.first().json;\nconst censorship = $('CENSORSHIP').first().json;\nconst rawSnapshot = $('HA Snapshot Balance').first().json;\nconst snapshot = (rawSnapshot && typeof rawSnapshot.data === 'string')\n ? (() => { try { return JSON.parse(rawSnapshot.data); } catch { return rawSnapshot; } })()\n : ((rawSnapshot && rawSnapshot.data) || rawSnapshot);\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 = [];\n\nfunction push(domain, service, data) {\n calls.push({ json: { domain, service, data } });\n}\nfunction setText(entity, value) {\n push('input_text', 'set_value', { entity_id: entity, value: (value ?? '').toString().slice(0, 250) });\n}\nfunction setNumber(entity, value) {\n if (!Number.isFinite(value)) return;\n push('input_number', 'set_value', { entity_id: entity, value });\n}\nfunction setBool(entity, on) {\n push('input_boolean', on ? 'turn_on' : 'turn_off', { entity_id: entity });\n}\n\n// Kill-switch : à OFF, on ne touche à rien du tout, pas même au statut.\nconst enabled = (snapshot.balance_enabled ?? 'on').toString().toLowerCase() === 'on';\nif (!enabled) return [];\n\n// --- Ce que le bilan constate de la production, dans tous les cas ---------\nsetBool('input_boolean.evcs_balance_pv_censored', !!censorship.censorship.pvCensored);\nsetText('input_text.evcs_balance_censor_reason',\n censorship.censorship.pvCensored\n ? censorship.censorship.reasons.join(', ')\n : 'production non bridée');\nsetText('input_text.evcs_balance_last_run', stampParis());\n\n// --- Le bilan lui-même ---------------------------------------------------\nif (!balance.usable) {\n setText('input_text.evcs_balance_status', 'indisponible');\n // Jamais de verdict voiture perime quand le bilan ne sait plus rien.\n setBool('input_boolean.evcs_balance_vehicle_grid_needed', false);\n setText('input_text.evcs_balance_reason',\n `Entrées manquantes : ${(balance.missing || []).join(', ')}. Les contrôleurs gardent leurs règles.`);\n return calls;\n}\n\nconst c = balance.coverage;\n\n// `ok` ne veut pas dire « le calcul a abouti » mais « on peut s'appuyer\n// dessus ». Deux cas dégradent la réponse sans l'empêcher :\n// · aucun croisement production/consommation sur l'horizon — le SOC requis\n// n'est alors plus une exigence datée mais la somme d'un horizon entier ;\n// · des heures de déficit reposant sur une forme de production minorée\n// (repli sur l'enveloppe faute de courbe horaire fraîche).\n// Dans les deux cas le bilan publie quand même ses chiffres — ils restent\n// informatifs — mais il le DIT, pour qu'aucun câblage futur ne s'y fie en\n// silence. Mesuré le 25/08 : sans cette distinction, la nuit précédant la mise\n// en service de la courbe horaire, le bilan a publié un SOC requis de 100 %\n// avec un statut `ok`.\nconst degraded =\n !balance.crossing.found ||\n !(balance.confidence && balance.confidence.deficitTrustworthy);\nsetText('input_text.evcs_balance_status', degraded ? 'degrade' : 'ok');\nsetNumber('input_number.evcs_balance_required_soc', c.requiredSocPct);\nsetNumber('input_number.evcs_balance_required_soc_prudent', c.prudent.requiredSocPct);\n// Regle HC (28/09) publiee pour etre verifiable : 0 = hors fenetre HC.\nsetNumber('input_number.evcs_balance_hc_floor_pct', balance.hcReach ? balance.hcReach.floorPct : 0);\nsetNumber('input_number.evcs_balance_hc_floor_prudent_pct', balance.hcReach ? balance.hcReach.floorPrudentPct : 0);\n// Voiture branchee en HC, depart avant la prochaine HC (28/09). OFF hors regle.\nconst vm = balance.vehicleMidday || {};\nsetBool('input_boolean.evcs_balance_vehicle_grid_needed', !!vm.gridNeeded);\nsetNumber('input_number.evcs_balance_vehicle_need_kwh', vm.active ? vm.reachableKwh : 0);\nsetNumber('input_number.evcs_balance_vehicle_solar_kwh', vm.active ? vm.solarKwh : 0);\nsetNumber('input_number.evcs_balance_energy_to_cover_kwh', c.energyToCoverKwh);\nsetNumber('input_number.evcs_balance_grid_energy_needed_kwh', c.gridEnergyNeededKwh);\nsetNumber('input_number.evcs_balance_vehicle_surplus_kwh', balance.vehicle.surplusAvailableKwh);\nsetBool('input_boolean.evcs_balance_grid_needed', !!c.gridNeeded);\n\n// Prochaine traversée déficitaire : ce que la décision immédiate ne montre pas.\nconst nd = balance.nextDeficit || { found: false };\nif (nd.found) {\n setNumber('input_number.evcs_balance_next_deficit_kwh', nd.energyKwh);\n setNumber('input_number.evcs_balance_next_deficit_required_soc', nd.requiredSocAtStartPct);\n setNumber('input_number.evcs_balance_storage_shortfall_kwh', nd.storageShortfallKwh);\n setText('input_text.evcs_balance_next_deficit_window',\n `${String(nd.startHour).padStart(2, '0')} h → ${nd.endHour == null ? '?' : String(nd.endHour).padStart(2, '0') + ' h'}`\n + (nd.feasibleWithCurrentStorage ? '' : ' (stockage insuffisant)'));\n} else {\n setText('input_text.evcs_balance_next_deficit_window',\n nd.reason === 'deficit_en_cours' ? 'déficit en cours' : 'aucune sur l\\'horizon');\n}\n// --- Réserve d'orage -----------------------------------------------------\n// Publiée même hors vigilance : le chiffre dit en permanence si l'installation\n// passerait la prochaine nuit sans réseau. Le helper SOC est borné à 100 pour\n// rester dans la plage de l'entrée ; le dépassement éventuel reste lisible\n// dans la raison et dans le manque en kWh.\nconst st = balance.storm;\nif (st) {\n setText('input_text.evcs_balance_storm_level', st.level);\n setBool('input_boolean.evcs_balance_storm_active', !!st.active);\n setNumber('input_number.evcs_balance_storm_reserve_kwh', st.reserveKwh);\n setNumber('input_number.evcs_balance_storm_reserve_soc',\n Number.isFinite(st.reserveSocPct) ? Math.min(100, Math.max(0, st.reserveSocPct)) : null);\n setText('input_text.evcs_balance_storm_reason',\n (st.known ? `Vigilance ${st.level}` : 'Vigilance indisponible')\n + (st.phenomena.length ? ` (${st.phenomena.join(', ')})` : '')\n + ` — sans réseau il faut ${st.reserveKwh} kWh`\n + (st.windowStartHour == null ? ' pour la fenêtre en cours' : ` dès ${st.windowStartHour} h`)\n + `, soit SOC ${st.reserveSocPct}% ; actuel ${c.currentSocPct}% → `\n + (st.covered ? 'couvert.' : `manque ${st.shortfallKwh} kWh.`));\n}\n\nsetText('input_text.evcs_balance_crossing_hour',\n balance.crossing.found ? `${String(balance.crossing.hour).padStart(2, '0')} h` : 'aucun sur l\\'horizon');\n\nconst ndText = nd.found\n ? ` | prochaine traversée ${nd.energyKwh} kWh dès ${nd.startHour} h → SOC requis ${nd.requiredSocAtStartPct}%${nd.feasibleWithCurrentStorage ? '' : ` (manque ${Math.round(nd.storageShortfallKwh * 100) / 100} kWh de stockage)`}`\n : '';\n\nconst degradedNote = !balance.crossing.found\n ? ' ⚠ aucun croisement sur l\\'horizon : chiffre indicatif, pas une exigence datée.'\n : (degraded ? ' ⚠ forme de production minorée sur les heures de déficit.' : '');\nconst reason = balance.crossing.found\n ? `Croisement à ${balance.crossing.hour} h, ${c.energyToCoverKwh} kWh à couvrir → SOC requis ${c.requiredSocPct}% (actuel ${c.currentSocPct}%). ${c.gridNeeded ? `Réseau : ${c.gridEnergyNeededKwh} kWh.` : 'Réseau non nécessaire.'}`\n : `Aucun croisement PV/conso sur l'horizon : ${c.energyToCoverKwh} kWh à couvrir → SOC requis ${c.requiredSocPct}%.`;\n// Fenetre HC (28/09) : dire quand c'est « tenir jusqu'a la prochaine HC » qui\n// fixe l'exigence, sinon la raison affiche un croisement qui ne l'explique pas.\nconst hc = balance.hcReach || {};\nconst hcText = hc.active && hc.raisesRequirement\n ? ` Tenir jusqu'à la prochaine HC (${new Date(hc.nextHcStartMs).toLocaleTimeString('fr-FR', { timeZone: 'Europe/Paris', hour: '2-digit', minute: '2-digit' })}) → ${hc.floorPct}% en fin de fenêtre.`\n : '';\nconst hhmm = ms => new Date(ms).toLocaleTimeString('fr-FR', { timeZone: 'Europe/Paris', hour: '2-digit', minute: '2-digit' });\nconst vehText = vm.active\n ? ` Voiture (départ ${hhmm(vm.departureMs)}) : ${vm.reachableKwh} kWh à donner, solaire ${vm.solarKwh} kWh → ${vm.gridNeeded ? 'réseau justifié' : 'solaire suffisant'}.`\n : '';\nconst nm = balance.nightModel || {};\nconst nightText = nm.active && Math.abs(nm.factor - 1) >= 0.005\n ? ` Nuit recalée sur la soirée : conso ×${String(nm.factor).replace('.', ',')}${nm.forecastKwh ? ` (${String(nm.forecastKwh).replace('.', ',')} kWh prévus 21:30-09:00)` : ''}.`\n : '';\nsetText('input_text.evcs_balance_reason', reason + nightText + hcText + vehText + degradedNote + ndText);\n\n// Trace longue durée, pour comparer jour après jour le seuil calculé au seuil\n// fixe encore en vigueur — c'est cette comparaison qui décidera du câblage.\npush('logbook', 'log', {\n name: 'EVCS Bilan énergétique (ombre)',\n message: `${reason} | seuil fixe actuel grid_soc_off = ${snapshot.grid_soc_off ?? 'n/a'}% | production ${censorship.censorship.pvCensored ? 'BRIDÉE (' + censorship.censorship.reasons.join(', ') + ')' : 'libre'}`,\n entity_id: 'input_text.evcs_balance_status',\n});\n\nreturn calls;\n" } }, {