diff --git a/HANDOFF.md b/HANDOFF.md index 8dc688f..2afdbe6 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -1014,6 +1014,19 @@ fermer le contacteur a midi n'y gagnerait rien et etoufferait le solaire. - ⚠️ A surveiller : le niveau doit etre saisi a chaque branchement (sinon la regle se tait) ; la capacite JEEP 8,7 est a affiner sur une charge complete depuis un niveau connu. +- **Etendu aux TROIS vehicules le meme jour** (remarque du proprietaire : « pas logique + de ne traiter que la JEEP ») : par vehicule, jours + heure de depart apres midi, + capacite et puissance de charge MESUREES (`victron_evcs_{jeep,vw,zero}_*`) ; le bilan + lit celles du vehicule selectionne. JEEP 8,7 kWh / 3,66 kW, VW 7,7 / 3,68, ZERO 0 / 0 + = inconnues -> regle muette pour la moto. VW : aucun jour de depart apres midi + (routine MANUEL §0). Puissance inconnue -> pas de borne (erreur vers « reseau + justifie », jamais vers la batterie videe). `ZERO` ajoute au selecteur + heure de + depart du matin (P9) ; ⚠️ creee a 00:00, recalee sur 08:50 (VW) sinon P9 ouvrait + une fenetre vers minuit. Bilan 64/64, golden 123/123. Deploye 14:04. +- Agent vocal (SPARK, accord du proprietaire) : script d'ecriture limite au selecteur + `victron_evcs_vehicle` puis au niveau (jamais `target_vehicle`, helper mort ; jamais + `level_ref_kwh`). ⚠️ un niveau identique reecrit ne change pas l'etat HA -> la + reference du compteur n'est pas recalee. --- *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/dashboards/evcs_master.yaml b/dashboards/evcs_master.yaml index 8f1ebad..7e208bd 100755 --- a/dashboards/evcs_master.yaml +++ b/dashboards/evcs_master.yaml @@ -219,14 +219,36 @@ views: name: Heure départ VW - entity: input_datetime.victron_evcs_departure_time_jeep name: Heure départ JEEP + - entity: input_datetime.victron_evcs_departure_time_zero + name: Heure départ ZERO - entity: input_number.victron_evcs_vehicle_level_pct - name: 🔋 Niveau de la voiture (à saisir au branchement) + name: 🔋 Niveau du véhicule branché (à saisir au branchement) - entity: input_datetime.victron_evcs_jeep_midday_departure name: JEEP repart après midi à - entity: input_text.victron_evcs_jeep_midday_departure_days name: Jours où la JEEP repart (lun mar mer jeu ven sam dim) + - entity: input_datetime.victron_evcs_vw_midday_departure + name: VW repart après midi à + - entity: input_text.victron_evcs_vw_midday_departure_days + name: Jours où la VW repart (lun mar mer jeu ven sam dim) + - entity: input_datetime.victron_evcs_zero_midday_departure + name: ZERO repart après midi à + - entity: input_text.victron_evcs_zero_midday_departure_days + name: Jours où la ZERO repart (lun mar mer jeu ven sam dim) + - entity: input_number.victron_evcs_capacity_jeep_kwh + name: Capacité mesurée JEEP + - entity: input_number.victron_evcs_charger_kw_jeep + name: Puissance de charge JEEP + - entity: input_number.victron_evcs_capacity_vw_kwh + name: Capacité mesurée VW + - entity: input_number.victron_evcs_charger_kw_vw + name: Puissance de charge VW + - entity: input_number.victron_evcs_capacity_zero_kwh + name: Capacité mesurée ZERO + - entity: input_number.victron_evcs_charger_kw_zero + name: Puissance de charge ZERO - entity: input_boolean.evcs_balance_vehicle_grid_needed - name: Réseau justifié pour la voiture à midi (verdict du bilan) + name: Réseau justifié pour le véhicule à midi (verdict du bilan) - entity: input_number.evcs_balance_vehicle_need_kwh name: Besoin voiture avant départ - entity: input_number.evcs_balance_vehicle_solar_kwh diff --git a/docs/MANUEL_COMPLET.md b/docs/MANUEL_COMPLET.md index e5a28e3..c875945 100644 --- a/docs/MANUEL_COMPLET.md +++ b/docs/MANUEL_COMPLET.md @@ -278,6 +278,14 @@ A faire a ce moment-la, et rien avant : 3. la table `session_charge` n'a rien a changer : elle enregistre la valeur du selecteur, quelle qu'elle soit. +**FAIT le 28/09/2026** (points 1 et 2) : `ZERO` est une option du selecteur, +`input_datetime.victron_evcs_departure_time_zero` existe (cale provisoirement sur +08:50, l'heure de la VW, en attendant la sienne) et `INPUT_PARSE` la retient +quand la ZERO est selectionnee (golden `p9-depart-zero-*`). **Reste a donner** : +sa capacite mesuree (`input_number.victron_evcs_capacity_zero_kwh`, 0 = inconnue) +et sa puissance de charge (`..._charger_kw_zero`) ; tant qu'elles valent 0, la +regle de midi se tait pour la moto. + ⚠️ Une moto a une batterie bien plus petite qu'une voiture (une SR/F est de l'ordre de 14 a 17 kWh) et un chargeur embarque plus faible. **C'est donc peut-etre le premier vehicule du foyer qui soit electriquement distinguable diff --git a/packages/victron_evcs_vehicles.yaml b/packages/victron_evcs_vehicles.yaml index 698ee7b..318b5d5 100755 --- a/packages/victron_evcs_vehicles.yaml +++ b/packages/victron_evcs_vehicles.yaml @@ -30,6 +30,7 @@ input_select: options: - VW - JEEP + - ZERO icon: mdi:car-multiple input_datetime: @@ -37,10 +38,24 @@ input_datetime: name: EVCS - Heure départ JEEP has_date: false has_time: true + victron_evcs_departure_time_zero: + name: EVCS - Heure départ ZERO + has_date: false + has_time: true + # Depart APRES LE DEJEUNER, par vehicule (28/09/2026) : les jours ou il + # repart, ce qu'il n'a pas pris avant ne pourra pas l'etre en HC la nuit. victron_evcs_jeep_midday_departure: name: EVCS - Heure de départ JEEP après midi has_date: false has_time: true + victron_evcs_vw_midday_departure: + name: EVCS - Heure de départ VW après midi + has_date: false + has_time: true + victron_evcs_zero_midday_departure: + name: EVCS - Heure de départ ZERO après midi + has_date: false + has_time: true input_boolean: victron_evcs_allow_grid_departure_urgent: @@ -48,7 +63,7 @@ input_boolean: icon: mdi:car-clock # ===================================================== -# JEEP BRANCHEE A MIDI (28/09/2026, strategie du proprietaire) +# VEHICULE BRANCHE A MIDI — JEEP, VW, ZERO (28/09/2026, strategie du proprietaire) # « JEEP branchee engage le reseau en HC midi si l'analyse de production le # justifie ». Le bilan energetique compare le besoin de la voiture a ce que le # solaire peut lui donner AVANT son depart sans descendre la batterie sous le @@ -77,7 +92,8 @@ input_number: icon: mdi:counter # Capacites MESUREES au compteur de la borne (charge complete depuis un # niveau bas), pas les valeurs constructeur. VW 7,70 (15/09, MANUEL §0) ; - # JEEP 8,7 = plus grosse session relevee (22/09), a affiner. + # JEEP 8,7 = plus grosse session relevee (22/09), a affiner. 0 = INCONNUE : + # la regle de midi se tait pour ce vehicule plutot que de deviner. victron_evcs_capacity_jeep_kwh: name: EVCS - Capacité mesurée JEEP min: 1 @@ -94,6 +110,41 @@ input_number: unit_of_measurement: kWh mode: box icon: mdi:car-battery + victron_evcs_capacity_zero_kwh: + name: EVCS - Capacité mesurée ZERO (0 = inconnue) + min: 0 + max: 100 + step: 0.1 + unit_of_measurement: kWh + mode: box + icon: mdi:motorbike-electric + # Puissance que le chargeur EMBARQUE absorbe a consigne pleine (plateau + # mesure). VW 3 680 W et JEEP 3 655 W le 05/09 (MANUEL §0). 0 = inconnue : + # le bilan ne borne alors pas ce que le vehicule peut prendre avant son depart. + victron_evcs_charger_kw_jeep: + name: EVCS - Puissance de charge JEEP (mesurée) + min: 0 + max: 22 + step: 0.01 + unit_of_measurement: kW + mode: box + icon: mdi:ev-station + victron_evcs_charger_kw_vw: + name: EVCS - Puissance de charge VW (mesurée) + min: 0 + max: 22 + step: 0.01 + unit_of_measurement: kW + mode: box + icon: mdi:ev-station + victron_evcs_charger_kw_zero: + name: EVCS - Puissance de charge ZERO (0 = inconnue) + min: 0 + max: 22 + step: 0.01 + unit_of_measurement: kW + mode: box + icon: mdi:ev-station input_text: # Jours ou la JEEP repart apres le dejeuner (lun mar mer jeu ven sam dim). @@ -101,6 +152,14 @@ input_text: name: EVCS - Jours de départ JEEP après midi max: 60 icon: mdi:calendar-week + victron_evcs_vw_midday_departure_days: + name: EVCS - Jours de départ VW après midi + max: 60 + icon: mdi:calendar-week + victron_evcs_zero_midday_departure_days: + name: EVCS - Jours de départ ZERO après midi + max: 60 + icon: mdi:calendar-week automation: - id: evcs_niveau_vehicule_reference diff --git a/src/energy-balance/BALANCE.js b/src/energy-balance/BALANCE.js index f0dfaa0..e250509 100644 --- a/src/energy-balance/BALANCE.js +++ b/src/energy-balance/BALANCE.js @@ -349,7 +349,7 @@ const typical = coverage(slotsP50, hcFloorTypicalPct); const prudent = coverage(slotsP80, hcFloorPrudentPct); // --------------------------------------------------------------------- -// VOITURE BRANCHEE EN HC AVEC UN DEPART AVANT LA PROCHAINE HC (28/09/2026) +// VEHICULE BRANCHE EN HC AVEC UN DEPART AVANT LA PROCHAINE HC (28/09/2026) // // Regle du proprietaire : « JEEP branchee engage le reseau en HC midi si // l'analyse de production le justifie ». La JEEP repart a 15:30 certains jours @@ -369,14 +369,17 @@ const prudent = coverage(slotsP80, hcFloorPrudentPct); // moins ce que la borne a deja delivre depuis la saisie. Aucun capteur ne donne // le niveau de la voiture : sans saisie valable, la regle se tait. // -// Deux constantes, justifiees : -// - 3,7 kW : plateau MESURE des deux vehicules du foyer (3 655 et 3 680 W a -// consigne pleine, MANUEL §0) ; au-dela la voiture n'absorbe rien de plus. -// - 0,5 kWh : seuil de manque en dessous duquel une session ne se justifie -// pas — une fermeture du contacteur coute 50 a 150 Wh de transitoire (§57), -// on exige un gain d'au moins trois fois ce cout. -const VEHICLE_CHARGER_KW = 3.7; +// Vaut pour les TROIS vehicules (JEEP, VW, moto ZERO) : capacite, puissance de +// charge et calendrier viennent de HA pour le vehicule selectionne. +// La puissance du chargeur EMBARQUE (plateau mesure : JEEP 3,66 kW, VW +// 3,68 kW) borne ce que le vehicule peut prendre avant son depart ; inconnue +// (0), on ne borne pas — l'erreur va alors vers « reseau justifie », c'est-a- +// dire vers le vehicule charge, jamais vers la batterie videe. +// Seuil de 0,5 kWh : en dessous une session ne se justifie pas — une fermeture +// du contacteur coute 50 a 150 Wh de transitoire (§57), on exige un gain d'au +// moins trois fois ce cout. const VEHICLE_GRID_MIN_SHORTFALL_KWH = 0.5; +const vehicleChargerKw = num(input.vehicle_charger_kw, NaN); const nowMsBalance = num(input.nowMs, Date.now()); const vehicleConnected = input.vehicle_connected === true || input.vehicle_connected === 'true'; const vehicleDepartureMs = num(input.vehicle_departure_ms, NaN); @@ -414,7 +417,9 @@ if (vehicleRuleActive) { ? energyBetweenSoc(plancherDepart, socPct) : -energyBetweenSoc(socPct, plancherDepart); vehicleSolarKwh = Math.max(0, batterieAuDessusKwh + netKwh); - vehicleReachableKwh = Math.min(vehicleNeedKwh, VEHICLE_CHARGER_KW * (vehicleDepartureMs - nowMsBalance) / 3600000); + vehicleReachableKwh = Number.isFinite(vehicleChargerKw) && vehicleChargerKw > 0 + ? Math.min(vehicleNeedKwh, vehicleChargerKw * (vehicleDepartureMs - nowMsBalance) / 3600000) + : vehicleNeedKwh; vehicleShortfallKwh = Math.max(0, vehicleReachableKwh - vehicleSolarKwh); } const vehicleGridNeeded = vehicleRuleActive && vehicleShortfallKwh > VEHICLE_GRID_MIN_SHORTFALL_KWH; diff --git a/src/energy-balance/HA_SNAPSHOT_TEMPLATE.json.tpl b/src/energy-balance/HA_SNAPSHOT_TEMPLATE.json.tpl index 6d8ae6c..21e15d5 100644 --- a/src/energy-balance/HA_SNAPSHOT_TEMPLATE.json.tpl +++ b/src/energy-balance/HA_SNAPSHOT_TEMPLATE.json.tpl @@ -20,20 +20,24 @@ {%- if w[0] >= repere and ns.suivant == '' -%}{%- set ns.suivant = w[0] -%}{%- endif -%} {%- if w[0] >= repere and w[2] == 'nuit' and ns.nuit == '' -%}{%- set ns.nuit = w[0] -%}{%- endif -%} {%- endfor -%} -{#- Voiture branchee a midi (28/09/2026). Le calendrier (jours et heure de - depart de la JEEP apres le dejeuner) reste dans HA : le bilan recoit un - instant de depart, vide si la voiture ne repart pas aujourd'hui. Le niveau - saisi n'est retenu que s'il date de ce branchement (tolerance 1 h avant). -#} -{%- set vh = namespace(dep='', valide=false, cap=none) -%} +{#- Vehicule branche a midi (28/09/2026) — JEEP, VW ou ZERO, selon le + selecteur. Pour chacun, HA porte le calendrier (jours et heure de depart + apres le dejeuner), la capacite et la puissance de charge MESUREES ; le + bilan recoit un instant de depart, vide si le vehicule ne repart pas + aujourd'hui. Le niveau saisi n'est retenu que s'il date de ce branchement + (tolerance 1 h avant). Capacite ou puissance a 0 = inconnue. -#} +{%- set vh = namespace(dep='', valide=false, cap=none, kw=none) -%} {%- set jours = ['lun', 'mar', 'mer', 'jeu', 'ven', 'sam', 'dim'] -%} -{%- if is_state('input_select.victron_evcs_vehicle', 'JEEP') -%} - {%- set vh.cap = states('input_number.victron_evcs_capacity_jeep_kwh') -%} - {%- if jours[now().weekday()] in states('input_text.victron_evcs_jeep_midday_departure_days') | lower -%} - {%- set d = today_at(states('input_datetime.victron_evcs_jeep_midday_departure')) -%} +{%- set cle = {'JEEP': 'jeep', 'VW': 'vw', 'ZERO': 'zero'}.get(states('input_select.victron_evcs_vehicle')) -%} +{%- if cle -%} + {%- set vh.cap = states('input_number.victron_evcs_capacity_' ~ cle ~ '_kwh') -%} + {%- set vh.kw = states('input_number.victron_evcs_charger_kw_' ~ cle) -%} + {%- set heure = states('input_datetime.victron_evcs_' ~ cle ~ '_midday_departure') -%} + {%- if jours[now().weekday()] in states('input_text.victron_evcs_' ~ cle ~ '_midday_departure_days') | lower + and heure not in ['unknown', 'unavailable', ''] -%} + {%- set d = today_at(heure) -%} {%- if d > now() -%}{%- set vh.dep = d -%}{%- endif -%} {%- endif -%} -{%- elif is_state('input_select.victron_evcs_vehicle', 'VW') -%} - {%- set vh.cap = states('input_number.victron_evcs_capacity_vw_kwh') -%} {%- endif -%} {%- set branche = states.binary_sensor.victron_evcs_vehicle_connected -%} {%- set niveau = states.input_number.victron_evcs_vehicle_level_pct -%} @@ -47,6 +51,7 @@ "vehicle_level_pct": states("input_number.victron_evcs_vehicle_level_pct"), "vehicle_level_valid": vh.valide, "vehicle_capacity_kwh": vh.cap, + "vehicle_charger_kw": vh.kw, "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, "hc_window_end_ms": (as_timestamp(ns.fin) * 1000) if ns.fin != '' else none, "next_hc_start_ms": (as_timestamp(ns.suivant) * 1000) if ns.suivant != '' else none, diff --git a/src/energy-balance/PREPARE_BALANCE.js b/src/energy-balance/PREPARE_BALANCE.js index 1e821d3..5a9ad1a 100644 --- a/src/energy-balance/PREPARE_BALANCE.js +++ b/src/energy-balance/PREPARE_BALANCE.js @@ -60,12 +60,13 @@ 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, - // Voiture branchee a midi (28/09) : depart du jour et besoin saisi. + // 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, vehicle_level_pct: snapshot.vehicle_level_pct, vehicle_level_valid: snapshot.vehicle_level_valid, vehicle_capacity_kwh: snapshot.vehicle_capacity_kwh, + vehicle_charger_kw: snapshot.vehicle_charger_kw, vehicle_delivered_since_level_kwh: snapshot.vehicle_delivered_since_level_kwh, hc_window_end_ms: snapshot.hc_window_end_ms, next_hc_start_ms: snapshot.next_hc_start_ms, diff --git a/src/nodes/HA_SNAPSHOT_TEMPLATE.json.tpl b/src/nodes/HA_SNAPSHOT_TEMPLATE.json.tpl index e9301d6..5ba32d6 100644 --- a/src/nodes/HA_SNAPSHOT_TEMPLATE.json.tpl +++ b/src/nodes/HA_SNAPSHOT_TEMPLATE.json.tpl @@ -100,6 +100,7 @@ "departure_time": states("input_datetime.victron_evcs_departure_time"), "departure_time_jeep": states("input_datetime.victron_evcs_departure_time_jeep"), + "departure_time_zero": states("input_datetime.victron_evcs_departure_time_zero"), "evcs_vehicle": states("input_select.victron_evcs_vehicle"), "allow_grid_departure_urgent": states("input_boolean.victron_evcs_allow_grid_departure_urgent"), "last_start_at": states("input_datetime.victron_evcs_last_start_at"), diff --git a/src/nodes/INPUT_PARSE.js b/src/nodes/INPUT_PARSE.js index 66b36b6..c01788f 100644 --- a/src/nodes/INPUT_PARSE.js +++ b/src/nodes/INPUT_PARSE.js @@ -445,6 +445,8 @@ const params = { // Le sélecteur input_select.victron_evcs_vehicle choisit l'heure de départ // effective : VW = victron_evcs_departure_time (~08:50), // JEEP = victron_evcs_departure_time_jeep (~06:30). + // ZERO (moto, 28/09/2026) = victron_evcs_departure_time_zero. Heure absente + // ou illisible -> repli sur celle de la VW, comme la JEEP. selectedVehicle: cleanText(input.evcs_vehicle, 'VW'), departureTimeMin: low(input.evcs_vehicle || 'vw') === 'jeep' @@ -452,7 +454,12 @@ const params = { input.departure_time_jeep, parseTimeToMinutes(input.departure_time, null) ) - : parseTimeToMinutes(input.departure_time, null), + : low(input.evcs_vehicle || 'vw') === 'zero' + ? parseTimeToMinutes( + input.departure_time_zero, + parseTimeToMinutes(input.departure_time, null) + ) + : parseTimeToMinutes(input.departure_time, null), allowGridDepartureUrgent: low(input.allow_grid_departure_urgent || 'off') === 'on', dayStartTimeMin: parseTimeToMinutes(input.day_start_time, 7 * 60 + 30), dayStopTimeMin: parseTimeToMinutes(input.day_stop_time, 20 * 60 + 30), diff --git a/tests/energy-balance/scenarios-balance.mjs b/tests/energy-balance/scenarios-balance.mjs index 678d1a2..28a5e3f 100644 --- a/tests/energy-balance/scenarios-balance.mjs +++ b/tests/energy-balance/scenarios-balance.mjs @@ -69,7 +69,8 @@ const SOMBRE = 18 / 25.1; function jeep(snapshot, niveau, extra = {}) { return Object.assign(snapshot, { vehicle_connected: true, vehicle_departure_ms: Date.parse('2026-09-27T15:30:00+02:00'), vehicle_level_pct: niveau, - vehicle_level_valid: true, vehicle_capacity_kwh: 8.7, vehicle_delivered_since_level_kwh: 0 }, extra); + vehicle_level_valid: true, vehicle_capacity_kwh: 8.7, vehicle_charger_kw: 3.7, + vehicle_delivered_since_level_kwh: 0 }, extra); } export const balanceScenarios = [ @@ -489,4 +490,26 @@ export const balanceScenarios = [ snapshot: jeep(snap2709('2026-09-27T15:00:00+02:00', 30, null, SOMBRE), 12), expect: { usable: true, vehicleActive: false, vehicleGridNeeded: false }, }, + // ======== LES TROIS VEHICULES (28/09/2026) ======== + { + node: 'BALANCE', + id: 'bilan-vehicule-capacite-inconnue-regle-muette', + label: 'Moto ZERO, capacite non mesuree (0) : on ne devine pas son besoin, la regle se tait', + snapshot: jeep(snap2709('2026-09-27T12:30:00+02:00', 30, MIDI_2709, SOMBRE), 12, { vehicle_capacity_kwh: 0 }), + expect: { usable: true, vehicleActive: false, vehicleGridNeeded: false }, + }, + { + node: 'BALANCE', + id: 'bilan-vehicule-puissance-bornee', + label: 'Chargeur embarque a 3,7 kW, 3 h avant le depart : le vehicule ne peut pas prendre plus de 11,1 kWh', + snapshot: jeep(snap2709('2026-09-27T12:30:00+02:00', 30, MIDI_2709, SOMBRE), 0, { vehicle_capacity_kwh: 15 }), + expect: { usable: true, vehicleActive: true, vehicleNeedKwh: 15, vehicleReachableKwh: 11.1, vehicleGridNeeded: true }, + }, + { + node: 'BALANCE', + id: 'bilan-vehicule-puissance-inconnue-non-bornee', + label: 'Puissance de charge inconnue (0) : on ne borne pas, tout le besoin est compte', + 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 }, + }, ]; diff --git a/tests/golden/scenarios.mjs b/tests/golden/scenarios.mjs index 7905902..fdbd064 100644 --- a/tests/golden/scenarios.mjs +++ b/tests/golden/scenarios.mjs @@ -216,6 +216,37 @@ export const scenarios = [ takeoverHelperService: 'turn_off', }, }, + { + // ZERO (moto, 28/09/2026) : elle a SA propre heure de depart. Meme matin que + // p9-depart-vw-grid, VW a 08:50 : la ZERO part a 08:30, donc la fenetre P9 + // est ouverte pour elle aussi. + id: 'p9-depart-zero-sa-propre-heure', + label: 'Matin 08:00, ZERO sélectionnée, départ ZERO 08:30, toggle P9 ON → réseau autorisé sur SON heure', + time: '2026-08-10T08:00:00+02:00', + overrides: { + ...VEHICLE_NIGHT, soc_percent: '45', + evcs_vehicle: 'ZERO', + departure_time: '08:50:00', + departure_time_jeep: '06:30:00', + departure_time_zero: '08:30:00', + allow_grid_departure_urgent: 'on', + }, + expect: { profile: 'morning_solar', sourceMode: 'grid_hc_allowed', edfOnAction: true }, + }, + { + id: 'p9-depart-zero-passe', + label: 'CONTREFACTUEL — ZERO partie a 07:00 : sa fenetre est passee, meme si celle de la VW (08:50) serait ouverte', + time: '2026-08-10T08:00:00+02:00', + overrides: { + ...VEHICLE_NIGHT, soc_percent: '45', + evcs_vehicle: 'ZERO', + departure_time: '08:50:00', + departure_time_jeep: '06:30:00', + departure_time_zero: '07:00:00', + allow_grid_departure_urgent: 'on', + }, + expect: { profile: 'morning_solar', sourceMode: 'blocked', edfOnAction: false }, + }, { id: 'p9-done-idle-collant', label: 'Matin 08:10, VW pleine détectée (prev=vehicle_full_or_paused) pendant la fenêtre départ → veille collante, réseau relâché', diff --git a/workflows/evcs-energy-balance-v1.json b/workflows/evcs-energy-balance-v1.json index dc0c844..2f63c3c 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{#- Voiture branchee a midi (28/09/2026). Le calendrier (jours et heure de\n depart de la JEEP apres le dejeuner) reste dans HA : le bilan recoit un\n instant de depart, vide si la voiture ne repart pas aujourd'hui. Le niveau\n saisi n'est retenu que s'il date de ce branchement (tolerance 1 h avant). -#}\n{%- set vh = namespace(dep='', valide=false, cap=none) -%}\n{%- set jours = ['lun', 'mar', 'mer', 'jeu', 'ven', 'sam', 'dim'] -%}\n{%- if is_state('input_select.victron_evcs_vehicle', 'JEEP') -%}\n {%- set vh.cap = states('input_number.victron_evcs_capacity_jeep_kwh') -%}\n {%- if jours[now().weekday()] in states('input_text.victron_evcs_jeep_midday_departure_days') | lower -%}\n {%- set d = today_at(states('input_datetime.victron_evcs_jeep_midday_departure')) -%}\n {%- if d > now() -%}{%- set vh.dep = d -%}{%- endif -%}\n {%- endif -%}\n{%- elif is_state('input_select.victron_evcs_vehicle', 'VW') -%}\n {%- set vh.cap = states('input_number.victron_evcs_capacity_vw_kwh') -%}\n{%- endif -%}\n{%- set branche = states.binary_sensor.victron_evcs_vehicle_connected -%}\n{%- set niveau = states.input_number.victron_evcs_vehicle_level_pct -%}\n{%- if branche is not none and niveau is not none and branche.state == 'on' -%}\n {%- set vh.valide = as_timestamp(niveau.last_changed) >= 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_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{%- if branche is not none and niveau is not none and branche.state == 'on' -%}\n {%- set vh.valide = as_timestamp(niveau.last_changed) >= 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" } ] }, @@ -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 // Voiture branchee a midi (28/09) : 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_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 // 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// VOITURE BRANCHEE 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// Deux constantes, justifiees :\n// - 3,7 kW : plateau MESURE des deux vehicules du foyer (3 655 et 3 680 W a\n// consigne pleine, MANUEL §0) ; au-dela la voiture n'absorbe rien de plus.\n// - 0,5 kWh : seuil de manque en dessous duquel une session ne se justifie\n// pas — une fermeture du contacteur coute 50 a 150 Wh de transitoire (§57),\n// on exige un gain d'au moins trois fois ce cout.\nconst VEHICLE_CHARGER_KW = 3.7;\nconst VEHICLE_GRID_MIN_SHORTFALL_KWH = 0.5;\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 = Math.min(vehicleNeedKwh, VEHICLE_CHARGER_KW * (vehicleDepartureMs - nowMsBalance) / 3600000);\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// ---------------------------------------------------------------------\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" } }, { diff --git a/workflows/evcs-surplus-controller-v2.6.json b/workflows/evcs-surplus-controller-v2.6.json index 2e4793a..32e4eda 100644 --- a/workflows/evcs-surplus-controller-v2.6.json +++ b/workflows/evcs-surplus-controller-v2.6.json @@ -94,7 +94,7 @@ }, { "parameters": { - "jsCode": "function num(v, fallback = NaN) {\n const n = Number.parseFloat(v);\n return Number.isFinite(n) ? n : fallback;\n}\n\nfunction low(v) {\n return (v ?? '').toString().trim().toLowerCase();\n}\n\nfunction clamp(v, min, max) {\n return Math.min(Math.max(v, min), max);\n}\n\nfunction round1(v) {\n return Math.round(v * 10) / 10;\n}\n\nfunction round2(v) {\n return Math.round(v * 100) / 100;\n}\n\nfunction to2(n) {\n return String(n).padStart(2, '0');\n}\n\nfunction parseMaybeJson(v) {\n if (typeof v !== 'string') return v;\n try {\n return JSON.parse(v);\n } catch {\n return v;\n }\n}\n\nfunction extractSnapshot(input) {\n if (typeof input === 'string') return parseMaybeJson(input);\n if (input?.data && typeof input.data === 'string') return parseMaybeJson(input.data);\n if (input?.body && typeof input.body === 'string') return parseMaybeJson(input.body);\n if (input?.data && typeof input.data === 'object') return input.data;\n if (input?.body && typeof input.body === 'object') return input.body;\n return input;\n}\n\nfunction parseTimeToMinutes(v, fallback = null) {\n const s = (v ?? '').toString().trim();\n const m = s.match(/^(\\d{1,2}):(\\d{2})(?::(\\d{2}))?$/);\n if (!m) return fallback;\n\n const hh = Number(m[1]);\n const mm = Number(m[2]);\n\n if (!Number.isFinite(hh) || !Number.isFinite(mm)) return fallback;\n return (hh % 24) * 60 + mm;\n}\n\nfunction getParisParts(ms = Date.now()) {\n const fmt = new Intl.DateTimeFormat('fr-FR', {\n timeZone: 'Europe/Paris',\n year: 'numeric',\n month: '2-digit',\n day: '2-digit',\n hour: '2-digit',\n minute: '2-digit',\n second: '2-digit',\n hour12: false,\n });\n\n const parts = Object.fromEntries(\n fmt.formatToParts(new Date(ms))\n .filter(p => p.type !== 'literal')\n .map(p => [p.type, p.value])\n );\n\n const hour = Number(parts.hour) === 24 ? 0 : Number(parts.hour);\n const date = `${parts.year}-${parts.month}-${parts.day}`;\n const stamp = `${date} ${to2(hour)}:${parts.minute}:${parts.second}`;\n\n return {\n year: Number(parts.year),\n month: Number(parts.month),\n day: Number(parts.day),\n hour,\n minute: Number(parts.minute),\n second: Number(parts.second),\n date,\n stamp,\n minNow: hour * 60 + Number(parts.minute),\n };\n}\n\nfunction getTzOffsetMs(utcTs, timeZone = 'Europe/Paris') {\n const dtf = new Intl.DateTimeFormat('en-US', {\n timeZone,\n year: 'numeric',\n month: '2-digit',\n day: '2-digit',\n hour: '2-digit',\n minute: '2-digit',\n second: '2-digit',\n hour12: false,\n });\n\n const parts = Object.fromEntries(\n dtf.formatToParts(new Date(utcTs))\n .filter(p => p.type !== 'literal')\n .map(p => [p.type, p.value])\n );\n\n const asIfUtc = Date.UTC(\n Number(parts.year),\n Number(parts.month) - 1,\n Number(parts.day),\n Number(parts.hour) === 24 ? 0 : Number(parts.hour),\n Number(parts.minute),\n Number(parts.second)\n );\n\n const d = new Date(utcTs);\n const actualUtc = Date.UTC(\n d.getUTCFullYear(),\n d.getUTCMonth(),\n d.getUTCDate(),\n d.getUTCHours(),\n d.getUTCMinutes(),\n d.getUTCSeconds()\n );\n\n return asIfUtc - actualUtc;\n}\n\nfunction parseParisStampToMs(s) {\n const text = (s ?? '').toString().trim();\n const m = text.match(/^(\\d{4})-(\\d{2})-(\\d{2}) (\\d{2}):(\\d{2}):(\\d{2})$/);\n if (!m) return null;\n\n const y = Number(m[1]);\n const mo = Number(m[2]);\n const d = Number(m[3]);\n const h = Number(m[4]);\n const mi = Number(m[5]);\n const se = Number(m[6]);\n\n if (![y, mo, d, h, mi, se].every(Number.isFinite)) return null;\n\n const guessUtc = Date.UTC(y, mo - 1, d, h, mi, se);\n const off1 = getTzOffsetMs(guessUtc, 'Europe/Paris');\n let ts = guessUtc - off1;\n const off2 = getTzOffsetMs(ts, 'Europe/Paris');\n\n if (off2 !== off1) ts = guessUtc - off2;\n return ts;\n}\n\nfunction cleanText(v, fallback = '') {\n const s = (v ?? '').toString().trim();\n const x = s.toLowerCase();\n if (['unknown', 'unavailable', 'none', 'null', 'undefined'].includes(x)) return fallback;\n return s;\n}\n\n// P11 (12/08/2026) : durée de charge EVCS \"1h 49m 53s\" / \"8m 27s\" / \"0s\"\n// → secondes. Repart de 0 à chaque NOUVELLE connexion physique d'un véhicule\n// (débranchement JEEP → branchement VW) → signal de reset du collant \"plein\".\nfunction parseChargingTimeToSec(s) {\n const t = (s ?? '').toString().toLowerCase();\n if (!t) return null;\n let sec = 0;\n let matched = false;\n const h = t.match(/(\\d+)\\s*h/); if (h) { sec += Number(h[1]) * 3600; matched = true; }\n const m = t.match(/(\\d+)\\s*m/); if (m) { sec += Number(m[1]) * 60; matched = true; }\n const s2 = t.match(/(\\d+)\\s*s/); if (s2){ sec += Number(s2[1]); matched = true; }\n return matched ? sec : null;\n}\n\n// Horodatage \"DD/MM HH:mm\" publie par le bilan energetique\n// (input_text.evcs_balance_last_run). Le conseiller employait le meme format ;\n// son cablage a ete retire le 13/09/2026, la fonction sert desormais au bilan.\n// (pas d'année). On suppose l'année courante ; si le résultat est dans le\n// futur (> 1 jour), c'est un passage d'année → on retire un an.\nfunction parseRunStampToMs(s) {\n const m = (s ?? '').toString().trim().match(/^(\\d{2})\\/(\\d{2}) (\\d{2}):(\\d{2})$/);\n if (!m) return null;\n\n const nowMsLocal = Date.now();\n const year = Number(\n new Intl.DateTimeFormat('fr-FR', { timeZone: 'Europe/Paris', year: 'numeric' })\n .format(new Date(nowMsLocal))\n );\n\n const build = (y) => {\n const guessUtc = Date.UTC(y, Number(m[2]) - 1, Number(m[1]), Number(m[3]), Number(m[4]), 0);\n const off1 = getTzOffsetMs(guessUtc, 'Europe/Paris');\n let ts = guessUtc - off1;\n const off2 = getTzOffsetMs(ts, 'Europe/Paris');\n if (off2 !== off1) ts = guessUtc - off2;\n return ts;\n };\n\n let ts = build(year);\n if (ts - nowMsLocal > 24 * 3600 * 1000) ts = build(year - 1);\n return ts;\n}\n\nfunction finiteOrNull(v) {\n return Number.isFinite(v) ? v : null;\n}\n\nconst raw = $input.first().json;\nconst input = extractSnapshot(raw);\n\nconst nowMs = Date.now();\nconst now = getParisParts(nowMs);\n\nconst CFG = {\n mobileNotifyDomain: 'notify',\n mobileNotifyService: 'mobile_app_iphone_16_de_tt',\n\n // 03/09/2026 — valeur MESUREE (576 paliers de SOC sur 8 jours,\n // tools/mesure-courbe-batterie.mjs) : 0,169 kWh par point au-dessus de 33 %,\n // soit 16,9 kWh. Le 17,5 precedent etait une valeur de plaque.\n // NB : sous 33 % un point ne vaut plus que 0,097 kWh. Cette constante ne sert\n // ici qu'a des ecarts SOC -> cible, tous deux au-dessus du genou. Si un calcul\n // devait un jour traverser le genou, il faut la COURBE (src/energy-balance/\n // BALANCE.js) et non cette constante.\n // 20/09/2026 — RE-MESURE sur le parc assaini (13 modules, courbe PLATE a\n // 0,30 kWh/point, plus de genou) : 0,30/point / 13 modules x 100 points =\n // 2,308 kWh par module (etait 2,414 sur l'ancien parc a 7 modules abimes).\n // Le nombre de modules se LIT (meme helper que le bilan) — les deux decrivent\n // forcement le meme parc. Absent ou illisible => 7 (aucun changement tant que\n // personne ne l'ecrit). A remesurer apres toute evolution du parc.\n batteryUsableCapacityKwh: 2.308 * (() => {\n const n = Number(input.modules_en_service);\n return Number.isFinite(n) && n >= 1 && n <= 24 ? n : 7;\n })(),\n nominalAcVoltageV: 230,\n evcsDedicatedMaxW: 4900,\n systemReserveW: 300,\n\n vebusDcVoltageSoftFloorV: 49.0,\n vebusDcVoltageHardFloorV: 48.6,\n\n departurePriorityWindowMinutes: 240,\n sampleSeconds: 30,\n\n cloudDebtRecoveryThresholdKwh: 0.05,\n cloudDebtMeaningfulThresholdKwh: 0.01,\n};\n\n// =====================================================\n// PARSED SNAPSHOT\n// =====================================================\nconst parsed = {\n soc: clamp(num(input.soc_percent, 0), 0, 100),\n batteryCurrentA: num(input.battery_current_a, 0),\n\n pvPowerW: Math.max(0, num(input.pv_power_w, 0)),\n\n // MODE DE FONCTIONNEMENT DES CHARGEURS SOLAIRES (03/09/2026)\n // Chaque tracker declare lui-meme s'il est a son point de puissance maximale\n // ('mppt_active' : il donne tout ce qu'il a) ou s'il se retient\n // ('voltage_current_limited', 'off' : il y a de la ressource REFUSEE).\n // Lectures inexploitables (integration muette) => listes vides => les gardes\n // aval retombent sur le comportement anterieur. On ne devine pas un etat.\n ...(() => {\n const modes = String(input.mppt_operation_modes || '')\n .split(',')\n .map(s => s.trim().toLowerCase())\n .filter(s => s !== '' && s !== 'unavailable' && s !== 'unknown');\n const atMaxPower = modes.filter(s => s === 'mppt_active').length;\n // Un chargeur qui SE RETIENT (`voltage_current_limited`) prouve qu'il reste\n // de la ressource refusee. Un chargeur `off` n'a rien a donner : la nuit,\n // tous le sont. `mpptHoldingBackCount` confond les deux, ce qui est sans\n // consequence pour la garde de provocation solaire (§37, demarrage de jour\n // seulement) mais ferait descendre la consigne reseau au crepuscule si on\n // s'en servait ici — un oscillateur de plus. On compte donc separement ce\n // qui se declare BRIDE, et tout libelle inconnu retombe du cote prudent\n // (pas de ressource refusee, donc comportement anterieur).\n const limited = modes.filter(s => s.includes('limited')).length;\n return {\n mpptReadableCount: modes.length,\n mpptAtMaxPowerCount: atMaxPower,\n mpptHoldingBackCount: modes.length - atMaxPower,\n mpptLimitedCount: limited,\n };\n })(),\n pvPowerAvg10mW: Math.max(0, num(input.pv_power_10m_avg_w, num(input.pv_power_w, 0))),\n pvTrendW: num(input.pv_trend_w, 0),\n\n houseAcPowerW: Math.max(0, num(input.house_ac_power_w, 0)),\n houseAcPowerPrevW: Math.max(0, num(input.house_ac_power_prev_w, num(input.house_ac_power_w, 0))),\n gridPowerW: num(input.grid_power_w, 0),\n\n gridIsOn: low(input.grid_state || 'off') === 'on',\n acInAvailable: num(input.victron_acin1_available ?? input.ac_in1_available, 0) === 1,\n\n // Consigne de puissance réseau de l'ESS, lue telle qu'elle est appliquée.\n // C'est ELLE qui décide si un contacteur fermé sert à quelque chose : au-dessus\n // du SOC minimal, l'ESS en autoconsommation ne prélève que cette puissance et\n // laisse la batterie porter le reste. Nuit du 01/09 : contacteur fermé,\n // consigne 50 W, batterie à -803 W pour 761 W de maison.\n gridSetpointW: num(input.grid_setpoint_w, NaN),\n gridAvailable:\n (low(input.grid_state || 'off') === 'on') ||\n (num(input.victron_acin1_available ?? input.ac_in1_available, 0) === 1),\n\n // FENETRE D'ETABLISSEMENT (31/08/2026) — age, en secondes, de la derniere\n // transition du reseau. On retient la plus RECENTE des deux sources : la\n // presence physique AC-IN (celle qui declenche reellement le transitoire) et\n // le contacteur (qui peut bouger sans qu'AC-IN suive, disjoncteur ouvert).\n // Horodatage absent ou nul => age tres grand => aucune fenetre, comportement\n // historique. On ne devine jamais une transition qu'on n'a pas observee.\n gridTransitionAgeSec: (() => {\n const ts = Math.max(\n num(input.acin1_last_changed_ts, 0),\n num(input.grid_switch_last_changed_ts, 0)\n );\n if (!(ts > 0)) return Number.POSITIVE_INFINITY;\n return Math.max(0, (nowMs / 1000) - ts);\n })(),\n\n todayRemainingKwh: Math.max(0, num(input.today_remaining_kwh, 0)),\n tomorrowKwh: Math.max(0, num(input.tomorrow_kwh, 0)),\n\n evcsDeviceConnected: low(input.evcs_device_connected || 'off') === 'on',\n evcsVehicleConnected: low(input.evcs_vehicle_connected || 'off') === 'on',\n evcsStatusRaw: cleanText(input.evcs_status_raw),\n evcsModeRaw: [\n input.evcs_mode_sensor,\n input.evcs_mode_fallback,\n input.evcs_mode\n ]\n .map(v => cleanText(v))\n .find(v => v) || 'unknown',\n\n evcsSetCurrentA: num(input.evcs_set_current_a, NaN),\n evcsCommandCurrentA: num(input.evcs_command_current_a, NaN),\n\n // Mesure réelle côté borne\n evcsCurrentA: Math.max(0, num(input.evcs_current_a, 0)),\n evcsCurrentRealA: Math.max(0, num(input.evcs_current_a, 0)),\n\n evcsAcPowerW: Math.max(0, num(input.evcs_ac_power_w, 0)),\n evcsChargingTimeSec: parseChargingTimeToSec(input.evcs_charging_time),\n // P11-bis (19/09/2026) : age, en secondes, du dernier changement du\n // capteur de connexion. C'est le SECOND signal de fraicheur, independant\n // du charging_time de la borne — qui gele sur un swap trop rapide (14 s\n // le 19/09) et laisse alors P11 aveugle. last_changed du binary_sensor,\n // lui, bouge sur ce meme swap. Horodatage absent/nul => age infini =>\n // aucun effet, comportement historique (on ne devine pas un branchement).\n evcsVehicleConnectedAgeSec: (() => {\n const ts = num(input.evcs_vehicle_connected_ts, 0);\n if (!(ts > 0)) return Number.POSITIVE_INFINITY;\n return Math.max(0, (nowMs / 1000) - ts);\n })(),\n // Energie deja livree par la session EN COURS (kWh). Le capteur de la borne\n // s'appelle \"last_session_energy\" mais il suit la session courante et repart\n // de zero a chaque nouvelle connexion (verifie le 07/09/2026 : 0 -> 0,1 -> 0,2\n // ... pendant la charge). Sert a juger la credibilite d'un statut \"charged\".\n evcsSessionEnergyKwh: Math.max(0, num(input.evcs_session_energy_kwh, 0)),\n evcsChargingSwitchOn: low(input.evcs_charging_switch || 'off') === 'on',\n evcsAutoStartSwitchOn: low(input.evcs_auto_start_switch || 'off') === 'on',\n\n controllerEnabled: low(input.controller_enabled || 'off') === 'on',\n notificationsEnabled: low(input.notifications_enabled || 'off') === 'on',\n hardStopEnabledLegacy: low(input.hard_stop_enabled || 'off') === 'on',\n chargePermissionNow: low(input.charge_permission_now || 'off') === 'on',\n controlLatch: low(input.control_latch || 'off') === 'on',\n departurePriorityEnabled: low(input.departure_priority_enabled || 'off') === 'on',\n forecastModeEnabled: low(input.forecast_mode_enabled || 'off') === 'on',\n\n evcsPriorityManualOverride: low(input.evcs_priority_manual_override || 'off') === 'on',\n evcsPriorityManualMode: cleanText(input.evcs_priority_manual_mode || 'normal', 'normal').toLowerCase(),\n evcsPriorityManualReason: cleanText(input.evcs_priority_manual_reason),\n\n vebusOverload: low(input.vebus_overload || 'off') === 'on',\n vebusL1Overload: low(input.vebus_l1_overload || 'off') === 'on',\n vebusLowBattery: low(input.vebus_low_battery || 'off') === 'on',\n vebusL1LowBattery: low(input.vebus_l1_low_battery || 'off') === 'on',\n\n vebusAcOutPowerW: Math.max(0, num(input.vebus_ac_out_l1_power_w, 0)),\n vebusAcOutCurrentA: Math.max(0, num(input.vebus_ac_out_l1_current_a, 0)),\n vebusAcOutVoltageV: num(input.vebus_ac_out_l1_voltage_v, CFG.nominalAcVoltageV),\n\n vebusDcVoltageV: num(input.vebus_dc_voltage_v, NaN),\n vebusDcCurrentA: num(input.vebus_dc_current_a, NaN),\n vebusDcPowerW: num(input.vebus_dc_power_w, NaN),\n\n vebusState: num(input.vebus_state, NaN),\n vebusMainState: num(input.vebus_main_state, NaN),\n};\n\nparsed.evcsModeIsAuto = low(parsed.evcsModeRaw).includes('auto');\n\n// =====================================================\n// PARAMETERS\n// =====================================================\nconst params = {\n userStrategyRaw: input.victron_evcs_strategy || 'auto_solar',\n\n targetCurrentAutoA: clamp(num(input.target_current_auto_a, 6), 0, 32),\n pvStartMinW: Math.max(0, num(input.pv_start_min_w, 1800)),\n pvHoldMinW: Math.max(0, num(input.pv_hold_min_w, 1200)),\n houseAcHeadroomW: Math.max(0, num(input.house_ac_headroom_w, 1200)),\n\n batteryDischargeSoftA: num(input.battery_discharge_soft_a, -5),\n batteryDischargeHardA: num(input.battery_discharge_hard_a, -15),\n batteryReserveUpA: Math.max(0, num(input.battery_reserve_up_a, 8)),\n batteryNeutralBandA: Math.max(0, num(input.battery_neutral_band_a, 3)),\n\n socStartMin: clamp(num(input.soc_start_min, 60), 0, 100),\n socStopMin: clamp(num(input.soc_stop_min, 35), 0, 100),\n\n currentMinA: clamp(num(input.current_min_a, 6), 6, 32),\n currentMaxAutoA: clamp(\n num(input.current_max_auto_a, 16),\n clamp(num(input.current_min_a, 6), 6, 32),\n 32\n ),\n gridCurrentMaxA: clamp(num(input.grid_current_max_a, 20), 6, 32),\n currentStepUpA: clamp(num(input.current_step_up_a, 1), 1, 5),\n currentStepDownA: clamp(num(input.current_step_down_a, 2), 1, 8),\n\n currentChangeCooldownSeconds: Math.max(5, num(input.current_change_cooldown_seconds, 60)),\n minRunMinutes: Math.max(0, num(input.min_run_minutes, 3)),\n minStopMinutes: Math.max(0, num(input.min_stop_minutes, 10)),\n\n requiredEnergyKwh: Math.max(0, num(input.required_energy_kwh, 0)),\n socComfortTarget: clamp(num(input.soc_comfort_target, 85), 0, 100),\n endOfDaySocGuard: clamp(num(input.end_of_day_soc_guard, 85), 0, 100),\n baseLoadRemainingKwh: Math.max(0, num(input.base_load_remaining_kwh, 0)),\n flexibleSafetyMarginKwh: Math.max(0, num(input.flexible_safety_margin_kwh, 0)),\n endOfDaySocTarget: clamp(num(input.end_of_day_soc_target, 85), 0, 100),\n\n acLoadsMaxSafeW: Math.max(0, num(input.ac_loads_max_safe_w, 8500)),\n batteryFullSoc: clamp(num(input.battery_full_soc, 90), 0, 100),\n batteryFullForecastMinKwh: Math.max(0, num(input.battery_full_forecast_min_kwh, 8)),\n forecastCommitSocFloor: clamp(num(input.forecast_commit_soc_floor, 35), 0, 100),\n forecastCommitMinKwh: Math.max(0, num(input.forecast_commit_min_kwh, 25)),\n\n cloudDebtPrevKwh: Math.max(0, num(input.cloud_debt_kwh, 0)),\n cloudDebtMaxKwh: Math.max(0.05, num(input.cloud_debt_max_kwh, 1.2)),\n cloudToleranceMinutes: Math.max(1, num(input.cloud_tolerance_minutes, 20)),\n\n // P9 (09/08/2026) : deux véhicules alternent sur la borne.\n // Le sélecteur input_select.victron_evcs_vehicle choisit l'heure de départ\n // effective : VW = victron_evcs_departure_time (~08:50),\n // JEEP = victron_evcs_departure_time_jeep (~06:30).\n selectedVehicle: cleanText(input.evcs_vehicle, 'VW'),\n departureTimeMin:\n low(input.evcs_vehicle || 'vw') === 'jeep'\n ? parseTimeToMinutes(\n input.departure_time_jeep,\n parseTimeToMinutes(input.departure_time, null)\n )\n : parseTimeToMinutes(input.departure_time, null),\n allowGridDepartureUrgent: low(input.allow_grid_departure_urgent || 'off') === 'on',\n dayStartTimeMin: parseTimeToMinutes(input.day_start_time, 7 * 60 + 30),\n dayStopTimeMin: parseTimeToMinutes(input.day_stop_time, 20 * 60 + 30),\n\n hcNightStartMin: parseTimeToMinutes(input.hc_night_start_time, 23 * 60),\n hcNightStopMin: parseTimeToMinutes(input.hc_night_stop_time, 6 * 60),\n morningStartMin: parseTimeToMinutes(\n input.morning_start_time,\n parseTimeToMinutes(input.day_start_time, 7 * 60 + 30)\n ),\n morningStopMin: parseTimeToMinutes(input.morning_stop_time, 11 * 60),\n hcMiddayStartMin: parseTimeToMinutes(input.hc_midday_start_time, 12 * 60 + 21),\n hcMiddayStopMin: parseTimeToMinutes(input.hc_midday_stop_time, 14 * 60 + 21),\n eveningStartMin: parseTimeToMinutes(input.evening_start_time, 17 * 60),\n eveningStopMin: parseTimeToMinutes(\n input.evening_stop_time,\n parseTimeToMinutes(input.day_stop_time, 20 * 60 + 30)\n ),\n\n // Bouton reset borne (19/09/2026) : echappatoire MANUELLE quand la borne\n // se fige (swap trop rapide, statut coince). Force une session fraiche\n // pour un cycle -> nettoie le collant -> retest. Le controleur le\n // rebascule off apres avoir agi (momentane).\n resetBorneRequested: low(input.reset_borne || 'off') === 'on',\n allowGridHcNight: low(input.allow_grid_hc_night || 'off') === 'on',\n allowGridHcMidday: low(input.allow_grid_hc_midday || 'off') === 'on',\n\n // Advisor IA — premier câblage réel (09/08/2026, HANDOFF §20) :\n // cible SOC matin recommandée, appliquée UNIQUEMENT si le toggle est ON.\n // Garde-fous consommés dans WINDOWS_SOURCEV2 (statut ok + fraîcheur + bornes).\n\n // BILAN ENERGETIQUE (03/09/2026). Meme format d'horodatage que l'advisor\n // (\"DD/MM HH:mm\"), d'ou la reutilisation du parseur.\n balanceRequiredSocPct: num(input.balance_required_soc, NaN),\n // Voiture branchee en HC avec un depart avant la prochaine HC (28/09) : le\n // bilan juge que le solaire ne lui donnera pas son besoin avant le depart.\n balanceVehicleGridNeeded: low(input.balance_vehicle_grid_needed || 'off') === 'on',\n // P12 — charge nuit sur batterie. Le plancher est le SOC requis PRUDENT (p80)\n // et non le p50 : la marge est mesuree, pas inventee. On descend la voiture\n // jusqu'a ce que la batterie n'ait plus que ce qu'il lui faut pour tenir\n // jusqu'au croisement PV.\n balanceRequiredSocPrudentPct: num(input.balance_required_soc_prudent, NaN),\n nightBatteryChargeEnabled: low(input.night_battery_charge_enabled || 'off') === 'on',\n nightBatteryDonePrev: low(input.night_battery_done_prev || 'off') === 'on',\n balanceEnergyToCoverKwh: num(input.balance_energy_to_cover_kwh, NaN),\n // ⚠️ NE PAS CONFONDRE LES DEUX GRANDEURS DU BILAN.\n // `balanceEnergyToCoverKwh` est l'energie que la batterie devra FOURNIR\n // jusqu'au croisement PV ; elle sert a CALCULER la cible\n // (`requiredSocPct = socForEnergy(energyToCoverKwh, floor)`) et ne tombe\n // jamais a zero tant qu'il reste de la nuit a traverser, meme batterie\n // pleine. Releve du 17/09 a 20:00 : 11,96 kWh « a couvrir » avec un SOC de\n // 91 %. La prendre pour un deficit ferait reclamer du reseau toute la nuit.\n //\n // `balanceGridEnergyNeededKwh` est le MANQUE :\n // `socMargin < 0 ? energyBetweenSoc(soc, requiredSoc) : 0` (BALANCE.js l.234)\n // — nul des que la batterie couvre, et converti par la COURBE par morceaux\n // mesuree le 03/09 (0,097 kWh/point sous le genou de 33 %, 0,169 au-dessus).\n // C'est donc la seule des deux qui soit juste sous le genou, c'est-a-dire au\n // SOC ou l'on decide de fermer le contacteur.\n balanceGridEnergyNeededKwh: num(input.balance_grid_energy_needed_kwh, NaN),\n // Ce que l'Auto Charge Manager autorise la batterie a PRENDRE (retour de\n // lecture de DVCC). A ne pas confondre avec `bms_max_charge_current_a`, qui\n // est le maximum tolere par le BMS — 250 A, soit plus de 12 kW, donc\n // au-dessus de GRID_SETPOINT_MAX_W : il ne bornerait rien.\n dvccChargeLimitA: num(input.dvcc_max_charge_current_a, NaN),\n balanceStatusOk: cleanText(input.balance_status).toLowerCase() === 'ok',\n balanceLastRunMs: parseRunStampToMs(cleanText(input.balance_last_run)),\n\n // Duree de la fenetre d'etablissement. Valeur mesuree le 31/08/2026 : le\n // transitoire de fermeture dure ~150 s (import 1985 -> 25 W et 2600 -> 52 W,\n // deux campagnes), on prend 180 s de marge. Reglable sans redeploiement via\n // input_number.evcs_grid_settle_seconds ; absent => 180.\n gridSettleSeconds: clamp(num(input.grid_settle_seconds, 180), 0, 900),\n\n // Consigne réseau au repos. 50 W est le défaut Victron (légèrement positif\n // pour ne jamais injecter), pas une valeur choisie ici.\n // CONSIGNE DE REPOS : 200 W et non plus 50 (04/09/2026).\n // 50 W etait le defaut Victron. Mesure sur la nuit du 03-04/09, 12 074\n // echantillons du point de raccordement : 17 % sont NEGATIFS, mediane -18 W,\n // excursions jusqu'a -356 W, episodes de 35 s au plus. Ce n'est pas de\n // l'injection — la batterie se dechargeait a 744 W au meme moment — c'est le\n // regulateur qui chasse autour d'une cible trop proche de zero. A 200 W le\n // point de fonctionnement reste positif sur l'essentiel des excursions.\n // ⚠️ Ne supprime pas les pointes les plus fortes : une excursion de -356 W\n // passerait encore sous zero. Reduit leur nombre, ne les annule pas.\n // CONSIGNE DE REPOS : 50 -> 200 (04/09) -> 700 (06/09).\n //\n // Elle ne s'applique que contacteur FERME sans session reseau EVCS — donc\n // apres une coupure sur SOC bas, ou apres une fermeture par la regle de\n // protection du parc. Contacteur ouvert, elle n'a aucun effet.\n //\n // A 200 W, une plaque chauffante qui cycle fait passer le point de\n // fonctionnement sous zero : mesure du 06/09 sur la fenetre 12:07-12:22,\n // 9 % des echantillons negatifs, minimum -477 W, mediane des negatifs\n // -146 W. Le regulateur ne rattrape pas assez vite un delestage brutal de\n // ~1 kW. A 700 W toute la courbe monte de 500 W : minimum +23 W, plus aucune\n // excursion negative sur cette fenetre.\n //\n // Le surcout est faible et va dans le bon sens : les DEUX seules raisons de\n // fermer le contacteur sans session EVCS sont « la batterie est a son\n // plancher » et « la maison demande plus que le parc ne sait donner ». Dans\n // les deux cas on VEUT que le reseau contribue plus que 200 W.\n //\n // ⚠️ Ne corrige PAS les gros a-coups. Le 06/09 a 13:41:40, le debranchement\n // du vehicule a supprime 2,2 kW d'un coup alors que la batterie, a 93 % de\n // SOC, n'absorbait rien et que le MPPT produisait 4 kW : -2 819 W pendant\n // plus de deux minutes, le temps que le systeme bride la production. Aucune\n // consigne n'absorbe cela. Ce qui l'evite est la correction du meme jour sur\n // la regle de protection (elle porte desormais sur `maison - PV`) : avec une\n // demande sur le parc de 741 W, le contacteur n'aurait pas ete ferme, et\n // sans reseau il n'y a rien ou injecter.\n gridSetpointIdleW: clamp(num(input.grid_setpoint_idle_w, 700), 0, 1500),\n\n probeEnabled: low(input.probe_enabled || 'off') === 'on',\n probeMinutes: Math.max(1, num(input.probe_minutes, 3)),\n probeRetryMinutes: Math.max(5, num(input.probe_retry_minutes, 45)),\n probeCurrentA: clamp(\n num(input.probe_current_a, clamp(num(input.current_min_a, 10), 6, 32)),\n clamp(num(input.current_min_a, 10), 6, 32),\n clamp(num(input.current_max_auto_a, 16), clamp(num(input.current_min_a, 10), 6, 32), 32)\n ),\n\n gridBatterySoftA: num(input.grid_battery_soft_a, -3),\n gridBatteryHardA: num(input.grid_battery_hard_a, -6),\n\n morningProbePvAvgMinW: Math.max(0, num(input.morning_probe_pv_avg_min_w, 1500)),\n morningProbePvTrendMinW: num(input.morning_probe_pv_trend_min_w, 0),\n middayProbePvAvgMinW: Math.max(0, num(input.midday_probe_pv_avg_min_w, 1200)),\n\n morningSocMin: clamp(num(input.morning_soc_min, 35), 0, 100),\n middaySocMin: clamp(num(input.midday_soc_min, 75), 0, 100),\n eveningSocStartMin: clamp(num(input.evening_soc_start_min, 85), 0, 100),\n eveningSocStopMin: clamp(num(input.evening_soc_stop_min, 80), 0, 100),\n\n morningBatteryMaxDischargeA: num(input.morning_battery_max_discharge_a, -20),\n middayBatteryMaxDischargeA: num(input.midday_battery_max_discharge_a, -40),\n eveningBatteryMaxDischargeA: num(input.evening_battery_max_discharge_a, -30),\n\n // =====================================================\n // EVCS NIGHT ENGINE — NEW PARAMETERS\n // =====================================================\n evcsNightBatteryHardFloorPct: clamp(\n num(input.evcs_night_battery_hard_floor_pct, 22),\n 0,\n 100\n ),\n\n evcsNightStrongForecastKwh: Math.max(\n 0,\n num(input.evcs_night_strong_forecast_kwh, 18)\n ),\n\n evcsNightMediumForecastKwh: Math.max(\n 0,\n num(input.evcs_night_medium_forecast_kwh, 10)\n ),\n\n evcsNightTargetStrongForecastPct: clamp(\n num(input.evcs_night_target_strong_forecast_pct, 35),\n 0,\n 100\n ),\n\n evcsNightTargetMediumForecastPct: clamp(\n num(input.evcs_night_target_medium_forecast_pct, 55),\n 0,\n 100\n ),\n\n evcsNightTargetWeakForecastPct: clamp(\n num(input.evcs_night_target_weak_forecast_pct, 85),\n 0,\n 100\n ),\n\n evcsNightBatteryRecoveryPowerKw: Math.max(\n 0.1,\n num(input.evcs_night_battery_recovery_power_kw, 2.5)\n ),\n\n evcsNightDepartureSafetyMin: Math.max(\n 0,\n num(input.evcs_night_departure_safety_min, 30)\n ),\n};\n\n// =====================================================\n// HISTORY / PERSISTED HELPERS\n// =====================================================\nconst nightSessionStartSocRaw = num(\n input.evcs_night_session_start_soc ?? input.night_session_start_soc,\n NaN\n);\n\nconst nightSessionStartAtRaw = cleanText(\n input.evcs_night_session_start_at ?? input.night_session_start_at\n);\n\n// =====================================================\n// VEHICLE DEMAND FILTER HISTORY RAW INPUTS\n// =====================================================\nconst evcsNoDemandSinceAtRaw = cleanText(input.evcs_no_demand_since_at);\nconst evcsNoDemandFilterEvalAtRaw = cleanText(input.evcs_no_demand_filter_eval_at);\n\nconst history = {\n lastBlockReason: cleanText(input.last_block_reason, 'none') || 'none',\n blockedUntilAt: cleanText(input.blocked_until),\n probeStartedAt: cleanText(input.probe_started_at),\n lastStartAt: cleanText(input.last_start_at),\n lastStopAt: cleanText(input.last_stop_at),\n lastCurrentChangeAt: cleanText(input.last_current_change_at),\n lastSignature: cleanText(input.last_signature),\n cloudDebtStartedAt: cleanText(input.cloud_debt_started_at),\n evcsPriorityManualUntilAt: cleanText(input.evcs_priority_manual_until),\n\n blockedUntilMs: parseParisStampToMs(cleanText(input.blocked_until)),\n probeStartedMs: parseParisStampToMs(cleanText(input.probe_started_at)),\n lastStartMs: parseParisStampToMs(cleanText(input.last_start_at)),\n lastStopMs: parseParisStampToMs(cleanText(input.last_stop_at)),\n lastCurrentChangeMs: parseParisStampToMs(cleanText(input.last_current_change_at)),\n cloudDebtStartedMsPrev: parseParisStampToMs(cleanText(input.cloud_debt_started_at)),\n\n // DEPASSEMENT SOUPLE BATTERIE (01/09/2026) : horodatage du debut du\n // depassement en cours, ecrit par HA_SERVICE_CALLS_OUTPUT. Vide/illisible\n // => null => aucun depassement en cours, comportement de repli sur.\n nightGridSessionCommittedPrev:\n (input.night_grid_session_committed_prev ?? 'off').toString().trim().toLowerCase() === 'on',\n batterySoftBreachAt: cleanText(input.battery_soft_breach_at),\n batterySoftBreachMsPrev: parseParisStampToMs(cleanText(input.battery_soft_breach_at)),\n evcsPriorityManualUntilMs: parseParisStampToMs(cleanText(input.evcs_priority_manual_until)),\n\n // =====================================================\n // EVCS NIGHT SESSION MEMORY\n // =====================================================\n nightSessionStartSocPct: Number.isFinite(nightSessionStartSocRaw)\n ? clamp(nightSessionStartSocRaw, 0, 100)\n : null,\n\n nightSessionStartAt: nightSessionStartAtRaw || '',\n nightSessionStartMs: parseParisStampToMs(nightSessionStartAtRaw),\n\n // =====================================================\n // VEHICLE DEMAND FILTER PERSISTENCE\n // =====================================================\n evcsDeliveryRatioEma: num(input.evcs_delivery_ratio_ema, NaN),\n evcsDeliveryNoiseEma: num(input.evcs_delivery_noise_ema, NaN),\n evcsNoDemandScoreEma: num(input.evcs_no_demand_score_ema, NaN),\n evcsNoDemandSemanticActive: low(input.evcs_no_demand_semantic_active || 'off') === 'on',\n\n evcsNoDemandSinceAt: evcsNoDemandSinceAtRaw || '',\n evcsNoDemandSinceMs: parseParisStampToMs(evcsNoDemandSinceAtRaw),\n\n evcsNoDemandFilterEvalAt: evcsNoDemandFilterEvalAtRaw || '',\n evcsNoDemandFilterEvalMs: parseParisStampToMs(evcsNoDemandFilterEvalAtRaw),\n};\n\n// =====================================================\n// MISE A L'ECHELLE DU PARC (12/09/2026)\n// =====================================================\n// Les bandes ci-dessous sont en AMPERES ABSOLUS, et elles decrivent un parc\n// de 4 modules -- celui sur lequel elles sont aujourd'hui en vigueur et\n// observees justes (l'arret dur du 11/09 a mordu au bon endroit, 87 A pour\n// une limite de 70). Avec quatre US5000 en plus, le parc triple : garder ces\n// ampere-la briderait le nouveau parc au niveau de l'ancien.\n//\n// On les met donc a l'echelle du parc DECLARE — le meme helper que la\n// capacite utile et que le bilan energetique, exprime en equivalents\n// US2000C (un US5000 en vaut DEUX). Une seule valeur a tenir a jour, et les\n// trois consommateurs restent forcement d'accord entre eux.\n//\n// ⚠️ On ne met PAS a l'echelle sur la limite BMS lue en direct : elle\n// s'effondre quand une cellule monte en tension (04/09 : plafond de charge\n// tombe de 150 a 20 A en cinq secondes). Une regle de confort ne doit pas\n// suivre un signal aussi nerveux. En revanche le BMS PLAFONNE : on ne\n// s'autorise jamais plus que ce que le parc declare accepter a l'instant.\n//\n// Parc absent ou illisible => echelle 1 et plafond inactif, donc comportement\n// d'aujourd'hui strictement inchange.\nconst parkScale = (() => {\n const n = Number(input.modules_en_service);\n return Number.isFinite(n) && n >= 1 && n <= 24 ? n / 4 : 1;\n})();\nconst bmsDischargeCapA = (() => {\n const n = Number(input.bms_max_discharge_current_a);\n return Number.isFinite(n) && n > 0 ? n : Infinity;\n})();\nconst bmsChargeCapA = (() => {\n const n = Number(input.bms_max_charge_current_a);\n return Number.isFinite(n) && n > 0 ? n : Infinity;\n})();\n// Decharge : valeurs NEGATIVES, donc le plafond est un `Math.max`.\nconst scaleDischargeA = v => Math.max(-bmsDischargeCapA, v * parkScale);\nconst scaleChargeA = v => Math.min(bmsChargeCapA, Math.max(0, v) * parkScale);\n\n// =====================================================\n// BATTERY TUNING\n// =====================================================\nconst batteryTune = {\n global: {\n houseSpikeGraceMinutes: Math.max(0, num(input.house_spike_grace_minutes, 10)),\n solarPotentialGraceMinutes: Math.max(0, num(input.solar_potential_grace_minutes, 12)),\n stepUpCooldownSeconds: Math.max(5, num(input.step_up_cooldown_seconds, params.currentChangeCooldownSeconds)),\n stepDownCooldownSeconds: Math.max(5, num(input.step_down_cooldown_seconds, params.currentChangeCooldownSeconds)),\n forceModeAutoRevertMinutes: Math.max(0, num(input.force_mode_auto_revert_minutes, 120)),\n overloadStepDownA: Math.max(1, num(input.overload_step_down_a, 2)),\n overloadHoldSeconds: Math.max(5, num(input.overload_hold_seconds, 30)),\n overloadStopAfterSeconds: Math.max(10, num(input.overload_stop_after_seconds, 120)),\n },\n\n bands: {\n gt90: {\n dischargeSoftA: scaleDischargeA(num(input.batt_discharge_soft_gt90_a, -50)),\n dischargeHardA: scaleDischargeA(num(input.batt_discharge_hard_gt90_a, -100)),\n chargeReserveA: scaleChargeA(num(input.batt_charge_reserve_gt90_a, 5)),\n softToleranceMin: Math.max(0, num(input.batt_soft_tol_gt90_min, 12)),\n hardToleranceSec: Math.max(0, num(input.batt_hard_tol_gt90_sec, 20)),\n recoveryMin: Math.max(0, num(input.batt_recovery_gt90_min, 6)),\n },\n\n '80_90': {\n dischargeSoftA: scaleDischargeA(num(input.batt_discharge_soft_80_90_a, -40)),\n dischargeHardA: scaleDischargeA(num(input.batt_discharge_hard_80_90_a, -80)),\n chargeReserveA: scaleChargeA(num(input.batt_charge_reserve_80_90_a, 10)),\n softToleranceMin: Math.max(0, num(input.batt_soft_tol_80_90_min, 10)),\n hardToleranceSec: Math.max(0, num(input.batt_hard_tol_80_90_sec, 20)),\n recoveryMin: Math.max(0, num(input.batt_recovery_80_90_min, 6)),\n },\n\n '60_80': {\n dischargeSoftA: scaleDischargeA(num(input.batt_discharge_soft_60_80_a, -20)),\n dischargeHardA: scaleDischargeA(num(input.batt_discharge_hard_60_80_a, -50)),\n chargeReserveA: scaleChargeA(num(input.batt_charge_reserve_60_80_a, 40)),\n softToleranceMin: Math.max(0, num(input.batt_soft_tol_60_80_min, 6)),\n hardToleranceSec: Math.max(0, num(input.batt_hard_tol_60_80_sec, 15)),\n recoveryMin: Math.max(0, num(input.batt_recovery_60_80_min, 8)),\n },\n\n '40_60': {\n dischargeSoftA: scaleDischargeA(num(input.batt_discharge_soft_40_60_a, -12)),\n dischargeHardA: scaleDischargeA(num(input.batt_discharge_hard_40_60_a, -30)),\n chargeReserveA: scaleChargeA(num(input.batt_charge_reserve_40_60_a, 70)),\n softToleranceMin: Math.max(0, num(input.batt_soft_tol_40_60_min, 4)),\n hardToleranceSec: Math.max(0, num(input.batt_hard_tol_40_60_sec, 10)),\n recoveryMin: Math.max(0, num(input.batt_recovery_40_60_min, 10)),\n },\n\n '25_40': {\n dischargeSoftA: scaleDischargeA(num(input.batt_discharge_soft_25_40_a, -5)),\n dischargeHardA: scaleDischargeA(num(input.batt_discharge_hard_25_40_a, -15)),\n chargeReserveA: scaleChargeA(num(input.batt_charge_reserve_25_40_a, 110)),\n softToleranceMin: Math.max(0, num(input.batt_soft_tol_25_40_min, 2)),\n hardToleranceSec: Math.max(0, num(input.batt_hard_tol_25_40_sec, 5)),\n recoveryMin: Math.max(0, num(input.batt_recovery_25_40_min, 12)),\n },\n\n lt25: {\n dischargeSoftA: scaleDischargeA(num(input.batt_discharge_soft_lt25_a, -3)),\n dischargeHardA: scaleDischargeA(num(input.batt_discharge_hard_lt25_a, -8)),\n chargeReserveA: scaleChargeA(num(input.batt_charge_reserve_lt25_a, 150)),\n softToleranceMin: Math.max(0, num(input.batt_soft_tol_lt25_min, 1)),\n hardToleranceSec: Math.max(0, num(input.batt_hard_tol_lt25_sec, 2)),\n recoveryMin: Math.max(0, num(input.batt_recovery_lt25_min, 15)),\n },\n },\n};\n\n// =====================================================\n// OPTIONAL DEBUG / SUMMARY\n// =====================================================\nconst evcsNightConfig = {\n batteryHardFloorPct: params.evcsNightBatteryHardFloorPct,\n strongForecastKwh: params.evcsNightStrongForecastKwh,\n mediumForecastKwh: params.evcsNightMediumForecastKwh,\n targetStrongForecastPct: params.evcsNightTargetStrongForecastPct,\n targetMediumForecastPct: params.evcsNightTargetMediumForecastPct,\n targetWeakForecastPct: params.evcsNightTargetWeakForecastPct,\n batteryRecoveryPowerKw: params.evcsNightBatteryRecoveryPowerKw,\n departureSafetyMin: params.evcsNightDepartureSafetyMin,\n sessionStartSocPct: history.nightSessionStartSocPct,\n sessionStartAt: history.nightSessionStartAt || null,\n};\n\nconst vehicleDemandHistoryDebug = {\n evcsDeliveryRatioEma: finiteOrNull(history.evcsDeliveryRatioEma),\n evcsDeliveryNoiseEma: finiteOrNull(history.evcsDeliveryNoiseEma),\n evcsNoDemandScoreEma: finiteOrNull(history.evcsNoDemandScoreEma),\n evcsNoDemandSemanticActive: history.evcsNoDemandSemanticActive,\n evcsNoDemandSinceAt: history.evcsNoDemandSinceAt || null,\n evcsNoDemandSinceMs: finiteOrNull(history.evcsNoDemandSinceMs),\n evcsNoDemandFilterEvalAt: history.evcsNoDemandFilterEvalAt || null,\n evcsNoDemandFilterEvalMs: finiteOrNull(history.evcsNoDemandFilterEvalMs),\n};\n\n// =====================================================\n// OUTPUT\n// =====================================================\nreturn [{\n json: {\n raw,\n input,\n nowMs,\n now,\n cfg: CFG,\n parsed,\n params,\n history,\n batteryTune,\n evcsNightConfig,\n vehicleDemandHistoryDebug,\n }\n}];\n" + "jsCode": "function num(v, fallback = NaN) {\n const n = Number.parseFloat(v);\n return Number.isFinite(n) ? n : fallback;\n}\n\nfunction low(v) {\n return (v ?? '').toString().trim().toLowerCase();\n}\n\nfunction clamp(v, min, max) {\n return Math.min(Math.max(v, min), max);\n}\n\nfunction round1(v) {\n return Math.round(v * 10) / 10;\n}\n\nfunction round2(v) {\n return Math.round(v * 100) / 100;\n}\n\nfunction to2(n) {\n return String(n).padStart(2, '0');\n}\n\nfunction parseMaybeJson(v) {\n if (typeof v !== 'string') return v;\n try {\n return JSON.parse(v);\n } catch {\n return v;\n }\n}\n\nfunction extractSnapshot(input) {\n if (typeof input === 'string') return parseMaybeJson(input);\n if (input?.data && typeof input.data === 'string') return parseMaybeJson(input.data);\n if (input?.body && typeof input.body === 'string') return parseMaybeJson(input.body);\n if (input?.data && typeof input.data === 'object') return input.data;\n if (input?.body && typeof input.body === 'object') return input.body;\n return input;\n}\n\nfunction parseTimeToMinutes(v, fallback = null) {\n const s = (v ?? '').toString().trim();\n const m = s.match(/^(\\d{1,2}):(\\d{2})(?::(\\d{2}))?$/);\n if (!m) return fallback;\n\n const hh = Number(m[1]);\n const mm = Number(m[2]);\n\n if (!Number.isFinite(hh) || !Number.isFinite(mm)) return fallback;\n return (hh % 24) * 60 + mm;\n}\n\nfunction getParisParts(ms = Date.now()) {\n const fmt = new Intl.DateTimeFormat('fr-FR', {\n timeZone: 'Europe/Paris',\n year: 'numeric',\n month: '2-digit',\n day: '2-digit',\n hour: '2-digit',\n minute: '2-digit',\n second: '2-digit',\n hour12: false,\n });\n\n const parts = Object.fromEntries(\n fmt.formatToParts(new Date(ms))\n .filter(p => p.type !== 'literal')\n .map(p => [p.type, p.value])\n );\n\n const hour = Number(parts.hour) === 24 ? 0 : Number(parts.hour);\n const date = `${parts.year}-${parts.month}-${parts.day}`;\n const stamp = `${date} ${to2(hour)}:${parts.minute}:${parts.second}`;\n\n return {\n year: Number(parts.year),\n month: Number(parts.month),\n day: Number(parts.day),\n hour,\n minute: Number(parts.minute),\n second: Number(parts.second),\n date,\n stamp,\n minNow: hour * 60 + Number(parts.minute),\n };\n}\n\nfunction getTzOffsetMs(utcTs, timeZone = 'Europe/Paris') {\n const dtf = new Intl.DateTimeFormat('en-US', {\n timeZone,\n year: 'numeric',\n month: '2-digit',\n day: '2-digit',\n hour: '2-digit',\n minute: '2-digit',\n second: '2-digit',\n hour12: false,\n });\n\n const parts = Object.fromEntries(\n dtf.formatToParts(new Date(utcTs))\n .filter(p => p.type !== 'literal')\n .map(p => [p.type, p.value])\n );\n\n const asIfUtc = Date.UTC(\n Number(parts.year),\n Number(parts.month) - 1,\n Number(parts.day),\n Number(parts.hour) === 24 ? 0 : Number(parts.hour),\n Number(parts.minute),\n Number(parts.second)\n );\n\n const d = new Date(utcTs);\n const actualUtc = Date.UTC(\n d.getUTCFullYear(),\n d.getUTCMonth(),\n d.getUTCDate(),\n d.getUTCHours(),\n d.getUTCMinutes(),\n d.getUTCSeconds()\n );\n\n return asIfUtc - actualUtc;\n}\n\nfunction parseParisStampToMs(s) {\n const text = (s ?? '').toString().trim();\n const m = text.match(/^(\\d{4})-(\\d{2})-(\\d{2}) (\\d{2}):(\\d{2}):(\\d{2})$/);\n if (!m) return null;\n\n const y = Number(m[1]);\n const mo = Number(m[2]);\n const d = Number(m[3]);\n const h = Number(m[4]);\n const mi = Number(m[5]);\n const se = Number(m[6]);\n\n if (![y, mo, d, h, mi, se].every(Number.isFinite)) return null;\n\n const guessUtc = Date.UTC(y, mo - 1, d, h, mi, se);\n const off1 = getTzOffsetMs(guessUtc, 'Europe/Paris');\n let ts = guessUtc - off1;\n const off2 = getTzOffsetMs(ts, 'Europe/Paris');\n\n if (off2 !== off1) ts = guessUtc - off2;\n return ts;\n}\n\nfunction cleanText(v, fallback = '') {\n const s = (v ?? '').toString().trim();\n const x = s.toLowerCase();\n if (['unknown', 'unavailable', 'none', 'null', 'undefined'].includes(x)) return fallback;\n return s;\n}\n\n// P11 (12/08/2026) : durée de charge EVCS \"1h 49m 53s\" / \"8m 27s\" / \"0s\"\n// → secondes. Repart de 0 à chaque NOUVELLE connexion physique d'un véhicule\n// (débranchement JEEP → branchement VW) → signal de reset du collant \"plein\".\nfunction parseChargingTimeToSec(s) {\n const t = (s ?? '').toString().toLowerCase();\n if (!t) return null;\n let sec = 0;\n let matched = false;\n const h = t.match(/(\\d+)\\s*h/); if (h) { sec += Number(h[1]) * 3600; matched = true; }\n const m = t.match(/(\\d+)\\s*m/); if (m) { sec += Number(m[1]) * 60; matched = true; }\n const s2 = t.match(/(\\d+)\\s*s/); if (s2){ sec += Number(s2[1]); matched = true; }\n return matched ? sec : null;\n}\n\n// Horodatage \"DD/MM HH:mm\" publie par le bilan energetique\n// (input_text.evcs_balance_last_run). Le conseiller employait le meme format ;\n// son cablage a ete retire le 13/09/2026, la fonction sert desormais au bilan.\n// (pas d'année). On suppose l'année courante ; si le résultat est dans le\n// futur (> 1 jour), c'est un passage d'année → on retire un an.\nfunction parseRunStampToMs(s) {\n const m = (s ?? '').toString().trim().match(/^(\\d{2})\\/(\\d{2}) (\\d{2}):(\\d{2})$/);\n if (!m) return null;\n\n const nowMsLocal = Date.now();\n const year = Number(\n new Intl.DateTimeFormat('fr-FR', { timeZone: 'Europe/Paris', year: 'numeric' })\n .format(new Date(nowMsLocal))\n );\n\n const build = (y) => {\n const guessUtc = Date.UTC(y, Number(m[2]) - 1, Number(m[1]), Number(m[3]), Number(m[4]), 0);\n const off1 = getTzOffsetMs(guessUtc, 'Europe/Paris');\n let ts = guessUtc - off1;\n const off2 = getTzOffsetMs(ts, 'Europe/Paris');\n if (off2 !== off1) ts = guessUtc - off2;\n return ts;\n };\n\n let ts = build(year);\n if (ts - nowMsLocal > 24 * 3600 * 1000) ts = build(year - 1);\n return ts;\n}\n\nfunction finiteOrNull(v) {\n return Number.isFinite(v) ? v : null;\n}\n\nconst raw = $input.first().json;\nconst input = extractSnapshot(raw);\n\nconst nowMs = Date.now();\nconst now = getParisParts(nowMs);\n\nconst CFG = {\n mobileNotifyDomain: 'notify',\n mobileNotifyService: 'mobile_app_iphone_16_de_tt',\n\n // 03/09/2026 — valeur MESUREE (576 paliers de SOC sur 8 jours,\n // tools/mesure-courbe-batterie.mjs) : 0,169 kWh par point au-dessus de 33 %,\n // soit 16,9 kWh. Le 17,5 precedent etait une valeur de plaque.\n // NB : sous 33 % un point ne vaut plus que 0,097 kWh. Cette constante ne sert\n // ici qu'a des ecarts SOC -> cible, tous deux au-dessus du genou. Si un calcul\n // devait un jour traverser le genou, il faut la COURBE (src/energy-balance/\n // BALANCE.js) et non cette constante.\n // 20/09/2026 — RE-MESURE sur le parc assaini (13 modules, courbe PLATE a\n // 0,30 kWh/point, plus de genou) : 0,30/point / 13 modules x 100 points =\n // 2,308 kWh par module (etait 2,414 sur l'ancien parc a 7 modules abimes).\n // Le nombre de modules se LIT (meme helper que le bilan) — les deux decrivent\n // forcement le meme parc. Absent ou illisible => 7 (aucun changement tant que\n // personne ne l'ecrit). A remesurer apres toute evolution du parc.\n batteryUsableCapacityKwh: 2.308 * (() => {\n const n = Number(input.modules_en_service);\n return Number.isFinite(n) && n >= 1 && n <= 24 ? n : 7;\n })(),\n nominalAcVoltageV: 230,\n evcsDedicatedMaxW: 4900,\n systemReserveW: 300,\n\n vebusDcVoltageSoftFloorV: 49.0,\n vebusDcVoltageHardFloorV: 48.6,\n\n departurePriorityWindowMinutes: 240,\n sampleSeconds: 30,\n\n cloudDebtRecoveryThresholdKwh: 0.05,\n cloudDebtMeaningfulThresholdKwh: 0.01,\n};\n\n// =====================================================\n// PARSED SNAPSHOT\n// =====================================================\nconst parsed = {\n soc: clamp(num(input.soc_percent, 0), 0, 100),\n batteryCurrentA: num(input.battery_current_a, 0),\n\n pvPowerW: Math.max(0, num(input.pv_power_w, 0)),\n\n // MODE DE FONCTIONNEMENT DES CHARGEURS SOLAIRES (03/09/2026)\n // Chaque tracker declare lui-meme s'il est a son point de puissance maximale\n // ('mppt_active' : il donne tout ce qu'il a) ou s'il se retient\n // ('voltage_current_limited', 'off' : il y a de la ressource REFUSEE).\n // Lectures inexploitables (integration muette) => listes vides => les gardes\n // aval retombent sur le comportement anterieur. On ne devine pas un etat.\n ...(() => {\n const modes = String(input.mppt_operation_modes || '')\n .split(',')\n .map(s => s.trim().toLowerCase())\n .filter(s => s !== '' && s !== 'unavailable' && s !== 'unknown');\n const atMaxPower = modes.filter(s => s === 'mppt_active').length;\n // Un chargeur qui SE RETIENT (`voltage_current_limited`) prouve qu'il reste\n // de la ressource refusee. Un chargeur `off` n'a rien a donner : la nuit,\n // tous le sont. `mpptHoldingBackCount` confond les deux, ce qui est sans\n // consequence pour la garde de provocation solaire (§37, demarrage de jour\n // seulement) mais ferait descendre la consigne reseau au crepuscule si on\n // s'en servait ici — un oscillateur de plus. On compte donc separement ce\n // qui se declare BRIDE, et tout libelle inconnu retombe du cote prudent\n // (pas de ressource refusee, donc comportement anterieur).\n const limited = modes.filter(s => s.includes('limited')).length;\n return {\n mpptReadableCount: modes.length,\n mpptAtMaxPowerCount: atMaxPower,\n mpptHoldingBackCount: modes.length - atMaxPower,\n mpptLimitedCount: limited,\n };\n })(),\n pvPowerAvg10mW: Math.max(0, num(input.pv_power_10m_avg_w, num(input.pv_power_w, 0))),\n pvTrendW: num(input.pv_trend_w, 0),\n\n houseAcPowerW: Math.max(0, num(input.house_ac_power_w, 0)),\n houseAcPowerPrevW: Math.max(0, num(input.house_ac_power_prev_w, num(input.house_ac_power_w, 0))),\n gridPowerW: num(input.grid_power_w, 0),\n\n gridIsOn: low(input.grid_state || 'off') === 'on',\n acInAvailable: num(input.victron_acin1_available ?? input.ac_in1_available, 0) === 1,\n\n // Consigne de puissance réseau de l'ESS, lue telle qu'elle est appliquée.\n // C'est ELLE qui décide si un contacteur fermé sert à quelque chose : au-dessus\n // du SOC minimal, l'ESS en autoconsommation ne prélève que cette puissance et\n // laisse la batterie porter le reste. Nuit du 01/09 : contacteur fermé,\n // consigne 50 W, batterie à -803 W pour 761 W de maison.\n gridSetpointW: num(input.grid_setpoint_w, NaN),\n gridAvailable:\n (low(input.grid_state || 'off') === 'on') ||\n (num(input.victron_acin1_available ?? input.ac_in1_available, 0) === 1),\n\n // FENETRE D'ETABLISSEMENT (31/08/2026) — age, en secondes, de la derniere\n // transition du reseau. On retient la plus RECENTE des deux sources : la\n // presence physique AC-IN (celle qui declenche reellement le transitoire) et\n // le contacteur (qui peut bouger sans qu'AC-IN suive, disjoncteur ouvert).\n // Horodatage absent ou nul => age tres grand => aucune fenetre, comportement\n // historique. On ne devine jamais une transition qu'on n'a pas observee.\n gridTransitionAgeSec: (() => {\n const ts = Math.max(\n num(input.acin1_last_changed_ts, 0),\n num(input.grid_switch_last_changed_ts, 0)\n );\n if (!(ts > 0)) return Number.POSITIVE_INFINITY;\n return Math.max(0, (nowMs / 1000) - ts);\n })(),\n\n todayRemainingKwh: Math.max(0, num(input.today_remaining_kwh, 0)),\n tomorrowKwh: Math.max(0, num(input.tomorrow_kwh, 0)),\n\n evcsDeviceConnected: low(input.evcs_device_connected || 'off') === 'on',\n evcsVehicleConnected: low(input.evcs_vehicle_connected || 'off') === 'on',\n evcsStatusRaw: cleanText(input.evcs_status_raw),\n evcsModeRaw: [\n input.evcs_mode_sensor,\n input.evcs_mode_fallback,\n input.evcs_mode\n ]\n .map(v => cleanText(v))\n .find(v => v) || 'unknown',\n\n evcsSetCurrentA: num(input.evcs_set_current_a, NaN),\n evcsCommandCurrentA: num(input.evcs_command_current_a, NaN),\n\n // Mesure réelle côté borne\n evcsCurrentA: Math.max(0, num(input.evcs_current_a, 0)),\n evcsCurrentRealA: Math.max(0, num(input.evcs_current_a, 0)),\n\n evcsAcPowerW: Math.max(0, num(input.evcs_ac_power_w, 0)),\n evcsChargingTimeSec: parseChargingTimeToSec(input.evcs_charging_time),\n // P11-bis (19/09/2026) : age, en secondes, du dernier changement du\n // capteur de connexion. C'est le SECOND signal de fraicheur, independant\n // du charging_time de la borne — qui gele sur un swap trop rapide (14 s\n // le 19/09) et laisse alors P11 aveugle. last_changed du binary_sensor,\n // lui, bouge sur ce meme swap. Horodatage absent/nul => age infini =>\n // aucun effet, comportement historique (on ne devine pas un branchement).\n evcsVehicleConnectedAgeSec: (() => {\n const ts = num(input.evcs_vehicle_connected_ts, 0);\n if (!(ts > 0)) return Number.POSITIVE_INFINITY;\n return Math.max(0, (nowMs / 1000) - ts);\n })(),\n // Energie deja livree par la session EN COURS (kWh). Le capteur de la borne\n // s'appelle \"last_session_energy\" mais il suit la session courante et repart\n // de zero a chaque nouvelle connexion (verifie le 07/09/2026 : 0 -> 0,1 -> 0,2\n // ... pendant la charge). Sert a juger la credibilite d'un statut \"charged\".\n evcsSessionEnergyKwh: Math.max(0, num(input.evcs_session_energy_kwh, 0)),\n evcsChargingSwitchOn: low(input.evcs_charging_switch || 'off') === 'on',\n evcsAutoStartSwitchOn: low(input.evcs_auto_start_switch || 'off') === 'on',\n\n controllerEnabled: low(input.controller_enabled || 'off') === 'on',\n notificationsEnabled: low(input.notifications_enabled || 'off') === 'on',\n hardStopEnabledLegacy: low(input.hard_stop_enabled || 'off') === 'on',\n chargePermissionNow: low(input.charge_permission_now || 'off') === 'on',\n controlLatch: low(input.control_latch || 'off') === 'on',\n departurePriorityEnabled: low(input.departure_priority_enabled || 'off') === 'on',\n forecastModeEnabled: low(input.forecast_mode_enabled || 'off') === 'on',\n\n evcsPriorityManualOverride: low(input.evcs_priority_manual_override || 'off') === 'on',\n evcsPriorityManualMode: cleanText(input.evcs_priority_manual_mode || 'normal', 'normal').toLowerCase(),\n evcsPriorityManualReason: cleanText(input.evcs_priority_manual_reason),\n\n vebusOverload: low(input.vebus_overload || 'off') === 'on',\n vebusL1Overload: low(input.vebus_l1_overload || 'off') === 'on',\n vebusLowBattery: low(input.vebus_low_battery || 'off') === 'on',\n vebusL1LowBattery: low(input.vebus_l1_low_battery || 'off') === 'on',\n\n vebusAcOutPowerW: Math.max(0, num(input.vebus_ac_out_l1_power_w, 0)),\n vebusAcOutCurrentA: Math.max(0, num(input.vebus_ac_out_l1_current_a, 0)),\n vebusAcOutVoltageV: num(input.vebus_ac_out_l1_voltage_v, CFG.nominalAcVoltageV),\n\n vebusDcVoltageV: num(input.vebus_dc_voltage_v, NaN),\n vebusDcCurrentA: num(input.vebus_dc_current_a, NaN),\n vebusDcPowerW: num(input.vebus_dc_power_w, NaN),\n\n vebusState: num(input.vebus_state, NaN),\n vebusMainState: num(input.vebus_main_state, NaN),\n};\n\nparsed.evcsModeIsAuto = low(parsed.evcsModeRaw).includes('auto');\n\n// =====================================================\n// PARAMETERS\n// =====================================================\nconst params = {\n userStrategyRaw: input.victron_evcs_strategy || 'auto_solar',\n\n targetCurrentAutoA: clamp(num(input.target_current_auto_a, 6), 0, 32),\n pvStartMinW: Math.max(0, num(input.pv_start_min_w, 1800)),\n pvHoldMinW: Math.max(0, num(input.pv_hold_min_w, 1200)),\n houseAcHeadroomW: Math.max(0, num(input.house_ac_headroom_w, 1200)),\n\n batteryDischargeSoftA: num(input.battery_discharge_soft_a, -5),\n batteryDischargeHardA: num(input.battery_discharge_hard_a, -15),\n batteryReserveUpA: Math.max(0, num(input.battery_reserve_up_a, 8)),\n batteryNeutralBandA: Math.max(0, num(input.battery_neutral_band_a, 3)),\n\n socStartMin: clamp(num(input.soc_start_min, 60), 0, 100),\n socStopMin: clamp(num(input.soc_stop_min, 35), 0, 100),\n\n currentMinA: clamp(num(input.current_min_a, 6), 6, 32),\n currentMaxAutoA: clamp(\n num(input.current_max_auto_a, 16),\n clamp(num(input.current_min_a, 6), 6, 32),\n 32\n ),\n gridCurrentMaxA: clamp(num(input.grid_current_max_a, 20), 6, 32),\n currentStepUpA: clamp(num(input.current_step_up_a, 1), 1, 5),\n currentStepDownA: clamp(num(input.current_step_down_a, 2), 1, 8),\n\n currentChangeCooldownSeconds: Math.max(5, num(input.current_change_cooldown_seconds, 60)),\n minRunMinutes: Math.max(0, num(input.min_run_minutes, 3)),\n minStopMinutes: Math.max(0, num(input.min_stop_minutes, 10)),\n\n requiredEnergyKwh: Math.max(0, num(input.required_energy_kwh, 0)),\n socComfortTarget: clamp(num(input.soc_comfort_target, 85), 0, 100),\n endOfDaySocGuard: clamp(num(input.end_of_day_soc_guard, 85), 0, 100),\n baseLoadRemainingKwh: Math.max(0, num(input.base_load_remaining_kwh, 0)),\n flexibleSafetyMarginKwh: Math.max(0, num(input.flexible_safety_margin_kwh, 0)),\n endOfDaySocTarget: clamp(num(input.end_of_day_soc_target, 85), 0, 100),\n\n acLoadsMaxSafeW: Math.max(0, num(input.ac_loads_max_safe_w, 8500)),\n batteryFullSoc: clamp(num(input.battery_full_soc, 90), 0, 100),\n batteryFullForecastMinKwh: Math.max(0, num(input.battery_full_forecast_min_kwh, 8)),\n forecastCommitSocFloor: clamp(num(input.forecast_commit_soc_floor, 35), 0, 100),\n forecastCommitMinKwh: Math.max(0, num(input.forecast_commit_min_kwh, 25)),\n\n cloudDebtPrevKwh: Math.max(0, num(input.cloud_debt_kwh, 0)),\n cloudDebtMaxKwh: Math.max(0.05, num(input.cloud_debt_max_kwh, 1.2)),\n cloudToleranceMinutes: Math.max(1, num(input.cloud_tolerance_minutes, 20)),\n\n // P9 (09/08/2026) : deux véhicules alternent sur la borne.\n // Le sélecteur input_select.victron_evcs_vehicle choisit l'heure de départ\n // effective : VW = victron_evcs_departure_time (~08:50),\n // JEEP = victron_evcs_departure_time_jeep (~06:30).\n // ZERO (moto, 28/09/2026) = victron_evcs_departure_time_zero. Heure absente\n // ou illisible -> repli sur celle de la VW, comme la JEEP.\n selectedVehicle: cleanText(input.evcs_vehicle, 'VW'),\n departureTimeMin:\n low(input.evcs_vehicle || 'vw') === 'jeep'\n ? parseTimeToMinutes(\n input.departure_time_jeep,\n parseTimeToMinutes(input.departure_time, null)\n )\n : low(input.evcs_vehicle || 'vw') === 'zero'\n ? parseTimeToMinutes(\n input.departure_time_zero,\n parseTimeToMinutes(input.departure_time, null)\n )\n : parseTimeToMinutes(input.departure_time, null),\n allowGridDepartureUrgent: low(input.allow_grid_departure_urgent || 'off') === 'on',\n dayStartTimeMin: parseTimeToMinutes(input.day_start_time, 7 * 60 + 30),\n dayStopTimeMin: parseTimeToMinutes(input.day_stop_time, 20 * 60 + 30),\n\n hcNightStartMin: parseTimeToMinutes(input.hc_night_start_time, 23 * 60),\n hcNightStopMin: parseTimeToMinutes(input.hc_night_stop_time, 6 * 60),\n morningStartMin: parseTimeToMinutes(\n input.morning_start_time,\n parseTimeToMinutes(input.day_start_time, 7 * 60 + 30)\n ),\n morningStopMin: parseTimeToMinutes(input.morning_stop_time, 11 * 60),\n hcMiddayStartMin: parseTimeToMinutes(input.hc_midday_start_time, 12 * 60 + 21),\n hcMiddayStopMin: parseTimeToMinutes(input.hc_midday_stop_time, 14 * 60 + 21),\n eveningStartMin: parseTimeToMinutes(input.evening_start_time, 17 * 60),\n eveningStopMin: parseTimeToMinutes(\n input.evening_stop_time,\n parseTimeToMinutes(input.day_stop_time, 20 * 60 + 30)\n ),\n\n // Bouton reset borne (19/09/2026) : echappatoire MANUELLE quand la borne\n // se fige (swap trop rapide, statut coince). Force une session fraiche\n // pour un cycle -> nettoie le collant -> retest. Le controleur le\n // rebascule off apres avoir agi (momentane).\n resetBorneRequested: low(input.reset_borne || 'off') === 'on',\n allowGridHcNight: low(input.allow_grid_hc_night || 'off') === 'on',\n allowGridHcMidday: low(input.allow_grid_hc_midday || 'off') === 'on',\n\n // Advisor IA — premier câblage réel (09/08/2026, HANDOFF §20) :\n // cible SOC matin recommandée, appliquée UNIQUEMENT si le toggle est ON.\n // Garde-fous consommés dans WINDOWS_SOURCEV2 (statut ok + fraîcheur + bornes).\n\n // BILAN ENERGETIQUE (03/09/2026). Meme format d'horodatage que l'advisor\n // (\"DD/MM HH:mm\"), d'ou la reutilisation du parseur.\n balanceRequiredSocPct: num(input.balance_required_soc, NaN),\n // Voiture branchee en HC avec un depart avant la prochaine HC (28/09) : le\n // bilan juge que le solaire ne lui donnera pas son besoin avant le depart.\n balanceVehicleGridNeeded: low(input.balance_vehicle_grid_needed || 'off') === 'on',\n // P12 — charge nuit sur batterie. Le plancher est le SOC requis PRUDENT (p80)\n // et non le p50 : la marge est mesuree, pas inventee. On descend la voiture\n // jusqu'a ce que la batterie n'ait plus que ce qu'il lui faut pour tenir\n // jusqu'au croisement PV.\n balanceRequiredSocPrudentPct: num(input.balance_required_soc_prudent, NaN),\n nightBatteryChargeEnabled: low(input.night_battery_charge_enabled || 'off') === 'on',\n nightBatteryDonePrev: low(input.night_battery_done_prev || 'off') === 'on',\n balanceEnergyToCoverKwh: num(input.balance_energy_to_cover_kwh, NaN),\n // ⚠️ NE PAS CONFONDRE LES DEUX GRANDEURS DU BILAN.\n // `balanceEnergyToCoverKwh` est l'energie que la batterie devra FOURNIR\n // jusqu'au croisement PV ; elle sert a CALCULER la cible\n // (`requiredSocPct = socForEnergy(energyToCoverKwh, floor)`) et ne tombe\n // jamais a zero tant qu'il reste de la nuit a traverser, meme batterie\n // pleine. Releve du 17/09 a 20:00 : 11,96 kWh « a couvrir » avec un SOC de\n // 91 %. La prendre pour un deficit ferait reclamer du reseau toute la nuit.\n //\n // `balanceGridEnergyNeededKwh` est le MANQUE :\n // `socMargin < 0 ? energyBetweenSoc(soc, requiredSoc) : 0` (BALANCE.js l.234)\n // — nul des que la batterie couvre, et converti par la COURBE par morceaux\n // mesuree le 03/09 (0,097 kWh/point sous le genou de 33 %, 0,169 au-dessus).\n // C'est donc la seule des deux qui soit juste sous le genou, c'est-a-dire au\n // SOC ou l'on decide de fermer le contacteur.\n balanceGridEnergyNeededKwh: num(input.balance_grid_energy_needed_kwh, NaN),\n // Ce que l'Auto Charge Manager autorise la batterie a PRENDRE (retour de\n // lecture de DVCC). A ne pas confondre avec `bms_max_charge_current_a`, qui\n // est le maximum tolere par le BMS — 250 A, soit plus de 12 kW, donc\n // au-dessus de GRID_SETPOINT_MAX_W : il ne bornerait rien.\n dvccChargeLimitA: num(input.dvcc_max_charge_current_a, NaN),\n balanceStatusOk: cleanText(input.balance_status).toLowerCase() === 'ok',\n balanceLastRunMs: parseRunStampToMs(cleanText(input.balance_last_run)),\n\n // Duree de la fenetre d'etablissement. Valeur mesuree le 31/08/2026 : le\n // transitoire de fermeture dure ~150 s (import 1985 -> 25 W et 2600 -> 52 W,\n // deux campagnes), on prend 180 s de marge. Reglable sans redeploiement via\n // input_number.evcs_grid_settle_seconds ; absent => 180.\n gridSettleSeconds: clamp(num(input.grid_settle_seconds, 180), 0, 900),\n\n // Consigne réseau au repos. 50 W est le défaut Victron (légèrement positif\n // pour ne jamais injecter), pas une valeur choisie ici.\n // CONSIGNE DE REPOS : 200 W et non plus 50 (04/09/2026).\n // 50 W etait le defaut Victron. Mesure sur la nuit du 03-04/09, 12 074\n // echantillons du point de raccordement : 17 % sont NEGATIFS, mediane -18 W,\n // excursions jusqu'a -356 W, episodes de 35 s au plus. Ce n'est pas de\n // l'injection — la batterie se dechargeait a 744 W au meme moment — c'est le\n // regulateur qui chasse autour d'une cible trop proche de zero. A 200 W le\n // point de fonctionnement reste positif sur l'essentiel des excursions.\n // ⚠️ Ne supprime pas les pointes les plus fortes : une excursion de -356 W\n // passerait encore sous zero. Reduit leur nombre, ne les annule pas.\n // CONSIGNE DE REPOS : 50 -> 200 (04/09) -> 700 (06/09).\n //\n // Elle ne s'applique que contacteur FERME sans session reseau EVCS — donc\n // apres une coupure sur SOC bas, ou apres une fermeture par la regle de\n // protection du parc. Contacteur ouvert, elle n'a aucun effet.\n //\n // A 200 W, une plaque chauffante qui cycle fait passer le point de\n // fonctionnement sous zero : mesure du 06/09 sur la fenetre 12:07-12:22,\n // 9 % des echantillons negatifs, minimum -477 W, mediane des negatifs\n // -146 W. Le regulateur ne rattrape pas assez vite un delestage brutal de\n // ~1 kW. A 700 W toute la courbe monte de 500 W : minimum +23 W, plus aucune\n // excursion negative sur cette fenetre.\n //\n // Le surcout est faible et va dans le bon sens : les DEUX seules raisons de\n // fermer le contacteur sans session EVCS sont « la batterie est a son\n // plancher » et « la maison demande plus que le parc ne sait donner ». Dans\n // les deux cas on VEUT que le reseau contribue plus que 200 W.\n //\n // ⚠️ Ne corrige PAS les gros a-coups. Le 06/09 a 13:41:40, le debranchement\n // du vehicule a supprime 2,2 kW d'un coup alors que la batterie, a 93 % de\n // SOC, n'absorbait rien et que le MPPT produisait 4 kW : -2 819 W pendant\n // plus de deux minutes, le temps que le systeme bride la production. Aucune\n // consigne n'absorbe cela. Ce qui l'evite est la correction du meme jour sur\n // la regle de protection (elle porte desormais sur `maison - PV`) : avec une\n // demande sur le parc de 741 W, le contacteur n'aurait pas ete ferme, et\n // sans reseau il n'y a rien ou injecter.\n gridSetpointIdleW: clamp(num(input.grid_setpoint_idle_w, 700), 0, 1500),\n\n probeEnabled: low(input.probe_enabled || 'off') === 'on',\n probeMinutes: Math.max(1, num(input.probe_minutes, 3)),\n probeRetryMinutes: Math.max(5, num(input.probe_retry_minutes, 45)),\n probeCurrentA: clamp(\n num(input.probe_current_a, clamp(num(input.current_min_a, 10), 6, 32)),\n clamp(num(input.current_min_a, 10), 6, 32),\n clamp(num(input.current_max_auto_a, 16), clamp(num(input.current_min_a, 10), 6, 32), 32)\n ),\n\n gridBatterySoftA: num(input.grid_battery_soft_a, -3),\n gridBatteryHardA: num(input.grid_battery_hard_a, -6),\n\n morningProbePvAvgMinW: Math.max(0, num(input.morning_probe_pv_avg_min_w, 1500)),\n morningProbePvTrendMinW: num(input.morning_probe_pv_trend_min_w, 0),\n middayProbePvAvgMinW: Math.max(0, num(input.midday_probe_pv_avg_min_w, 1200)),\n\n morningSocMin: clamp(num(input.morning_soc_min, 35), 0, 100),\n middaySocMin: clamp(num(input.midday_soc_min, 75), 0, 100),\n eveningSocStartMin: clamp(num(input.evening_soc_start_min, 85), 0, 100),\n eveningSocStopMin: clamp(num(input.evening_soc_stop_min, 80), 0, 100),\n\n morningBatteryMaxDischargeA: num(input.morning_battery_max_discharge_a, -20),\n middayBatteryMaxDischargeA: num(input.midday_battery_max_discharge_a, -40),\n eveningBatteryMaxDischargeA: num(input.evening_battery_max_discharge_a, -30),\n\n // =====================================================\n // EVCS NIGHT ENGINE — NEW PARAMETERS\n // =====================================================\n evcsNightBatteryHardFloorPct: clamp(\n num(input.evcs_night_battery_hard_floor_pct, 22),\n 0,\n 100\n ),\n\n evcsNightStrongForecastKwh: Math.max(\n 0,\n num(input.evcs_night_strong_forecast_kwh, 18)\n ),\n\n evcsNightMediumForecastKwh: Math.max(\n 0,\n num(input.evcs_night_medium_forecast_kwh, 10)\n ),\n\n evcsNightTargetStrongForecastPct: clamp(\n num(input.evcs_night_target_strong_forecast_pct, 35),\n 0,\n 100\n ),\n\n evcsNightTargetMediumForecastPct: clamp(\n num(input.evcs_night_target_medium_forecast_pct, 55),\n 0,\n 100\n ),\n\n evcsNightTargetWeakForecastPct: clamp(\n num(input.evcs_night_target_weak_forecast_pct, 85),\n 0,\n 100\n ),\n\n evcsNightBatteryRecoveryPowerKw: Math.max(\n 0.1,\n num(input.evcs_night_battery_recovery_power_kw, 2.5)\n ),\n\n evcsNightDepartureSafetyMin: Math.max(\n 0,\n num(input.evcs_night_departure_safety_min, 30)\n ),\n};\n\n// =====================================================\n// HISTORY / PERSISTED HELPERS\n// =====================================================\nconst nightSessionStartSocRaw = num(\n input.evcs_night_session_start_soc ?? input.night_session_start_soc,\n NaN\n);\n\nconst nightSessionStartAtRaw = cleanText(\n input.evcs_night_session_start_at ?? input.night_session_start_at\n);\n\n// =====================================================\n// VEHICLE DEMAND FILTER HISTORY RAW INPUTS\n// =====================================================\nconst evcsNoDemandSinceAtRaw = cleanText(input.evcs_no_demand_since_at);\nconst evcsNoDemandFilterEvalAtRaw = cleanText(input.evcs_no_demand_filter_eval_at);\n\nconst history = {\n lastBlockReason: cleanText(input.last_block_reason, 'none') || 'none',\n blockedUntilAt: cleanText(input.blocked_until),\n probeStartedAt: cleanText(input.probe_started_at),\n lastStartAt: cleanText(input.last_start_at),\n lastStopAt: cleanText(input.last_stop_at),\n lastCurrentChangeAt: cleanText(input.last_current_change_at),\n lastSignature: cleanText(input.last_signature),\n cloudDebtStartedAt: cleanText(input.cloud_debt_started_at),\n evcsPriorityManualUntilAt: cleanText(input.evcs_priority_manual_until),\n\n blockedUntilMs: parseParisStampToMs(cleanText(input.blocked_until)),\n probeStartedMs: parseParisStampToMs(cleanText(input.probe_started_at)),\n lastStartMs: parseParisStampToMs(cleanText(input.last_start_at)),\n lastStopMs: parseParisStampToMs(cleanText(input.last_stop_at)),\n lastCurrentChangeMs: parseParisStampToMs(cleanText(input.last_current_change_at)),\n cloudDebtStartedMsPrev: parseParisStampToMs(cleanText(input.cloud_debt_started_at)),\n\n // DEPASSEMENT SOUPLE BATTERIE (01/09/2026) : horodatage du debut du\n // depassement en cours, ecrit par HA_SERVICE_CALLS_OUTPUT. Vide/illisible\n // => null => aucun depassement en cours, comportement de repli sur.\n nightGridSessionCommittedPrev:\n (input.night_grid_session_committed_prev ?? 'off').toString().trim().toLowerCase() === 'on',\n batterySoftBreachAt: cleanText(input.battery_soft_breach_at),\n batterySoftBreachMsPrev: parseParisStampToMs(cleanText(input.battery_soft_breach_at)),\n evcsPriorityManualUntilMs: parseParisStampToMs(cleanText(input.evcs_priority_manual_until)),\n\n // =====================================================\n // EVCS NIGHT SESSION MEMORY\n // =====================================================\n nightSessionStartSocPct: Number.isFinite(nightSessionStartSocRaw)\n ? clamp(nightSessionStartSocRaw, 0, 100)\n : null,\n\n nightSessionStartAt: nightSessionStartAtRaw || '',\n nightSessionStartMs: parseParisStampToMs(nightSessionStartAtRaw),\n\n // =====================================================\n // VEHICLE DEMAND FILTER PERSISTENCE\n // =====================================================\n evcsDeliveryRatioEma: num(input.evcs_delivery_ratio_ema, NaN),\n evcsDeliveryNoiseEma: num(input.evcs_delivery_noise_ema, NaN),\n evcsNoDemandScoreEma: num(input.evcs_no_demand_score_ema, NaN),\n evcsNoDemandSemanticActive: low(input.evcs_no_demand_semantic_active || 'off') === 'on',\n\n evcsNoDemandSinceAt: evcsNoDemandSinceAtRaw || '',\n evcsNoDemandSinceMs: parseParisStampToMs(evcsNoDemandSinceAtRaw),\n\n evcsNoDemandFilterEvalAt: evcsNoDemandFilterEvalAtRaw || '',\n evcsNoDemandFilterEvalMs: parseParisStampToMs(evcsNoDemandFilterEvalAtRaw),\n};\n\n// =====================================================\n// MISE A L'ECHELLE DU PARC (12/09/2026)\n// =====================================================\n// Les bandes ci-dessous sont en AMPERES ABSOLUS, et elles decrivent un parc\n// de 4 modules -- celui sur lequel elles sont aujourd'hui en vigueur et\n// observees justes (l'arret dur du 11/09 a mordu au bon endroit, 87 A pour\n// une limite de 70). Avec quatre US5000 en plus, le parc triple : garder ces\n// ampere-la briderait le nouveau parc au niveau de l'ancien.\n//\n// On les met donc a l'echelle du parc DECLARE — le meme helper que la\n// capacite utile et que le bilan energetique, exprime en equivalents\n// US2000C (un US5000 en vaut DEUX). Une seule valeur a tenir a jour, et les\n// trois consommateurs restent forcement d'accord entre eux.\n//\n// ⚠️ On ne met PAS a l'echelle sur la limite BMS lue en direct : elle\n// s'effondre quand une cellule monte en tension (04/09 : plafond de charge\n// tombe de 150 a 20 A en cinq secondes). Une regle de confort ne doit pas\n// suivre un signal aussi nerveux. En revanche le BMS PLAFONNE : on ne\n// s'autorise jamais plus que ce que le parc declare accepter a l'instant.\n//\n// Parc absent ou illisible => echelle 1 et plafond inactif, donc comportement\n// d'aujourd'hui strictement inchange.\nconst parkScale = (() => {\n const n = Number(input.modules_en_service);\n return Number.isFinite(n) && n >= 1 && n <= 24 ? n / 4 : 1;\n})();\nconst bmsDischargeCapA = (() => {\n const n = Number(input.bms_max_discharge_current_a);\n return Number.isFinite(n) && n > 0 ? n : Infinity;\n})();\nconst bmsChargeCapA = (() => {\n const n = Number(input.bms_max_charge_current_a);\n return Number.isFinite(n) && n > 0 ? n : Infinity;\n})();\n// Decharge : valeurs NEGATIVES, donc le plafond est un `Math.max`.\nconst scaleDischargeA = v => Math.max(-bmsDischargeCapA, v * parkScale);\nconst scaleChargeA = v => Math.min(bmsChargeCapA, Math.max(0, v) * parkScale);\n\n// =====================================================\n// BATTERY TUNING\n// =====================================================\nconst batteryTune = {\n global: {\n houseSpikeGraceMinutes: Math.max(0, num(input.house_spike_grace_minutes, 10)),\n solarPotentialGraceMinutes: Math.max(0, num(input.solar_potential_grace_minutes, 12)),\n stepUpCooldownSeconds: Math.max(5, num(input.step_up_cooldown_seconds, params.currentChangeCooldownSeconds)),\n stepDownCooldownSeconds: Math.max(5, num(input.step_down_cooldown_seconds, params.currentChangeCooldownSeconds)),\n forceModeAutoRevertMinutes: Math.max(0, num(input.force_mode_auto_revert_minutes, 120)),\n overloadStepDownA: Math.max(1, num(input.overload_step_down_a, 2)),\n overloadHoldSeconds: Math.max(5, num(input.overload_hold_seconds, 30)),\n overloadStopAfterSeconds: Math.max(10, num(input.overload_stop_after_seconds, 120)),\n },\n\n bands: {\n gt90: {\n dischargeSoftA: scaleDischargeA(num(input.batt_discharge_soft_gt90_a, -50)),\n dischargeHardA: scaleDischargeA(num(input.batt_discharge_hard_gt90_a, -100)),\n chargeReserveA: scaleChargeA(num(input.batt_charge_reserve_gt90_a, 5)),\n softToleranceMin: Math.max(0, num(input.batt_soft_tol_gt90_min, 12)),\n hardToleranceSec: Math.max(0, num(input.batt_hard_tol_gt90_sec, 20)),\n recoveryMin: Math.max(0, num(input.batt_recovery_gt90_min, 6)),\n },\n\n '80_90': {\n dischargeSoftA: scaleDischargeA(num(input.batt_discharge_soft_80_90_a, -40)),\n dischargeHardA: scaleDischargeA(num(input.batt_discharge_hard_80_90_a, -80)),\n chargeReserveA: scaleChargeA(num(input.batt_charge_reserve_80_90_a, 10)),\n softToleranceMin: Math.max(0, num(input.batt_soft_tol_80_90_min, 10)),\n hardToleranceSec: Math.max(0, num(input.batt_hard_tol_80_90_sec, 20)),\n recoveryMin: Math.max(0, num(input.batt_recovery_80_90_min, 6)),\n },\n\n '60_80': {\n dischargeSoftA: scaleDischargeA(num(input.batt_discharge_soft_60_80_a, -20)),\n dischargeHardA: scaleDischargeA(num(input.batt_discharge_hard_60_80_a, -50)),\n chargeReserveA: scaleChargeA(num(input.batt_charge_reserve_60_80_a, 40)),\n softToleranceMin: Math.max(0, num(input.batt_soft_tol_60_80_min, 6)),\n hardToleranceSec: Math.max(0, num(input.batt_hard_tol_60_80_sec, 15)),\n recoveryMin: Math.max(0, num(input.batt_recovery_60_80_min, 8)),\n },\n\n '40_60': {\n dischargeSoftA: scaleDischargeA(num(input.batt_discharge_soft_40_60_a, -12)),\n dischargeHardA: scaleDischargeA(num(input.batt_discharge_hard_40_60_a, -30)),\n chargeReserveA: scaleChargeA(num(input.batt_charge_reserve_40_60_a, 70)),\n softToleranceMin: Math.max(0, num(input.batt_soft_tol_40_60_min, 4)),\n hardToleranceSec: Math.max(0, num(input.batt_hard_tol_40_60_sec, 10)),\n recoveryMin: Math.max(0, num(input.batt_recovery_40_60_min, 10)),\n },\n\n '25_40': {\n dischargeSoftA: scaleDischargeA(num(input.batt_discharge_soft_25_40_a, -5)),\n dischargeHardA: scaleDischargeA(num(input.batt_discharge_hard_25_40_a, -15)),\n chargeReserveA: scaleChargeA(num(input.batt_charge_reserve_25_40_a, 110)),\n softToleranceMin: Math.max(0, num(input.batt_soft_tol_25_40_min, 2)),\n hardToleranceSec: Math.max(0, num(input.batt_hard_tol_25_40_sec, 5)),\n recoveryMin: Math.max(0, num(input.batt_recovery_25_40_min, 12)),\n },\n\n lt25: {\n dischargeSoftA: scaleDischargeA(num(input.batt_discharge_soft_lt25_a, -3)),\n dischargeHardA: scaleDischargeA(num(input.batt_discharge_hard_lt25_a, -8)),\n chargeReserveA: scaleChargeA(num(input.batt_charge_reserve_lt25_a, 150)),\n softToleranceMin: Math.max(0, num(input.batt_soft_tol_lt25_min, 1)),\n hardToleranceSec: Math.max(0, num(input.batt_hard_tol_lt25_sec, 2)),\n recoveryMin: Math.max(0, num(input.batt_recovery_lt25_min, 15)),\n },\n },\n};\n\n// =====================================================\n// OPTIONAL DEBUG / SUMMARY\n// =====================================================\nconst evcsNightConfig = {\n batteryHardFloorPct: params.evcsNightBatteryHardFloorPct,\n strongForecastKwh: params.evcsNightStrongForecastKwh,\n mediumForecastKwh: params.evcsNightMediumForecastKwh,\n targetStrongForecastPct: params.evcsNightTargetStrongForecastPct,\n targetMediumForecastPct: params.evcsNightTargetMediumForecastPct,\n targetWeakForecastPct: params.evcsNightTargetWeakForecastPct,\n batteryRecoveryPowerKw: params.evcsNightBatteryRecoveryPowerKw,\n departureSafetyMin: params.evcsNightDepartureSafetyMin,\n sessionStartSocPct: history.nightSessionStartSocPct,\n sessionStartAt: history.nightSessionStartAt || null,\n};\n\nconst vehicleDemandHistoryDebug = {\n evcsDeliveryRatioEma: finiteOrNull(history.evcsDeliveryRatioEma),\n evcsDeliveryNoiseEma: finiteOrNull(history.evcsDeliveryNoiseEma),\n evcsNoDemandScoreEma: finiteOrNull(history.evcsNoDemandScoreEma),\n evcsNoDemandSemanticActive: history.evcsNoDemandSemanticActive,\n evcsNoDemandSinceAt: history.evcsNoDemandSinceAt || null,\n evcsNoDemandSinceMs: finiteOrNull(history.evcsNoDemandSinceMs),\n evcsNoDemandFilterEvalAt: history.evcsNoDemandFilterEvalAt || null,\n evcsNoDemandFilterEvalMs: finiteOrNull(history.evcsNoDemandFilterEvalMs),\n};\n\n// =====================================================\n// OUTPUT\n// =====================================================\nreturn [{\n json: {\n raw,\n input,\n nowMs,\n now,\n cfg: CFG,\n parsed,\n params,\n history,\n batteryTune,\n evcsNightConfig,\n vehicleDemandHistoryDebug,\n }\n}];\n" }, "type": "n8n-nodes-base.code", "typeVersion": 2, @@ -142,7 +142,7 @@ "parameters": [ { "name": "template", - "value": "{{ {\n \"soc_percent\": states(\"sensor.victron_battery_soc\"),\n \"battery_current_a\": states(\"sensor.victron_battery_current\"),\n\n \"pv_power_w\": states(\"sensor.victron_pv_power_system\"),\n \"pv_power_10m_avg_w\": states(\"sensor.victron_pv_power_10m_avg\"),\n \"pv_trend_w\": states(\"sensor.victron_pv_power_trend_w\"),\n\n \"mppt_operation_modes\": states.sensor\n | selectattr('entity_id','search','mppt')\n | selectattr('entity_id','search','operation_mode')\n | map(attribute='state') | join(','),\n\n \"house_ac_power_w\": states(\"sensor.victron_consumption_power_l1\"),\n \"house_ac_power_prev_w\": states(\"sensor.victron_consumption_power_l1\"),\n\n \"grid_power_w\": states(\"sensor.victron_grid_power_l1\"),\n \"grid_state\": states(\"switch.edf_general_ok_switch\"),\n \"victron_acin1_available\": states(\"sensor.victron_ac_in1_available\"),\n\n \n \"acin1_last_changed_ts\": as_timestamp(states.sensor.victron_ac_in1_available.last_changed, 0) if states.sensor.victron_ac_in1_available is not none else 0,\n \"grid_switch_last_changed_ts\": as_timestamp(states.switch.edf_general_ok_switch.last_changed, 0) if states.switch.edf_general_ok_switch is not none else 0,\n \"grid_settle_seconds\": states(\"input_number.evcs_grid_settle_seconds\"),\n \"grid_setpoint_w\": states(\"number.gx_device_ac_power_setpoint\"),\n \"grid_setpoint_idle_w\": states(\"input_number.evcs_grid_setpoint_idle_w\"),\n\n \"today_remaining_kwh\": states(\"sensor.energy_production_today_remaining\"),\n \"tomorrow_kwh\": states(\"sensor.energy_production_tomorrow\"),\n\n \"evcs_device_connected\": states(\"binary_sensor.victron_evcs_device_connected\"),\n \"evcs_vehicle_connected\": states(\"binary_sensor.victron_evcs_vehicle_connected\"),\n \"evcs_status_raw\": states(\"sensor.victron_evcs_status_raw\"),\n \"evcs_mode_sensor\": states(\"sensor.victron_evcs_mode\"),\n \"evcs_mode_fallback\": states(\"input_text.sensor_victron_evcs_mode\"),\n \"evcs_set_current_a\": states(\"sensor.victron_evcs_set_current\"),\n \"evcs_command_current_a\": states(\"number.victron_evcs_set_current_command\"),\n \"evcs_current_a\": states(\"sensor.victron_evcs_current\"),\n \"evcs_ac_power_w\": states(\"sensor.victron_evcs_ac_power\"),\n \"evcs_charging_time\": states(\"sensor.victron_evcs_charging_time\"),\n \"evcs_vehicle_connected_ts\": as_timestamp(states.binary_sensor.victron_evcs_vehicle_connected.last_changed, 0) if states.binary_sensor.victron_evcs_vehicle_connected is not none else 0,\n \"evcs_session_energy_kwh\": states(\"sensor.evcs_hq2239dnt7p_last_session_energy\"),\n \"evcs_charging_switch\": states(\"switch.victron_evcs_charging\"),\n \"evcs_auto_start_switch\": states(\"switch.victron_evcs_auto_start\"),\n\n \"controller_enabled\": states(\"input_boolean.victron_evcs_surplus_auto_enabled\"),\n \"notifications_enabled\": states(\"input_boolean.victron_evcs_surplus_notifications\"),\n \"hard_stop_enabled\": states(\"input_boolean.victron_evcs_hard_stop_enabled\"),\n \"charge_permission_now\": states(\"input_boolean.victron_evcs_charge_permission_now\"),\n \"control_latch\": states(\"input_boolean.victron_evcs_control_latch\"),\n \"departure_priority_enabled\": states(\"input_boolean.victron_evcs_departure_priority_enabled\"),\n \"forecast_mode_enabled\": states(\"input_boolean.victron_evcs_forecast_mode_enabled\"),\n\n \"target_current_auto_a\": states(\"input_number.victron_evcs_target_current_auto\"),\n \"budget_remaining_kwh\": states(\"input_number.victron_evcs_budget_remaining_kwh\"),\n \"projected_end_soc_after_charge\": states(\"input_number.victron_evcs_projected_end_soc_after_charge\"),\n\n \"pv_start_min_w\": states(\"input_number.victron_evcs_pv_start_min_w\"),\n \"pv_hold_min_w\": states(\"input_number.victron_evcs_pv_hold_min_w\"),\n \"house_ac_headroom_w\": states(\"input_number.victron_evcs_house_ac_headroom_w\"),\n \"battery_discharge_soft_a\": states(\"input_number.victron_evcs_battery_discharge_soft_a\"),\n \"battery_discharge_hard_a\": states(\"input_number.victron_evcs_battery_discharge_hard_a\"),\n \"battery_reserve_up_a\": states(\"input_number.victron_evcs_battery_reserve_up_a\"),\n \"battery_neutral_band_a\": states(\"input_number.victron_evcs_battery_neutral_band_a\"),\n \"soc_start_min\": states(\"input_number.victron_evcs_soc_start_min\"),\n \"modules_en_service\": states(\"input_number.evcs_balance_modules_en_service\"),\n\n \"bms_max_discharge_current_a\": states(\"sensor.airtria_8_us2000c_maximum_allowed_discharge_current\"),\n \"bms_max_charge_current_a\": states(\"sensor.airtria_8_us2000c_maximum_allowed_charge_current\"),\n \"soc_stop_min\": states(\"input_number.victron_evcs_soc_stop_min\"),\n \"current_min_a\": states(\"input_number.victron_evcs_current_min_a\"),\n \"current_max_auto_a\": states(\"input_number.victron_evcs_current_max_auto_a\"),\n \"grid_current_max_a\": states(\"input_number.evcs_plafond_courant_edf_on\"),\n \"current_step_up_a\": states(\"input_number.victron_evcs_current_step_up_a\"),\n \"current_step_down_a\": states(\"input_number.victron_evcs_current_step_down_a\"),\n \"current_change_cooldown_seconds\": states(\"input_number.victron_evcs_current_change_cooldown_seconds\"),\n \"min_run_minutes\": states(\"input_number.victron_evcs_min_run_minutes\"),\n \"min_stop_minutes\": states(\"input_number.victron_evcs_min_stop_minutes\"),\n\n \"required_energy_kwh\": states(\"input_number.victron_evcs_required_energy_kwh\"),\n \"soc_comfort_target\": states(\"input_number.victron_evcs_soc_comfort_target\"),\n \"end_of_day_soc_guard\": states(\"input_number.victron_evcs_end_of_day_soc_guard\"),\n\n \"base_load_remaining_kwh\": states(\"input_number.victron_base_load_remaining_kwh\"),\n \"flexible_safety_margin_kwh\": states(\"input_number.victron_flexible_safety_margin_kwh\"),\n \"end_of_day_soc_target\": states(\"input_number.victron_end_of_day_soc_target\"),\n \"ac_loads_max_safe_w\": states(\"input_number.victron_ac_loads_max_safe_w\"),\n\n \"battery_full_soc\": states(\"input_number.victron_evcs_battery_full_soc\"),\n \"battery_full_forecast_min_kwh\": states(\"input_number.victron_evcs_battery_full_forecast_min_kwh\"),\n \"forecast_commit_soc_floor\": states(\"input_number.victron_evcs_forecast_commit_soc_floor\"),\n \"forecast_commit_min_kwh\": states(\"input_number.victron_evcs_forecast_commit_min_kwh\"),\n\n \"cloud_debt_kwh\": states(\"input_number.victron_evcs_cloud_debt_kwh\"),\n \"cloud_debt_max_kwh\": states(\"input_number.victron_evcs_cloud_debt_max_kwh\"),\n \"cloud_tolerance_minutes\": states(\"input_number.victron_evcs_cloud_tolerance_minutes\"),\n \"cloud_debt_started_at\": states(\"input_datetime.victron_evcs_cloud_debt_started_at\"),\n \"battery_soft_breach_at\": states(\"input_text.victron_evcs_battery_soft_breach_at\"),\n \"operating_mode_helper\": states(\"input_text.victron_evcs_operating_mode\"),\n\n \"departure_time\": states(\"input_datetime.victron_evcs_departure_time\"),\n \"departure_time_jeep\": states(\"input_datetime.victron_evcs_departure_time_jeep\"),\n \"evcs_vehicle\": states(\"input_select.victron_evcs_vehicle\"),\n \"allow_grid_departure_urgent\": states(\"input_boolean.victron_evcs_allow_grid_departure_urgent\"),\n \"last_start_at\": states(\"input_datetime.victron_evcs_last_start_at\"),\n \"last_stop_at\": states(\"input_datetime.victron_evcs_last_stop_at\"),\n \"last_current_change_at\": states(\"input_datetime.victron_evcs_last_current_change_at\"),\n\n \"day_start_time\": states(\"input_datetime.victron_flexible_day_start_time\"),\n \"day_stop_time\": states(\"input_datetime.victron_flexible_day_stop_time\"),\n\n \"vebus_overload\": states(\"binary_sensor.victron_ve_bus_overload\"),\n \"vebus_l1_overload\": states(\"binary_sensor.victron_ve_bus_l1_overload\"),\n \"vebus_low_battery\": states(\"binary_sensor.victron_ve_bus_low_battery\"),\n \"vebus_l1_low_battery\": states(\"binary_sensor.victron_ve_bus_l1_low_battery\"),\n\n \"vebus_ac_out_l1_power_w\": states(\"sensor.victron_ve_bus_ac_out_l1_power\"),\n \"vebus_ac_out_l1_current_a\": states(\"sensor.victron_ve_bus_ac_out_l1_current\"),\n \"vebus_ac_out_l1_voltage_v\": states(\"sensor.victron_ve_bus_ac_out_l1_voltage\"),\n\n \"vebus_dc_voltage_v\": states(\"sensor.victron_ve_bus_dc_voltage\"),\n \"vebus_dc_current_a\": states(\"sensor.victron_ve_bus_dc_current\"),\n \"vebus_dc_power_w\": states(\"sensor.victron_ve_bus_dc_power_system\"),\n\n \"vebus_state\": states(\"sensor.victron_ve_bus_state\"),\n \"vebus_main_state\": states(\"sensor.victron_ve_bus_main_state\"),\n\n \"last_signature\": states(\"input_text.victron_evcs_control_last_signature\"),\n\n \"hc_night_start_time\": states(\"input_datetime.victron_evcs_hc_night_start\"),\n \"hc_night_stop_time\": states(\"input_datetime.victron_evcs_hc_night_stop\"),\n \"morning_start_time\": states(\"input_datetime.victron_evcs_morning_start\"),\n \"morning_stop_time\": states(\"input_datetime.victron_evcs_morning_stop\"),\n \"hc_midday_start_time\": states(\"input_datetime.victron_evcs_hc_midday_start\"),\n \"hc_midday_stop_time\": states(\"input_datetime.victron_evcs_hc_midday_stop\"),\n \"evening_start_time\": states(\"input_datetime.victron_evcs_evening_start\"),\n \"evening_stop_time\": states(\"input_datetime.victron_evcs_evening_stop\"),\n\n \"allow_grid_hc_night\": states(\"input_boolean.victron_evcs_allow_grid_hc_night\"),\n \"reset_borne\": states(\"input_boolean.evcs_reset_borne\"),\n \"night_takeover_prev\": states(\"input_boolean.victron_evcs_night_grid_takeover_needed\"),\n \"night_grid_session_committed_prev\": states(\"input_boolean.victron_evcs_night_grid_session_committed\"),\n \"prev_surplus_state\": states(\"input_text.victron_evcs_surplus_state\"),\n \"evcs_night_session_start_soc\": states(\"input_text.victron_evcs_night_session_start_soc\"),\n \"evcs_night_session_start_at\": states(\"input_text.victron_evcs_night_session_start_at\"),\n\n \"balance_required_soc_prudent\": states(\"input_number.evcs_balance_required_soc_prudent\"),\n \"night_battery_charge_enabled\": states(\"input_boolean.victron_evcs_night_battery_charge\"),\n \"night_battery_done_prev\": states(\"input_boolean.victron_evcs_night_battery_done\"),\n \"balance_required_soc\": states(\"input_number.evcs_balance_required_soc\"),\n \"balance_energy_to_cover_kwh\": states(\"input_number.evcs_balance_energy_to_cover_kwh\"),\n \"balance_grid_energy_needed_kwh\": states(\"input_number.evcs_balance_grid_energy_needed_kwh\"),\n \"dvcc_max_charge_current_a\": states(\"sensor.victron_max_charge_current_setting\"),\n \"balance_status\": states(\"input_text.evcs_balance_status\"),\n \"balance_last_run\": states(\"input_text.evcs_balance_last_run\"),\n \"balance_vehicle_grid_needed\": states(\"input_boolean.evcs_balance_vehicle_grid_needed\"),\n \"allow_grid_hc_midday\": states(\"input_boolean.victron_evcs_allow_grid_hc_midday\"),\n \"probe_enabled\": states(\"input_boolean.victron_evcs_probe_enabled\"),\n\n \"probe_minutes\": states(\"input_number.victron_evcs_probe_minutes\"),\n \"probe_retry_minutes\": states(\"input_number.victron_evcs_probe_retry_minutes\"),\n \"probe_current_a\": states(\"input_number.victron_evcs_probe_current_a\"),\n\n \"grid_battery_soft_a\": states(\"input_number.victron_evcs_grid_battery_soft_a\"),\n \"grid_battery_hard_a\": states(\"input_number.victron_evcs_grid_battery_hard_a\"),\n\n \"morning_probe_pv_avg_min_w\": states(\"input_number.victron_evcs_morning_probe_pv_avg_min_w\"),\n \"morning_probe_pv_trend_min_w\": states(\"input_number.victron_evcs_morning_probe_pv_trend_min_w\"),\n \"midday_probe_pv_avg_min_w\": states(\"input_number.victron_evcs_midday_probe_pv_avg_min_w\"),\n \"afternoon_probe_pv_avg_min_w\": states(\"input_number.victron_evcs_afternoon_probe_pv_avg_min_w\"),\n \"afternoon_probe_soc_min\": states(\"input_number.victron_evcs_afternoon_probe_soc_min\"),\n \"afternoon_battery_max_discharge_a\": states(\"input_number.victron_evcs_afternoon_battery_max_discharge_a\"),\n \"morning_soc_min\": states(\"input_number.victron_evcs_morning_soc_min\"),\n \"midday_soc_min\": states(\"input_number.victron_evcs_midday_soc_min\"),\n \"evening_soc_start_min\": states(\"input_number.victron_evcs_evening_soc_start_min\"),\n \"evening_soc_stop_min\": states(\"input_number.victron_evcs_evening_soc_stop_min\"),\n\n \"morning_battery_max_discharge_a\": states(\"input_number.victron_evcs_morning_battery_max_discharge_a\"),\n \"midday_battery_max_discharge_a\": states(\"input_number.victron_evcs_midday_battery_max_discharge_a\"),\n \"evening_battery_max_discharge_a\": states(\"input_number.victron_evcs_evening_battery_max_discharge_a\"),\n\n \"last_block_reason\": states(\"input_text.victron_evcs_last_block_reason\"),\n \"blocked_until\": states(\"input_datetime.victron_evcs_blocked_until\"),\n \"probe_started_at\": states(\"input_datetime.victron_evcs_probe_started_at\"),\n\n \"victron_evcs_strategy\": states(\"input_select.victron_evcs_strategy\"),\n \"evcs_priority_manual_override\": states(\"input_boolean.victron_evcs_priority_manual_override\"),\n \"evcs_priority_manual_mode\": states(\"input_select.victron_evcs_priority_manual_mode\"),\n \"evcs_priority_manual_reason\": states(\"input_text.victron_evcs_priority_manual_reason\"),\n \"evcs_priority_manual_until\": states(\"input_datetime.victron_evcs_priority_manual_until\"),\n\n \"house_spike_grace_minutes\": states(\"input_number.victron_evcs_house_spike_grace_minutes\"),\n \"solar_potential_grace_minutes\": states(\"input_number.victron_evcs_solar_potential_grace_minutes\"),\n \"step_up_cooldown_seconds\": states(\"input_number.victron_evcs_step_up_cooldown_seconds\"),\n \"step_down_cooldown_seconds\": states(\"input_number.victron_evcs_step_down_cooldown_seconds\"),\n \"force_mode_auto_revert_minutes\": states(\"input_number.victron_evcs_force_mode_auto_revert_minutes\"),\n\n \"overload_step_down_a\": states(\"input_number.victron_evcs_overload_step_down_a\"),\n \"overload_hold_seconds\": states(\"input_number.victron_evcs_overload_hold_seconds\"),\n \"overload_stop_after_seconds\": states(\"input_number.victron_evcs_overload_stop_after_seconds\"),\n\n \"batt_discharge_soft_gt90_a\": states(\"input_number.victron_evcs_batt_discharge_soft_gt90_a\"),\n \"batt_discharge_hard_gt90_a\": states(\"input_number.victron_evcs_batt_discharge_hard_gt90_a\"),\n \"batt_charge_reserve_gt90_a\": states(\"input_number.victron_evcs_batt_charge_reserve_gt90_a\"),\n \"batt_soft_tol_gt90_min\": states(\"input_number.victron_evcs_batt_soft_tol_gt90_min\"),\n \"batt_hard_tol_gt90_sec\": states(\"input_number.victron_evcs_batt_hard_tol_gt90_sec\"),\n \"batt_recovery_gt90_min\": states(\"input_number.victron_evcs_batt_recovery_gt90_min\"),\n\n \"batt_discharge_soft_80_90_a\": states(\"input_number.victron_evcs_batt_discharge_soft_80_90_a\"),\n \"batt_discharge_hard_80_90_a\": states(\"input_number.victron_evcs_batt_discharge_hard_80_90_a\"),\n \"batt_charge_reserve_80_90_a\": states(\"input_number.victron_evcs_batt_charge_reserve_80_90_a\"),\n \"batt_soft_tol_80_90_min\": states(\"input_number.victron_evcs_batt_soft_tol_80_90_min\"),\n \"batt_hard_tol_80_90_sec\": states(\"input_number.victron_evcs_batt_hard_tol_80_90_sec\"),\n \"batt_recovery_80_90_min\": states(\"input_number.victron_evcs_batt_recovery_80_90_min\"),\n\n \"batt_discharge_soft_60_80_a\": states(\"input_number.victron_evcs_batt_discharge_soft_60_80_a\"),\n \"batt_discharge_hard_60_80_a\": states(\"input_number.victron_evcs_batt_discharge_hard_60_80_a\"),\n \"batt_charge_reserve_60_80_a\": states(\"input_number.victron_evcs_batt_charge_reserve_60_80_a\"),\n \"batt_soft_tol_60_80_min\": states(\"input_number.victron_evcs_batt_soft_tol_60_80_min\"),\n \"batt_hard_tol_60_80_sec\": states(\"input_number.victron_evcs_batt_hard_tol_60_80_sec\"),\n \"batt_recovery_60_80_min\": states(\"input_number.victron_evcs_batt_recovery_60_80_min\"),\n\n \"batt_discharge_soft_40_60_a\": states(\"input_number.victron_evcs_batt_discharge_soft_40_60_a\"),\n \"batt_discharge_hard_40_60_a\": states(\"input_number.victron_evcs_batt_discharge_hard_40_60_a\"),\n \"batt_charge_reserve_40_60_a\": states(\"input_number.victron_evcs_batt_charge_reserve_40_60_a\"),\n \"batt_soft_tol_40_60_min\": states(\"input_number.victron_evcs_batt_soft_tol_40_60_min\"),\n \"batt_hard_tol_40_60_sec\": states(\"input_number.victron_evcs_batt_hard_tol_40_60_sec\"),\n \"batt_recovery_40_60_min\": states(\"input_number.victron_evcs_batt_recovery_40_60_min\"),\n\n \"batt_discharge_soft_25_40_a\": states(\"input_number.victron_evcs_batt_discharge_soft_25_40_a\"),\n \"batt_discharge_hard_25_40_a\": states(\"input_number.victron_evcs_batt_discharge_hard_25_40_a\"),\n \"batt_charge_reserve_25_40_a\": states(\"input_number.victron_evcs_batt_charge_reserve_25_40_a\"),\n \"batt_soft_tol_25_40_min\": states(\"input_number.victron_evcs_batt_soft_tol_25_40_min\"),\n \"batt_hard_tol_25_40_sec\": states(\"input_number.victron_evcs_batt_hard_tol_25_40_sec\"),\n \"batt_recovery_25_40_min\": states(\"input_number.victron_evcs_batt_recovery_25_40_min\"),\n\n \"batt_discharge_soft_lt25_a\": states(\"input_number.victron_evcs_batt_discharge_soft_lt25_a\"),\n \"batt_discharge_hard_lt25_a\": states(\"input_number.victron_evcs_batt_discharge_hard_lt25_a\"),\n \"batt_charge_reserve_lt25_a\": states(\"input_number.victron_evcs_batt_charge_reserve_lt25_a\"),\n \"batt_soft_tol_lt25_min\": states(\"input_number.victron_evcs_batt_soft_tol_lt25_min\"),\n \"batt_hard_tol_lt25_sec\": states(\"input_number.victron_evcs_batt_hard_tol_lt25_sec\"),\n \"batt_recovery_lt25_min\": states(\"input_number.victron_evcs_batt_recovery_lt25_min\"),\n\n \"evcs_delivery_ratio_ema\": states(\"input_number.victron_evcs_delivery_ratio_ema\"),\n \"evcs_delivery_noise_ema\": states(\"input_number.victron_evcs_delivery_noise_ema\"),\n \"evcs_no_demand_score_ema\": states(\"input_number.victron_evcs_no_demand_score_ema\"),\n \"evcs_no_demand_semantic_active\": states(\"input_boolean.victron_evcs_no_demand_semantic_active\"),\n \"evcs_no_demand_since_at\": states(\"input_datetime.victron_evcs_no_demand_since_at\"),\n \"evcs_no_demand_filter_eval_at\": states(\"input_datetime.victron_evcs_no_demand_filter_eval_at\")\n} | tojson }}\n" + "value": "{{ {\n \"soc_percent\": states(\"sensor.victron_battery_soc\"),\n \"battery_current_a\": states(\"sensor.victron_battery_current\"),\n\n \"pv_power_w\": states(\"sensor.victron_pv_power_system\"),\n \"pv_power_10m_avg_w\": states(\"sensor.victron_pv_power_10m_avg\"),\n \"pv_trend_w\": states(\"sensor.victron_pv_power_trend_w\"),\n\n \"mppt_operation_modes\": states.sensor\n | selectattr('entity_id','search','mppt')\n | selectattr('entity_id','search','operation_mode')\n | map(attribute='state') | join(','),\n\n \"house_ac_power_w\": states(\"sensor.victron_consumption_power_l1\"),\n \"house_ac_power_prev_w\": states(\"sensor.victron_consumption_power_l1\"),\n\n \"grid_power_w\": states(\"sensor.victron_grid_power_l1\"),\n \"grid_state\": states(\"switch.edf_general_ok_switch\"),\n \"victron_acin1_available\": states(\"sensor.victron_ac_in1_available\"),\n\n \n \"acin1_last_changed_ts\": as_timestamp(states.sensor.victron_ac_in1_available.last_changed, 0) if states.sensor.victron_ac_in1_available is not none else 0,\n \"grid_switch_last_changed_ts\": as_timestamp(states.switch.edf_general_ok_switch.last_changed, 0) if states.switch.edf_general_ok_switch is not none else 0,\n \"grid_settle_seconds\": states(\"input_number.evcs_grid_settle_seconds\"),\n \"grid_setpoint_w\": states(\"number.gx_device_ac_power_setpoint\"),\n \"grid_setpoint_idle_w\": states(\"input_number.evcs_grid_setpoint_idle_w\"),\n\n \"today_remaining_kwh\": states(\"sensor.energy_production_today_remaining\"),\n \"tomorrow_kwh\": states(\"sensor.energy_production_tomorrow\"),\n\n \"evcs_device_connected\": states(\"binary_sensor.victron_evcs_device_connected\"),\n \"evcs_vehicle_connected\": states(\"binary_sensor.victron_evcs_vehicle_connected\"),\n \"evcs_status_raw\": states(\"sensor.victron_evcs_status_raw\"),\n \"evcs_mode_sensor\": states(\"sensor.victron_evcs_mode\"),\n \"evcs_mode_fallback\": states(\"input_text.sensor_victron_evcs_mode\"),\n \"evcs_set_current_a\": states(\"sensor.victron_evcs_set_current\"),\n \"evcs_command_current_a\": states(\"number.victron_evcs_set_current_command\"),\n \"evcs_current_a\": states(\"sensor.victron_evcs_current\"),\n \"evcs_ac_power_w\": states(\"sensor.victron_evcs_ac_power\"),\n \"evcs_charging_time\": states(\"sensor.victron_evcs_charging_time\"),\n \"evcs_vehicle_connected_ts\": as_timestamp(states.binary_sensor.victron_evcs_vehicle_connected.last_changed, 0) if states.binary_sensor.victron_evcs_vehicle_connected is not none else 0,\n \"evcs_session_energy_kwh\": states(\"sensor.evcs_hq2239dnt7p_last_session_energy\"),\n \"evcs_charging_switch\": states(\"switch.victron_evcs_charging\"),\n \"evcs_auto_start_switch\": states(\"switch.victron_evcs_auto_start\"),\n\n \"controller_enabled\": states(\"input_boolean.victron_evcs_surplus_auto_enabled\"),\n \"notifications_enabled\": states(\"input_boolean.victron_evcs_surplus_notifications\"),\n \"hard_stop_enabled\": states(\"input_boolean.victron_evcs_hard_stop_enabled\"),\n \"charge_permission_now\": states(\"input_boolean.victron_evcs_charge_permission_now\"),\n \"control_latch\": states(\"input_boolean.victron_evcs_control_latch\"),\n \"departure_priority_enabled\": states(\"input_boolean.victron_evcs_departure_priority_enabled\"),\n \"forecast_mode_enabled\": states(\"input_boolean.victron_evcs_forecast_mode_enabled\"),\n\n \"target_current_auto_a\": states(\"input_number.victron_evcs_target_current_auto\"),\n \"budget_remaining_kwh\": states(\"input_number.victron_evcs_budget_remaining_kwh\"),\n \"projected_end_soc_after_charge\": states(\"input_number.victron_evcs_projected_end_soc_after_charge\"),\n\n \"pv_start_min_w\": states(\"input_number.victron_evcs_pv_start_min_w\"),\n \"pv_hold_min_w\": states(\"input_number.victron_evcs_pv_hold_min_w\"),\n \"house_ac_headroom_w\": states(\"input_number.victron_evcs_house_ac_headroom_w\"),\n \"battery_discharge_soft_a\": states(\"input_number.victron_evcs_battery_discharge_soft_a\"),\n \"battery_discharge_hard_a\": states(\"input_number.victron_evcs_battery_discharge_hard_a\"),\n \"battery_reserve_up_a\": states(\"input_number.victron_evcs_battery_reserve_up_a\"),\n \"battery_neutral_band_a\": states(\"input_number.victron_evcs_battery_neutral_band_a\"),\n \"soc_start_min\": states(\"input_number.victron_evcs_soc_start_min\"),\n \"modules_en_service\": states(\"input_number.evcs_balance_modules_en_service\"),\n\n \"bms_max_discharge_current_a\": states(\"sensor.airtria_8_us2000c_maximum_allowed_discharge_current\"),\n \"bms_max_charge_current_a\": states(\"sensor.airtria_8_us2000c_maximum_allowed_charge_current\"),\n \"soc_stop_min\": states(\"input_number.victron_evcs_soc_stop_min\"),\n \"current_min_a\": states(\"input_number.victron_evcs_current_min_a\"),\n \"current_max_auto_a\": states(\"input_number.victron_evcs_current_max_auto_a\"),\n \"grid_current_max_a\": states(\"input_number.evcs_plafond_courant_edf_on\"),\n \"current_step_up_a\": states(\"input_number.victron_evcs_current_step_up_a\"),\n \"current_step_down_a\": states(\"input_number.victron_evcs_current_step_down_a\"),\n \"current_change_cooldown_seconds\": states(\"input_number.victron_evcs_current_change_cooldown_seconds\"),\n \"min_run_minutes\": states(\"input_number.victron_evcs_min_run_minutes\"),\n \"min_stop_minutes\": states(\"input_number.victron_evcs_min_stop_minutes\"),\n\n \"required_energy_kwh\": states(\"input_number.victron_evcs_required_energy_kwh\"),\n \"soc_comfort_target\": states(\"input_number.victron_evcs_soc_comfort_target\"),\n \"end_of_day_soc_guard\": states(\"input_number.victron_evcs_end_of_day_soc_guard\"),\n\n \"base_load_remaining_kwh\": states(\"input_number.victron_base_load_remaining_kwh\"),\n \"flexible_safety_margin_kwh\": states(\"input_number.victron_flexible_safety_margin_kwh\"),\n \"end_of_day_soc_target\": states(\"input_number.victron_end_of_day_soc_target\"),\n \"ac_loads_max_safe_w\": states(\"input_number.victron_ac_loads_max_safe_w\"),\n\n \"battery_full_soc\": states(\"input_number.victron_evcs_battery_full_soc\"),\n \"battery_full_forecast_min_kwh\": states(\"input_number.victron_evcs_battery_full_forecast_min_kwh\"),\n \"forecast_commit_soc_floor\": states(\"input_number.victron_evcs_forecast_commit_soc_floor\"),\n \"forecast_commit_min_kwh\": states(\"input_number.victron_evcs_forecast_commit_min_kwh\"),\n\n \"cloud_debt_kwh\": states(\"input_number.victron_evcs_cloud_debt_kwh\"),\n \"cloud_debt_max_kwh\": states(\"input_number.victron_evcs_cloud_debt_max_kwh\"),\n \"cloud_tolerance_minutes\": states(\"input_number.victron_evcs_cloud_tolerance_minutes\"),\n \"cloud_debt_started_at\": states(\"input_datetime.victron_evcs_cloud_debt_started_at\"),\n \"battery_soft_breach_at\": states(\"input_text.victron_evcs_battery_soft_breach_at\"),\n \"operating_mode_helper\": states(\"input_text.victron_evcs_operating_mode\"),\n\n \"departure_time\": states(\"input_datetime.victron_evcs_departure_time\"),\n \"departure_time_jeep\": states(\"input_datetime.victron_evcs_departure_time_jeep\"),\n \"departure_time_zero\": states(\"input_datetime.victron_evcs_departure_time_zero\"),\n \"evcs_vehicle\": states(\"input_select.victron_evcs_vehicle\"),\n \"allow_grid_departure_urgent\": states(\"input_boolean.victron_evcs_allow_grid_departure_urgent\"),\n \"last_start_at\": states(\"input_datetime.victron_evcs_last_start_at\"),\n \"last_stop_at\": states(\"input_datetime.victron_evcs_last_stop_at\"),\n \"last_current_change_at\": states(\"input_datetime.victron_evcs_last_current_change_at\"),\n\n \"day_start_time\": states(\"input_datetime.victron_flexible_day_start_time\"),\n \"day_stop_time\": states(\"input_datetime.victron_flexible_day_stop_time\"),\n\n \"vebus_overload\": states(\"binary_sensor.victron_ve_bus_overload\"),\n \"vebus_l1_overload\": states(\"binary_sensor.victron_ve_bus_l1_overload\"),\n \"vebus_low_battery\": states(\"binary_sensor.victron_ve_bus_low_battery\"),\n \"vebus_l1_low_battery\": states(\"binary_sensor.victron_ve_bus_l1_low_battery\"),\n\n \"vebus_ac_out_l1_power_w\": states(\"sensor.victron_ve_bus_ac_out_l1_power\"),\n \"vebus_ac_out_l1_current_a\": states(\"sensor.victron_ve_bus_ac_out_l1_current\"),\n \"vebus_ac_out_l1_voltage_v\": states(\"sensor.victron_ve_bus_ac_out_l1_voltage\"),\n\n \"vebus_dc_voltage_v\": states(\"sensor.victron_ve_bus_dc_voltage\"),\n \"vebus_dc_current_a\": states(\"sensor.victron_ve_bus_dc_current\"),\n \"vebus_dc_power_w\": states(\"sensor.victron_ve_bus_dc_power_system\"),\n\n \"vebus_state\": states(\"sensor.victron_ve_bus_state\"),\n \"vebus_main_state\": states(\"sensor.victron_ve_bus_main_state\"),\n\n \"last_signature\": states(\"input_text.victron_evcs_control_last_signature\"),\n\n \"hc_night_start_time\": states(\"input_datetime.victron_evcs_hc_night_start\"),\n \"hc_night_stop_time\": states(\"input_datetime.victron_evcs_hc_night_stop\"),\n \"morning_start_time\": states(\"input_datetime.victron_evcs_morning_start\"),\n \"morning_stop_time\": states(\"input_datetime.victron_evcs_morning_stop\"),\n \"hc_midday_start_time\": states(\"input_datetime.victron_evcs_hc_midday_start\"),\n \"hc_midday_stop_time\": states(\"input_datetime.victron_evcs_hc_midday_stop\"),\n \"evening_start_time\": states(\"input_datetime.victron_evcs_evening_start\"),\n \"evening_stop_time\": states(\"input_datetime.victron_evcs_evening_stop\"),\n\n \"allow_grid_hc_night\": states(\"input_boolean.victron_evcs_allow_grid_hc_night\"),\n \"reset_borne\": states(\"input_boolean.evcs_reset_borne\"),\n \"night_takeover_prev\": states(\"input_boolean.victron_evcs_night_grid_takeover_needed\"),\n \"night_grid_session_committed_prev\": states(\"input_boolean.victron_evcs_night_grid_session_committed\"),\n \"prev_surplus_state\": states(\"input_text.victron_evcs_surplus_state\"),\n \"evcs_night_session_start_soc\": states(\"input_text.victron_evcs_night_session_start_soc\"),\n \"evcs_night_session_start_at\": states(\"input_text.victron_evcs_night_session_start_at\"),\n\n \"balance_required_soc_prudent\": states(\"input_number.evcs_balance_required_soc_prudent\"),\n \"night_battery_charge_enabled\": states(\"input_boolean.victron_evcs_night_battery_charge\"),\n \"night_battery_done_prev\": states(\"input_boolean.victron_evcs_night_battery_done\"),\n \"balance_required_soc\": states(\"input_number.evcs_balance_required_soc\"),\n \"balance_energy_to_cover_kwh\": states(\"input_number.evcs_balance_energy_to_cover_kwh\"),\n \"balance_grid_energy_needed_kwh\": states(\"input_number.evcs_balance_grid_energy_needed_kwh\"),\n \"dvcc_max_charge_current_a\": states(\"sensor.victron_max_charge_current_setting\"),\n \"balance_status\": states(\"input_text.evcs_balance_status\"),\n \"balance_last_run\": states(\"input_text.evcs_balance_last_run\"),\n \"balance_vehicle_grid_needed\": states(\"input_boolean.evcs_balance_vehicle_grid_needed\"),\n \"allow_grid_hc_midday\": states(\"input_boolean.victron_evcs_allow_grid_hc_midday\"),\n \"probe_enabled\": states(\"input_boolean.victron_evcs_probe_enabled\"),\n\n \"probe_minutes\": states(\"input_number.victron_evcs_probe_minutes\"),\n \"probe_retry_minutes\": states(\"input_number.victron_evcs_probe_retry_minutes\"),\n \"probe_current_a\": states(\"input_number.victron_evcs_probe_current_a\"),\n\n \"grid_battery_soft_a\": states(\"input_number.victron_evcs_grid_battery_soft_a\"),\n \"grid_battery_hard_a\": states(\"input_number.victron_evcs_grid_battery_hard_a\"),\n\n \"morning_probe_pv_avg_min_w\": states(\"input_number.victron_evcs_morning_probe_pv_avg_min_w\"),\n \"morning_probe_pv_trend_min_w\": states(\"input_number.victron_evcs_morning_probe_pv_trend_min_w\"),\n \"midday_probe_pv_avg_min_w\": states(\"input_number.victron_evcs_midday_probe_pv_avg_min_w\"),\n \"afternoon_probe_pv_avg_min_w\": states(\"input_number.victron_evcs_afternoon_probe_pv_avg_min_w\"),\n \"afternoon_probe_soc_min\": states(\"input_number.victron_evcs_afternoon_probe_soc_min\"),\n \"afternoon_battery_max_discharge_a\": states(\"input_number.victron_evcs_afternoon_battery_max_discharge_a\"),\n \"morning_soc_min\": states(\"input_number.victron_evcs_morning_soc_min\"),\n \"midday_soc_min\": states(\"input_number.victron_evcs_midday_soc_min\"),\n \"evening_soc_start_min\": states(\"input_number.victron_evcs_evening_soc_start_min\"),\n \"evening_soc_stop_min\": states(\"input_number.victron_evcs_evening_soc_stop_min\"),\n\n \"morning_battery_max_discharge_a\": states(\"input_number.victron_evcs_morning_battery_max_discharge_a\"),\n \"midday_battery_max_discharge_a\": states(\"input_number.victron_evcs_midday_battery_max_discharge_a\"),\n \"evening_battery_max_discharge_a\": states(\"input_number.victron_evcs_evening_battery_max_discharge_a\"),\n\n \"last_block_reason\": states(\"input_text.victron_evcs_last_block_reason\"),\n \"blocked_until\": states(\"input_datetime.victron_evcs_blocked_until\"),\n \"probe_started_at\": states(\"input_datetime.victron_evcs_probe_started_at\"),\n\n \"victron_evcs_strategy\": states(\"input_select.victron_evcs_strategy\"),\n \"evcs_priority_manual_override\": states(\"input_boolean.victron_evcs_priority_manual_override\"),\n \"evcs_priority_manual_mode\": states(\"input_select.victron_evcs_priority_manual_mode\"),\n \"evcs_priority_manual_reason\": states(\"input_text.victron_evcs_priority_manual_reason\"),\n \"evcs_priority_manual_until\": states(\"input_datetime.victron_evcs_priority_manual_until\"),\n\n \"house_spike_grace_minutes\": states(\"input_number.victron_evcs_house_spike_grace_minutes\"),\n \"solar_potential_grace_minutes\": states(\"input_number.victron_evcs_solar_potential_grace_minutes\"),\n \"step_up_cooldown_seconds\": states(\"input_number.victron_evcs_step_up_cooldown_seconds\"),\n \"step_down_cooldown_seconds\": states(\"input_number.victron_evcs_step_down_cooldown_seconds\"),\n \"force_mode_auto_revert_minutes\": states(\"input_number.victron_evcs_force_mode_auto_revert_minutes\"),\n\n \"overload_step_down_a\": states(\"input_number.victron_evcs_overload_step_down_a\"),\n \"overload_hold_seconds\": states(\"input_number.victron_evcs_overload_hold_seconds\"),\n \"overload_stop_after_seconds\": states(\"input_number.victron_evcs_overload_stop_after_seconds\"),\n\n \"batt_discharge_soft_gt90_a\": states(\"input_number.victron_evcs_batt_discharge_soft_gt90_a\"),\n \"batt_discharge_hard_gt90_a\": states(\"input_number.victron_evcs_batt_discharge_hard_gt90_a\"),\n \"batt_charge_reserve_gt90_a\": states(\"input_number.victron_evcs_batt_charge_reserve_gt90_a\"),\n \"batt_soft_tol_gt90_min\": states(\"input_number.victron_evcs_batt_soft_tol_gt90_min\"),\n \"batt_hard_tol_gt90_sec\": states(\"input_number.victron_evcs_batt_hard_tol_gt90_sec\"),\n \"batt_recovery_gt90_min\": states(\"input_number.victron_evcs_batt_recovery_gt90_min\"),\n\n \"batt_discharge_soft_80_90_a\": states(\"input_number.victron_evcs_batt_discharge_soft_80_90_a\"),\n \"batt_discharge_hard_80_90_a\": states(\"input_number.victron_evcs_batt_discharge_hard_80_90_a\"),\n \"batt_charge_reserve_80_90_a\": states(\"input_number.victron_evcs_batt_charge_reserve_80_90_a\"),\n \"batt_soft_tol_80_90_min\": states(\"input_number.victron_evcs_batt_soft_tol_80_90_min\"),\n \"batt_hard_tol_80_90_sec\": states(\"input_number.victron_evcs_batt_hard_tol_80_90_sec\"),\n \"batt_recovery_80_90_min\": states(\"input_number.victron_evcs_batt_recovery_80_90_min\"),\n\n \"batt_discharge_soft_60_80_a\": states(\"input_number.victron_evcs_batt_discharge_soft_60_80_a\"),\n \"batt_discharge_hard_60_80_a\": states(\"input_number.victron_evcs_batt_discharge_hard_60_80_a\"),\n \"batt_charge_reserve_60_80_a\": states(\"input_number.victron_evcs_batt_charge_reserve_60_80_a\"),\n \"batt_soft_tol_60_80_min\": states(\"input_number.victron_evcs_batt_soft_tol_60_80_min\"),\n \"batt_hard_tol_60_80_sec\": states(\"input_number.victron_evcs_batt_hard_tol_60_80_sec\"),\n \"batt_recovery_60_80_min\": states(\"input_number.victron_evcs_batt_recovery_60_80_min\"),\n\n \"batt_discharge_soft_40_60_a\": states(\"input_number.victron_evcs_batt_discharge_soft_40_60_a\"),\n \"batt_discharge_hard_40_60_a\": states(\"input_number.victron_evcs_batt_discharge_hard_40_60_a\"),\n \"batt_charge_reserve_40_60_a\": states(\"input_number.victron_evcs_batt_charge_reserve_40_60_a\"),\n \"batt_soft_tol_40_60_min\": states(\"input_number.victron_evcs_batt_soft_tol_40_60_min\"),\n \"batt_hard_tol_40_60_sec\": states(\"input_number.victron_evcs_batt_hard_tol_40_60_sec\"),\n \"batt_recovery_40_60_min\": states(\"input_number.victron_evcs_batt_recovery_40_60_min\"),\n\n \"batt_discharge_soft_25_40_a\": states(\"input_number.victron_evcs_batt_discharge_soft_25_40_a\"),\n \"batt_discharge_hard_25_40_a\": states(\"input_number.victron_evcs_batt_discharge_hard_25_40_a\"),\n \"batt_charge_reserve_25_40_a\": states(\"input_number.victron_evcs_batt_charge_reserve_25_40_a\"),\n \"batt_soft_tol_25_40_min\": states(\"input_number.victron_evcs_batt_soft_tol_25_40_min\"),\n \"batt_hard_tol_25_40_sec\": states(\"input_number.victron_evcs_batt_hard_tol_25_40_sec\"),\n \"batt_recovery_25_40_min\": states(\"input_number.victron_evcs_batt_recovery_25_40_min\"),\n\n \"batt_discharge_soft_lt25_a\": states(\"input_number.victron_evcs_batt_discharge_soft_lt25_a\"),\n \"batt_discharge_hard_lt25_a\": states(\"input_number.victron_evcs_batt_discharge_hard_lt25_a\"),\n \"batt_charge_reserve_lt25_a\": states(\"input_number.victron_evcs_batt_charge_reserve_lt25_a\"),\n \"batt_soft_tol_lt25_min\": states(\"input_number.victron_evcs_batt_soft_tol_lt25_min\"),\n \"batt_hard_tol_lt25_sec\": states(\"input_number.victron_evcs_batt_hard_tol_lt25_sec\"),\n \"batt_recovery_lt25_min\": states(\"input_number.victron_evcs_batt_recovery_lt25_min\"),\n\n \"evcs_delivery_ratio_ema\": states(\"input_number.victron_evcs_delivery_ratio_ema\"),\n \"evcs_delivery_noise_ema\": states(\"input_number.victron_evcs_delivery_noise_ema\"),\n \"evcs_no_demand_score_ema\": states(\"input_number.victron_evcs_no_demand_score_ema\"),\n \"evcs_no_demand_semantic_active\": states(\"input_boolean.victron_evcs_no_demand_semantic_active\"),\n \"evcs_no_demand_since_at\": states(\"input_datetime.victron_evcs_no_demand_since_at\"),\n \"evcs_no_demand_filter_eval_at\": states(\"input_datetime.victron_evcs_no_demand_filter_eval_at\")\n} | tojson }}\n" } ] },