From cf7eb473562c386ad09887604dd57befc0d90205 Mon Sep 17 00:00:00 2001 From: Tallagrand Thierry Date: Thu, 27 Aug 2026 12:05:15 +0200 Subject: [PATCH] Bilan : reserve d orage, en mode ombre MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Une coupure reseau n arrete pas l installation : le solaire et la batterie continuent, seul le filet de secours disparait. La reserve a tenir est donc la couverture prudente p80 que le bilan calcule deja — aucune constante nouvelle. En deficit c est la fenetre traversee, en surplus la prochaine, car a 15 h la reponse « rien a couvrir » est exacte et inutile. Le seuil d activation est la vigilance orange, echelle officielle de Meteo-France et non un nombre choisi ici. Le snapshot lit l alerte sans connaitre la commune, donc le code marche des l installation de l integration et publie « indisponible » en attendant. Tests 38 -> 42. Le fixture du test de perimetre ombre ne contenait pas storm : les nouvelles ecritures n etaient pas couvertes. Corrige, assertion explicite, preuve par mutation. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01KKzkkVTG6KXnGXFFC7X9XJ --- HANDOFF.md | 52 +++++++++ src/energy-balance/BALANCE.js | 72 ++++++++++++ src/energy-balance/BUILD_HA_CALLS.js | 21 ++++ .../HA_SNAPSHOT_TEMPLATE.json.tpl | 9 +- src/energy-balance/PREPARE_BALANCE.js | 7 ++ tests/energy-balance/run.mjs | 11 ++ tests/energy-balance/scenarios-balance.mjs | 109 ++++++++++++++++++ tests/energy-balance/scenarios-shadow.mjs | 10 ++ workflows/evcs-energy-balance-v1.json | 8 +- 9 files changed, 294 insertions(+), 5 deletions(-) diff --git a/HANDOFF.md b/HANDOFF.md index 6f07015..910f67e 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -1313,6 +1313,58 @@ hiver doit se faire APRÈS ce correctif, sous peine de dimensionner sur une installation qui perd les trois quarts de sa production dès que le réseau est présent. +### Réserve d'orage (déployé 27/08 12:04, mode ombre, tests 42/42) +Besoin : les coupures réseau d'orage sont fréquentes sur ce site, et le +propriétaire veut garder de quoi tenir jusqu'au retour du solaire. + +**Point de départ, souvent mal posé** : une coupure n'arrête PAS +l'installation. Le solaire et la batterie continuent, seul le filet de secours +disparaît. La question n'est donc pas « combien stocker pour survivre » mais +« la batterie traverse-t-elle la prochaine fenêtre de déficit SANS réseau ». +C'est **exactement la couverture prudente (p80) que le bilan calcule déjà** : +aucune constante nouvelle. Le profil prudent est le bon choix ici précisément +parce qu'une coupure retire la possibilité de rattraper une sous-estimation. + +Chaîne : `HA_SNAPSHOT_TEMPLATE` lit la vigilance **sans connaître la commune** +(`states.sensor | selectattr('entity_id','search','_weather_alert')`) → +`PREPARE_BALANCE` passe-plat → `BALANCE` calcule le bloc `storm` → +`BUILD_HA_CALLS` publie 5 helpers `evcs_balance_storm_*`. + +Fenêtre retenue : en déficit, celle qu'on traverse (couverture prudente) ; en +surplus, la prochaine (`nextDeficit`) — car à 15 h la réponse « rien à couvrir » +est exacte et inutile, le risque étant la nuit qui vient. + +Seuil d'activation : **vigilance orange**, c'est-à-dire l'échelle officielle de +Météo-France (vert/jaune/orange/rouge) et non un nombre choisi ici. + +Helpers (package `evcs_energy_balance.yaml`, sauvegarde +`*.bak.20260827`, rechargés et vérifiés) : `evcs_balance_storm_level` · +`_storm_active` · `_storm_reserve_kwh` · `_storm_reserve_soc` (borné à 100 pour +la plage de l'entrée) · `_storm_reason`. + +Tests 38 → 42. Deux résultats notables figés : +- 24/08 06:43, SOC 21 % : réserve 2,42 kWh (SOC 38,1 %) → **manque 2,57 kWh** + pour tenir la matinée sans réseau ; +- 24/08 15:30, SOC 95 %, vigilance rouge : la fenêtre qui compte devient celle + de 19 h, 12,77 kWh, **SOC requis 107,1 %** — même pleine, la batterie + actuelle ne passe pas la nuit sans réseau. Valeur volontairement NON écrêtée : + c'est le signal de dimensionnement pour l'achat de batteries de cet hiver. + +Garantie mode ombre étendue : le fixture synthétique `BALANCE_OK` de +`scenarios-shadow.mjs` ne contenait pas `storm`, donc les nouvelles écritures +n'étaient PAS couvertes par le test de périmètre. Corrigé, plus une assertion +explicite `wroteStorm: 5`. Preuve par mutation : une écriture détournée vers +`input_boolean.victron_evcs_night_grid_takeover_needed` fait échouer la suite +(`outOfScope` la nomme). + +**RESTE À FAIRE — action utilisateur** : l'intégration **Météo-France n'est pas +installée** (HA n'a que `met` / Met.no). Elle est gratuite, sans clé d'API, +intégrée au cœur de HA. Le flux de configuration a été testé par API : il +réclame un **nom de commune** et refuse les coordonnées (`{"city":"empty"}` sur +48.7732657 / -3.5187179) — donc à ajouter par l'interface, ou à me donner la +commune. Tant qu'elle est absente, le bilan publie `indisponible`, `active` +reste faux, et la réserve est quand même chiffrée : **rien ne casse**. + ### Historique HA plus court que configuré `configuration.yaml` déclare `purge_keep_days: 90`, mais l'historique brut s'arrête au **20/08** (~7 jours) : testé vide au 01/06, 29/07, 10/08, 17/08 ; diff --git a/src/energy-balance/BALANCE.js b/src/energy-balance/BALANCE.js index 8ffd3db..379e661 100644 --- a/src/energy-balance/BALANCE.js +++ b/src/energy-balance/BALANCE.js @@ -193,6 +193,60 @@ function nextDeficitWindow(slots) { const nextDeficit = nextDeficitWindow(prudent.slots); +// --------------------------------------------------------------------- +// RÉSERVE D'ORAGE (27/08/2026) +// +// Les coupures réseau sont fréquentes ici lors des orages. Point de départ +// souvent mal posé : une coupure n'arrête PAS l'installation. Le solaire et +// la batterie continuent de fonctionner, seul le filet de secours disparaît. +// La question n'est donc pas « combien stocker pour survivre » mais « la +// batterie traverse-t-elle la prochaine fenêtre de déficit SANS le réseau ». +// +// C'est exactement la couverture PRUDENTE (p80) déjà calculée plus haut : +// rien à inventer, aucune constante de plus. Le profil prudent est le bon +// choix ici parce qu'une coupure retire précisément la possibilité de +// rattraper une sous-estimation. +// +// Le niveau vient de la vigilance Météo-France, dont l'échelle officielle est +// vert/jaune/orange/rouge. « Actif » à partir d'orange : c'est le seuil de +// l'émetteur, pas un nombre choisi ici. +// --------------------------------------------------------------------- +const STORM_SCALE = ['vert', 'jaune', 'orange', 'rouge']; +const STORM_ACTIVE_FROM = STORM_SCALE.indexOf('orange'); + +const stormRaw = typeof input.storm_alert_level === 'string' + ? input.storm_alert_level.trim().toLowerCase() + : ''; +const stormIndex = STORM_SCALE.indexOf(stormRaw); +const stormKnown = stormIndex >= 0; +const stormActive = stormIndex >= STORM_ACTIVE_FROM; + +// Phénomènes atteignant le niveau d'alerte, s'ils sont exposés en attributs +// (« Orages », « Vent violent »…). Purement informatif. +const stormDetail = (input.storm_alert_detail && typeof input.storm_alert_detail === 'object') + ? input.storm_alert_detail + : {}; +const stormPhenomena = Object.keys(stormDetail) + .filter(k => typeof stormDetail[k] === 'string' + && STORM_SCALE.indexOf(stormDetail[k].trim().toLowerCase()) >= STORM_ACTIVE_FROM) + .sort(); + +// En surplus, la fenêtre qui compte est la prochaine (la nuit qui vient) ; +// en déficit, c'est celle qu'on est en train de traverser. +const inSurplusNow = prudent.found && prudent.crossingIndex === 0; +const stormWindow = (inSurplusNow && nextDeficit.found) + ? { + kwh: nextDeficit.energyKwh, + socPct: nextDeficit.requiredSocAtStartPct, + startHour: nextDeficit.startHour, + } + : { + kwh: prudent.energyToCoverKwh, + socPct: prudent.requiredSocPct, + startHour: null, + }; +const stormMarginPct = socPct - stormWindow.socPct; + const slots = typical.slots; const crossingFound = typical.found; const crossingIndex = typical.crossingIndex; @@ -278,6 +332,24 @@ return [{ surplusAvailableKwh: round2(surplusAfterCrossingKwh), }, + // Réserve à tenir pour traverser SANS réseau la fenêtre de déficit qui + // compte. Publiée en permanence — elle documente le risque même par temps + // calme ; `active` dit seulement si la vigilance la rend d'actualité. + storm: { + level: stormKnown ? stormRaw : 'indisponible', + known: stormKnown, + active: stormActive, + phenomena: stormPhenomena, + windowStartHour: stormWindow.startHour, + reserveKwh: round2(stormWindow.kwh), + reserveSocPct: round1(stormWindow.socPct), + socMarginPct: round1(stormMarginPct), + covered: stormMarginPct >= 0, + shortfallKwh: stormMarginPct < 0 + ? round2((Math.abs(stormMarginPct) / 100) * usableCapacityKwh) + : 0, + }, + confidence, slots, diff --git a/src/energy-balance/BUILD_HA_CALLS.js b/src/energy-balance/BUILD_HA_CALLS.js index bc795b2..313e2b1 100644 --- a/src/energy-balance/BUILD_HA_CALLS.js +++ b/src/energy-balance/BUILD_HA_CALLS.js @@ -100,6 +100,27 @@ if (nd.found) { setText('input_text.evcs_balance_next_deficit_window', nd.reason === 'deficit_en_cours' ? 'déficit en cours' : 'aucune sur l\'horizon'); } +// --- Réserve d'orage ----------------------------------------------------- +// Publiée même hors vigilance : le chiffre dit en permanence si l'installation +// passerait la prochaine nuit sans réseau. Le helper SOC est borné à 100 pour +// rester dans la plage de l'entrée ; le dépassement éventuel reste lisible +// dans la raison et dans le manque en kWh. +const st = balance.storm; +if (st) { + setText('input_text.evcs_balance_storm_level', st.level); + setBool('input_boolean.evcs_balance_storm_active', !!st.active); + setNumber('input_number.evcs_balance_storm_reserve_kwh', st.reserveKwh); + setNumber('input_number.evcs_balance_storm_reserve_soc', + Number.isFinite(st.reserveSocPct) ? Math.min(100, Math.max(0, st.reserveSocPct)) : null); + setText('input_text.evcs_balance_storm_reason', + (st.known ? `Vigilance ${st.level}` : 'Vigilance indisponible') + + (st.phenomena.length ? ` (${st.phenomena.join(', ')})` : '') + + ` — sans réseau il faut ${st.reserveKwh} kWh` + + (st.windowStartHour == null ? ' pour la fenêtre en cours' : ` dès ${st.windowStartHour} h`) + + `, soit SOC ${st.reserveSocPct}% ; actuel ${c.currentSocPct}% → ` + + (st.covered ? 'couvert.' : `manque ${st.shortfallKwh} kWh.`)); +} + setText('input_text.evcs_balance_crossing_hour', balance.crossing.found ? `${String(balance.crossing.hour).padStart(2, '0')} h` : 'aucun sur l\'horizon'); diff --git a/src/energy-balance/HA_SNAPSHOT_TEMPLATE.json.tpl b/src/energy-balance/HA_SNAPSHOT_TEMPLATE.json.tpl index 2a7746a..b79341c 100644 --- a/src/energy-balance/HA_SNAPSHOT_TEMPLATE.json.tpl +++ b/src/energy-balance/HA_SNAPSHOT_TEMPLATE.json.tpl @@ -47,5 +47,12 @@ "forecast_tomorrow_kwh": states("sensor.energy_production_tomorrow"), "forecast_next_hour_w": states("sensor.power_production_next_hour"), - "sun_elevation": state_attr("sun.sun", "elevation") + "sun_elevation": state_attr("sun.sun", "elevation"), + + "storm_alert_level": (states.sensor + | selectattr('entity_id', 'search', '_weather_alert') + | map(attribute='state') | list | first) | default('indisponible', true), + "storm_alert_detail": (states.sensor + | selectattr('entity_id', 'search', '_weather_alert') + | map(attribute='attributes') | list | first) | default({}, true) } | tojson }} diff --git a/src/energy-balance/PREPARE_BALANCE.js b/src/energy-balance/PREPARE_BALANCE.js index e66cb04..fe2a220 100644 --- a/src/energy-balance/PREPARE_BALANCE.js +++ b/src/energy-balance/PREPARE_BALANCE.js @@ -46,6 +46,13 @@ return [{ // de code, cf. backlog du chantier). soc_floor_percent: 22, + // Vigilance Météo-France, lue sans connaître la commune (l'entité + // s'appelle sensor._weather_alert). Absente tant que + // l'intégration n'est pas installée : le bilan publie alors + // 'indisponible' et ne change rien. + storm_alert_level: snapshot.storm_alert_level, + storm_alert_detail: snapshot.storm_alert_detail, + // Repris pour l'écriture des helpers, sans influencer le calcul. resourceUsable: resource.usable, shapeConfidence: resource.source ? resource.source.shapeConfidence : null, diff --git a/tests/energy-balance/run.mjs b/tests/energy-balance/run.mjs index 921da24..987ac7e 100644 --- a/tests/energy-balance/run.mjs +++ b/tests/energy-balance/run.mjs @@ -88,6 +88,14 @@ const SUMMARIZE = { nextDeficitEnergyKwh: o.nextDeficit.found ? o.nextDeficit.energyKwh : null, nextDeficitRequiredSocPct: o.nextDeficit.found ? o.nextDeficit.requiredSocAtStartPct : null, storageShortfallKwh: o.nextDeficit.found ? Math.round(o.nextDeficit.storageShortfallKwh * 100) / 100 : null, + // Réserve d'orage : niveau lu, activation, et le chiffre qui compte — + // ce qu'il faut en batterie pour traverser sans réseau. + stormLevel: o.storm.level, + stormActive: o.storm.active, + stormReserveKwh: o.storm.reserveKwh, + stormReserveSocPct: o.storm.reserveSocPct, + stormCovered: o.storm.covered, + stormShortfallKwh: o.storm.shortfallKwh, }) : ({ usable: false, missing: o.missing.join(',') }), }; @@ -101,6 +109,9 @@ SUMMARIZE.BUILD_HA_CALLS = (calls) => { outOfScope: entities.filter(e => !/^(input_text|input_number|input_boolean)\.evcs_balance_/.test(e)).join(','), wroteRequiredSoc: entities.includes('input_number.evcs_balance_required_soc'), wroteGridNeeded: entities.includes('input_boolean.evcs_balance_grid_needed'), + // Les 5 écritures de la réserve d'orage sont-elles bien émises ET dans le + // périmètre ombre ? (outOfScope les attraperait si elles en sortaient). + wroteStorm: entities.filter(e => /evcs_balance_storm_/.test(e)).length, hasLogbook: calls.some(c => c.json.domain === 'logbook'), wroteProfiles: entities.includes('input_text.evcs_balance_consumption_p50'), status: (calls.find(c => c.json.data.entity_id === 'input_text.evcs_balance_status') || { json: { data: {} } }).json.data.value, diff --git a/tests/energy-balance/scenarios-balance.mjs b/tests/energy-balance/scenarios-balance.mjs index b531af2..3a5c284 100644 --- a/tests/energy-balance/scenarios-balance.mjs +++ b/tests/energy-balance/scenarios-balance.mjs @@ -145,4 +145,113 @@ export const balanceScenarios = [ missing: 'usable_capacity_kwh', }, }, + + // ================= RÉSERVE D'ORAGE (27/08/2026) ================= + // Une coupure réseau n'arrête PAS l'installation : le solaire et la batterie + // continuent, seul le filet de secours disparaît. La réserve à tenir est donc + // la couverture PRUDENTE (p80) de la fenêtre de déficit qui compte — pas une + // nouvelle constante. + { + node: 'BALANCE', + id: 'orage-vigilance-absente', + label: 'Intégration Météo-France non installée : niveau indisponible, aucune activation, mais la réserve reste chiffrée', + snapshot: { + nowMs: Date.parse('2026-08-24T06:43:00+02:00'), + consumption_p50: P50, + consumption_p80: P80, + horizon: horizonFrom('2026-08-24T06:43:00+02:00', 18, PROD_24_08), + soc_percent: 21, + usable_capacity_kwh: 15, + soc_floor_percent: 22, + storm_alert_level: 'indisponible', + }, + expect: { + usable: true, + stormLevel: 'indisponible', + stormActive: false, + // La réserve se calcule quand même : 2,42 kWh pour la matinée du 24/08 + // sur le profil prudent, soit SOC 38,1 %. + stormReserveKwh: 2.42, + stormReserveSocPct: 38.1, + }, + }, + { + node: 'BALANCE', + id: 'orage-vigilance-jaune-pas-active', + label: 'Vigilance jaune : en dessous du seuil de Météo-France, la réserve est publiée mais pas déclarée d\'actualité', + snapshot: { + nowMs: Date.parse('2026-08-24T06:43:00+02:00'), + consumption_p50: P50, + consumption_p80: P80, + horizon: horizonFrom('2026-08-24T06:43:00+02:00', 18, PROD_24_08), + soc_percent: 21, + usable_capacity_kwh: 15, + soc_floor_percent: 22, + storm_alert_level: 'Jaune', + }, + expect: { + usable: true, + stormLevel: 'jaune', + stormActive: false, + stormReserveKwh: 2.42, + }, + }, + { + node: 'BALANCE', + id: 'orage-vigilance-orange-deficit-decouvert', + label: 'Vigilance orange à 06:43 avec SOC 21 % : la matinée ne passe pas sans réseau, le manque est chiffré', + snapshot: { + nowMs: Date.parse('2026-08-24T06:43:00+02:00'), + consumption_p50: P50, + consumption_p80: P80, + horizon: horizonFrom('2026-08-24T06:43:00+02:00', 18, PROD_24_08), + soc_percent: 21, + usable_capacity_kwh: 15, + soc_floor_percent: 22, + storm_alert_level: 'Orange', + storm_alert_detail: { 'Orages': 'Orange', 'Vent violent': 'Jaune' }, + }, + expect: { + usable: true, + stormLevel: 'orange', + stormActive: true, + stormReserveKwh: 2.42, + stormReserveSocPct: 38.1, + stormCovered: false, + // SOC 21 contre 38,1 requis : il manque 2,57 kWh pour tenir la matinée + // sans réseau. + stormShortfallKwh: 2.57, + }, + }, + { + node: 'BALANCE', + id: 'orage-vigilance-rouge-surplus-nuit-a-venir', + label: 'Vigilance rouge à 15:30, batterie à 95 % : la fenêtre qui compte est la nuit qui vient, pas celle en cours', + snapshot: { + nowMs: Date.parse('2026-08-24T15:30:00+02:00'), + consumption_p50: P50, + consumption_p80: P80, + horizon: horizonFrom('2026-08-24T15:30:00+02:00', 24, { + ...PROD_24_08, + 15: 3.00, 16: 2.80, 17: 2.60, 18: 2.40, 19: 1.20, 20: 0.20, + 7: 1.50, 8: 3.00, 9: 5.00, + }), + soc_percent: 95, + usable_capacity_kwh: 15, + soc_floor_percent: 22, + storm_alert_level: 'Rouge', + }, + expect: { + usable: true, + stormLevel: 'rouge', + stormActive: true, + // Bascule sur la fenêtre de 19 h : 12,77 kWh, soit un SOC requis de + // 107,1 % — même pleine, la batterie actuelle ne passe pas la nuit sans + // réseau. C'est le signal de dimensionnement, il ne doit pas être écrêté. + stormReserveKwh: 12.77, + stormReserveSocPct: 107.1, + stormCovered: false, + stormShortfallKwh: 1.82, + }, + }, ]; diff --git a/tests/energy-balance/scenarios-shadow.mjs b/tests/energy-balance/scenarios-shadow.mjs index e130c9e..9e93c42 100644 --- a/tests/energy-balance/scenarios-shadow.mjs +++ b/tests/energy-balance/scenarios-shadow.mjs @@ -30,6 +30,13 @@ const BALANCE_OK = { }, vehicle: { surplusAvailableKwh: 20.17 }, confidence: { deficitTrustworthy: true }, + // La réserve d'orage fait partie de la sortie normale de BALANCE : elle doit + // figurer ici, sinon la garantie de périmètre ne couvre pas ses écritures. + storm: { + level: 'orange', known: true, active: true, phenomena: ['Orages'], + windowStartHour: null, reserveKwh: 2.42, reserveSocPct: 38.1, + socMarginPct: -17.1, covered: false, shortfallKwh: 2.57, + }, }; // Le même bilan, mais sans croisement trouvé sur l'horizon : le SOC requis @@ -54,6 +61,9 @@ export const shadowScenarios = [ outOfScope: '', wroteRequiredSoc: true, wroteGridNeeded: true, + // Les 5 écritures de la réserve d'orage sont émises et restent dans le + // périmètre ombre. + wroteStorm: 5, hasLogbook: true, status: 'ok', }, diff --git a/workflows/evcs-energy-balance-v1.json b/workflows/evcs-energy-balance-v1.json index ede9295..e00b376 100644 --- a/workflows/evcs-energy-balance-v1.json +++ b/workflows/evcs-energy-balance-v1.json @@ -51,7 +51,7 @@ "parameters": [ { "name": "template", - "value": "{{ {\n \"ts\": now().isoformat(),\n \"nowMs\": as_timestamp(now()) * 1000,\n\n \"balance_enabled\": states(\"input_boolean.evcs_balance_enabled\"),\n \"usable_capacity_kwh\": states(\"input_number.evcs_balance_usable_capacity_kwh\"),\n\n \"consumption_p50_csv\": states(\"input_text.evcs_balance_consumption_p50\"),\n \"consumption_p80_csv\": states(\"input_text.evcs_balance_consumption_p80\"),\n \"envelope_csv\": states(\"input_text.evcs_balance_envelope\"),\n \"censorship_rate_csv\": states(\"input_text.evcs_balance_censorship_rate\"),\n\n \"forecast_today_csv\": states(\"input_text.evcs_balance_forecast_today\"),\n \"forecast_tomorrow_csv\": states(\"input_text.evcs_balance_forecast_tomorrow\"),\n \"forecast_updated\": states(\"input_text.evcs_balance_forecast_updated\"),\n \"grid_soc_off\": states(\"input_number.victron_grid_soc_off\"),\n\n \"pv_power_w\": states(\"sensor.victron_pv_power_system\"),\n\n \"house_ac_power_w\": states(\"sensor.victron_consumption_power_l1\"),\n \"vebus_ac_out_l1_power_w\": states(\"sensor.victron_ve_bus_ac_out_l1_power\"),\n \"evcs_ac_power_w\": states(\"sensor.victron_evcs_ac_power\"),\n\n \"soc_percent\": states(\"sensor.victron_battery_soc\"),\n \"battery_current_a\": states(\"sensor.victron_battery_current\"),\n \"battery_voltage_v\": states(\"sensor.victron_battery_voltage\"),\n \"vebus_dc_power_w\": states(\"sensor.victron_ve_bus_dc_power_system\"),\n\n \"grid_switch\": states(\"switch.edf_general_ok_switch\"),\n \"acin1_available\": states(\"sensor.victron_ac_in1_available\"),\n \"grid_power_l1_w\": states(\"sensor.victron_grid_power_l1\"),\n\n \"multiplus_state\": states(\"sensor.airtria_mpii_v1_state\"),\n \"multiplus_overload_alarm\": states(\"sensor.airtria_mpii_v1_overload_alarm\"),\n \"system_state\": states(\"sensor.gx_device_system_state\"),\n \"ess_batterylife_state\": states(\"sensor.gx_device_ess_batterylife_state\"),\n \"dc_battery_state\": states(\"sensor.gx_device_dc_battery_state\"),\n\n \"mppt_1_state\": states(\"sensor.airtria_mppt1_state\"),\n \"mppt_2_state\": states(\"sensor.mppt_450_200_hq2449yr9pt_state\"),\n \"mppt_3_state\": states(\"sensor.v1_state\"),\n\n \"evcs_native_status\": states(\"sensor.evcs_hq2239dnt7p_status\"),\n\n \"forecast_today_remaining_kwh\": states(\"sensor.energy_production_today_remaining\"),\n \"forecast_today_kwh\": states(\"sensor.energy_production_today\"),\n \"forecast_tomorrow_kwh\": states(\"sensor.energy_production_tomorrow\"),\n \"forecast_next_hour_w\": states(\"sensor.power_production_next_hour\"),\n\n \"sun_elevation\": state_attr(\"sun.sun\", \"elevation\")\n} | tojson }}\n" + "value": "{{ {\n \"ts\": now().isoformat(),\n \"nowMs\": as_timestamp(now()) * 1000,\n\n \"balance_enabled\": states(\"input_boolean.evcs_balance_enabled\"),\n \"usable_capacity_kwh\": states(\"input_number.evcs_balance_usable_capacity_kwh\"),\n\n \"consumption_p50_csv\": states(\"input_text.evcs_balance_consumption_p50\"),\n \"consumption_p80_csv\": states(\"input_text.evcs_balance_consumption_p80\"),\n \"envelope_csv\": states(\"input_text.evcs_balance_envelope\"),\n \"censorship_rate_csv\": states(\"input_text.evcs_balance_censorship_rate\"),\n\n \"forecast_today_csv\": states(\"input_text.evcs_balance_forecast_today\"),\n \"forecast_tomorrow_csv\": states(\"input_text.evcs_balance_forecast_tomorrow\"),\n \"forecast_updated\": states(\"input_text.evcs_balance_forecast_updated\"),\n \"grid_soc_off\": states(\"input_number.victron_grid_soc_off\"),\n\n \"pv_power_w\": states(\"sensor.victron_pv_power_system\"),\n\n \"house_ac_power_w\": states(\"sensor.victron_consumption_power_l1\"),\n \"vebus_ac_out_l1_power_w\": states(\"sensor.victron_ve_bus_ac_out_l1_power\"),\n \"evcs_ac_power_w\": states(\"sensor.victron_evcs_ac_power\"),\n\n \"soc_percent\": states(\"sensor.victron_battery_soc\"),\n \"battery_current_a\": states(\"sensor.victron_battery_current\"),\n \"battery_voltage_v\": states(\"sensor.victron_battery_voltage\"),\n \"vebus_dc_power_w\": states(\"sensor.victron_ve_bus_dc_power_system\"),\n\n \"grid_switch\": states(\"switch.edf_general_ok_switch\"),\n \"acin1_available\": states(\"sensor.victron_ac_in1_available\"),\n \"grid_power_l1_w\": states(\"sensor.victron_grid_power_l1\"),\n\n \"multiplus_state\": states(\"sensor.airtria_mpii_v1_state\"),\n \"multiplus_overload_alarm\": states(\"sensor.airtria_mpii_v1_overload_alarm\"),\n \"system_state\": states(\"sensor.gx_device_system_state\"),\n \"ess_batterylife_state\": states(\"sensor.gx_device_ess_batterylife_state\"),\n \"dc_battery_state\": states(\"sensor.gx_device_dc_battery_state\"),\n\n \"mppt_1_state\": states(\"sensor.airtria_mppt1_state\"),\n \"mppt_2_state\": states(\"sensor.mppt_450_200_hq2449yr9pt_state\"),\n \"mppt_3_state\": states(\"sensor.v1_state\"),\n\n \"evcs_native_status\": states(\"sensor.evcs_hq2239dnt7p_status\"),\n\n \"forecast_today_remaining_kwh\": states(\"sensor.energy_production_today_remaining\"),\n \"forecast_today_kwh\": states(\"sensor.energy_production_today\"),\n \"forecast_tomorrow_kwh\": states(\"sensor.energy_production_tomorrow\"),\n \"forecast_next_hour_w\": states(\"sensor.power_production_next_hour\"),\n\n \"sun_elevation\": state_attr(\"sun.sun\", \"elevation\"),\n\n \"storm_alert_level\": (states.sensor\n | selectattr('entity_id', 'search', '_weather_alert')\n | map(attribute='state') | list | first) | default('indisponible', true),\n \"storm_alert_detail\": (states.sensor\n | selectattr('entity_id', 'search', '_weather_alert')\n | map(attribute='attributes') | list | first) | default({}, true)\n} | tojson }}\n" } ] }, @@ -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 // 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 // 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 // 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 // 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// 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\nfunction coverage(slots) {\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 const socNeededPct = (energyToCoverKwh / usableCapacityKwh) * 100;\n const requiredSocPct = Math.min(100, socFloorPct + socNeededPct);\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 ? (Math.abs(socMarginPct) / 100) * usableCapacityKwh : 0,\n };\n}\n\nconst typical = coverage(buildSlots(consumptionP50));\nconst prudent = coverage(buildSlots(consumptionP80));\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: socFloorPct + (energyKwh / usableCapacityKwh) * 100,\n surplusBeforeKwh,\n };\n}\n\nconst nextDeficit = nextDeficitWindow(prudent.slots);\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: Math.max(\n 0,\n ((nextDeficit.requiredSocAtStartPct - 100) / 100) * usableCapacityKwh\n ),\n reachable: nextDeficit.surplusBeforeKwh >= 0\n && (socPct + (nextDeficit.surplusBeforeKwh / usableCapacityKwh) * 100)\n >= nextDeficit.requiredSocAtStartPct,\n } : { found: false, reason: nextDeficit.reason },\n\n vehicle: {\n surplusAvailableKwh: round2(surplusAfterCrossingKwh),\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// 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\nfunction coverage(slots) {\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 const socNeededPct = (energyToCoverKwh / usableCapacityKwh) * 100;\n const requiredSocPct = Math.min(100, socFloorPct + socNeededPct);\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 ? (Math.abs(socMarginPct) / 100) * usableCapacityKwh : 0,\n };\n}\n\nconst typical = coverage(buildSlots(consumptionP50));\nconst prudent = coverage(buildSlots(consumptionP80));\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: socFloorPct + (energyKwh / usableCapacityKwh) * 100,\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: Math.max(\n 0,\n ((nextDeficit.requiredSocAtStartPct - 100) / 100) * usableCapacityKwh\n ),\n reachable: nextDeficit.surplusBeforeKwh >= 0\n && (socPct + (nextDeficit.surplusBeforeKwh / usableCapacityKwh) * 100)\n >= nextDeficit.requiredSocAtStartPct,\n } : { found: false, reason: nextDeficit.reason },\n\n vehicle: {\n surplusAvailableKwh: round2(surplusAfterCrossingKwh),\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((Math.abs(stormMarginPct) / 100) * usableCapacityKwh)\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 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);\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}\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}%.`;\nsetText('input_text.evcs_balance_reason', reason + 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 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);\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}%.`;\nsetText('input_text.evcs_balance_reason', reason + 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" } }, {