diff --git a/HANDOFF.md b/HANDOFF.md index 96cfcff..e550b2c 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -1983,5 +1983,32 @@ sur VRM comme le bilan, un seul workflow, securite eau chaude 1,5 kWh / 24 h. Forecast.Solar), bloc « Jour faible », historique avec jour faible / coupure / EDF. Rendus verifies par `/api/template`. +### 07/10 ~07:50 — HC pleine consommation pour le chauffe-eau, alerte BOOST, Victron Mobile refait +**Regle du proprietaire** : « aux HC de midi, consommation MAX toute la duree, AUTO ne fera pas le job » ; +si le ballon consomme peu en HC (pompe a chaleur), une alarme lui demande de passer en BOOST, sinon la +douche ne sera pas a 65 °C (06:00 et 20:00, tous les jours). Journee test : le 07/10 (jour faible). +- **Controleur (deploye 07:46, golden 142/142, +4, mutations qui mordent)** : `input_boolean.evcs_balance_ecs_nuit` + -> `params.balanceEcsNuit` (gabarit + INPUT_PARSE) ; WINDOWS : `hcEcsGridNeeded` engage la session HC de + MIDI, et la nuit `nightGridSessionUncovered` = batterie sous la cible OU jour faible. Memes gardes que la + cible (bilan frais, statut ok). Effet : jour faible -> EDF ferme les deux fenetres HC entieres, le + workflow ECS alimente le ballon (session engagee + contacteur ferme). ACM 20/20, hysteresis 24/24. +- **Workflow ECS (deploye 07:48, tests 18/18)** : alerte `input_text.evcs_alerte_chauffe_eau` (paquet + `evcs_chauffe_eau_nuit.yaml`, max 255) = JSON {active, since = debut de la fenetre HC, fenetre, + puissance_w, message} quand, 15 min apres le debut d'une fenetre HC, session engagee, contacteur ferme, + ballon sous tension et regime `hp_low` (pompe a chaleur) ; vide sinon. 0 W (thermostat satisfait) ou + resistance -> pas d'alerte. Fenetres lues dans `input_datetime.victron_evcs_hc_*`. Envoi WhatsApp par le + concierge (SPARK), une fois par fenetre (dedup `since`). Premier passage reel : vide (07:50, hors HC). + Aussi : chauffe-eau coupe = 0 W (le compteur Tuya remonte avec retard : « arret — 1595 W »). +- **A observer aujourd'hui** : 12:24 session de midi engagee pour le chauffe-eau (EDF ferme), regime mesure, + alerte a 12:39 si pompe a chaleur ; kWh pris en HC ; securite 16 h-20 h ; nuit du 07->08 (session de nuit + engagee par le jour faible), douche de 06:00. +- **Victron Mobile refait (lecture seule)** : alertes reelles seulement (`sensor.evcs_reseau` muet ou + incoherent, cellules perimees/muettes, auto batterie OFF, override ; heartbeat dans la carte du jour), + batterie (W, A, cible du matin, requis), reseau sans action + achete du jour, energie du jour (produit, + VRM prevu/reste, consomme, achete, diagnostic PV), nuit (`texte_rapport` prevision le soir / bilan le + matin + mode de charge), borne, chauffe-eau, cellules, flux 4 h. Plus aucune fiche switch ouvrable + (contacteur, borne, chauffe-eau). Flux : `sensor.maison_hors_chauffe_eau` (template, sans chauffe-eau NI + borne malgre le nom) + courbes chauffe-eau et borne a part. Sauvegarde `victron_mobile.yaml.bak-20261007`. + --- -*Document mis a jour le : 07/10/2026 (~07:45) — 1re decision jour faible en reel, mesure ECS reparee, douches 06:00/20:00, Grafana chauffe-eau, tableaux ECS ; chauffe-eau en HC de nuit les jours faibles (decision du bilan, workflow ECS sous controle du depot, VRM, V2.3 desactive) ; garde batterie reseau : voiture bridee a 6 A au lieu d'un arret ; garde-fou concierge 2 h ; allers-retours brefs de la borne retrouves dans l'historique HA (echange VW -> JEEP de 20 s), production 14,5 kWh contre 22 prevus, defaut garde batterie en mode reseau a trancher ; voiture a midi : la batterie n'est plus comptee comme du solaire (EDF automatique les jours de depart si le surplus ne couvre pas) ; nuit 05->06 : JEEP chargee sur EDF en complete (6,32 kWh), budget 0 du a la prevision, Windy/ECMWF contre VRM, a comparer ce soir ; concierge aligne sur la preuve fin de trajet ; a verifier : nuit 05->06 et mardi faible, comparaison VRM/Forecast.Solar ; JEEP identifiee aussi par sa fin de trajet, prevision de la nuit 05->06 ; identification JEEP reussie en reel, correction humaine qui prime (refus supprime), session du 04/10 coupee ; EVCS Master en quatre onglets ; Jeep lue par Uconnect et identifiee seule, capacite JEEP 10 kWh, concierge variante (a) ; releve par photo (SPARK), hass-uconnect en attente ; ecart semaine/week-end mesure (pas de profil week-end la nuit) ; nuit du budget : budget borne au plancher prudent, sans EDF = voiture bloquee meme EDF ferme (batterie seule) ; Energie : sauts corriges ; tableau Energie complete (Sud/Ouest, batterie par integration, borne ; aucun redemarrage HA) ; kilometrage des vehicules (km/kWh, capacite, releves de reference, agent cable 15/15) ; a verifier le 04/10 au matin : nuit du budget JEEP ; concierge : vehicule non confirme demande, heure de saisie ; Grafana vehicules en tableau ; Grafana vehicules : ZERO et Invite ajoutes, session 16:46 reetiquetee ZERO ; prevision SOC 10 h ~35 % ; concierge fiable : niveau note par le script, une ligne, dedoublonnage, simulation par drapeau ; agent : message incompris = question, bourse sur demande explicite (banc final 6/6) ; a verifier : nuit du samedi 03 au dimanche 04/10 (budget < besoin JEEP), cron orange-cloture lundi 05/10 apres 17:40, comparaison VRM / Forecast.Solar lundi 05/10* +*Document mis a jour le : 07/10/2026 (~07:55) — jour faible = EDF les deux fenetres HC pour le chauffe-eau, alerte BOOST, Victron Mobile refait ; 1re decision jour faible en reel, mesure ECS reparee, douches 06:00/20:00, Grafana chauffe-eau, tableaux ECS ; chauffe-eau en HC de nuit les jours faibles (decision du bilan, workflow ECS sous controle du depot, VRM, V2.3 desactive) ; garde batterie reseau : voiture bridee a 6 A au lieu d'un arret ; garde-fou concierge 2 h ; allers-retours brefs de la borne retrouves dans l'historique HA (echange VW -> JEEP de 20 s), production 14,5 kWh contre 22 prevus, defaut garde batterie en mode reseau a trancher ; voiture a midi : la batterie n'est plus comptee comme du solaire (EDF automatique les jours de depart si le surplus ne couvre pas) ; nuit 05->06 : JEEP chargee sur EDF en complete (6,32 kWh), budget 0 du a la prevision, Windy/ECMWF contre VRM, a comparer ce soir ; concierge aligne sur la preuve fin de trajet ; a verifier : nuit 05->06 et mardi faible, comparaison VRM/Forecast.Solar ; JEEP identifiee aussi par sa fin de trajet, prevision de la nuit 05->06 ; identification JEEP reussie en reel, correction humaine qui prime (refus supprime), session du 04/10 coupee ; EVCS Master en quatre onglets ; Jeep lue par Uconnect et identifiee seule, capacite JEEP 10 kWh, concierge variante (a) ; releve par photo (SPARK), hass-uconnect en attente ; ecart semaine/week-end mesure (pas de profil week-end la nuit) ; nuit du budget : budget borne au plancher prudent, sans EDF = voiture bloquee meme EDF ferme (batterie seule) ; Energie : sauts corriges ; tableau Energie complete (Sud/Ouest, batterie par integration, borne ; aucun redemarrage HA) ; kilometrage des vehicules (km/kWh, capacite, releves de reference, agent cable 15/15) ; a verifier le 04/10 au matin : nuit du budget JEEP ; concierge : vehicule non confirme demande, heure de saisie ; Grafana vehicules en tableau ; Grafana vehicules : ZERO et Invite ajoutes, session 16:46 reetiquetee ZERO ; prevision SOC 10 h ~35 % ; concierge fiable : niveau note par le script, une ligne, dedoublonnage, simulation par drapeau ; agent : message incompris = question, bourse sur demande explicite (banc final 6/6) ; a verifier : nuit du samedi 03 au dimanche 04/10 (budget < besoin JEEP), cron orange-cloture lundi 05/10 apres 17:40, comparaison VRM / Forecast.Solar lundi 05/10* diff --git a/packages/evcs_chauffe_eau_nuit.yaml b/packages/evcs_chauffe_eau_nuit.yaml index d68eccc..bf72a3f 100755 --- a/packages/evcs_chauffe_eau_nuit.yaml +++ b/packages/evcs_chauffe_eau_nuit.yaml @@ -18,6 +18,14 @@ input_boolean: icon: mdi:water-boiler-off input_text: + # Alerte « passe en BOOST » (07/10/2026) : ecrite par le workflow ECS quand, + # en heures creuses sur EDF, le ballon ne tire que la pompe a chaleur. JSON + # {active, since, fenetre, puissance_w, message} ou chaine vide. Le concierge + # OpenClaw envoie le WhatsApp une fois par fenetre (dedup sur `since`). + evcs_alerte_chauffe_eau: + name: "Alerte : chauffe-eau, passer en BOOST" + max: 255 + icon: mdi:water-boiler-alert # « AAAA-MM-JJ|on|raison » : date de la decision, decision, explication. evcs_balance_ecs_nuit_jour: name: "EVCS - Chauffe-eau : decision du jour" diff --git a/src/nodes/HA_SNAPSHOT_TEMPLATE.json.tpl b/src/nodes/HA_SNAPSHOT_TEMPLATE.json.tpl index eac8731..f3b9be2 100644 --- a/src/nodes/HA_SNAPSHOT_TEMPLATE.json.tpl +++ b/src/nodes/HA_SNAPSHOT_TEMPLATE.json.tpl @@ -175,6 +175,7 @@ "balance_status": states("input_text.evcs_balance_status"), "balance_last_run": states("input_text.evcs_balance_last_run"), "balance_vehicle_grid_needed": states("input_boolean.evcs_balance_vehicle_grid_needed"), + "balance_ecs_nuit": states("input_boolean.evcs_balance_ecs_nuit"), "allow_grid_hc_midday": states("input_boolean.victron_evcs_allow_grid_hc_midday"), "probe_enabled": states("input_boolean.victron_evcs_probe_enabled"), diff --git a/src/nodes/INPUT_PARSE.js b/src/nodes/INPUT_PARSE.js index 8c47c82..c4d4568 100644 --- a/src/nodes/INPUT_PARSE.js +++ b/src/nodes/INPUT_PARSE.js @@ -501,6 +501,9 @@ const params = { // Voiture branchee en HC avec un depart avant la prochaine HC (28/09) : le // bilan juge que le solaire ne lui donnera pas son besoin avant le depart. balanceVehicleGridNeeded: low(input.balance_vehicle_grid_needed || 'off') === 'on', + // Jour faible (07/10/2026) : decision quotidienne du bilan, l'eau chaude se + // fait en HC sur EDF -> la session reseau HC (nuit ET midi) s'engage. + balanceEcsNuit: low(input.balance_ecs_nuit || 'off') === 'on', // P12 — charge nuit sur batterie. Le plancher est le SOC requis PRUDENT (p80) // et non le p50 : la marge est mesuree, pas inventee. On descend la voiture // jusqu'a ce que la batterie n'ait plus que ce qu'il lui faut pour tenir diff --git a/src/nodes/WINDOWS_SOURCEV2.js b/src/nodes/WINDOWS_SOURCEV2.js index 5b7ba68..821a5bf 100644 --- a/src/nodes/WINDOWS_SOURCEV2.js +++ b/src/nodes/WINDOWS_SOURCEV2.js @@ -763,7 +763,8 @@ const nightGridSessionCommittedPrev = (input.night_grid_session_committed_prev ?? 'off').toString().trim().toLowerCase() === 'on'; const nightGridSessionUncovered = - balanceMorningTargetApplied && batteryBelowMorningTarget; + balanceMorningTargetApplied && + (batteryBelowMorningTarget || params.balanceEcsNuit); // ⚠️ SANS VEHICULE AUSSI (12/09/2026). // Nuit du 11-12/09, voiture rendue la veille : le bilan a reclame entre 92 % @@ -814,12 +815,23 @@ const middayVehicleGridNeeded = params.balanceVehicleGridNeeded && parsed.evcsVehicleConnected; +// ET LE CHAUFFE-EAU (07/10/2026, proprietaire) : un jour faible, l'eau chaude +// se fait en heures creuses sur EDF, a PLEINE consommation toute la fenetre +// (douches de 06:00 et 20:00 a 65 °C ; le 06/10 le ballon avait pris 5,4 kWh +// de solaire de 10 h a 15 h, l'energie qui manquait a la batterie). Le bilan +// decide une fois par jour (input_boolean.evcs_balance_ecs_nuit) ; le workflow +// ECS n'alimente le ballon que si la session est engagee ET le contacteur +// ferme. Memes gardes de fraicheur et de statut que la cible. +const hcEcsGridNeeded = + balanceMorningTargetApplied && params.balanceEcsNuit; + const middayGridSessionUncovered = balanceMorningTargetApplied && ( (Number.isFinite(params.balanceRequiredSocPct) && parsed.soc < params.balanceRequiredSocPct) || - middayVehicleGridNeeded + middayVehicleGridNeeded || + hcEcsGridNeeded ); const gridSessionInHcWindow = diff --git a/src/water-heater/ARBITRATION.js b/src/water-heater/ARBITRATION.js index 99244c4..e0c9089 100644 --- a/src/water-heater/ARBITRATION.js +++ b/src/water-heater/ARBITRATION.js @@ -329,6 +329,36 @@ if (advisorEnabled && ecsNuit) { reason.push('Jour normal : chauffe-eau remis sous tension (il chauffe au solaire)'); } +// ===================================================== +// ALERTE « PASSE EN BOOST » (07/10/2026, proprietaire) +// ===================================================== +// En heures creuses sur EDF, le ballon doit consommer AU MAXIMUM toute la +// fenetre : en AUTO il reste sur la pompe a chaleur (lent, 55 °C) et la douche +// ne sera pas a 65 °C (20 h apres la HC de midi, 06 h apres celle de nuit). +// Si, 15 min apres le debut de la fenetre, session engagee, contacteur ferme et +// ballon sous tension, il ne tire que la puissance de la pompe a chaleur +// (regime hp_low), on le SIGNALE : input_text.evcs_alerte_chauffe_eau (JSON, +// `since` = debut de la fenetre -> un seul message par fenetre, envoye par le +// concierge OpenClaw). 0 W = thermostat satisfait : pas d'alerte. +const ALERTE_DELAI_MIN = 15; +const minNow = Number.isFinite(now.minNow) ? now.minNow : null; +const fenetreHc = (normalized.hcWindows || []).find((w) => minNow !== null && ( + w.debut <= w.fin ? (minNow >= w.debut && minNow < w.fin) : (minNow >= w.debut || minNow < w.fin))); +const depuisDebutMin = fenetreHc ? ((minNow - fenetreHc.debut + 1440) % 1440) : null; +const alerteBoost = !!fenetreHc && depuisDebutMin >= ALERTE_DELAI_MIN + && hcSessionCommitted && gridIsOn && waterHeaterSwitchOn && observedRegime === 'hp_low'; +let alerteChauffeEau = ''; +if (alerteBoost) { + const h = String(Math.floor(fenetreHc.debut / 60)).padStart(2, '0') + ':' + String(fenetreHc.debut % 60).padStart(2, '0'); + const jourFenetre = (fenetreHc.debut > fenetreHc.fin && minNow < fenetreHc.fin) + ? new Date(nowMs - 86400000).toLocaleDateString('sv-SE', { timeZone: 'Europe/Paris' }) : now.date; + alerteChauffeEau = JSON.stringify({ + active: true, since: `${jourFenetre} ${h}`, fenetre: fenetreHc.nom, + puissance_w: Math.round(observedPowerW), + message: `🚿 Heures creuses EDF : le chauffe-eau ne tire que ${Math.round(observedPowerW)} W (pompe a chaleur). Passe-le en BOOST pour avoir 65 °C a ${fenetreHc.douche}.`, + }); +} + return [{ json: { ...root, @@ -366,7 +396,8 @@ return [{ powerSwitchAction, powerSwitchDesiredOn, offJourFaible, - jourFaibleState + jourFaibleState, + alerteChauffeEau } } }]; \ No newline at end of file diff --git a/src/water-heater/HA_SNAPSHOT_TEMPLATE.json.tpl b/src/water-heater/HA_SNAPSHOT_TEMPLATE.json.tpl index e4b64df..00800a8 100644 --- a/src/water-heater/HA_SNAPSHOT_TEMPLATE.json.tpl +++ b/src/water-heater/HA_SNAPSHOT_TEMPLATE.json.tpl @@ -33,6 +33,10 @@ "ecs_24h_kwh": states("sensor.evcs_chauffe_eau_24h_kwh"), "ecs_jour_kwh": states("sensor.evcs_chauffe_eau_jour_kwh"), "off_jour_faible_prev": states("input_boolean.victron_water_heater_off_jour_faible"), + "hc_midday_start": states("input_datetime.victron_evcs_hc_midday_start"), + "hc_midday_stop": states("input_datetime.victron_evcs_hc_midday_stop"), + "hc_night_start": states("input_datetime.victron_evcs_hc_night_start"), + "hc_night_stop": states("input_datetime.victron_evcs_hc_night_stop"), "advisor_enabled": states("input_boolean.victron_water_heater_advisor_enabled"), "notifications_enabled": states("input_boolean.victron_water_heater_notifications"), diff --git a/src/water-heater/NORMALIZE.js b/src/water-heater/NORMALIZE.js index 67ada02..e7d0f86 100644 --- a/src/water-heater/NORMALIZE.js +++ b/src/water-heater/NORMALIZE.js @@ -198,6 +198,12 @@ const ecs24hKwh = num(input.ecs_24h_kwh, NaN); const ecsJourKwh = num(input.ecs_jour_kwh, NaN); const offJourFaiblePrev = low(input.off_jour_faible_prev) === 'on'; const forecastSource = cleanStateText(input.forecast_source) || 'forecast_solar'; +// Fenetres HC (« HH:MM:SS » des input_datetime du controleur), en minutes du jour. +const hhmm = (v) => { const m = cleanStateText(v).match(/^(\d{1,2}):(\d{2})/); return m ? Number(m[1]) * 60 + Number(m[2]) : null; }; +const hcWindows = [ + { nom: 'midi', debut: hhmm(input.hc_midday_start), fin: hhmm(input.hc_midday_stop), douche: '20 h' }, + { nom: 'nuit', debut: hhmm(input.hc_night_start), fin: hhmm(input.hc_night_stop), douche: '06 h' }, +].filter((w) => w.debut !== null && w.fin !== null); return [{ json: { @@ -246,7 +252,8 @@ return [{ ecs24hKwh, ecsJourKwh, offJourFaiblePrev, - forecastSource + forecastSource, + hcWindows } } }]; \ No newline at end of file diff --git a/src/water-heater/OUTPUT_BUILDER.js b/src/water-heater/OUTPUT_BUILDER.js index 65c183d..4490f15 100644 --- a/src/water-heater/OUTPUT_BUILDER.js +++ b/src/water-heater/OUTPUT_BUILDER.js @@ -196,7 +196,8 @@ let { protectionState, powerSwitchAction, powerSwitchDesiredOn, - offJourFaible = false + offJourFaible = false, + alerteChauffeEau = '' } = finalDecision; reason = Array.isArray(reason) ? [...reason] : []; @@ -563,6 +564,15 @@ helper_budget_margin: { datetime: forcedOffAtSafe } }, + // Alerte « passe en BOOST » (07/10/2026), lue par le concierge OpenClaw. + helper_alerte_chauffe_eau: { + domain: 'input_text', + service: 'set_value', + data: { + entity_id: 'input_text.evcs_alerte_chauffe_eau', + value: alerteChauffeEau + } + }, // Jour faible (07/10/2026) : la coupure appartient a la regle « eau chaude en HC de nuit ». helper_off_jour_faible: { domain: 'input_boolean', diff --git a/tests/golden/scenarios.mjs b/tests/golden/scenarios.mjs index 523783b..8979742 100644 --- a/tests/golden/scenarios.mjs +++ b/tests/golden/scenarios.mjs @@ -2639,6 +2639,67 @@ export const scenarios = [ }, expect: { profile: 'midday_hc_or_solar', sessionCommittedService: 'turn_on', takeoverNeeded: true }, }, + // CHAUFFE-EAU (07/10/2026) : un jour faible, l'eau chaude se fait en HC sur + // EDF a pleine consommation -> la session HC s'engage meme sans voiture et + // sans manque batterie. + { + id: 'hc-midi-jour-faible-chauffe-eau-engage-la-session', + label: 'MIDI — jour faible (decision du bilan), pas de voiture, SOC au-dessus du requis : la session HC s engage pour le chauffe-eau', + time: MIDDAY, + overrides: { + evcs_vehicle_connected: 'off', evcs_device_connected: 'on', + soc_percent: '45', battery_current_a: '2', + pv_power_w: '1200', pv_power_10m_avg_w: '1200', pv_trend_w: '0', + house_ac_power_w: '900', vebus_ac_out_l1_power_w: '900', evcs_ac_power_w: '0', + balance_required_soc: '30', balance_energy_to_cover_kwh: '0', balance_grid_energy_needed_kwh: '0', + balance_status: 'ok', balance_last_run: '08/08 12:50', + balance_ecs_nuit: 'on', + }, + expect: { profile: 'midday_hc_or_solar', sessionCommittedService: 'turn_on', takeoverNeeded: true }, + }, + { + id: 'hc-midi-jour-faible-bilan-perime-aucun-engagement', + label: 'CONTREFACTUEL — jour faible mais bilan vieux de 2 h : on ne relaie pas une decision perimee', + time: MIDDAY, + overrides: { + evcs_vehicle_connected: 'off', evcs_device_connected: 'on', + soc_percent: '45', battery_current_a: '2', + pv_power_w: '1200', pv_power_10m_avg_w: '1200', pv_trend_w: '0', + house_ac_power_w: '900', vebus_ac_out_l1_power_w: '900', evcs_ac_power_w: '0', + balance_required_soc: '30', balance_energy_to_cover_kwh: '0', balance_grid_energy_needed_kwh: '0', + balance_status: 'ok', balance_last_run: '08/08 11:00', + balance_ecs_nuit: 'on', + }, + expect: { profile: 'midday_hc_or_solar', takeoverNeeded: false }, + }, + { + id: 'hc-nuit-jour-faible-chauffe-eau-engage-la-session', + label: 'NUIT 03:00 — jour faible, SOC 45 au-dessus du requis 30 : la session de nuit s engage quand meme pour le chauffe-eau', + time: '2026-08-08T03:00:00+02:00', + overrides: { + evcs_vehicle_connected: 'off', evcs_device_connected: 'on', + soc_percent: '45', battery_current_a: '-3', + house_ac_power_w: '700', vebus_ac_out_l1_power_w: '700', evcs_ac_power_w: '0', + balance_required_soc: '30', balance_energy_to_cover_kwh: '2.0', + balance_status: 'ok', balance_last_run: '08/08 02:58', + balance_ecs_nuit: 'on', + }, + expect: { profile: 'night_hc', sessionCommittedService: 'turn_on', takeoverNeeded: true }, + }, + { + id: 'hc-nuit-jour-normal-aucun-engagement', + label: 'CONTREFACTUEL — meme nuit, jour normal : rien ne justifie EDF', + time: '2026-08-08T03:00:00+02:00', + overrides: { + evcs_vehicle_connected: 'off', evcs_device_connected: 'on', + soc_percent: '45', battery_current_a: '-3', + house_ac_power_w: '700', vebus_ac_out_l1_power_w: '700', evcs_ac_power_w: '0', + balance_required_soc: '30', balance_energy_to_cover_kwh: '2.0', + balance_status: 'ok', balance_last_run: '08/08 02:58', + balance_ecs_nuit: 'off', + }, + expect: { profile: 'night_hc', takeoverNeeded: false }, + }, { id: 'hc-midi-jeep-sans-verdict-aucun-engagement', label: 'CONTREFACTUEL — meme midi, le bilan juge le solaire suffisant : aucune session', diff --git a/tests/water-heater/run.mjs b/tests/water-heater/run.mjs index 41f44f1..44fe3e7 100644 --- a/tests/water-heater/run.mjs +++ b/tests/water-heater/run.mjs @@ -81,6 +81,28 @@ for (const c of cas) { if (o.observedPowerW === 0 && o.observedRegime === 'off') { ok++; console.log('✅ coupe-zero-watt'); } else { ko++; console.log(`❌ coupe-zero-watt | ${o.observedPowerW} W ${o.observedRegime}`); } } +// Alerte BOOST (07/10) : en HC sur EDF, le ballon ne tire que la pompe a chaleur. +{ + const minuit = new Date(new Date().toLocaleDateString('sv-SE', { timeZone: 'Europe/Paris' }) + 'T00:00:00'); + const nowMin = (() => { const p = new Intl.DateTimeFormat('fr-FR', { timeZone: 'Europe/Paris', hour: '2-digit', minute: '2-digit', hour12: false }).format(new Date()).split(':'); return Number(p[0]) * 60 + Number(p[1]); })(); + const hm = (m) => { const x = ((m % 1440) + 1440) % 1440; return `${String(Math.floor(x / 60)).padStart(2, '0')}:${String(x % 60).padStart(2, '0')}:00`; }; + const stamp = new Date(Date.now() - 5 * 60000).toLocaleString('sv-SE', { timeZone: 'Europe/Paris' }); + const fenetre = { hc_midday_start: hm(nowMin - 30), hc_midday_stop: hm(nowMin + 60), hc_night_start: hm(nowMin + 300), hc_night_stop: hm(nowMin + 600) }; + const base = { ...BASE, ...fenetre, grid_state: 'on', hc_session_committed: 'on', water_heater_switch: 'on', + water_heater_energy_total_kwh: '18.65', energy_total_prev_kwh: '18.60', energy_prev_at: stamp }; // 0,05 kWh / 5 min = 600 W : pompe a chaleur + const verif = (nom, snap, attenduAlerte) => { + const s = executer({ ...base, ...snap }); + const a = s.ARBITRATION[0].json.finalDecision.alerteChauffeEau; + const ok1 = attenduAlerte ? (a && JSON.parse(a).active === true && JSON.parse(a).message.includes('BOOST')) : a === ''; + const appel = s.BUILD_ACTIONS.map((x) => x.json).find((c) => c.data?.entity_id === 'input_text.evcs_alerte_chauffe_eau'); + if (ok1 && appel) { ok++; console.log(`✅ ${nom}`); } else { ko++; console.log(`❌ ${nom} | alerte=${a} appel=${!!appel}`); } + }; + verif('alerte-boost-pompe-a-chaleur-en-hc', {}, true); + verif('alerte-boost-resistance-pas-d-alerte', { water_heater_energy_total_kwh: '18.75' }, false); + verif('alerte-boost-thermostat-satisfait-pas-d-alerte', { water_heater_energy_total_kwh: '18.60' }, false); + verif('alerte-boost-debut-de-fenetre-attente', { hc_midday_start: hm(nowMin - 5) }, false); + verif('alerte-boost-edf-ouvert-pas-d-alerte', { grid_state: 'off' }, false); +} // Prevision forte = le reste du jour seulement (06/10 : demain fort ne rend plus forte une journee faible). { const s = executer({ ...BASE, ecs_nuit: 'off', today_remaining_kwh: '8', tomorrow_kwh: '30' }); diff --git a/workflows/evcs-surplus-controller-v2.6.json b/workflows/evcs-surplus-controller-v2.6.json index 10458b1..a879ff4 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 // 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 // Jours de depart du matin (01/10/2026) : si le prochain passage de l'heure\n // ne tombe pas un jour de depart du vehicule, il n'y a PAS de depart (null) —\n // aucune urgence, aucune fenetre P9. Absent = 'on' (comportement d'avant).\n departureTimeMin: low(input.departure_day_ok || 'on') === 'off'\n ? null\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 // BUDGET VOITURE DE LA NUIT (30/09/2026, decisions du proprietaire) : ce\n // que la voiture peut prendre sur la batterie sans EDF, calcule par le bilan\n // (profil prudent, plancher 22 %, jusqu'a la nuit suivante), FIGE a\n // l'ouverture de la session et mesure au compteur de la borne. Choix de la\n // nuit : `sans_edf` (defaut : on s'arrete au budget) ou `complete` (budget\n // batterie puis EDF en HC pour le reste). Toute autre valeur = sans_edf.\n nightChargeMode: low(input.night_charge_mode || 'sans_edf') === 'complete' ? 'complete' : 'sans_edf',\n balanceNightVehicleBudgetKwh: num(input.balance_night_vehicle_budget_kwh, NaN),\n nightBudgetPrevRaw: cleanText(input.night_budget_prev),\n evcsTotalEnergyKwh: num(input.evcs_total_energy_kwh, NaN),\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 // Jours de depart du matin (01/10/2026) : si le prochain passage de l'heure\n // ne tombe pas un jour de depart du vehicule, il n'y a PAS de depart (null) —\n // aucune urgence, aucune fenetre P9. Absent = 'on' (comportement d'avant).\n departureTimeMin: low(input.departure_day_ok || 'on') === 'off'\n ? null\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 // Jour faible (07/10/2026) : decision quotidienne du bilan, l'eau chaude se\n // fait en HC sur EDF -> la session reseau HC (nuit ET midi) s'engage.\n balanceEcsNuit: low(input.balance_ecs_nuit || '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 // BUDGET VOITURE DE LA NUIT (30/09/2026, decisions du proprietaire) : ce\n // que la voiture peut prendre sur la batterie sans EDF, calcule par le bilan\n // (profil prudent, plancher 22 %, jusqu'a la nuit suivante), FIGE a\n // l'ouverture de la session et mesure au compteur de la borne. Choix de la\n // nuit : `sans_edf` (defaut : on s'arrete au budget) ou `complete` (budget\n // batterie puis EDF en HC pour le reste). Toute autre valeur = sans_edf.\n nightChargeMode: low(input.night_charge_mode || 'sans_edf') === 'complete' ? 'complete' : 'sans_edf',\n balanceNightVehicleBudgetKwh: num(input.balance_night_vehicle_budget_kwh, NaN),\n nightBudgetPrevRaw: cleanText(input.night_budget_prev),\n evcsTotalEnergyKwh: num(input.evcs_total_energy_kwh, NaN),\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": "{#- Jours de depart du MATIN (01/10/2026, proprietaire) : l'heure de depart du\n vehicule selectionne ne valait que l'heure, tous les jours. La JEEP part a\n 06:30 mar mer ven sam dim, jamais lun ni jeu : le jeudi 01/10, la marge de\n 30 min avant ce faux depart a ferme EDF a 06:00. Le gabarit dit si le\n PROCHAIN passage de l'heure de depart tombe un jour de depart. Jours\n absents ou illisibles = tous les jours (comportement d'avant). -#}\n{%- set jm = ['lun', 'mar', 'mer', 'jeu', 'ven', 'sam', 'dim'] -%}\n{%- set vm = {'JEEP': 'jeep', 'VW': 'vw', 'ZERO': 'zero'}.get(states('input_select.victron_evcs_vehicle'), 'vw') -%}\n{%- set hm = states('input_datetime.' ~ {'vw': 'victron_evcs_departure_time', 'jeep': 'victron_evcs_departure_time_jeep', 'zero': 'victron_evcs_departure_time_zero'}[vm]) -%}\n{%- set joursm = states('input_text.victron_evcs_' ~ vm ~ '_morning_departure_days') | lower -%}\n{%- set dm = namespace(ok=true) -%}\n{%- if hm not in ['unknown', 'unavailable', ''] and joursm not in ['unknown', 'unavailable', ''] -%}\n {%- set prochain = today_at(hm) if today_at(hm) > now() else today_at(hm) + timedelta(days=1) -%}\n {%- set dm.ok = jm[prochain.weekday()] in joursm.split() -%}\n{%- endif -%}\n{{ {\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 \"departure_day_ok\": \"on\" if dm.ok else \"off\",\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 \"night_charge_mode\": states(\"input_select.victron_evcs_night_charge_mode\"),\n \"balance_night_vehicle_budget_kwh\": states(\"input_number.evcs_balance_night_vehicle_budget_kwh\"),\n \"night_budget_prev\": states(\"input_text.victron_evcs_night_budget\"),\n \"evcs_total_energy_kwh\": states(\"sensor.evcs_hq2239dnt7p_total_energy\"),\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": "{#- Jours de depart du MATIN (01/10/2026, proprietaire) : l'heure de depart du\n vehicule selectionne ne valait que l'heure, tous les jours. La JEEP part a\n 06:30 mar mer ven sam dim, jamais lun ni jeu : le jeudi 01/10, la marge de\n 30 min avant ce faux depart a ferme EDF a 06:00. Le gabarit dit si le\n PROCHAIN passage de l'heure de depart tombe un jour de depart. Jours\n absents ou illisibles = tous les jours (comportement d'avant). -#}\n{%- set jm = ['lun', 'mar', 'mer', 'jeu', 'ven', 'sam', 'dim'] -%}\n{%- set vm = {'JEEP': 'jeep', 'VW': 'vw', 'ZERO': 'zero'}.get(states('input_select.victron_evcs_vehicle'), 'vw') -%}\n{%- set hm = states('input_datetime.' ~ {'vw': 'victron_evcs_departure_time', 'jeep': 'victron_evcs_departure_time_jeep', 'zero': 'victron_evcs_departure_time_zero'}[vm]) -%}\n{%- set joursm = states('input_text.victron_evcs_' ~ vm ~ '_morning_departure_days') | lower -%}\n{%- set dm = namespace(ok=true) -%}\n{%- if hm not in ['unknown', 'unavailable', ''] and joursm not in ['unknown', 'unavailable', ''] -%}\n {%- set prochain = today_at(hm) if today_at(hm) > now() else today_at(hm) + timedelta(days=1) -%}\n {%- set dm.ok = jm[prochain.weekday()] in joursm.split() -%}\n{%- endif -%}\n{{ {\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 \"departure_day_ok\": \"on\" if dm.ok else \"off\",\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 \"night_charge_mode\": states(\"input_select.victron_evcs_night_charge_mode\"),\n \"balance_night_vehicle_budget_kwh\": states(\"input_number.evcs_balance_night_vehicle_budget_kwh\"),\n \"night_budget_prev\": states(\"input_text.victron_evcs_night_budget\"),\n \"evcs_total_energy_kwh\": states(\"sensor.evcs_hq2239dnt7p_total_energy\"),\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 \"balance_ecs_nuit\": states(\"input_boolean.evcs_balance_ecs_nuit\"),\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" } ] }, @@ -165,7 +165,7 @@ }, { "parameters": { - "jsCode": "function minutesSince(ts, nowMs) {\n if (ts === null || nowMs === null) return null;\n return Math.max(0, (nowMs - ts) / 60000);\n}\n\nfunction secondsSince(ts, nowMs) {\n if (ts === null || nowMs === null) return null;\n return Math.max(0, (nowMs - ts) / 1000);\n}\n\nfunction isWithinWindow(nowMin, startMin, stopMin) {\n if (!Number.isFinite(startMin) || !Number.isFinite(stopMin)) return false;\n if (startMin === stopMin) return false;\n if (startMin < stopMin) return nowMin >= startMin && nowMin < stopMin;\n return nowMin >= startMin || nowMin < stopMin;\n}\n\n// Temps restant dans une fenetre, en minutes, ou null si on n'y est pas.\n//\n// ⚠️ IL VIT ICI, A COTE DE `isWithinWindow`, ET PAS DANS LE NOEUD QUI L'UTILISE.\n// Ce qui serait duplique ailleurs n'est pas l'analyse des heures — INPUT_PARSE\n// les a deja converties en minutes — c'est la LOGIQUE D'APPARTENANCE, et en\n// particulier le traitement du passage de minuit. Une seconde interpretation\n// divergerait le jour ou une borne bouge, et elles ont deja bouge une fois\n// (01:30 -> 01:24 le 09/07/2026).\nfunction minutesRemainingInWindow(nowMin, startMin, stopMin) {\n if (!isWithinWindow(nowMin, startMin, stopMin)) return null;\n if (startMin < stopMin) return stopMin - nowMin;\n // Fenetre a cheval sur minuit : avant minuit il reste la fin de la nuit EN\n // PLUS du reste de la journee. Atteignable par les valeurs de repli\n // d'INPUT_PARSE (23:00 -> 06:00) si les helpers d'horaire sont illisibles.\n return nowMin >= startMin ? stopMin + 1440 - nowMin : stopMin - nowMin;\n}\n\nfunction clamp(v, min, max) {\n return Math.min(Math.max(v, min), max);\n}\n\nconst prev = $input.first().json;\nconst {\n input,\n nowMs,\n now,\n cfg,\n parsed,\n params,\n history,\n batteryTune\n} = prev;\n\n// =====================================================\n// STRATEGY\n// =====================================================\nconst userStrategy = (params.userStrategyRaw ?? 'auto_solar').toString().trim().toLowerCase();\n\nconst isStopMode = userStrategy === 'stop';\nconst forceGridRequested = userStrategy === 'force_grid_now';\nconst forceChargeRequested = userStrategy === 'force_charge_now';\nconst isForceMode = forceGridRequested || forceChargeRequested;\n\n// =====================================================\n// LIFECYCLE / TIMING\n// =====================================================\nconst minutesSinceStart = minutesSince(history.lastStartMs, nowMs);\nconst minutesSinceStop = minutesSince(history.lastStopMs, nowMs);\nconst secondsSinceCurrentChange = secondsSince(history.lastCurrentChangeMs, nowMs);\nconst minutesSinceProbeStart = minutesSince(history.probeStartedMs, nowMs);\n\nconst minRunElapsed =\n minutesSinceStart === null ? true : minutesSinceStart >= params.minRunMinutes;\n\nconst minStopElapsed =\n minutesSinceStop === null ? true : minutesSinceStop >= params.minStopMinutes;\n\nconst currentCooldownBaseSec = parsed.evcsChargingSwitchOn\n ? batteryTune.global.stepDownCooldownSeconds\n : batteryTune.global.stepUpCooldownSeconds;\n\nconst currentCooldownElapsed =\n secondsSinceCurrentChange === null\n ? true\n : secondsSinceCurrentChange >= Math.max(\n params.currentChangeCooldownSeconds,\n currentCooldownBaseSec\n );\n\n// =====================================================\n// DEPARTURE PRIORITY\n// =====================================================\nconst minutesUntilDeparture =\n params.departureTimeMin === null\n ? null\n : (() => {\n const rawDiff = params.departureTimeMin - now.minNow;\n return rawDiff >= 0 ? rawDiff : (1440 - now.minNow + params.departureTimeMin);\n })();\n\nconst departurePriorityActive =\n parsed.departurePriorityEnabled &&\n Number.isFinite(minutesUntilDeparture) &&\n minutesUntilDeparture >= 0 &&\n minutesUntilDeparture <= cfg.departurePriorityWindowMinutes &&\n parsed.evcsVehicleConnected;\n\n// =====================================================\n// MANUAL CHARGE SEMANTICS\n// IMPORTANT:\n// force_charge_now = bypass fenêtres pour la charge,\n// MAIS PAS demande implicite de réseau.\n// =====================================================\nconst manualChargeBypassWindows = forceChargeRequested;\nconst manualChargeLocalRequested = forceChargeRequested && !forceGridRequested;\n\n// Compatibilité ascendante : on garde le champ dans context,\n// mais on le neutralise volontairement.\nconst forceChargeDepartureGridOverride = false;\n\n// =====================================================\n// SYSTEM DERIVED VALUES\n// =====================================================\nconst acVoltageRef =\n Number.isFinite(parsed.vebusAcOutVoltageV) && parsed.vebusAcOutVoltageV > 180\n ? parsed.vebusAcOutVoltageV\n : cfg.nominalAcVoltageV;\n\nconst batteryVoltageRef =\n Number.isFinite(parsed.vebusDcVoltageV) && parsed.vebusDcVoltageV > 0\n ? parsed.vebusDcVoltageV\n : 51.2;\n\nconst houseNonEvcsW = Math.max(0, parsed.houseAcPowerW - parsed.evcsAcPowerW);\nconst vebusNonEvcsW = Math.max(0, parsed.vebusAcOutPowerW - parsed.evcsAcPowerW);\n\nconst totalVebusSoftCeilingW = Math.max(6500, params.acLoadsMaxSafeW || 8500);\nconst totalVebusHardCeilingW = totalVebusSoftCeilingW + 400;\n\n// =====================================================\n// FENETRE D'ETABLISSEMENT DU RESEAU (31/08/2026)\n// =====================================================\n// Mesure du 31/08 : a la fermeture du contacteur, le reseau reprend d'un coup\n// TOUTE la charge de la maison et le solaire bascule vers la batterie. Batterie\n// preneuse, la production ne bouge pas ; batterie pleine, elle s'effondre (2663\n// -> 559 W) faute de destination. Dans les deux cas l'import decroit ensuite\n// jusqu'a la consigne en ~150 s (1985 -> 25 W, puis 2600 -> 52 W).\n//\n// Pendant cette phase, PV, import et courant batterie ne decrivent aucun regime\n// permanent : la boucle de 30 s y prend cinq decisions, et la moyenne PV 10 min\n// en garde la trace une dizaine de minutes de plus. Le systeme lit \"plus de\n// soleil\", en conclut qu'il a besoin du reseau, et confirme ainsi sa propre\n// premisse -- une boucle de rattrapage, comme les trois du 24/08.\n//\n// ASYMETRIE VOULUE : la fenetre ne s'ouvre qu'a l'ARRIVEE du reseau. A sa\n// disparition, les onduleurs reprennent instantanement toute la charge : les\n// gardes doivent agir plus vite, jamais moins vite.\nconst gridSettling =\n parsed.gridAvailable &&\n parsed.gridTransitionAgeSec < params.gridSettleSeconds;\n\nconst endDayGuardSoc = Math.max(params.endOfDaySocGuard, params.endOfDaySocTarget);\n\nconst energyNeededForEndDayGuardKwh = Math.max(\n 0,\n ((endDayGuardSoc - parsed.soc) / 100) * cfg.batteryUsableCapacityKwh\n);\n\nconst forecastBufferKwh =\n parsed.todayRemainingKwh -\n params.baseLoadRemainingKwh -\n params.flexibleSafetyMarginKwh -\n params.requiredEnergyKwh -\n energyNeededForEndDayGuardKwh;\n\nconst projectedEndSocAfterCharge = clamp(\n parsed.soc + (\n (\n parsed.todayRemainingKwh -\n params.baseLoadRemainingKwh -\n params.flexibleSafetyMarginKwh -\n params.requiredEnergyKwh\n ) / cfg.batteryUsableCapacityKwh\n ) * 100,\n 0,\n 100\n);\n\nconst currentReferenceA = clamp(\n Number.isFinite(parsed.evcsCommandCurrentA)\n ? parsed.evcsCommandCurrentA\n : Number.isFinite(parsed.evcsSetCurrentA)\n ? parsed.evcsSetCurrentA\n : params.targetCurrentAutoA,\n 0,\n 32\n);\n\nconst anyVebusOverload = parsed.vebusOverload || parsed.vebusL1Overload;\nconst anyVebusLowBattery = parsed.vebusLowBattery || parsed.vebusL1LowBattery;\n\nconst vebusAcOutSoftReached = parsed.vebusAcOutPowerW >= totalVebusSoftCeilingW;\nconst vebusAcOutHardReached = parsed.vebusAcOutPowerW >= totalVebusHardCeilingW;\n\nconst vebusDcVoltageSoftLow =\n Number.isFinite(parsed.vebusDcVoltageV) &&\n parsed.vebusDcVoltageV > 0 &&\n parsed.vebusDcVoltageV <= cfg.vebusDcVoltageSoftFloorV;\n\nconst vebusDcVoltageHardLow =\n Number.isFinite(parsed.vebusDcVoltageV) &&\n parsed.vebusDcVoltageV > 0 &&\n parsed.vebusDcVoltageV <= cfg.vebusDcVoltageHardFloorV;\n\nconst houseLoadHighVsHeadroom = houseNonEvcsW > params.houseAcHeadroomW;\n\n// =====================================================\n// WINDOWS / PROFILE\n// =====================================================\nconst inNightHcWindow = isWithinWindow(now.minNow, params.hcNightStartMin, params.hcNightStopMin);\nconst inMorningWindow = isWithinWindow(now.minNow, params.morningStartMin, params.morningStopMin);\nconst inMiddayWindow = isWithinWindow(now.minNow, params.hcMiddayStartMin, params.hcMiddayStopMin);\nconst inEveningWindow = isWithinWindow(now.minNow, params.eveningStartMin, params.eveningStopMin);\n\nconst profile =\n inNightHcWindow ? 'night_hc' :\n inMorningWindow ? 'morning_solar' :\n inMiddayWindow ? 'midday_hc_or_solar' :\n inEveningWindow ? 'evening_high_soc' :\n 'outside_window';\n\n// =====================================================\n// EVCS NIGHT PREVIEW ENGINE\n// =====================================================\nconst nightSessionActive =\n profile === 'night_hc' &&\n parsed.evcsVehicleConnected;\n\nconst nightSessionStartSocPct =\n nightSessionActive\n ? (\n Number.isFinite(history.nightSessionStartSocPct)\n ? history.nightSessionStartSocPct\n : parsed.soc\n )\n : null;\n\nconst nightSessionStartAt =\n nightSessionActive\n ? (history.nightSessionStartAt || '')\n : '';\n\nconst nightSessionStartMs =\n nightSessionActive\n ? (history.nightSessionStartMs ?? null)\n : null;\n\nconst nightSessionNeedsCapture =\n nightSessionActive &&\n (\n !Number.isFinite(history.nightSessionStartSocPct) ||\n !nightSessionStartAt ||\n nightSessionStartMs === null\n );\n\nconst nightSessionNeedsClear =\n !nightSessionActive &&\n (\n Number.isFinite(history.nightSessionStartSocPct) ||\n !!history.nightSessionStartAt\n );\n\nconst batteryHardFloorPct =\n params.evcsNightBatteryHardFloorPct;\n\n// P4 : la nuit avant minuit, today_remaining ~ 0 => utiliser la prévision du lendemain.\nconst nightForecastHour = now.hour;\nconst nightForecastKwh =\n (nightForecastHour >= 20 || nightForecastHour < 8)\n ? Math.max(parsed.todayRemainingKwh, parsed.tomorrowKwh || 0)\n : parsed.todayRemainingKwh;\n\nconst batteryMorningTargetRulePct =\n nightForecastKwh >= params.evcsNightStrongForecastKwh\n ? params.evcsNightTargetStrongForecastPct\n : nightForecastKwh >= params.evcsNightMediumForecastKwh\n ? params.evcsNightTargetMediumForecastPct\n : params.evcsNightTargetWeakForecastPct;\n\n// =====================================================\n// LE CONSEILLER NE FIXE PLUS LA CIBLE (retire le 13/09/2026)\n// =====================================================\n// Le premier cablage reel (09/08/2026, HANDOFF §20) faisait suivre a la cible\n// du matin `input_number.evcs_advisor_night_target_soc`, derriere un toggle,\n// un statut `ok`, une fraicheur de 120 min et un bornage.\n// Le 12/09 le conseiller est devenu OBSERVATEUR : il n'emet plus de cible, et\n// ce helper est fige depuis. Les quatre gardes continuaient pourtant de passer\n// toutes les quatre — la garde de fraicheur date le RUN, pas la VALEUR, et le\n// conseiller tourne toujours. Activer le toggle aurait donc applique un chiffre\n// perime sans qu'aucune garde ne s'y oppose. Un cablage qui ne peut plus que se\n// tromper se retire ; le scenario `advisor-cible-ignoree` le verifie.\nconst batteryMorningTargetBasePct = batteryMorningTargetRulePct;\n\n// =====================================================\n// CIBLE DU MATIN : UNE ENERGIE A COUVRIR, PAS UN PALIER (03/09/2026)\n// =====================================================\n// La regle historique classe la journee du lendemain en trois paliers\n// (>= 18 kWh -> 35 %, >= 10 -> 55 %, sinon 85 %) a partir de son TOTAL. Elle\n// est donc aveugle a l'heure d'arrivee de l'energie : le 03/09, demain\n// annoncait 33,9 kWh — « fort », donc 35 % — mais avec un matin creux\n// (0,03 kWh a 7 h, 0,77 a 8 h, 1,81 a 9 h) et un croisement a 9 h.\n//\n// C'est le meme defaut que le plancher du soir (cf. le bloc BILAN de\n// BATTERY_CLOUD_LOCKS), a l'autre bout de la nuit : un palier fige contre une\n// energie mesurable. Le bilan publie la seconde, instant par instant.\n//\n// RELEVE DE LA NUIT 02-03/09, qui a motive ce cablage :\n//\n// heure requis SOC reel cible fixe\n// 00:30 62,4 53 35\n// 03:00 50,4 45 35 <- EDF relache ici, 5 points sous la ligne\n// 04:30 41,7 37 35\n// 06:00 37,1 22 35 <- hysteresis, puis reseau PLEIN TARIF\n// de 07:24 a 10:33 (1,49 kWh)\n//\n// La batterie est restee sous la ligne toute la nuit et le bilan le savait des\n// 00:30. Le `required_soc` decroit proprement avec la nuit qui se consomme\n// (6,1 -> 0 kWh a couvrir) : c'est bien une cible instantanee, pas une\n// constante deguisee.\n//\n// SENS UNIQUE, comme pour le plancher du soir : le bilan ne peut que RELEVER\n// la cible, jamais l'abaisser. La direction corrigee est celle de l'echec\n// observe (cible trop basse -> batterie a plat avant le croisement -> reseau\n// au plein tarif le matin). L'abaisser — la regle dit 85 % l'hiver quand le\n// bilan dira parfois moins — ouvrirait un risque neuf que rien n'a encore\n// valide. Le principe unificateur : le bilan ne pousse JAMAIS dans le sens\n// « couvre moins bien la nuit ».\n//\n// PLAFOND 90 % : au-dela, la batterie cesse d'etre un puits et la production\n// du matin se fait evincer (cf. mesures du 31/08). Charger au reseau au-dela\n// de 90 echangerait un kWh bon marche cette nuit contre plusieurs kWh censures\n// demain. C'est deja la borne du chemin advisor : aucune constante nouvelle.\n//\n// PAS D'OSCILLATEUR : `required_soc` decroit de facon monotone pendant la nuit\n// (l'energie restant a couvrir ne fait que diminuer), et la passe unique de\n// recovery (`nightRecoveryDonePrev`, 27/08) reste en place.\n//\n// REPLI identique au plancher du soir : fraicheur, statut, valeur finie —\n// sinon regle historique. Kill-switch existant : `evcs_balance_enabled` OFF\n// fige `balance_last_run` et la cible retombe seule sur les paliers.\nconst BALANCE_TARGET_MAX_AGE_MIN = 30;\nconst balanceTargetAgeMin = minutesSince(params.balanceLastRunMs, nowMs);\nconst balanceMorningTargetApplied =\n params.balanceStatusOk &&\n Number.isFinite(params.balanceRequiredSocPct) &&\n balanceTargetAgeMin !== null &&\n balanceTargetAgeMin <= BALANCE_TARGET_MAX_AGE_MIN;\n\n// ⚠️ 01/10/2026 — DECISION DU PROPRIETAIRE : LE BILAN FIXE LA CIBLE, DANS LES\n// DEUX SENS. « La valeur de 35 % c'est idiot si le besoin est prevu 26,6 %, et\n// c'est aussi idiot si la prevision est de 60 % pour une journee nuageuse. »\n// Le sens unique ci-dessus (03/09) gardait le palier comme plancher : le\n// 01/10 a 06:00, cible 35 (palier « forte prevision ») pour un besoin du bilan\n// de 28,6 %. Le palier n'est plus qu'un REPLI quand le bilan est absent,\n// perime ou degrade. Bornes : le plancher dur en bas, 90 % en haut.\nconst batteryMorningTargetPct = balanceMorningTargetApplied\n ? Math.min(90, Math.max(batteryHardFloorPct, params.balanceRequiredSocPct))\n : batteryMorningTargetBasePct;\n\nconst maxBatteryDischargePoints =\n nightSessionActive && Number.isFinite(nightSessionStartSocPct)\n ? Math.max(0, nightSessionStartSocPct - batteryMorningTargetPct)\n : 0;\n\nconst gridTakeoverTriggerPct =\n Math.max(batteryHardFloorPct, batteryMorningTargetPct);\n\nconst batteryBelowHardFloor =\n parsed.soc <= batteryHardFloorPct;\n\nconst batteryBelowMorningTarget =\n parsed.soc < batteryMorningTargetPct;\n\nconst batteryAtOrBelowGridTakeoverTrigger =\n parsed.soc <= gridTakeoverTriggerPct;\n\nconst batteryKwhMissingToTarget =\n nightSessionActive\n ? Math.max(\n 0,\n ((batteryMorningTargetPct - parsed.soc) / 100) * cfg.batteryUsableCapacityKwh\n )\n : 0;\n\nconst batteryRecoveryMinutesNeeded =\n nightSessionActive && params.evcsNightBatteryRecoveryPowerKw > 0\n ? (batteryKwhMissingToTarget / params.evcsNightBatteryRecoveryPowerKw) * 60\n : 0;\n\n// 01/10/2026 : l'urgence avant depart exige qu'il MANQUE de l'energie. Sans\n// cette garde, « 0 min de recharge + 30 min de marge >= 30 min » la declenchait\n// a 06:00 chaque jour de depart, batterie au-dessus de sa cible (01/10 : SOC 37,\n// cible 35, EDF ferme 06:00-06:38 pour rien ; idem 21, 22, 25 et 27/09).\nconst batteryRecoveryUrgent =\n nightSessionActive &&\n batteryKwhMissingToTarget > 0 &&\n Number.isFinite(minutesUntilDeparture) &&\n minutesUntilDeparture >= 0 &&\n (\n batteryRecoveryMinutesNeeded + params.evcsNightDepartureSafetyMin\n >= minutesUntilDeparture\n );\n\n// P2 ANTI-CRENEAUX : continuité du takeover réseau nocturne.\n// Une fois le réseau HC engagé pendant une session nuit, on y reste\n// jusqu'à la fin de la session (fenêtre/débranchement) ou batterie quasi pleine.\n// Évite le cycle batterie->jeep->réseau->batterie (pertes + charge hachée).\nconst NIGHT_TAKEOVER_RELEASE_SOC_PCT = 90;\nconst nightTakeoverPrev =\n (input.night_takeover_prev ?? 'off').toString().trim().toLowerCase() === 'on';\n\n// P8 (07/08/2026) : \"charge terminée\" COLLANT via l'état surplus du cycle\n// précédent (helper input_text.victron_evcs_surplus_state relu par le snapshot).\n// Chaîne : VDF stoppe la charge (state vehicle_full_or_paused) → ce flag s'allume\n// → DECISION émet night_session_done_idle → le flag s'auto-entretient jusqu'au\n// débranchement (state vehicle_not_connected) ou à la fin de la fenêtre nuit.\nconst prevSurplusState = (input.prev_surplus_state ?? '').toString().trim().toLowerCase();\n\n// P11 (12/08/2026) : RESET du collant \"véhicule plein\" au CHANGEMENT DE\n// VÉHICULE. Incident 12/08 : JEEP chargée → night_recovery_done_idle collant ;\n// débranchement JEEP + branchement VW → l'état collant a survécu et bloquait\n// la VW (« charge terminée » = celle de la JEEP, pas de la VW).\n// Signal fiable : sensor.victron_evcs_charging_time repart de 0 à chaque\n// nouvelle connexion physique. Sous ~90 s → session neuve → on neutralise le\n// collant pour laisser le système RETESTER le véhicule fraîchement branché.\nconst P11_FRESH_SESSION_MAX_SEC = 90;\n// P11-bis (19/09/2026) : le charging_time de la borne repart de 0 sur un\n// debranchement FRANC, mais reste FIGE sur un swap trop rapide (14 s le 19/09 :\n// VW debranchee, JEEP branchee, borne gelee a l'etat VW). P11 restait alors\n// aveugle et le collant \"vehicule plein\" de la VW bloquait la JEEP. Le\n// last_changed du capteur de connexion, lui, a bien bouge sur ce swap : c'est\n// un SECOND signal de fraicheur, independant de la borne. Meme fenetre (90 s =\n// 3 cycles de 30 s, assez pour capter un (re)branchement entre deux cycles).\n// Cout d'un faux positif (glitch MQTT sub-seconde) : au pire un retest, que le\n// statut 3 / VDF re-latche aussitot si le vehicule est vraiment plein.\nconst sessionFresh =\n parsed.evcsVehicleConnected &&\n (\n params.resetBorneRequested || // echappatoire manuelle (bouton reset borne)\n (Number.isFinite(parsed.evcsChargingTimeSec) &&\n parsed.evcsChargingTimeSec <= P11_FRESH_SESSION_MAX_SEC) ||\n (Number.isFinite(parsed.evcsVehicleConnectedAgeSec) &&\n parsed.evcsVehicleConnectedAgeSec <= P11_FRESH_SESSION_MAX_SEC)\n );\n\n// Fix D (08/08/2026) : le statut natif EVCS \"charged\" (3) vaut aussi fin de\n// charge, sans attendre le VehicleDemandFilter (désarmé si la session a été\n// stoppée par une garde, ex battery_current_hard_stop → détection tardive\n// observée la nuit du 07-08/08).\n// Fix E (09/08/2026) : garde puissance ≤ 300 W pour ne jamais latcher \"fini\"\n// sur un glitch de statut pendant une vraie charge. NB : le statut retombe\n// (3→1/6) dès l'ouverture du relais → la persistance est assurée par l'état\n// collant écrit par DECISION pendant la session (voir Fix E DECISION).\n// P11 : sur une session neuve, on ignore aussi un éventuel statut 3 résiduel\n// (le temps que la borne renégocie avec le nouveau véhicule).\n// Fix H (07/09/2026) : un statut \"charged\" n'est pas cru sur parole.\n// La borne l'a annonce le 07/09 a 14:28:18 apres 2 min 42 s de charge et\n// 0,10 kWh livre, ce qui a latche `day_vehicle_full_idle` pour deux heures\n// alors que le vehicule pouvait charger (il a repris a 16:30 et tenu).\n//\n// Aucun signal ELECTRIQUE instantane ne separe une vraie fin de charge d'une\n// interruption : mesure a la seconde des deux coupures, elles sont\n// superposables (VW 06/09, vraie fin : 1534 -> 550 -> 82 -> 11 -> 0 W ;\n// JEEP 07/09, faux positif : 1626 -> 741 -> 100 -> 13 -> 0 W). Seule la\n// DUREE de la session les distingue, et le systeme porte deja la grandeur qui\n// dit ce qu'est une charge etablie : min_run_minutes (29 min en production).\n//\n// Deux formes de \"charged\" sont credibles, et une seule ne l'est pas :\n// energie de session nulle -> le vehicule etait plein en arrivant (P10)\n// session >= min_run_minutes -> charge menee a son terme\n// entre les deux -> charge INTERROMPUE : on ne latche pas\n// Convergence : une session interrompue redemarre a energie nulle, donc un\n// second \"charged\" immediat retombe dans le premier cas et latche.\nconst chargedSessionEnergyKwh = Math.max(0, parsed.evcsSessionEnergyKwh || 0);\nconst chargedSessionEstablished =\n Number.isFinite(parsed.evcsChargingTimeSec) &&\n parsed.evcsChargingTimeSec >= params.minRunMinutes * 60;\nconst chargedCredible =\n chargedSessionEnergyKwh <= 0 || chargedSessionEstablished;\n\nconst evcsStatusCharged =\n !sessionFresh &&\n Number.parseFloat(parsed.evcsStatusRaw) === 3 &&\n Math.max(0, parsed.evcsAcPowerW || 0) <= 300 &&\n chargedCredible;\n\nconst vehicleChargeCompleted =\n nightSessionActive &&\n !sessionFresh &&\n (\n prevSurplusState === 'vehicle_full_or_paused' ||\n prevSurplusState === 'night_session_done_idle' ||\n prevSurplusState === 'night_recovery_done_idle' ||\n prevSurplusState === 'day_vehicle_full_idle' ||\n evcsStatusCharged\n );\n\n// Fix A (08/08/2026) : recovery batterie post-charge = UNE SEULE passe.\n// Sans flag collant, SOC==cible est un point dégénéré (engage soc<=cible,\n// release soc>cible, l'ACM recharge pile à la cible) → rebond EDF ON/OFF\n// période ~10 min observé la nuit du 07-08/08. Une fois la cible atteinte,\n// l'état 'night_recovery_done_idle' (auto-entretenu via prev_surplus_state)\n// coupe le takeover. Ré-engagement uniquement sous cible − 2 pts (bande),\n// hard floor et fenêtre/débranchement restent prioritaires.\n// ⚠ 'night_recovery_done_idle' ne doit matcher AUCUN pattern no-demand de\n// l'hystérésis (full_or_paused / not_requesting_power / charge_complete /\n// no_demand) — même contrainte que 'night_session_done_idle'.\n// 27/08/2026 : la bande de 2 points fabriquait un oscillateur. Relevé de la\n// nuit du 26-27/08 (cible 35) : engagement à SOC 33, relâchement à 35, trois\n// cycles EDF de 52, 79 et 25 min, 2,5 kWh importés pour rien un matin de grand\n// soleil ; la nuit du 20-21/08 en comptait six. Deux points de SOC valent\n// ~0,34 kWh, soit une demi-heure de conso nocturne : ce n'est pas une\n// hystérésis, c'est la période d'une horloge — d'autant plus régulière que la\n// charge de nuit est parfaitement plate.\n// La recovery batterie post-charge est donc UNE SEULE passe par session de\n// nuit, ce qui était déjà l'intention affichée du Fix A (\"relâché\n// DÉFINITIVEMENT\"). Aucun seuil de ré-engagement ici : le plancher dur reste\n// un terme indépendant de nightGridTakeoverNeeded (voir plus bas), et\n// l'hystérésis réseau garde sa propre bande 20/30. Le vrai critère de\n// ré-engagement est un budget d'énergie restant à couvrir, pas un delta de\n// SOC : il appartient au bilan énergétique, pas à une constante de plus ici.\nconst nightRecoveryDonePrev = prevSurplusState === 'night_recovery_done_idle';\nconst nightRecoveryDone =\n vehicleChargeCompleted &&\n (parsed.soc >= batteryMorningTargetPct || nightRecoveryDonePrev);\n\n// =====================================================\n// P9 (09/08/2026) : CHARGE RÉSEAU AVANT DÉPART, HORS FENÊTRE HC.\n// Cas VW : départ 08:50 > fin HC 07:24 → fenêtre morte où le véhicule\n// sous-chargé attendait un solaire insuffisant (observé le 09/08 : advisor\n// force_grid_now, divergence n°10). Si le toggle est ON et que le départ du\n// véhicule SÉLECTIONNÉ est dans ≤ 120 min : réseau autorisé (grid_hc_allowed)\n// jusqu'à fin de charge. L'arrêt réutilise TOUTE la mécanique anti-rebond :\n// VDF → vehicle_full_or_paused → état collant 'departure_done_idle'\n// (réécrit chaque cycle, patterns hystérésis sûrs, coupure immédiate E2).\n// =====================================================\nconst P9_DEPARTURE_LEAD_MIN = 120;\n\nconst departureWindowActive =\n params.allowGridDepartureUrgent &&\n parsed.evcsVehicleConnected &&\n !inNightHcWindow &&\n Number.isFinite(minutesUntilDeparture) &&\n minutesUntilDeparture >= 0 &&\n minutesUntilDeparture <= P9_DEPARTURE_LEAD_MIN;\n\nconst departureDonePrev =\n prevSurplusState === 'departure_done_idle' ||\n (!inNightHcWindow && prevSurplusState === 'vehicle_full_or_paused');\n\nconst departureChargeDone =\n departureWindowActive &&\n !sessionFresh &&\n (departureDonePrev || evcsStatusCharged);\n\nconst departureUrgentActive =\n departureWindowActive &&\n !departureChargeDone;\n\n// =====================================================\n// P10 (09/08/2026) : MÉMOIRE \"VÉHICULE PLEIN\" EN JOURNÉE.\n// Observé le 09/08 09:49 : VW pleine branchée depuis 07:00, le surplus\n// solaire retentait une charge (statut \"Charged\", 0 W) toutes les ~15 min\n// toute la journée (usure relais, bruit). Les états collants P8/P9 étaient\n// scopés nuit/fenêtre départ. Ici : une fois le véhicule vu plein (statut 3\n// ou VDF), état collant 'day_vehicle_full_idle' jusqu'au DÉBRANCHEMENT.\n// - hérite des états \"done\" de la nuit (07:24 → pas de re-tentative) ;\n// - la nuit hérite en retour (vehicleChargeCompleted inclut day_*) ;\n// - échappatoires : force_charge_now / force_grid_now / débrancher-rebrancher.\n// =====================================================\nconst dayVehicleFullPrev =\n prevSurplusState === 'day_vehicle_full_idle' ||\n (\n !inNightHcWindow &&\n (\n prevSurplusState === 'vehicle_full_or_paused' ||\n prevSurplusState === 'night_recovery_done_idle' ||\n prevSurplusState === 'night_session_done_idle' ||\n prevSurplusState === 'departure_done_idle'\n )\n );\n\nconst dayVehicleFullIdle =\n parsed.evcsVehicleConnected &&\n !sessionFresh &&\n !inNightHcWindow &&\n !isStopMode &&\n !forceGridRequested &&\n !forceChargeRequested &&\n (dayVehicleFullPrev || evcsStatusCharged);\n\n// P8 : correction du fix 04/08 (logique inversée : \"|| vehicleChargeCompleted\"\n// GARDAIT le latch après la charge au lieu de le libérer).\nconst nightTakeoverLatched =\n nightSessionActive &&\n nightTakeoverPrev &&\n parsed.soc < NIGHT_TAKEOVER_RELEASE_SOC_PCT &&\n !vehicleChargeCompleted;\n\n// =====================================================\n// P12 — CHARGE NUIT SUR LA BATTERIE (15/09/2026)\n// =====================================================\n// Idee du proprietaire : plutot qu'acheter du reseau la nuit pour charger la\n// voiture, lui donner le solaire DEJA STOCKE, doucement, et laisser ainsi de la\n// place dans la batterie pour la production du lendemain. Payer du reseau la\n// nuit pour ecreter le solaire le lendemain, c'est perdre deux fois.\n//\n// Le mode `battery_assist_night` existe depuis toujours pour exactement ca, et\n// il est MORT depuis toujours : il exige `batteryCurrentA >= plancher de\n// decharge` (0 A), impossible la nuit puisque la maison tire sur la batterie\n// (§11 du HANDOFF, ~ -14 A). On ne cree donc pas un chemin, on rend viable\n// celui qui existe — en remplacant un test de COURANT impossible par un test\n// d'ENERGIE : rester au-dessus de ce qu'il faut pour tenir jusqu'au croisement.\n//\n// Le plancher n'est PAS une constante nouvelle : c'est `required_soc_prudent`,\n// que le bilan publie deja, en profil p80. La marge est mesuree, pas inventee.\nconst nightBatterySurplusFloorPct = Math.max(\n batteryHardFloorPct,\n Number.isFinite(params.balanceRequiredSocPrudentPct)\n ? params.balanceRequiredSocPrudentPct\n : Infinity,\n);\n\n// ⚠️ LE VERROU, ET IL N'EST PAS DECORATIF. Le plancher DESCEND pendant la nuit\n// (il reste moins de nuit a couvrir) et le SOC aussi. Mesure du 15/09 :\n//\n// voiture en charge : SOC -7,1 pt/h, plancher -3,7 pt/h -> le surplus s'epuise\n// voiture arretee : SOC -2,6 pt/h, plancher -3,7 pt/h -> le surplus REVIENT\n//\n// Sans verrou, la charge repartirait des qu'elle s'arrete : cinquieme\n// oscillateur du projet, par construction. UNE SEULE PASSE de surplus par\n// session de nuit, comme la recovery batterie depuis le 27/08.\n// BUDGET VOITURE DE LA NUIT (30/09/2026, decisions du proprietaire).\n// « Definir combien la voiture peut prendre sans utiliser EDF, et brider la\n// charge a cette valeur ou autoriser une charge complete avec EDF. »\n// Le bilan publie le budget ; il est FIGE au premier cycle de la session\n// (avec l'index du compteur de la borne) dans input_text\n// victron_evcs_night_budget = « budget|index », efface en fin de session.\n// L'arret se juge sur le COMPTEUR, qui ne fait que monter : condition\n// monotone, aucun oscillateur possible (meme logique que §40). Le plancher de\n// SOC prudent reste un second arret, jamais retire.\nconst nightBudgetPrev = (() => {\n const m = String(params.nightBudgetPrevRaw || '').match(/^\\s*([\\d.]+)\\|([\\d.]+)\\s*$/);\n return m ? { budgetKwh: Number(m[1]), refKwh: Number(m[2]) } : null;\n})();\nconst nightBudgetCaptureOk =\n nightSessionActive && !nightBudgetPrev &&\n balanceMorningTargetApplied &&\n Number.isFinite(params.balanceNightVehicleBudgetKwh) &&\n Number.isFinite(params.evcsTotalEnergyKwh);\nconst nightBudget = !nightSessionActive\n ? null\n : nightBudgetPrev || (nightBudgetCaptureOk\n ? { budgetKwh: Math.max(0, params.balanceNightVehicleBudgetKwh), refKwh: params.evcsTotalEnergyKwh }\n : null);\nconst nightBudgetToWrite = nightSessionActive\n ? (nightBudgetCaptureOk ? `${Math.round(nightBudget.budgetKwh * 100) / 100}|${nightBudget.refKwh}` : null)\n : (nightBudgetPrev ? '' : null);\nconst nightBudgetDeliveredKwh = nightBudget && Number.isFinite(params.evcsTotalEnergyKwh)\n ? Math.max(0, params.evcsTotalEnergyKwh - nightBudget.refKwh)\n : null;\nconst nightBudgetReached =\n !!nightBudget && nightBudgetDeliveredKwh !== null &&\n nightBudgetDeliveredKwh >= nightBudget.budgetKwh - 0.05;\nconst nightChargeComplete = params.nightChargeMode === 'complete';\n\n// Le verrou ne vaut QUE pendant la session de nuit (01/10/2026). Ecrit sous la\n// forme `prev || …`, il restait vrai pour toujours une fois pose ; invisible\n// tant que son ecriture n'atteignait pas HA (jamais relayee du 15/09 au 01/10).\n// Fin de session (debranchement ou fin de fenetre HC) = verrou leve.\nconst nightBatterySurplusExhausted =\n nightSessionActive && (\n params.nightBatteryDonePrev ||\n parsed.soc <= nightBatterySurplusFloorPct ||\n nightBudgetReached\n );\n\nconst nightBatterySurplusAvailable =\n nightSessionActive &&\n params.nightBatteryChargeEnabled &&\n balanceMorningTargetApplied &&\n Number.isFinite(params.balanceRequiredSocPrudentPct) &&\n !vehicleChargeCompleted &&\n // Le plancher est teste UNE SEULE FOIS, dans `nightBatterySurplusExhausted`.\n // Le retester ici serait redondant — et une redondance ne se teste pas : la\n // mutation du 15/09 l'a montre en ne cassant aucun scenario.\n !nightBatterySurplusExhausted;\n\n// La batterie d'abord, le reseau quand le surplus est epuise. C'est la soupape\n// decrite par le proprietaire — « quitte a fermer EDF entre 6h et 7h24 pour\n// completer » — et elle devient automatique : l'epuisement du surplus rend\n// `nightGridHcPreferred` vrai, donc P7 prend le relais.\n// « Sans EDF » (defaut) : la voiture ne prend JAMAIS le reseau la nuit ; une\n// fois le budget batterie atteint (ou sans budget exploitable), elle attend le\n// soleil. « Charge complete » : EDF en HC finit la charge, comme avant.\nconst nightGridHcPreferred =\n nightSessionActive &&\n params.allowGridHcNight &&\n nightChargeComplete &&\n !vehicleChargeCompleted &&\n !nightBatterySurplusAvailable;\n\nconst nightCarWaitsNoGrid =\n nightSessionActive &&\n !nightChargeComplete &&\n !vehicleChargeCompleted &&\n !nightBatterySurplusAvailable;\n\nconst batteryAssistAllowed =\n nightSessionActive &&\n !nightGridHcPreferred &&\n !nightCarWaitsNoGrid &&\n parsed.soc > batteryHardFloorPct &&\n parsed.soc > gridTakeoverTriggerPct &&\n !nightTakeoverLatched &&\n !batteryRecoveryUrgent;\n\n// =====================================================\n// SESSION RESEAU UNIQUE DE LA NUIT (03/09/2026)\n// =====================================================\n// §26 avait pose l'objectif — « une session reseau unique et dimensionnee » —\n// et designe le bon critere : « un budget d'energie restant a couvrir, il\n// appartient au bilan energetique ». Le bilan est cable depuis ce soir.\n//\n// CE QUI A ETE MESURE AVANT D'ECRIRE CECI, et qui a dicte la conception :\n// la conversion kWh -> points de SOC du bilan est une constante unique\n// (15 kWh). La jauge reelle vaut ~17 kWh au-dessus de 42 % (0,164 a\n// 0,171 kWh/point sur trois fenetres independantes) mais s'effondre en\n// dessous : 12,1 kWh entre 45 et 22 %, 8,2 kWh entre 33 et 20 %. Un point de\n// SOC vaut donc MOITIE MOINS dans la bande ou la nuit se termine, et\n// `required_soc` y est systematiquement trop BAS — c'est pourquoi la batterie\n// est restee sous la ligne toute la nuit du 02-03/09, en descendant 1,75 fois\n// plus vite qu'elle.\n//\n// CONSEQUENCE DE CONCEPTION : le relachement ne doit PAS etre un seuil de SOC.\n// Il porterait sur une grandeur fausse, et tout seuil que le SOC repasse\n// ensuite rouvre l'oscillateur eteint le 27/08.\n//\n// Donc : ENGAGEMENT sur le bilan (batterie sous la cible), RELACHEMENT a la\n// FIN DE LA FENETRE. L'oscillation devient structurellement impossible : la\n// condition de sortie est monotone, une fenetre ne se ferme qu'une fois. Le\n// bit d'engagement est porte par\n// input_boolean.victron_evcs_night_grid_session_committed — sans lui le\n// systeme reevaluerait a chaque cycle et retomberait dans une bande.\n//\n// CE QUE CA FAIT CONCRETEMENT : l'ACM recharge jusqu'a la cible puis tient la\n// batterie a 2 A (evcs_takeover_hold) ; la maison finit la nuit sur le reseau\n// en HEURES CREUSES au lieu de vider la reserve, et la batterie arrive a\n// l'aube pres de sa cible, donc avec de la place pour le solaire du matin —\n// au lieu d'etre a 20 % avec le reseau encore ouvert a 10:33 (03/09).\n//\n// PORTEE : uniquement quand le bilan est exploitable, et uniquement pendant\n// une session nuit (fenetre HC + vehicule branche). Sinon, comportement\n// historique inchange. Le bit se nettoie tout seul a la fin de la session.\nconst nightGridSessionCommittedPrev =\n (input.night_grid_session_committed_prev ?? 'off').toString().trim().toLowerCase() === 'on';\n\nconst nightGridSessionUncovered =\n balanceMorningTargetApplied && batteryBelowMorningTarget;\n\n// ⚠️ SANS VEHICULE AUSSI (12/09/2026).\n// Nuit du 11-12/09, voiture rendue la veille : le bilan a reclame entre 92 %\n// et 59 % de SOC a CHAQUE calcul de la nuit, `reseau_necessaire` vrai partout,\n// et rien ne s'est passe. SOC bloque a 20 % de 23:43 a l'aube, consigne reseau\n// figee a 700 W depuis 18:36 — soit exactement la maison. L'hysteresis avait\n// bien ferme le contacteur (SOC <= 20), mais fermer le contacteur ne suffit\n// pas : il faut DEMANDER la puissance (lecon du 01/09). Personne ne la\n// demandait, parce que la session exigeait un vehicule branche.\n//\n// Or le besoin n'a rien a voir avec la voiture : c'est la maison qui doit\n// traverser la nuit, et le bilan sait exactement ce qu'il lui faut. La\n// fenetre HC suffit. Le relachement reste la FIN DE FENETRE, donc monotone,\n// donc toujours immune a l'oscillation (§40).\n//\n// Sans cette correction, le cout se paye le matin : a 07:24 le SOC est encore\n// sous le seuil OFF de l'hysteresis (30), donc le contacteur reste ferme et\n// la maison passe au PLEIN TARIF jusqu'au croisement PV — le 1,49 kWh deja\n// consigne le 03/09.\n// ⚠️ LA FENETRE DE MIDI EST AUSSI UNE FENETRE D'HEURES CREUSES (18/09/2026).\n// L'engagement de session etait gate sur `night_hc` seul, parce que §40 traitait\n// la nuit. Consequence mesuree : la charge HC manuelle demandee par le\n// proprietaire ne pouvait RIEN faire a midi — le plancher relevait bien la cible\n// du bilan, mais aucune session ne s'engageait, donc le contacteur ne se fermait\n// pas et la consigne restait au repos. Le reglage aurait releve le plancher du\n// soir (donc bloque la voiture) sans jamais charger la batterie.\n//\n// ⚠️ ET LE CRITERE N'EST PAS LE MEME AUX DEUX FENETRES. La nuit, on compare le\n// SOC a `batteryMorningTargetPct`, qui contient le palier de prevision\n// (35/55/85). A midi ce palier n'a aucun sens : il vaut 35 par beau temps, donc\n// un SOC de 30 % en plein soleil engagerait une session reseau — c'est-a-dire la\n// substitution du solaire par le reseau qu'on a mis des semaines a eteindre.\n//\n// A midi le critere honnete est le SOC requis du bilan lui-meme, qui\n// s'auto-regule : le croisement PV etant passe, `energyToCoverKwh` vaut zero et\n// `required_soc` retombe au plancher dur. Releve du 17/09 a 13:51, plein soleil :\n// `required_soc` = 22,0 pour `energy_to_cover` = 0,0. Il ne remonte qu'un jour\n// sombre — ou quand le proprietaire pose un plancher manuel, ce qui est\n// exactement le cas d'usage.\n//\n// ET LA VOITURE (28/09/2026, strategie du proprietaire) : « JEEP branchee\n// engage le reseau en HC midi si l'analyse de production le justifie ». Le\n// bilan fait l'analyse (besoin saisi contre solaire disponible avant le depart,\n// batterie gardee pour tenir jusqu'a 01:24) et publie un verdict ; on le relaie,\n// sous les memes gardes de fraicheur et de statut que la cible.\nconst middayVehicleGridNeeded =\n balanceMorningTargetApplied &&\n params.balanceVehicleGridNeeded &&\n parsed.evcsVehicleConnected;\n\nconst middayGridSessionUncovered =\n balanceMorningTargetApplied &&\n (\n (Number.isFinite(params.balanceRequiredSocPct) &&\n parsed.soc < params.balanceRequiredSocPct) ||\n middayVehicleGridNeeded\n );\n\nconst gridSessionInHcWindow =\n profile === 'night_hc' || profile === 'midday_hc_or_solar';\n\nconst gridSessionUncovered =\n profile === 'night_hc' ? nightGridSessionUncovered : middayGridSessionUncovered;\n\nconst nightGridSessionCommitted =\n gridSessionInHcWindow &&\n (gridSessionUncovered || nightGridSessionCommittedPrev);\n\nconst nightGridTakeoverNeeded =\n nightGridSessionCommitted ||\n (\n nightSessionActive &&\n (\n nightGridSessionCommitted ||\n nightGridHcPreferred ||\n batteryBelowHardFloor ||\n (\n // Fix A : après fin de charge, le seul motif réseau restant est la\n // recovery batterie, et elle ne se ré-engage pas une fois done.\n vehicleChargeCompleted\n ? !nightRecoveryDone\n : (\n batteryAtOrBelowGridTakeoverTrigger ||\n batteryRecoveryUrgent ||\n nightTakeoverLatched\n )\n )\n )\n );\n\nconst nightPreferredSourceMode =\n !nightSessionActive\n ? 'blocked'\n : vehicleChargeCompleted\n ? 'blocked'\n // « Sans EDF » : la voiture ne prend JAMAIS le reseau la nuit, MEME quand\n // EDF se ferme pour la batterie (plancher dur, cible du matin, session du\n // bilan, verrou). Decision du proprietaire le 04/10 (option A) : la nuit du\n // 03-04/10, EDF ferme a 02:57 pour la batterie, l'ancienne exception a\n // laisse la JEEP prendre 4,7 kWh au reseau jusqu'au plein. La protection\n // du materiel reste entiere : EDF se ferme quand meme (takeover), seule la\n // voiture attend le soleil.\n : nightCarWaitsNoGrid\n ? 'blocked'\n : batteryAssistAllowed\n ? 'battery_assist_night'\n : (\n batteryBelowHardFloor ||\n batteryBelowMorningTarget ||\n batteryRecoveryUrgent\n )\n ? 'grid_forced'\n : 'grid_hc_allowed';\n\n// « Sans EDF » ET EDF necessaire pour la BATTERIE (04/10/2026, option A du\n// proprietaire) : la voiture reste bloquee, mais la batterie a sa session\n// reseau. Sans ce drapeau, la fermeture d'EDF et la consigne de recharge, qui\n// suivent la source de la VOITURE, seraient perdues avec elle (golden\n// budget-sans-edf-plancher-dur-edf-protege, depart-matin-…-sous-la-cible).\nconst nightBatteryGridOnly =\n nightSessionActive &&\n nightCarWaitsNoGrid &&\n nightGridTakeoverNeeded;\n\nconst batteryRecoveryAllowedNow =\n nightSessionActive &&\n nightGridTakeoverNeeded &&\n (\n batteryBelowMorningTarget ||\n batteryRecoveryUrgent\n );\n\nconst batteryTargetReached =\n nightSessionActive &&\n parsed.soc >= batteryMorningTargetPct;\n\n// =====================================================\n// PROBES / SOLAR POTENTIAL\n// =====================================================\n// IMPORTANT:\n// On ne met plus effectivePvStartMinW à 0 en force_charge_now,\n// sinon la nuit 0W >= 0W ferait croire à un faux signal solaire.\nconst effectivePvStartMinW = params.pvStartMinW;\n\nconst morningProbeEligible =\n profile === 'morning_solar' &&\n params.probeEnabled &&\n parsed.pvPowerAvg10mW >= params.morningProbePvAvgMinW &&\n parsed.pvTrendW >= params.morningProbePvTrendMinW &&\n parsed.soc >= params.morningSocMin &&\n parsed.batteryCurrentA >= params.morningBatteryMaxDischargeA;\n\nconst middayProbeEligible =\n profile === 'midday_hc_or_solar' &&\n params.probeEnabled &&\n parsed.pvPowerAvg10mW >= params.middayProbePvAvgMinW &&\n parsed.soc >= params.middaySocMin &&\n parsed.batteryCurrentA >= params.middayBatteryMaxDischargeA;\n\nconst afternoonProbeEligible =\n profile === 'outside_window' &&\n params.probeEnabled &&\n parsed.soc >= params.afternoonProbeSocMin &&\n parsed.batteryCurrentA >= params.afternoonBatteryMaxDischargeA &&\n parsed.pvPowerAvg10mW >= params.afternoonProbePvAvgMinW;\n\nconst cloudDebtOkForPotential = params.cloudDebtPrevKwh <= params.cloudDebtMaxKwh;\nconst batteryOkForPotential = parsed.batteryCurrentA >= -Math.max(1, params.batteryNeutralBandA);\n\n// P3 PROVOCATION PV (installation sans injection) :\n// Batteries quasi pleines => le MPPT bride la production à la conso maison.\n// Les seuils PV observés, le forecast buffer et la cloud debt ne veulent\n// alors plus rien dire (la dette ne peut d'ailleurs plus se rembourser,\n// batterie pleine = courant nul). On autorise solar_potential sur la seule\n// santé batterie : SOC très haut + batterie non déchargée.\n// Les gardes aval (BATTERY_CLOUD_LOCKS / DECISION) restent actives.\nconst PV_PROVOCATION_SOC_MIN = 90;\nconst pvProvocationEligible =\n parsed.soc >= PV_PROVOCATION_SOC_MIN &&\n batteryOkForPotential;\n\nconst isHighBatteryForPotential =\n pvProvocationEligible ||\n (\n parsed.soc >= 85 &&\n forecastBufferKwh > 0 &&\n cloudDebtOkForPotential &&\n batteryOkForPotential\n );\n\nconst potentialAllowed = userStrategy !== 'auto_solar';\n\n// IMPORTANT:\n// On impose un vrai PV > 0 pour le test sur pvPowerW.\n// Ça évite qu'en force_charge_now la nuit, 0W soit pris pour un signal solaire.\nconst strongSolarSignal =\n (\n parsed.pvPowerW > 0 &&\n parsed.pvPowerW >= effectivePvStartMinW\n ) ||\n parsed.pvPowerAvg10mW >= params.pvHoldMinW ||\n (departurePriorityActive && parsed.pvPowerAvg10mW >= params.pvHoldMinW);\n\n// =====================================================\n// SOURCE MODE\n// SÉMANTIQUE CORRIGÉE:\n// - force_charge_now bypass les fenêtres\n// - mais ne devient jamais grid_forced à lui seul\n// - outside_window = pas de réseau programmé\n// - la charge locale peut rester autorisée\n// =====================================================\nlet sourceMode = 'blocked';\n\nif (isStopMode) {\n sourceMode = 'blocked';\n}\n\nelse if (forceGridRequested) {\n // Demande explicite de réseau.\n sourceMode = 'grid_forced';\n}\n\nelse if (forceChargeRequested) {\n // IMPORTANT:\n // force_charge_now = intention de charger maintenant,\n // sans exiger le réseau.\n\n if (strongSolarSignal) {\n sourceMode = 'solar_only';\n }\n\n else if (nightSessionActive && batteryAssistAllowed) {\n sourceMode = 'battery_assist_night';\n }\n\n else if (isHighBatteryForPotential && potentialAllowed) {\n sourceMode = 'solar_potential';\n }\n\n else if (profile === 'midday_hc_or_solar' && middayProbeEligible) {\n sourceMode = 'hybrid_probe';\n }\n\n else if (profile === 'morning_solar' && morningProbeEligible) {\n sourceMode = 'hybrid_probe';\n }\n\n else {\n // Fallback manuel local :\n // on n'impose pas le grid, on laisse l'étage aval décider\n // selon les garde-fous physiques / batterie.\n sourceMode = 'solar_potential';\n }\n}\n\nelse if (profile === 'night_hc') {\n if (nightSessionActive) {\n sourceMode = nightPreferredSourceMode;\n } else {\n sourceMode = 'blocked';\n }\n}\n\nelse if (profile === 'midday_hc_or_solar' || profile === 'morning_solar') {\n // Session de midi engagee et contacteur ferme : la voiture charge sur le\n // reseau avec la batterie (« batterie et voiture ensemble », 28/09), meme au\n // soleil. La consigne garde son credit solaire (HA_SERVICE_CALLS_OUTPUT) :\n // elle ne preleve au reseau que ce que les chargeurs ne donnent pas.\n if (profile === 'midday_hc_or_solar' && nightGridSessionCommitted && parsed.gridAvailable) {\n sourceMode = 'grid_hc_allowed';\n }\n\n else if (strongSolarSignal) {\n sourceMode = 'solar_only';\n }\n\n else if (isHighBatteryForPotential && potentialAllowed) {\n sourceMode = 'solar_potential';\n }\n\n else if (profile === 'midday_hc_or_solar' && middayProbeEligible) {\n sourceMode = 'hybrid_probe';\n }\n\n else if (profile === 'morning_solar' && morningProbeEligible) {\n sourceMode = 'hybrid_probe';\n }\n\n else if (profile === 'midday_hc_or_solar' && params.allowGridHcMidday && parsed.gridAvailable) {\n sourceMode = 'grid_hc_allowed';\n }\n\n else {\n sourceMode = 'blocked';\n }\n}\n\nelse if (profile === 'evening_high_soc') {\n if (strongSolarSignal) {\n sourceMode = 'solar_only';\n }\n\n else if (isHighBatteryForPotential && potentialAllowed) {\n sourceMode = 'solar_potential';\n }\n\n else {\n sourceMode = 'blocked';\n }\n}\n\nelse if (profile === 'outside_window') {\n if (strongSolarSignal) {\n sourceMode = 'solar_only';\n }\n\n else if (isHighBatteryForPotential && potentialAllowed) {\n sourceMode = 'solar_potential';\n }\n\n else if (afternoonProbeEligible) {\n sourceMode = 'hybrid_probe';\n }\n\n else {\n // IMPORTANT:\n // hors fenêtre = pas de réseau programmé.\n // Donc on ne bascule plus vers grid_forced juste parce que le réseau existe.\n sourceMode = 'blocked';\n }\n}\n\n// P9 : le réseau avant départ prend le pas sur les modes spéculatifs du jour\n// (blocked / solar_potential / hybrid_probe), mais JAMAIS sur le solaire réel\n// (solar_only), ni sur stop / force_* (déjà traités en tête de cascade).\nif (\n departureUrgentActive &&\n !isStopMode &&\n !forceGridRequested &&\n !forceChargeRequested &&\n profile !== 'night_hc' &&\n sourceMode !== 'solar_only'\n) {\n sourceMode = 'grid_hc_allowed';\n}\n\n// P9 : véhicule plein pendant la fenêtre départ → aucune source.\nif (departureChargeDone && profile !== 'night_hc' && !isStopMode && !forceGridRequested && !forceChargeRequested) {\n sourceMode = 'blocked';\n}\n\n// P10 : véhicule plein en journée (mémoire collante) → aucune source,\n// y compris solaire (charger un véhicule plein ne sert à rien).\nif (dayVehicleFullIdle && profile !== 'night_hc') {\n sourceMode = 'blocked';\n}\n\n// =====================================================\n// PLAFOND DE COURANT EVCS — calculé APRÈS sourceMode (31/08/2026)\n// =====================================================\n// P5 : plafond majoré (input_number.evcs_plafond_courant_edf_on, jusqu'à 28 A\n// en BOOST) quand le réseau alimente réellement AC-IN ; sinon plafond câblé\n// prudent, celui que les deux MultiPlus peuvent porter SEULS.\n//\n// SÉQUENCE D'OUVERTURE (31/08/2026) — pourquoi ce bloc a été déplacé ici.\n// Le plafond majoré n'est légitime que tant que le réseau est là. Or c'est\n// l'hystérésis (cycle 1 min) qui ouvre le contacteur, et le contrôleur EVCS ne\n// l'apprenait qu'après coup : pendant ce temps les deux onduleurs portaient\n// seuls la voiture au courant majoré plus la maison — le mécanisme du W-105.\n//\n// Correctif sans poignée de main entre les deux automates : le plafond majoré\n// exige désormais que l'EVCS DEMANDE lui-même le réseau. Sa demande tombe au\n// cycle où la fenêtre se ferme (30 s), alors que l'hystérésis ne peut ouvrir\n// qu'ensuite (override disparu ET min_on écoulé, cycle 1 min). L'EVCS baisse\n// donc AVANT, mécaniquement, sans rien coordonner.\n// Le calcul doit venir après sourceMode ; rien entre les deux ne l'utilisait.\nconst evcsGridCeilingAllowed =\n parsed.acInAvailable &&\n (\n sourceMode === 'grid_forced' ||\n sourceMode === 'grid_hc_allowed' ||\n forceGridRequested\n );\n\nconst evcsDedicatedCapBaseW = Math.max(0, cfg.evcsDedicatedMaxW - cfg.systemReserveW);\nconst safeEvcsByDedicatedCapW = evcsGridCeilingAllowed\n ? Math.max(0, params.gridCurrentMaxA * acVoltageRef)\n : evcsDedicatedCapBaseW;\nconst safeEvcsByVebusW = Math.max(0, totalVebusSoftCeilingW - vebusNonEvcsW - cfg.systemReserveW);\nconst safeEvcsCombinedW = Math.max(0, Math.min(safeEvcsByDedicatedCapW, safeEvcsByVebusW));\n\nconst systemCapCurrentA = Math.max(0, Math.floor(safeEvcsCombinedW / acVoltageRef));\nconst physicalCapA = clamp(\n systemCapCurrentA,\n 0,\n evcsGridCeilingAllowed\n ? Math.max(params.currentMaxAutoA, params.gridCurrentMaxA)\n : params.currentMaxAutoA\n);\nconst canRunAtMinCurrent = physicalCapA >= params.currentMinA;\n\nconst operatingMode = profile;\n\n// =====================================================\n// OUTPUT\n// =====================================================\nreturn [{\n json: {\n ...prev,\n\n strategy: {\n userStrategy,\n isStopMode,\n isForceMode,\n forceGridRequested,\n forceChargeRequested,\n },\n\n lifecycle: {\n minutesSinceStart,\n minutesSinceStop,\n secondsSinceCurrentChange,\n minutesSinceProbeStart,\n minRunElapsed,\n minStopElapsed,\n currentCooldownElapsed,\n minutesUntilDeparture,\n },\n\n windows: {\n inNightHcWindow,\n inMorningWindow,\n inMiddayWindow,\n inEveningWindow,\n // Temps restant dans la fenetre d'HEURES CREUSES courante (nuit ou midi),\n // null hors de toute fenetre creuse. Consomme par le dimensionnement de\n // la consigne reseau : recharger la batterie sur le reseau n'a de sens\n // economique que la-dedans, donc `null` y vaut « pas de part batterie »\n // sans test supplementaire.\n minutesRemainingInHcWindow:\n minutesRemainingInWindow(now.minNow, params.hcNightStartMin, params.hcNightStopMin)\n ?? minutesRemainingInWindow(now.minNow, params.hcMiddayStartMin, params.hcMiddayStopMin),\n },\n\n derived: {\n acVoltageRef,\n batteryVoltageRef,\n houseNonEvcsW,\n vebusNonEvcsW,\n totalVebusSoftCeilingW,\n totalVebusHardCeilingW,\n safeEvcsByDedicatedCapW,\n safeEvcsByVebusW,\n safeEvcsCombinedW,\n systemCapCurrentA,\n physicalCapA,\n canRunAtMinCurrent,\n evcsGridCeilingAllowed,\n gridSettling,\n gridTransitionAgeSec: parsed.gridTransitionAgeSec,\n endDayGuardSoc,\n energyNeededForEndDayGuardKwh,\n forecastBufferKwh,\n projectedEndSocAfterCharge,\n currentReferenceA,\n anyVebusOverload,\n anyVebusLowBattery,\n vebusAcOutSoftReached,\n vebusAcOutHardReached,\n vebusDcVoltageSoftLow,\n vebusDcVoltageHardLow,\n houseLoadHighVsHeadroom,\n },\n\n context: {\n departurePriorityActive,\n forceChargeDepartureGridOverride,\n manualChargeBypassWindows,\n manualChargeLocalRequested,\n morningProbeEligible,\n middayProbeEligible,\n afternoonProbeEligible,\n pvProvocationEligible,\n isHighBatteryForPotential,\n strongSolarSignal,\n effectivePvStartMinW,\n profile,\n operatingMode,\n sourceMode,\n },\n\n // R1 : source de vérité UNIQUE du moteur nuit.\n // BATTERY_CLOUD_LOCKS consomme cet objet, il ne recalcule plus rien.\n nightEvcsPreview: {\n nightSessionActive,\n nightSessionNeedsCapture,\n nightSessionNeedsClear,\n nightSessionStartSocPct,\n nightSessionStartAt,\n nightSessionStartMs,\n\n batteryHardFloorPct,\n batteryMorningTargetPct,\n batteryMorningTargetBasePct,\n balanceMorningTargetApplied,\n nightBatterySurplusFloorPct,\n nightBatterySurplusAvailable,\n nightBatterySurplusExhausted,\n nightBudgetKwh: nightBudget ? nightBudget.budgetKwh : null,\n nightBudgetDeliveredKwh,\n nightBudgetReached,\n nightBudgetToWrite,\n nightChargeMode: params.nightChargeMode,\n nightCarWaitsNoGrid,\n nightBatteryGridOnly,\n nightGridSessionCommitted,\n middayVehicleGridNeeded,\n nightGridSessionUncovered,\n balanceTargetAgeMin:\n balanceTargetAgeMin === null ? null : Math.round(balanceTargetAgeMin * 10) / 10,\n batteryMorningTargetRulePct,\n maxBatteryDischargePoints,\n gridTakeoverTriggerPct,\n\n batteryBelowHardFloor,\n batteryBelowMorningTarget,\n batteryAtOrBelowGridTakeoverTrigger,\n\n batteryKwhMissingToTarget,\n batteryRecoveryMinutesNeeded,\n batteryRecoveryUrgent,\n batteryRecoveryAllowedNow,\n batteryTargetReached,\n\n batteryAssistAllowed,\n nightGridTakeoverNeeded,\n nightTakeoverLatched,\n nightGridHcPreferred,\n vehicleChargeCompleted,\n nightRecoveryDone,\n nightPreferredSourceMode,\n\n // P9\n departureUrgentActive,\n departureChargeDone,\n\n // P10\n dayVehicleFullIdle,\n\n // P11\n sessionFresh,\n chargedSessionEnergyKwh,\n chargedSessionEstablished,\n chargedCredible,\n }\n }\n}];\n" + "jsCode": "function minutesSince(ts, nowMs) {\n if (ts === null || nowMs === null) return null;\n return Math.max(0, (nowMs - ts) / 60000);\n}\n\nfunction secondsSince(ts, nowMs) {\n if (ts === null || nowMs === null) return null;\n return Math.max(0, (nowMs - ts) / 1000);\n}\n\nfunction isWithinWindow(nowMin, startMin, stopMin) {\n if (!Number.isFinite(startMin) || !Number.isFinite(stopMin)) return false;\n if (startMin === stopMin) return false;\n if (startMin < stopMin) return nowMin >= startMin && nowMin < stopMin;\n return nowMin >= startMin || nowMin < stopMin;\n}\n\n// Temps restant dans une fenetre, en minutes, ou null si on n'y est pas.\n//\n// ⚠️ IL VIT ICI, A COTE DE `isWithinWindow`, ET PAS DANS LE NOEUD QUI L'UTILISE.\n// Ce qui serait duplique ailleurs n'est pas l'analyse des heures — INPUT_PARSE\n// les a deja converties en minutes — c'est la LOGIQUE D'APPARTENANCE, et en\n// particulier le traitement du passage de minuit. Une seconde interpretation\n// divergerait le jour ou une borne bouge, et elles ont deja bouge une fois\n// (01:30 -> 01:24 le 09/07/2026).\nfunction minutesRemainingInWindow(nowMin, startMin, stopMin) {\n if (!isWithinWindow(nowMin, startMin, stopMin)) return null;\n if (startMin < stopMin) return stopMin - nowMin;\n // Fenetre a cheval sur minuit : avant minuit il reste la fin de la nuit EN\n // PLUS du reste de la journee. Atteignable par les valeurs de repli\n // d'INPUT_PARSE (23:00 -> 06:00) si les helpers d'horaire sont illisibles.\n return nowMin >= startMin ? stopMin + 1440 - nowMin : stopMin - nowMin;\n}\n\nfunction clamp(v, min, max) {\n return Math.min(Math.max(v, min), max);\n}\n\nconst prev = $input.first().json;\nconst {\n input,\n nowMs,\n now,\n cfg,\n parsed,\n params,\n history,\n batteryTune\n} = prev;\n\n// =====================================================\n// STRATEGY\n// =====================================================\nconst userStrategy = (params.userStrategyRaw ?? 'auto_solar').toString().trim().toLowerCase();\n\nconst isStopMode = userStrategy === 'stop';\nconst forceGridRequested = userStrategy === 'force_grid_now';\nconst forceChargeRequested = userStrategy === 'force_charge_now';\nconst isForceMode = forceGridRequested || forceChargeRequested;\n\n// =====================================================\n// LIFECYCLE / TIMING\n// =====================================================\nconst minutesSinceStart = minutesSince(history.lastStartMs, nowMs);\nconst minutesSinceStop = minutesSince(history.lastStopMs, nowMs);\nconst secondsSinceCurrentChange = secondsSince(history.lastCurrentChangeMs, nowMs);\nconst minutesSinceProbeStart = minutesSince(history.probeStartedMs, nowMs);\n\nconst minRunElapsed =\n minutesSinceStart === null ? true : minutesSinceStart >= params.minRunMinutes;\n\nconst minStopElapsed =\n minutesSinceStop === null ? true : minutesSinceStop >= params.minStopMinutes;\n\nconst currentCooldownBaseSec = parsed.evcsChargingSwitchOn\n ? batteryTune.global.stepDownCooldownSeconds\n : batteryTune.global.stepUpCooldownSeconds;\n\nconst currentCooldownElapsed =\n secondsSinceCurrentChange === null\n ? true\n : secondsSinceCurrentChange >= Math.max(\n params.currentChangeCooldownSeconds,\n currentCooldownBaseSec\n );\n\n// =====================================================\n// DEPARTURE PRIORITY\n// =====================================================\nconst minutesUntilDeparture =\n params.departureTimeMin === null\n ? null\n : (() => {\n const rawDiff = params.departureTimeMin - now.minNow;\n return rawDiff >= 0 ? rawDiff : (1440 - now.minNow + params.departureTimeMin);\n })();\n\nconst departurePriorityActive =\n parsed.departurePriorityEnabled &&\n Number.isFinite(minutesUntilDeparture) &&\n minutesUntilDeparture >= 0 &&\n minutesUntilDeparture <= cfg.departurePriorityWindowMinutes &&\n parsed.evcsVehicleConnected;\n\n// =====================================================\n// MANUAL CHARGE SEMANTICS\n// IMPORTANT:\n// force_charge_now = bypass fenêtres pour la charge,\n// MAIS PAS demande implicite de réseau.\n// =====================================================\nconst manualChargeBypassWindows = forceChargeRequested;\nconst manualChargeLocalRequested = forceChargeRequested && !forceGridRequested;\n\n// Compatibilité ascendante : on garde le champ dans context,\n// mais on le neutralise volontairement.\nconst forceChargeDepartureGridOverride = false;\n\n// =====================================================\n// SYSTEM DERIVED VALUES\n// =====================================================\nconst acVoltageRef =\n Number.isFinite(parsed.vebusAcOutVoltageV) && parsed.vebusAcOutVoltageV > 180\n ? parsed.vebusAcOutVoltageV\n : cfg.nominalAcVoltageV;\n\nconst batteryVoltageRef =\n Number.isFinite(parsed.vebusDcVoltageV) && parsed.vebusDcVoltageV > 0\n ? parsed.vebusDcVoltageV\n : 51.2;\n\nconst houseNonEvcsW = Math.max(0, parsed.houseAcPowerW - parsed.evcsAcPowerW);\nconst vebusNonEvcsW = Math.max(0, parsed.vebusAcOutPowerW - parsed.evcsAcPowerW);\n\nconst totalVebusSoftCeilingW = Math.max(6500, params.acLoadsMaxSafeW || 8500);\nconst totalVebusHardCeilingW = totalVebusSoftCeilingW + 400;\n\n// =====================================================\n// FENETRE D'ETABLISSEMENT DU RESEAU (31/08/2026)\n// =====================================================\n// Mesure du 31/08 : a la fermeture du contacteur, le reseau reprend d'un coup\n// TOUTE la charge de la maison et le solaire bascule vers la batterie. Batterie\n// preneuse, la production ne bouge pas ; batterie pleine, elle s'effondre (2663\n// -> 559 W) faute de destination. Dans les deux cas l'import decroit ensuite\n// jusqu'a la consigne en ~150 s (1985 -> 25 W, puis 2600 -> 52 W).\n//\n// Pendant cette phase, PV, import et courant batterie ne decrivent aucun regime\n// permanent : la boucle de 30 s y prend cinq decisions, et la moyenne PV 10 min\n// en garde la trace une dizaine de minutes de plus. Le systeme lit \"plus de\n// soleil\", en conclut qu'il a besoin du reseau, et confirme ainsi sa propre\n// premisse -- une boucle de rattrapage, comme les trois du 24/08.\n//\n// ASYMETRIE VOULUE : la fenetre ne s'ouvre qu'a l'ARRIVEE du reseau. A sa\n// disparition, les onduleurs reprennent instantanement toute la charge : les\n// gardes doivent agir plus vite, jamais moins vite.\nconst gridSettling =\n parsed.gridAvailable &&\n parsed.gridTransitionAgeSec < params.gridSettleSeconds;\n\nconst endDayGuardSoc = Math.max(params.endOfDaySocGuard, params.endOfDaySocTarget);\n\nconst energyNeededForEndDayGuardKwh = Math.max(\n 0,\n ((endDayGuardSoc - parsed.soc) / 100) * cfg.batteryUsableCapacityKwh\n);\n\nconst forecastBufferKwh =\n parsed.todayRemainingKwh -\n params.baseLoadRemainingKwh -\n params.flexibleSafetyMarginKwh -\n params.requiredEnergyKwh -\n energyNeededForEndDayGuardKwh;\n\nconst projectedEndSocAfterCharge = clamp(\n parsed.soc + (\n (\n parsed.todayRemainingKwh -\n params.baseLoadRemainingKwh -\n params.flexibleSafetyMarginKwh -\n params.requiredEnergyKwh\n ) / cfg.batteryUsableCapacityKwh\n ) * 100,\n 0,\n 100\n);\n\nconst currentReferenceA = clamp(\n Number.isFinite(parsed.evcsCommandCurrentA)\n ? parsed.evcsCommandCurrentA\n : Number.isFinite(parsed.evcsSetCurrentA)\n ? parsed.evcsSetCurrentA\n : params.targetCurrentAutoA,\n 0,\n 32\n);\n\nconst anyVebusOverload = parsed.vebusOverload || parsed.vebusL1Overload;\nconst anyVebusLowBattery = parsed.vebusLowBattery || parsed.vebusL1LowBattery;\n\nconst vebusAcOutSoftReached = parsed.vebusAcOutPowerW >= totalVebusSoftCeilingW;\nconst vebusAcOutHardReached = parsed.vebusAcOutPowerW >= totalVebusHardCeilingW;\n\nconst vebusDcVoltageSoftLow =\n Number.isFinite(parsed.vebusDcVoltageV) &&\n parsed.vebusDcVoltageV > 0 &&\n parsed.vebusDcVoltageV <= cfg.vebusDcVoltageSoftFloorV;\n\nconst vebusDcVoltageHardLow =\n Number.isFinite(parsed.vebusDcVoltageV) &&\n parsed.vebusDcVoltageV > 0 &&\n parsed.vebusDcVoltageV <= cfg.vebusDcVoltageHardFloorV;\n\nconst houseLoadHighVsHeadroom = houseNonEvcsW > params.houseAcHeadroomW;\n\n// =====================================================\n// WINDOWS / PROFILE\n// =====================================================\nconst inNightHcWindow = isWithinWindow(now.minNow, params.hcNightStartMin, params.hcNightStopMin);\nconst inMorningWindow = isWithinWindow(now.minNow, params.morningStartMin, params.morningStopMin);\nconst inMiddayWindow = isWithinWindow(now.minNow, params.hcMiddayStartMin, params.hcMiddayStopMin);\nconst inEveningWindow = isWithinWindow(now.minNow, params.eveningStartMin, params.eveningStopMin);\n\nconst profile =\n inNightHcWindow ? 'night_hc' :\n inMorningWindow ? 'morning_solar' :\n inMiddayWindow ? 'midday_hc_or_solar' :\n inEveningWindow ? 'evening_high_soc' :\n 'outside_window';\n\n// =====================================================\n// EVCS NIGHT PREVIEW ENGINE\n// =====================================================\nconst nightSessionActive =\n profile === 'night_hc' &&\n parsed.evcsVehicleConnected;\n\nconst nightSessionStartSocPct =\n nightSessionActive\n ? (\n Number.isFinite(history.nightSessionStartSocPct)\n ? history.nightSessionStartSocPct\n : parsed.soc\n )\n : null;\n\nconst nightSessionStartAt =\n nightSessionActive\n ? (history.nightSessionStartAt || '')\n : '';\n\nconst nightSessionStartMs =\n nightSessionActive\n ? (history.nightSessionStartMs ?? null)\n : null;\n\nconst nightSessionNeedsCapture =\n nightSessionActive &&\n (\n !Number.isFinite(history.nightSessionStartSocPct) ||\n !nightSessionStartAt ||\n nightSessionStartMs === null\n );\n\nconst nightSessionNeedsClear =\n !nightSessionActive &&\n (\n Number.isFinite(history.nightSessionStartSocPct) ||\n !!history.nightSessionStartAt\n );\n\nconst batteryHardFloorPct =\n params.evcsNightBatteryHardFloorPct;\n\n// P4 : la nuit avant minuit, today_remaining ~ 0 => utiliser la prévision du lendemain.\nconst nightForecastHour = now.hour;\nconst nightForecastKwh =\n (nightForecastHour >= 20 || nightForecastHour < 8)\n ? Math.max(parsed.todayRemainingKwh, parsed.tomorrowKwh || 0)\n : parsed.todayRemainingKwh;\n\nconst batteryMorningTargetRulePct =\n nightForecastKwh >= params.evcsNightStrongForecastKwh\n ? params.evcsNightTargetStrongForecastPct\n : nightForecastKwh >= params.evcsNightMediumForecastKwh\n ? params.evcsNightTargetMediumForecastPct\n : params.evcsNightTargetWeakForecastPct;\n\n// =====================================================\n// LE CONSEILLER NE FIXE PLUS LA CIBLE (retire le 13/09/2026)\n// =====================================================\n// Le premier cablage reel (09/08/2026, HANDOFF §20) faisait suivre a la cible\n// du matin `input_number.evcs_advisor_night_target_soc`, derriere un toggle,\n// un statut `ok`, une fraicheur de 120 min et un bornage.\n// Le 12/09 le conseiller est devenu OBSERVATEUR : il n'emet plus de cible, et\n// ce helper est fige depuis. Les quatre gardes continuaient pourtant de passer\n// toutes les quatre — la garde de fraicheur date le RUN, pas la VALEUR, et le\n// conseiller tourne toujours. Activer le toggle aurait donc applique un chiffre\n// perime sans qu'aucune garde ne s'y oppose. Un cablage qui ne peut plus que se\n// tromper se retire ; le scenario `advisor-cible-ignoree` le verifie.\nconst batteryMorningTargetBasePct = batteryMorningTargetRulePct;\n\n// =====================================================\n// CIBLE DU MATIN : UNE ENERGIE A COUVRIR, PAS UN PALIER (03/09/2026)\n// =====================================================\n// La regle historique classe la journee du lendemain en trois paliers\n// (>= 18 kWh -> 35 %, >= 10 -> 55 %, sinon 85 %) a partir de son TOTAL. Elle\n// est donc aveugle a l'heure d'arrivee de l'energie : le 03/09, demain\n// annoncait 33,9 kWh — « fort », donc 35 % — mais avec un matin creux\n// (0,03 kWh a 7 h, 0,77 a 8 h, 1,81 a 9 h) et un croisement a 9 h.\n//\n// C'est le meme defaut que le plancher du soir (cf. le bloc BILAN de\n// BATTERY_CLOUD_LOCKS), a l'autre bout de la nuit : un palier fige contre une\n// energie mesurable. Le bilan publie la seconde, instant par instant.\n//\n// RELEVE DE LA NUIT 02-03/09, qui a motive ce cablage :\n//\n// heure requis SOC reel cible fixe\n// 00:30 62,4 53 35\n// 03:00 50,4 45 35 <- EDF relache ici, 5 points sous la ligne\n// 04:30 41,7 37 35\n// 06:00 37,1 22 35 <- hysteresis, puis reseau PLEIN TARIF\n// de 07:24 a 10:33 (1,49 kWh)\n//\n// La batterie est restee sous la ligne toute la nuit et le bilan le savait des\n// 00:30. Le `required_soc` decroit proprement avec la nuit qui se consomme\n// (6,1 -> 0 kWh a couvrir) : c'est bien une cible instantanee, pas une\n// constante deguisee.\n//\n// SENS UNIQUE, comme pour le plancher du soir : le bilan ne peut que RELEVER\n// la cible, jamais l'abaisser. La direction corrigee est celle de l'echec\n// observe (cible trop basse -> batterie a plat avant le croisement -> reseau\n// au plein tarif le matin). L'abaisser — la regle dit 85 % l'hiver quand le\n// bilan dira parfois moins — ouvrirait un risque neuf que rien n'a encore\n// valide. Le principe unificateur : le bilan ne pousse JAMAIS dans le sens\n// « couvre moins bien la nuit ».\n//\n// PLAFOND 90 % : au-dela, la batterie cesse d'etre un puits et la production\n// du matin se fait evincer (cf. mesures du 31/08). Charger au reseau au-dela\n// de 90 echangerait un kWh bon marche cette nuit contre plusieurs kWh censures\n// demain. C'est deja la borne du chemin advisor : aucune constante nouvelle.\n//\n// PAS D'OSCILLATEUR : `required_soc` decroit de facon monotone pendant la nuit\n// (l'energie restant a couvrir ne fait que diminuer), et la passe unique de\n// recovery (`nightRecoveryDonePrev`, 27/08) reste en place.\n//\n// REPLI identique au plancher du soir : fraicheur, statut, valeur finie —\n// sinon regle historique. Kill-switch existant : `evcs_balance_enabled` OFF\n// fige `balance_last_run` et la cible retombe seule sur les paliers.\nconst BALANCE_TARGET_MAX_AGE_MIN = 30;\nconst balanceTargetAgeMin = minutesSince(params.balanceLastRunMs, nowMs);\nconst balanceMorningTargetApplied =\n params.balanceStatusOk &&\n Number.isFinite(params.balanceRequiredSocPct) &&\n balanceTargetAgeMin !== null &&\n balanceTargetAgeMin <= BALANCE_TARGET_MAX_AGE_MIN;\n\n// ⚠️ 01/10/2026 — DECISION DU PROPRIETAIRE : LE BILAN FIXE LA CIBLE, DANS LES\n// DEUX SENS. « La valeur de 35 % c'est idiot si le besoin est prevu 26,6 %, et\n// c'est aussi idiot si la prevision est de 60 % pour une journee nuageuse. »\n// Le sens unique ci-dessus (03/09) gardait le palier comme plancher : le\n// 01/10 a 06:00, cible 35 (palier « forte prevision ») pour un besoin du bilan\n// de 28,6 %. Le palier n'est plus qu'un REPLI quand le bilan est absent,\n// perime ou degrade. Bornes : le plancher dur en bas, 90 % en haut.\nconst batteryMorningTargetPct = balanceMorningTargetApplied\n ? Math.min(90, Math.max(batteryHardFloorPct, params.balanceRequiredSocPct))\n : batteryMorningTargetBasePct;\n\nconst maxBatteryDischargePoints =\n nightSessionActive && Number.isFinite(nightSessionStartSocPct)\n ? Math.max(0, nightSessionStartSocPct - batteryMorningTargetPct)\n : 0;\n\nconst gridTakeoverTriggerPct =\n Math.max(batteryHardFloorPct, batteryMorningTargetPct);\n\nconst batteryBelowHardFloor =\n parsed.soc <= batteryHardFloorPct;\n\nconst batteryBelowMorningTarget =\n parsed.soc < batteryMorningTargetPct;\n\nconst batteryAtOrBelowGridTakeoverTrigger =\n parsed.soc <= gridTakeoverTriggerPct;\n\nconst batteryKwhMissingToTarget =\n nightSessionActive\n ? Math.max(\n 0,\n ((batteryMorningTargetPct - parsed.soc) / 100) * cfg.batteryUsableCapacityKwh\n )\n : 0;\n\nconst batteryRecoveryMinutesNeeded =\n nightSessionActive && params.evcsNightBatteryRecoveryPowerKw > 0\n ? (batteryKwhMissingToTarget / params.evcsNightBatteryRecoveryPowerKw) * 60\n : 0;\n\n// 01/10/2026 : l'urgence avant depart exige qu'il MANQUE de l'energie. Sans\n// cette garde, « 0 min de recharge + 30 min de marge >= 30 min » la declenchait\n// a 06:00 chaque jour de depart, batterie au-dessus de sa cible (01/10 : SOC 37,\n// cible 35, EDF ferme 06:00-06:38 pour rien ; idem 21, 22, 25 et 27/09).\nconst batteryRecoveryUrgent =\n nightSessionActive &&\n batteryKwhMissingToTarget > 0 &&\n Number.isFinite(minutesUntilDeparture) &&\n minutesUntilDeparture >= 0 &&\n (\n batteryRecoveryMinutesNeeded + params.evcsNightDepartureSafetyMin\n >= minutesUntilDeparture\n );\n\n// P2 ANTI-CRENEAUX : continuité du takeover réseau nocturne.\n// Une fois le réseau HC engagé pendant une session nuit, on y reste\n// jusqu'à la fin de la session (fenêtre/débranchement) ou batterie quasi pleine.\n// Évite le cycle batterie->jeep->réseau->batterie (pertes + charge hachée).\nconst NIGHT_TAKEOVER_RELEASE_SOC_PCT = 90;\nconst nightTakeoverPrev =\n (input.night_takeover_prev ?? 'off').toString().trim().toLowerCase() === 'on';\n\n// P8 (07/08/2026) : \"charge terminée\" COLLANT via l'état surplus du cycle\n// précédent (helper input_text.victron_evcs_surplus_state relu par le snapshot).\n// Chaîne : VDF stoppe la charge (state vehicle_full_or_paused) → ce flag s'allume\n// → DECISION émet night_session_done_idle → le flag s'auto-entretient jusqu'au\n// débranchement (state vehicle_not_connected) ou à la fin de la fenêtre nuit.\nconst prevSurplusState = (input.prev_surplus_state ?? '').toString().trim().toLowerCase();\n\n// P11 (12/08/2026) : RESET du collant \"véhicule plein\" au CHANGEMENT DE\n// VÉHICULE. Incident 12/08 : JEEP chargée → night_recovery_done_idle collant ;\n// débranchement JEEP + branchement VW → l'état collant a survécu et bloquait\n// la VW (« charge terminée » = celle de la JEEP, pas de la VW).\n// Signal fiable : sensor.victron_evcs_charging_time repart de 0 à chaque\n// nouvelle connexion physique. Sous ~90 s → session neuve → on neutralise le\n// collant pour laisser le système RETESTER le véhicule fraîchement branché.\nconst P11_FRESH_SESSION_MAX_SEC = 90;\n// P11-bis (19/09/2026) : le charging_time de la borne repart de 0 sur un\n// debranchement FRANC, mais reste FIGE sur un swap trop rapide (14 s le 19/09 :\n// VW debranchee, JEEP branchee, borne gelee a l'etat VW). P11 restait alors\n// aveugle et le collant \"vehicule plein\" de la VW bloquait la JEEP. Le\n// last_changed du capteur de connexion, lui, a bien bouge sur ce swap : c'est\n// un SECOND signal de fraicheur, independant de la borne. Meme fenetre (90 s =\n// 3 cycles de 30 s, assez pour capter un (re)branchement entre deux cycles).\n// Cout d'un faux positif (glitch MQTT sub-seconde) : au pire un retest, que le\n// statut 3 / VDF re-latche aussitot si le vehicule est vraiment plein.\nconst sessionFresh =\n parsed.evcsVehicleConnected &&\n (\n params.resetBorneRequested || // echappatoire manuelle (bouton reset borne)\n (Number.isFinite(parsed.evcsChargingTimeSec) &&\n parsed.evcsChargingTimeSec <= P11_FRESH_SESSION_MAX_SEC) ||\n (Number.isFinite(parsed.evcsVehicleConnectedAgeSec) &&\n parsed.evcsVehicleConnectedAgeSec <= P11_FRESH_SESSION_MAX_SEC)\n );\n\n// Fix D (08/08/2026) : le statut natif EVCS \"charged\" (3) vaut aussi fin de\n// charge, sans attendre le VehicleDemandFilter (désarmé si la session a été\n// stoppée par une garde, ex battery_current_hard_stop → détection tardive\n// observée la nuit du 07-08/08).\n// Fix E (09/08/2026) : garde puissance ≤ 300 W pour ne jamais latcher \"fini\"\n// sur un glitch de statut pendant une vraie charge. NB : le statut retombe\n// (3→1/6) dès l'ouverture du relais → la persistance est assurée par l'état\n// collant écrit par DECISION pendant la session (voir Fix E DECISION).\n// P11 : sur une session neuve, on ignore aussi un éventuel statut 3 résiduel\n// (le temps que la borne renégocie avec le nouveau véhicule).\n// Fix H (07/09/2026) : un statut \"charged\" n'est pas cru sur parole.\n// La borne l'a annonce le 07/09 a 14:28:18 apres 2 min 42 s de charge et\n// 0,10 kWh livre, ce qui a latche `day_vehicle_full_idle` pour deux heures\n// alors que le vehicule pouvait charger (il a repris a 16:30 et tenu).\n//\n// Aucun signal ELECTRIQUE instantane ne separe une vraie fin de charge d'une\n// interruption : mesure a la seconde des deux coupures, elles sont\n// superposables (VW 06/09, vraie fin : 1534 -> 550 -> 82 -> 11 -> 0 W ;\n// JEEP 07/09, faux positif : 1626 -> 741 -> 100 -> 13 -> 0 W). Seule la\n// DUREE de la session les distingue, et le systeme porte deja la grandeur qui\n// dit ce qu'est une charge etablie : min_run_minutes (29 min en production).\n//\n// Deux formes de \"charged\" sont credibles, et une seule ne l'est pas :\n// energie de session nulle -> le vehicule etait plein en arrivant (P10)\n// session >= min_run_minutes -> charge menee a son terme\n// entre les deux -> charge INTERROMPUE : on ne latche pas\n// Convergence : une session interrompue redemarre a energie nulle, donc un\n// second \"charged\" immediat retombe dans le premier cas et latche.\nconst chargedSessionEnergyKwh = Math.max(0, parsed.evcsSessionEnergyKwh || 0);\nconst chargedSessionEstablished =\n Number.isFinite(parsed.evcsChargingTimeSec) &&\n parsed.evcsChargingTimeSec >= params.minRunMinutes * 60;\nconst chargedCredible =\n chargedSessionEnergyKwh <= 0 || chargedSessionEstablished;\n\nconst evcsStatusCharged =\n !sessionFresh &&\n Number.parseFloat(parsed.evcsStatusRaw) === 3 &&\n Math.max(0, parsed.evcsAcPowerW || 0) <= 300 &&\n chargedCredible;\n\nconst vehicleChargeCompleted =\n nightSessionActive &&\n !sessionFresh &&\n (\n prevSurplusState === 'vehicle_full_or_paused' ||\n prevSurplusState === 'night_session_done_idle' ||\n prevSurplusState === 'night_recovery_done_idle' ||\n prevSurplusState === 'day_vehicle_full_idle' ||\n evcsStatusCharged\n );\n\n// Fix A (08/08/2026) : recovery batterie post-charge = UNE SEULE passe.\n// Sans flag collant, SOC==cible est un point dégénéré (engage soc<=cible,\n// release soc>cible, l'ACM recharge pile à la cible) → rebond EDF ON/OFF\n// période ~10 min observé la nuit du 07-08/08. Une fois la cible atteinte,\n// l'état 'night_recovery_done_idle' (auto-entretenu via prev_surplus_state)\n// coupe le takeover. Ré-engagement uniquement sous cible − 2 pts (bande),\n// hard floor et fenêtre/débranchement restent prioritaires.\n// ⚠ 'night_recovery_done_idle' ne doit matcher AUCUN pattern no-demand de\n// l'hystérésis (full_or_paused / not_requesting_power / charge_complete /\n// no_demand) — même contrainte que 'night_session_done_idle'.\n// 27/08/2026 : la bande de 2 points fabriquait un oscillateur. Relevé de la\n// nuit du 26-27/08 (cible 35) : engagement à SOC 33, relâchement à 35, trois\n// cycles EDF de 52, 79 et 25 min, 2,5 kWh importés pour rien un matin de grand\n// soleil ; la nuit du 20-21/08 en comptait six. Deux points de SOC valent\n// ~0,34 kWh, soit une demi-heure de conso nocturne : ce n'est pas une\n// hystérésis, c'est la période d'une horloge — d'autant plus régulière que la\n// charge de nuit est parfaitement plate.\n// La recovery batterie post-charge est donc UNE SEULE passe par session de\n// nuit, ce qui était déjà l'intention affichée du Fix A (\"relâché\n// DÉFINITIVEMENT\"). Aucun seuil de ré-engagement ici : le plancher dur reste\n// un terme indépendant de nightGridTakeoverNeeded (voir plus bas), et\n// l'hystérésis réseau garde sa propre bande 20/30. Le vrai critère de\n// ré-engagement est un budget d'énergie restant à couvrir, pas un delta de\n// SOC : il appartient au bilan énergétique, pas à une constante de plus ici.\nconst nightRecoveryDonePrev = prevSurplusState === 'night_recovery_done_idle';\nconst nightRecoveryDone =\n vehicleChargeCompleted &&\n (parsed.soc >= batteryMorningTargetPct || nightRecoveryDonePrev);\n\n// =====================================================\n// P9 (09/08/2026) : CHARGE RÉSEAU AVANT DÉPART, HORS FENÊTRE HC.\n// Cas VW : départ 08:50 > fin HC 07:24 → fenêtre morte où le véhicule\n// sous-chargé attendait un solaire insuffisant (observé le 09/08 : advisor\n// force_grid_now, divergence n°10). Si le toggle est ON et que le départ du\n// véhicule SÉLECTIONNÉ est dans ≤ 120 min : réseau autorisé (grid_hc_allowed)\n// jusqu'à fin de charge. L'arrêt réutilise TOUTE la mécanique anti-rebond :\n// VDF → vehicle_full_or_paused → état collant 'departure_done_idle'\n// (réécrit chaque cycle, patterns hystérésis sûrs, coupure immédiate E2).\n// =====================================================\nconst P9_DEPARTURE_LEAD_MIN = 120;\n\nconst departureWindowActive =\n params.allowGridDepartureUrgent &&\n parsed.evcsVehicleConnected &&\n !inNightHcWindow &&\n Number.isFinite(minutesUntilDeparture) &&\n minutesUntilDeparture >= 0 &&\n minutesUntilDeparture <= P9_DEPARTURE_LEAD_MIN;\n\nconst departureDonePrev =\n prevSurplusState === 'departure_done_idle' ||\n (!inNightHcWindow && prevSurplusState === 'vehicle_full_or_paused');\n\nconst departureChargeDone =\n departureWindowActive &&\n !sessionFresh &&\n (departureDonePrev || evcsStatusCharged);\n\nconst departureUrgentActive =\n departureWindowActive &&\n !departureChargeDone;\n\n// =====================================================\n// P10 (09/08/2026) : MÉMOIRE \"VÉHICULE PLEIN\" EN JOURNÉE.\n// Observé le 09/08 09:49 : VW pleine branchée depuis 07:00, le surplus\n// solaire retentait une charge (statut \"Charged\", 0 W) toutes les ~15 min\n// toute la journée (usure relais, bruit). Les états collants P8/P9 étaient\n// scopés nuit/fenêtre départ. Ici : une fois le véhicule vu plein (statut 3\n// ou VDF), état collant 'day_vehicle_full_idle' jusqu'au DÉBRANCHEMENT.\n// - hérite des états \"done\" de la nuit (07:24 → pas de re-tentative) ;\n// - la nuit hérite en retour (vehicleChargeCompleted inclut day_*) ;\n// - échappatoires : force_charge_now / force_grid_now / débrancher-rebrancher.\n// =====================================================\nconst dayVehicleFullPrev =\n prevSurplusState === 'day_vehicle_full_idle' ||\n (\n !inNightHcWindow &&\n (\n prevSurplusState === 'vehicle_full_or_paused' ||\n prevSurplusState === 'night_recovery_done_idle' ||\n prevSurplusState === 'night_session_done_idle' ||\n prevSurplusState === 'departure_done_idle'\n )\n );\n\nconst dayVehicleFullIdle =\n parsed.evcsVehicleConnected &&\n !sessionFresh &&\n !inNightHcWindow &&\n !isStopMode &&\n !forceGridRequested &&\n !forceChargeRequested &&\n (dayVehicleFullPrev || evcsStatusCharged);\n\n// P8 : correction du fix 04/08 (logique inversée : \"|| vehicleChargeCompleted\"\n// GARDAIT le latch après la charge au lieu de le libérer).\nconst nightTakeoverLatched =\n nightSessionActive &&\n nightTakeoverPrev &&\n parsed.soc < NIGHT_TAKEOVER_RELEASE_SOC_PCT &&\n !vehicleChargeCompleted;\n\n// =====================================================\n// P12 — CHARGE NUIT SUR LA BATTERIE (15/09/2026)\n// =====================================================\n// Idee du proprietaire : plutot qu'acheter du reseau la nuit pour charger la\n// voiture, lui donner le solaire DEJA STOCKE, doucement, et laisser ainsi de la\n// place dans la batterie pour la production du lendemain. Payer du reseau la\n// nuit pour ecreter le solaire le lendemain, c'est perdre deux fois.\n//\n// Le mode `battery_assist_night` existe depuis toujours pour exactement ca, et\n// il est MORT depuis toujours : il exige `batteryCurrentA >= plancher de\n// decharge` (0 A), impossible la nuit puisque la maison tire sur la batterie\n// (§11 du HANDOFF, ~ -14 A). On ne cree donc pas un chemin, on rend viable\n// celui qui existe — en remplacant un test de COURANT impossible par un test\n// d'ENERGIE : rester au-dessus de ce qu'il faut pour tenir jusqu'au croisement.\n//\n// Le plancher n'est PAS une constante nouvelle : c'est `required_soc_prudent`,\n// que le bilan publie deja, en profil p80. La marge est mesuree, pas inventee.\nconst nightBatterySurplusFloorPct = Math.max(\n batteryHardFloorPct,\n Number.isFinite(params.balanceRequiredSocPrudentPct)\n ? params.balanceRequiredSocPrudentPct\n : Infinity,\n);\n\n// ⚠️ LE VERROU, ET IL N'EST PAS DECORATIF. Le plancher DESCEND pendant la nuit\n// (il reste moins de nuit a couvrir) et le SOC aussi. Mesure du 15/09 :\n//\n// voiture en charge : SOC -7,1 pt/h, plancher -3,7 pt/h -> le surplus s'epuise\n// voiture arretee : SOC -2,6 pt/h, plancher -3,7 pt/h -> le surplus REVIENT\n//\n// Sans verrou, la charge repartirait des qu'elle s'arrete : cinquieme\n// oscillateur du projet, par construction. UNE SEULE PASSE de surplus par\n// session de nuit, comme la recovery batterie depuis le 27/08.\n// BUDGET VOITURE DE LA NUIT (30/09/2026, decisions du proprietaire).\n// « Definir combien la voiture peut prendre sans utiliser EDF, et brider la\n// charge a cette valeur ou autoriser une charge complete avec EDF. »\n// Le bilan publie le budget ; il est FIGE au premier cycle de la session\n// (avec l'index du compteur de la borne) dans input_text\n// victron_evcs_night_budget = « budget|index », efface en fin de session.\n// L'arret se juge sur le COMPTEUR, qui ne fait que monter : condition\n// monotone, aucun oscillateur possible (meme logique que §40). Le plancher de\n// SOC prudent reste un second arret, jamais retire.\nconst nightBudgetPrev = (() => {\n const m = String(params.nightBudgetPrevRaw || '').match(/^\\s*([\\d.]+)\\|([\\d.]+)\\s*$/);\n return m ? { budgetKwh: Number(m[1]), refKwh: Number(m[2]) } : null;\n})();\nconst nightBudgetCaptureOk =\n nightSessionActive && !nightBudgetPrev &&\n balanceMorningTargetApplied &&\n Number.isFinite(params.balanceNightVehicleBudgetKwh) &&\n Number.isFinite(params.evcsTotalEnergyKwh);\nconst nightBudget = !nightSessionActive\n ? null\n : nightBudgetPrev || (nightBudgetCaptureOk\n ? { budgetKwh: Math.max(0, params.balanceNightVehicleBudgetKwh), refKwh: params.evcsTotalEnergyKwh }\n : null);\nconst nightBudgetToWrite = nightSessionActive\n ? (nightBudgetCaptureOk ? `${Math.round(nightBudget.budgetKwh * 100) / 100}|${nightBudget.refKwh}` : null)\n : (nightBudgetPrev ? '' : null);\nconst nightBudgetDeliveredKwh = nightBudget && Number.isFinite(params.evcsTotalEnergyKwh)\n ? Math.max(0, params.evcsTotalEnergyKwh - nightBudget.refKwh)\n : null;\nconst nightBudgetReached =\n !!nightBudget && nightBudgetDeliveredKwh !== null &&\n nightBudgetDeliveredKwh >= nightBudget.budgetKwh - 0.05;\nconst nightChargeComplete = params.nightChargeMode === 'complete';\n\n// Le verrou ne vaut QUE pendant la session de nuit (01/10/2026). Ecrit sous la\n// forme `prev || …`, il restait vrai pour toujours une fois pose ; invisible\n// tant que son ecriture n'atteignait pas HA (jamais relayee du 15/09 au 01/10).\n// Fin de session (debranchement ou fin de fenetre HC) = verrou leve.\nconst nightBatterySurplusExhausted =\n nightSessionActive && (\n params.nightBatteryDonePrev ||\n parsed.soc <= nightBatterySurplusFloorPct ||\n nightBudgetReached\n );\n\nconst nightBatterySurplusAvailable =\n nightSessionActive &&\n params.nightBatteryChargeEnabled &&\n balanceMorningTargetApplied &&\n Number.isFinite(params.balanceRequiredSocPrudentPct) &&\n !vehicleChargeCompleted &&\n // Le plancher est teste UNE SEULE FOIS, dans `nightBatterySurplusExhausted`.\n // Le retester ici serait redondant — et une redondance ne se teste pas : la\n // mutation du 15/09 l'a montre en ne cassant aucun scenario.\n !nightBatterySurplusExhausted;\n\n// La batterie d'abord, le reseau quand le surplus est epuise. C'est la soupape\n// decrite par le proprietaire — « quitte a fermer EDF entre 6h et 7h24 pour\n// completer » — et elle devient automatique : l'epuisement du surplus rend\n// `nightGridHcPreferred` vrai, donc P7 prend le relais.\n// « Sans EDF » (defaut) : la voiture ne prend JAMAIS le reseau la nuit ; une\n// fois le budget batterie atteint (ou sans budget exploitable), elle attend le\n// soleil. « Charge complete » : EDF en HC finit la charge, comme avant.\nconst nightGridHcPreferred =\n nightSessionActive &&\n params.allowGridHcNight &&\n nightChargeComplete &&\n !vehicleChargeCompleted &&\n !nightBatterySurplusAvailable;\n\nconst nightCarWaitsNoGrid =\n nightSessionActive &&\n !nightChargeComplete &&\n !vehicleChargeCompleted &&\n !nightBatterySurplusAvailable;\n\nconst batteryAssistAllowed =\n nightSessionActive &&\n !nightGridHcPreferred &&\n !nightCarWaitsNoGrid &&\n parsed.soc > batteryHardFloorPct &&\n parsed.soc > gridTakeoverTriggerPct &&\n !nightTakeoverLatched &&\n !batteryRecoveryUrgent;\n\n// =====================================================\n// SESSION RESEAU UNIQUE DE LA NUIT (03/09/2026)\n// =====================================================\n// §26 avait pose l'objectif — « une session reseau unique et dimensionnee » —\n// et designe le bon critere : « un budget d'energie restant a couvrir, il\n// appartient au bilan energetique ». Le bilan est cable depuis ce soir.\n//\n// CE QUI A ETE MESURE AVANT D'ECRIRE CECI, et qui a dicte la conception :\n// la conversion kWh -> points de SOC du bilan est une constante unique\n// (15 kWh). La jauge reelle vaut ~17 kWh au-dessus de 42 % (0,164 a\n// 0,171 kWh/point sur trois fenetres independantes) mais s'effondre en\n// dessous : 12,1 kWh entre 45 et 22 %, 8,2 kWh entre 33 et 20 %. Un point de\n// SOC vaut donc MOITIE MOINS dans la bande ou la nuit se termine, et\n// `required_soc` y est systematiquement trop BAS — c'est pourquoi la batterie\n// est restee sous la ligne toute la nuit du 02-03/09, en descendant 1,75 fois\n// plus vite qu'elle.\n//\n// CONSEQUENCE DE CONCEPTION : le relachement ne doit PAS etre un seuil de SOC.\n// Il porterait sur une grandeur fausse, et tout seuil que le SOC repasse\n// ensuite rouvre l'oscillateur eteint le 27/08.\n//\n// Donc : ENGAGEMENT sur le bilan (batterie sous la cible), RELACHEMENT a la\n// FIN DE LA FENETRE. L'oscillation devient structurellement impossible : la\n// condition de sortie est monotone, une fenetre ne se ferme qu'une fois. Le\n// bit d'engagement est porte par\n// input_boolean.victron_evcs_night_grid_session_committed — sans lui le\n// systeme reevaluerait a chaque cycle et retomberait dans une bande.\n//\n// CE QUE CA FAIT CONCRETEMENT : l'ACM recharge jusqu'a la cible puis tient la\n// batterie a 2 A (evcs_takeover_hold) ; la maison finit la nuit sur le reseau\n// en HEURES CREUSES au lieu de vider la reserve, et la batterie arrive a\n// l'aube pres de sa cible, donc avec de la place pour le solaire du matin —\n// au lieu d'etre a 20 % avec le reseau encore ouvert a 10:33 (03/09).\n//\n// PORTEE : uniquement quand le bilan est exploitable, et uniquement pendant\n// une session nuit (fenetre HC + vehicule branche). Sinon, comportement\n// historique inchange. Le bit se nettoie tout seul a la fin de la session.\nconst nightGridSessionCommittedPrev =\n (input.night_grid_session_committed_prev ?? 'off').toString().trim().toLowerCase() === 'on';\n\nconst nightGridSessionUncovered =\n balanceMorningTargetApplied &&\n (batteryBelowMorningTarget || params.balanceEcsNuit);\n\n// ⚠️ SANS VEHICULE AUSSI (12/09/2026).\n// Nuit du 11-12/09, voiture rendue la veille : le bilan a reclame entre 92 %\n// et 59 % de SOC a CHAQUE calcul de la nuit, `reseau_necessaire` vrai partout,\n// et rien ne s'est passe. SOC bloque a 20 % de 23:43 a l'aube, consigne reseau\n// figee a 700 W depuis 18:36 — soit exactement la maison. L'hysteresis avait\n// bien ferme le contacteur (SOC <= 20), mais fermer le contacteur ne suffit\n// pas : il faut DEMANDER la puissance (lecon du 01/09). Personne ne la\n// demandait, parce que la session exigeait un vehicule branche.\n//\n// Or le besoin n'a rien a voir avec la voiture : c'est la maison qui doit\n// traverser la nuit, et le bilan sait exactement ce qu'il lui faut. La\n// fenetre HC suffit. Le relachement reste la FIN DE FENETRE, donc monotone,\n// donc toujours immune a l'oscillation (§40).\n//\n// Sans cette correction, le cout se paye le matin : a 07:24 le SOC est encore\n// sous le seuil OFF de l'hysteresis (30), donc le contacteur reste ferme et\n// la maison passe au PLEIN TARIF jusqu'au croisement PV — le 1,49 kWh deja\n// consigne le 03/09.\n// ⚠️ LA FENETRE DE MIDI EST AUSSI UNE FENETRE D'HEURES CREUSES (18/09/2026).\n// L'engagement de session etait gate sur `night_hc` seul, parce que §40 traitait\n// la nuit. Consequence mesuree : la charge HC manuelle demandee par le\n// proprietaire ne pouvait RIEN faire a midi — le plancher relevait bien la cible\n// du bilan, mais aucune session ne s'engageait, donc le contacteur ne se fermait\n// pas et la consigne restait au repos. Le reglage aurait releve le plancher du\n// soir (donc bloque la voiture) sans jamais charger la batterie.\n//\n// ⚠️ ET LE CRITERE N'EST PAS LE MEME AUX DEUX FENETRES. La nuit, on compare le\n// SOC a `batteryMorningTargetPct`, qui contient le palier de prevision\n// (35/55/85). A midi ce palier n'a aucun sens : il vaut 35 par beau temps, donc\n// un SOC de 30 % en plein soleil engagerait une session reseau — c'est-a-dire la\n// substitution du solaire par le reseau qu'on a mis des semaines a eteindre.\n//\n// A midi le critere honnete est le SOC requis du bilan lui-meme, qui\n// s'auto-regule : le croisement PV etant passe, `energyToCoverKwh` vaut zero et\n// `required_soc` retombe au plancher dur. Releve du 17/09 a 13:51, plein soleil :\n// `required_soc` = 22,0 pour `energy_to_cover` = 0,0. Il ne remonte qu'un jour\n// sombre — ou quand le proprietaire pose un plancher manuel, ce qui est\n// exactement le cas d'usage.\n//\n// ET LA VOITURE (28/09/2026, strategie du proprietaire) : « JEEP branchee\n// engage le reseau en HC midi si l'analyse de production le justifie ». Le\n// bilan fait l'analyse (besoin saisi contre solaire disponible avant le depart,\n// batterie gardee pour tenir jusqu'a 01:24) et publie un verdict ; on le relaie,\n// sous les memes gardes de fraicheur et de statut que la cible.\nconst middayVehicleGridNeeded =\n balanceMorningTargetApplied &&\n params.balanceVehicleGridNeeded &&\n parsed.evcsVehicleConnected;\n\n// ET LE CHAUFFE-EAU (07/10/2026, proprietaire) : un jour faible, l'eau chaude\n// se fait en heures creuses sur EDF, a PLEINE consommation toute la fenetre\n// (douches de 06:00 et 20:00 a 65 °C ; le 06/10 le ballon avait pris 5,4 kWh\n// de solaire de 10 h a 15 h, l'energie qui manquait a la batterie). Le bilan\n// decide une fois par jour (input_boolean.evcs_balance_ecs_nuit) ; le workflow\n// ECS n'alimente le ballon que si la session est engagee ET le contacteur\n// ferme. Memes gardes de fraicheur et de statut que la cible.\nconst hcEcsGridNeeded =\n balanceMorningTargetApplied && params.balanceEcsNuit;\n\nconst middayGridSessionUncovered =\n balanceMorningTargetApplied &&\n (\n (Number.isFinite(params.balanceRequiredSocPct) &&\n parsed.soc < params.balanceRequiredSocPct) ||\n middayVehicleGridNeeded ||\n hcEcsGridNeeded\n );\n\nconst gridSessionInHcWindow =\n profile === 'night_hc' || profile === 'midday_hc_or_solar';\n\nconst gridSessionUncovered =\n profile === 'night_hc' ? nightGridSessionUncovered : middayGridSessionUncovered;\n\nconst nightGridSessionCommitted =\n gridSessionInHcWindow &&\n (gridSessionUncovered || nightGridSessionCommittedPrev);\n\nconst nightGridTakeoverNeeded =\n nightGridSessionCommitted ||\n (\n nightSessionActive &&\n (\n nightGridSessionCommitted ||\n nightGridHcPreferred ||\n batteryBelowHardFloor ||\n (\n // Fix A : après fin de charge, le seul motif réseau restant est la\n // recovery batterie, et elle ne se ré-engage pas une fois done.\n vehicleChargeCompleted\n ? !nightRecoveryDone\n : (\n batteryAtOrBelowGridTakeoverTrigger ||\n batteryRecoveryUrgent ||\n nightTakeoverLatched\n )\n )\n )\n );\n\nconst nightPreferredSourceMode =\n !nightSessionActive\n ? 'blocked'\n : vehicleChargeCompleted\n ? 'blocked'\n // « Sans EDF » : la voiture ne prend JAMAIS le reseau la nuit, MEME quand\n // EDF se ferme pour la batterie (plancher dur, cible du matin, session du\n // bilan, verrou). Decision du proprietaire le 04/10 (option A) : la nuit du\n // 03-04/10, EDF ferme a 02:57 pour la batterie, l'ancienne exception a\n // laisse la JEEP prendre 4,7 kWh au reseau jusqu'au plein. La protection\n // du materiel reste entiere : EDF se ferme quand meme (takeover), seule la\n // voiture attend le soleil.\n : nightCarWaitsNoGrid\n ? 'blocked'\n : batteryAssistAllowed\n ? 'battery_assist_night'\n : (\n batteryBelowHardFloor ||\n batteryBelowMorningTarget ||\n batteryRecoveryUrgent\n )\n ? 'grid_forced'\n : 'grid_hc_allowed';\n\n// « Sans EDF » ET EDF necessaire pour la BATTERIE (04/10/2026, option A du\n// proprietaire) : la voiture reste bloquee, mais la batterie a sa session\n// reseau. Sans ce drapeau, la fermeture d'EDF et la consigne de recharge, qui\n// suivent la source de la VOITURE, seraient perdues avec elle (golden\n// budget-sans-edf-plancher-dur-edf-protege, depart-matin-…-sous-la-cible).\nconst nightBatteryGridOnly =\n nightSessionActive &&\n nightCarWaitsNoGrid &&\n nightGridTakeoverNeeded;\n\nconst batteryRecoveryAllowedNow =\n nightSessionActive &&\n nightGridTakeoverNeeded &&\n (\n batteryBelowMorningTarget ||\n batteryRecoveryUrgent\n );\n\nconst batteryTargetReached =\n nightSessionActive &&\n parsed.soc >= batteryMorningTargetPct;\n\n// =====================================================\n// PROBES / SOLAR POTENTIAL\n// =====================================================\n// IMPORTANT:\n// On ne met plus effectivePvStartMinW à 0 en force_charge_now,\n// sinon la nuit 0W >= 0W ferait croire à un faux signal solaire.\nconst effectivePvStartMinW = params.pvStartMinW;\n\nconst morningProbeEligible =\n profile === 'morning_solar' &&\n params.probeEnabled &&\n parsed.pvPowerAvg10mW >= params.morningProbePvAvgMinW &&\n parsed.pvTrendW >= params.morningProbePvTrendMinW &&\n parsed.soc >= params.morningSocMin &&\n parsed.batteryCurrentA >= params.morningBatteryMaxDischargeA;\n\nconst middayProbeEligible =\n profile === 'midday_hc_or_solar' &&\n params.probeEnabled &&\n parsed.pvPowerAvg10mW >= params.middayProbePvAvgMinW &&\n parsed.soc >= params.middaySocMin &&\n parsed.batteryCurrentA >= params.middayBatteryMaxDischargeA;\n\nconst afternoonProbeEligible =\n profile === 'outside_window' &&\n params.probeEnabled &&\n parsed.soc >= params.afternoonProbeSocMin &&\n parsed.batteryCurrentA >= params.afternoonBatteryMaxDischargeA &&\n parsed.pvPowerAvg10mW >= params.afternoonProbePvAvgMinW;\n\nconst cloudDebtOkForPotential = params.cloudDebtPrevKwh <= params.cloudDebtMaxKwh;\nconst batteryOkForPotential = parsed.batteryCurrentA >= -Math.max(1, params.batteryNeutralBandA);\n\n// P3 PROVOCATION PV (installation sans injection) :\n// Batteries quasi pleines => le MPPT bride la production à la conso maison.\n// Les seuils PV observés, le forecast buffer et la cloud debt ne veulent\n// alors plus rien dire (la dette ne peut d'ailleurs plus se rembourser,\n// batterie pleine = courant nul). On autorise solar_potential sur la seule\n// santé batterie : SOC très haut + batterie non déchargée.\n// Les gardes aval (BATTERY_CLOUD_LOCKS / DECISION) restent actives.\nconst PV_PROVOCATION_SOC_MIN = 90;\nconst pvProvocationEligible =\n parsed.soc >= PV_PROVOCATION_SOC_MIN &&\n batteryOkForPotential;\n\nconst isHighBatteryForPotential =\n pvProvocationEligible ||\n (\n parsed.soc >= 85 &&\n forecastBufferKwh > 0 &&\n cloudDebtOkForPotential &&\n batteryOkForPotential\n );\n\nconst potentialAllowed = userStrategy !== 'auto_solar';\n\n// IMPORTANT:\n// On impose un vrai PV > 0 pour le test sur pvPowerW.\n// Ça évite qu'en force_charge_now la nuit, 0W soit pris pour un signal solaire.\nconst strongSolarSignal =\n (\n parsed.pvPowerW > 0 &&\n parsed.pvPowerW >= effectivePvStartMinW\n ) ||\n parsed.pvPowerAvg10mW >= params.pvHoldMinW ||\n (departurePriorityActive && parsed.pvPowerAvg10mW >= params.pvHoldMinW);\n\n// =====================================================\n// SOURCE MODE\n// SÉMANTIQUE CORRIGÉE:\n// - force_charge_now bypass les fenêtres\n// - mais ne devient jamais grid_forced à lui seul\n// - outside_window = pas de réseau programmé\n// - la charge locale peut rester autorisée\n// =====================================================\nlet sourceMode = 'blocked';\n\nif (isStopMode) {\n sourceMode = 'blocked';\n}\n\nelse if (forceGridRequested) {\n // Demande explicite de réseau.\n sourceMode = 'grid_forced';\n}\n\nelse if (forceChargeRequested) {\n // IMPORTANT:\n // force_charge_now = intention de charger maintenant,\n // sans exiger le réseau.\n\n if (strongSolarSignal) {\n sourceMode = 'solar_only';\n }\n\n else if (nightSessionActive && batteryAssistAllowed) {\n sourceMode = 'battery_assist_night';\n }\n\n else if (isHighBatteryForPotential && potentialAllowed) {\n sourceMode = 'solar_potential';\n }\n\n else if (profile === 'midday_hc_or_solar' && middayProbeEligible) {\n sourceMode = 'hybrid_probe';\n }\n\n else if (profile === 'morning_solar' && morningProbeEligible) {\n sourceMode = 'hybrid_probe';\n }\n\n else {\n // Fallback manuel local :\n // on n'impose pas le grid, on laisse l'étage aval décider\n // selon les garde-fous physiques / batterie.\n sourceMode = 'solar_potential';\n }\n}\n\nelse if (profile === 'night_hc') {\n if (nightSessionActive) {\n sourceMode = nightPreferredSourceMode;\n } else {\n sourceMode = 'blocked';\n }\n}\n\nelse if (profile === 'midday_hc_or_solar' || profile === 'morning_solar') {\n // Session de midi engagee et contacteur ferme : la voiture charge sur le\n // reseau avec la batterie (« batterie et voiture ensemble », 28/09), meme au\n // soleil. La consigne garde son credit solaire (HA_SERVICE_CALLS_OUTPUT) :\n // elle ne preleve au reseau que ce que les chargeurs ne donnent pas.\n if (profile === 'midday_hc_or_solar' && nightGridSessionCommitted && parsed.gridAvailable) {\n sourceMode = 'grid_hc_allowed';\n }\n\n else if (strongSolarSignal) {\n sourceMode = 'solar_only';\n }\n\n else if (isHighBatteryForPotential && potentialAllowed) {\n sourceMode = 'solar_potential';\n }\n\n else if (profile === 'midday_hc_or_solar' && middayProbeEligible) {\n sourceMode = 'hybrid_probe';\n }\n\n else if (profile === 'morning_solar' && morningProbeEligible) {\n sourceMode = 'hybrid_probe';\n }\n\n else if (profile === 'midday_hc_or_solar' && params.allowGridHcMidday && parsed.gridAvailable) {\n sourceMode = 'grid_hc_allowed';\n }\n\n else {\n sourceMode = 'blocked';\n }\n}\n\nelse if (profile === 'evening_high_soc') {\n if (strongSolarSignal) {\n sourceMode = 'solar_only';\n }\n\n else if (isHighBatteryForPotential && potentialAllowed) {\n sourceMode = 'solar_potential';\n }\n\n else {\n sourceMode = 'blocked';\n }\n}\n\nelse if (profile === 'outside_window') {\n if (strongSolarSignal) {\n sourceMode = 'solar_only';\n }\n\n else if (isHighBatteryForPotential && potentialAllowed) {\n sourceMode = 'solar_potential';\n }\n\n else if (afternoonProbeEligible) {\n sourceMode = 'hybrid_probe';\n }\n\n else {\n // IMPORTANT:\n // hors fenêtre = pas de réseau programmé.\n // Donc on ne bascule plus vers grid_forced juste parce que le réseau existe.\n sourceMode = 'blocked';\n }\n}\n\n// P9 : le réseau avant départ prend le pas sur les modes spéculatifs du jour\n// (blocked / solar_potential / hybrid_probe), mais JAMAIS sur le solaire réel\n// (solar_only), ni sur stop / force_* (déjà traités en tête de cascade).\nif (\n departureUrgentActive &&\n !isStopMode &&\n !forceGridRequested &&\n !forceChargeRequested &&\n profile !== 'night_hc' &&\n sourceMode !== 'solar_only'\n) {\n sourceMode = 'grid_hc_allowed';\n}\n\n// P9 : véhicule plein pendant la fenêtre départ → aucune source.\nif (departureChargeDone && profile !== 'night_hc' && !isStopMode && !forceGridRequested && !forceChargeRequested) {\n sourceMode = 'blocked';\n}\n\n// P10 : véhicule plein en journée (mémoire collante) → aucune source,\n// y compris solaire (charger un véhicule plein ne sert à rien).\nif (dayVehicleFullIdle && profile !== 'night_hc') {\n sourceMode = 'blocked';\n}\n\n// =====================================================\n// PLAFOND DE COURANT EVCS — calculé APRÈS sourceMode (31/08/2026)\n// =====================================================\n// P5 : plafond majoré (input_number.evcs_plafond_courant_edf_on, jusqu'à 28 A\n// en BOOST) quand le réseau alimente réellement AC-IN ; sinon plafond câblé\n// prudent, celui que les deux MultiPlus peuvent porter SEULS.\n//\n// SÉQUENCE D'OUVERTURE (31/08/2026) — pourquoi ce bloc a été déplacé ici.\n// Le plafond majoré n'est légitime que tant que le réseau est là. Or c'est\n// l'hystérésis (cycle 1 min) qui ouvre le contacteur, et le contrôleur EVCS ne\n// l'apprenait qu'après coup : pendant ce temps les deux onduleurs portaient\n// seuls la voiture au courant majoré plus la maison — le mécanisme du W-105.\n//\n// Correctif sans poignée de main entre les deux automates : le plafond majoré\n// exige désormais que l'EVCS DEMANDE lui-même le réseau. Sa demande tombe au\n// cycle où la fenêtre se ferme (30 s), alors que l'hystérésis ne peut ouvrir\n// qu'ensuite (override disparu ET min_on écoulé, cycle 1 min). L'EVCS baisse\n// donc AVANT, mécaniquement, sans rien coordonner.\n// Le calcul doit venir après sourceMode ; rien entre les deux ne l'utilisait.\nconst evcsGridCeilingAllowed =\n parsed.acInAvailable &&\n (\n sourceMode === 'grid_forced' ||\n sourceMode === 'grid_hc_allowed' ||\n forceGridRequested\n );\n\nconst evcsDedicatedCapBaseW = Math.max(0, cfg.evcsDedicatedMaxW - cfg.systemReserveW);\nconst safeEvcsByDedicatedCapW = evcsGridCeilingAllowed\n ? Math.max(0, params.gridCurrentMaxA * acVoltageRef)\n : evcsDedicatedCapBaseW;\nconst safeEvcsByVebusW = Math.max(0, totalVebusSoftCeilingW - vebusNonEvcsW - cfg.systemReserveW);\nconst safeEvcsCombinedW = Math.max(0, Math.min(safeEvcsByDedicatedCapW, safeEvcsByVebusW));\n\nconst systemCapCurrentA = Math.max(0, Math.floor(safeEvcsCombinedW / acVoltageRef));\nconst physicalCapA = clamp(\n systemCapCurrentA,\n 0,\n evcsGridCeilingAllowed\n ? Math.max(params.currentMaxAutoA, params.gridCurrentMaxA)\n : params.currentMaxAutoA\n);\nconst canRunAtMinCurrent = physicalCapA >= params.currentMinA;\n\nconst operatingMode = profile;\n\n// =====================================================\n// OUTPUT\n// =====================================================\nreturn [{\n json: {\n ...prev,\n\n strategy: {\n userStrategy,\n isStopMode,\n isForceMode,\n forceGridRequested,\n forceChargeRequested,\n },\n\n lifecycle: {\n minutesSinceStart,\n minutesSinceStop,\n secondsSinceCurrentChange,\n minutesSinceProbeStart,\n minRunElapsed,\n minStopElapsed,\n currentCooldownElapsed,\n minutesUntilDeparture,\n },\n\n windows: {\n inNightHcWindow,\n inMorningWindow,\n inMiddayWindow,\n inEveningWindow,\n // Temps restant dans la fenetre d'HEURES CREUSES courante (nuit ou midi),\n // null hors de toute fenetre creuse. Consomme par le dimensionnement de\n // la consigne reseau : recharger la batterie sur le reseau n'a de sens\n // economique que la-dedans, donc `null` y vaut « pas de part batterie »\n // sans test supplementaire.\n minutesRemainingInHcWindow:\n minutesRemainingInWindow(now.minNow, params.hcNightStartMin, params.hcNightStopMin)\n ?? minutesRemainingInWindow(now.minNow, params.hcMiddayStartMin, params.hcMiddayStopMin),\n },\n\n derived: {\n acVoltageRef,\n batteryVoltageRef,\n houseNonEvcsW,\n vebusNonEvcsW,\n totalVebusSoftCeilingW,\n totalVebusHardCeilingW,\n safeEvcsByDedicatedCapW,\n safeEvcsByVebusW,\n safeEvcsCombinedW,\n systemCapCurrentA,\n physicalCapA,\n canRunAtMinCurrent,\n evcsGridCeilingAllowed,\n gridSettling,\n gridTransitionAgeSec: parsed.gridTransitionAgeSec,\n endDayGuardSoc,\n energyNeededForEndDayGuardKwh,\n forecastBufferKwh,\n projectedEndSocAfterCharge,\n currentReferenceA,\n anyVebusOverload,\n anyVebusLowBattery,\n vebusAcOutSoftReached,\n vebusAcOutHardReached,\n vebusDcVoltageSoftLow,\n vebusDcVoltageHardLow,\n houseLoadHighVsHeadroom,\n },\n\n context: {\n departurePriorityActive,\n forceChargeDepartureGridOverride,\n manualChargeBypassWindows,\n manualChargeLocalRequested,\n morningProbeEligible,\n middayProbeEligible,\n afternoonProbeEligible,\n pvProvocationEligible,\n isHighBatteryForPotential,\n strongSolarSignal,\n effectivePvStartMinW,\n profile,\n operatingMode,\n sourceMode,\n },\n\n // R1 : source de vérité UNIQUE du moteur nuit.\n // BATTERY_CLOUD_LOCKS consomme cet objet, il ne recalcule plus rien.\n nightEvcsPreview: {\n nightSessionActive,\n nightSessionNeedsCapture,\n nightSessionNeedsClear,\n nightSessionStartSocPct,\n nightSessionStartAt,\n nightSessionStartMs,\n\n batteryHardFloorPct,\n batteryMorningTargetPct,\n batteryMorningTargetBasePct,\n balanceMorningTargetApplied,\n nightBatterySurplusFloorPct,\n nightBatterySurplusAvailable,\n nightBatterySurplusExhausted,\n nightBudgetKwh: nightBudget ? nightBudget.budgetKwh : null,\n nightBudgetDeliveredKwh,\n nightBudgetReached,\n nightBudgetToWrite,\n nightChargeMode: params.nightChargeMode,\n nightCarWaitsNoGrid,\n nightBatteryGridOnly,\n nightGridSessionCommitted,\n middayVehicleGridNeeded,\n nightGridSessionUncovered,\n balanceTargetAgeMin:\n balanceTargetAgeMin === null ? null : Math.round(balanceTargetAgeMin * 10) / 10,\n batteryMorningTargetRulePct,\n maxBatteryDischargePoints,\n gridTakeoverTriggerPct,\n\n batteryBelowHardFloor,\n batteryBelowMorningTarget,\n batteryAtOrBelowGridTakeoverTrigger,\n\n batteryKwhMissingToTarget,\n batteryRecoveryMinutesNeeded,\n batteryRecoveryUrgent,\n batteryRecoveryAllowedNow,\n batteryTargetReached,\n\n batteryAssistAllowed,\n nightGridTakeoverNeeded,\n nightTakeoverLatched,\n nightGridHcPreferred,\n vehicleChargeCompleted,\n nightRecoveryDone,\n nightPreferredSourceMode,\n\n // P9\n departureUrgentActive,\n departureChargeDone,\n\n // P10\n dayVehicleFullIdle,\n\n // P11\n sessionFresh,\n chargedSessionEnergyKwh,\n chargedSessionEstablished,\n chargedCredible,\n }\n }\n}];\n" }, "type": "n8n-nodes-base.code", "typeVersion": 2, diff --git a/workflows/water-heater-ecs-v1.json b/workflows/water-heater-ecs-v1.json index 04fc5e5..706cff8 100644 --- a/workflows/water-heater-ecs-v1.json +++ b/workflows/water-heater-ecs-v1.json @@ -9,7 +9,7 @@ "nodes": [ { "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 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 clamp(v, min, max) {\n return Math.min(Math.max(v, min), max);\n}\n\nfunction to2(n) {\n return String(n).padStart(2, '0');\n}\n\nfunction getParisNowParts() {\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())\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 cleanStateText(v) {\n const s = (v ?? '').toString().trim();\n const x = s.toLowerCase();\n return ['unknown', 'unavailable', 'none', 'null'].includes(x) ? '' : s;\n}\n\nconst cfg = {\n mobileNotifyDomain: 'notify',\n mobileNotifyService: 'mobile_app_iphone_16_de_tt',\n batteryUsableCapacityKwh: 17.5,\n sampleWindowMinMin: 2,\n sampleWindowMinMax: 15,\n energyByDemandKwh: {\n low: { green: 0.9, auto: 1.4, boost: 2.0 },\n normal: { green: 1.4, auto: 2.2, boost: 3.1 },\n high: { green: 2.2, auto: 3.4, boost: 4.6 },\n three_showers: { green: 3.0, auto: 4.4, boost: 5.8 }\n }\n};\n\nconst raw = $input.first()?.json ?? {};\nconst input = extractSnapshot(raw);\nconst now = getParisNowParts();\nconst nowMs = Date.now();\n\nconst soc = clamp(num(input.soc_percent, 0), 0, 100);\nconst batteryCurrentA = num(input.battery_current_a, 0);\nconst pvPowerW = Math.max(0, num(input.pv_power_w, 0));\nconst pvPowerAvg10mW = Math.max(0, num(input.pv_power_10m_avg_w, pvPowerW));\nconst houseAcPowerW = Math.max(0, num(input.house_ac_power_w, 0));\nconst gridPowerW = num(input.grid_power_w, 0);\nconst gridIsOn = low(input.grid_state || 'off') === 'on';\nconst acInAvailable = num(input.victron_acin1_available, 0) === 1;\nconst gridAvailable = gridIsOn || acInAvailable;\nconst todayRemainingKwh = Math.max(0, num(input.today_remaining_kwh, 0));\nconst tomorrowKwh = Math.max(0, num(input.tomorrow_kwh, 0));\nconst advisorEnabled = low(input.advisor_enabled || 'off') === 'on';\nconst notificationsEnabled = low(input.notifications_enabled || 'off') === 'on';\n\nconst hotWaterDemandRaw = low(input.hot_water_demand || 'normal');\nconst hotWaterDemand = ['low', 'normal', 'high', 'three_showers'].includes(hotWaterDemandRaw)\n ? hotWaterDemandRaw\n : 'normal';\n\nconst greenPowerW = Math.max(0, num(input.green_power_w, 620));\nconst autoPowerW = Math.max(0, num(input.auto_power_w, 1200));\nconst boostPowerW = Math.max(0, num(input.boost_power_w, 2000));\nconst strongForecastKwh = Math.max(0, num(input.strong_forecast_kwh, 18));\nconst mediumForecastKwh = Math.max(0, num(input.medium_forecast_kwh, 10));\nconst strongPvNowW = Math.max(0, num(input.strong_pv_now_w, 2500));\nconst mediumPvNowW = Math.max(0, num(input.medium_pv_now_w, 1500));\nconst boostMinSocStrongForecastPct = clamp(num(input.morning_boost_min_soc_strong_day, 35), 0, 100);\nconst boostMinSocMediumForecastPct = clamp(num(input.boost_min_soc_medium_day, 50), 0, 100);\nconst endOfDaySocGuard = clamp(num(input.end_of_day_soc_guard, 85), 0, 100);\nconst endOfDaySocTarget = clamp(num(input.end_of_day_soc_target, 85), 0, 100);\nconst baseLoadRemainingKwh = Math.max(0, num(input.base_load_remaining_kwh, 0));\nconst flexibleSafetyMarginKwh = Math.max(0, num(input.flexible_safety_margin_kwh, 0));\nconst acLoadsMaxSafeW = Math.max(0, num(input.ac_loads_max_safe_w, 8000));\nconst lastSignature = cleanStateText(input.last_signature);\n// Jour faible (07/10/2026) : decision du bilan, session reseau HC engagee,\n// energie du chauffe-eau sur 24 h (service d'historisation), propriete de la coupure.\nconst ecsNuit = low(input.ecs_nuit) === 'on';\nconst hcSessionCommitted = low(input.hc_session_committed) === 'on';\nconst ecs24hKwh = num(input.ecs_24h_kwh, NaN);\nconst ecsJourKwh = num(input.ecs_jour_kwh, NaN);\nconst offJourFaiblePrev = low(input.off_jour_faible_prev) === 'on';\nconst forecastSource = cleanStateText(input.forecast_source) || 'forecast_solar';\n\nreturn [{\n json: {\n meta: {\n workflow: 'water_heater_advisor',\n architecture: 'ecs_6nodes_simple_early_boost',\n node: 'ecs_normalize_inputs',\n timestamp_paris: now.stamp\n },\n cfg,\n now,\n nowMs,\n input,\n normalized: {\n soc,\n batteryCurrentA,\n pvPowerW,\n pvPowerAvg10mW,\n houseAcPowerW,\n gridPowerW,\n gridIsOn,\n acInAvailable,\n gridAvailable,\n todayRemainingKwh,\n tomorrowKwh,\n advisorEnabled,\n notificationsEnabled,\n hotWaterDemand,\n greenPowerW,\n autoPowerW,\n boostPowerW,\n strongForecastKwh,\n mediumForecastKwh,\n strongPvNowW,\n mediumPvNowW,\n boostMinSocStrongForecastPct,\n boostMinSocMediumForecastPct,\n endOfDaySocGuard,\n endOfDaySocTarget,\n baseLoadRemainingKwh,\n flexibleSafetyMarginKwh,\n acLoadsMaxSafeW,\n lastSignature,\n ecsNuit,\n hcSessionCommitted,\n ecs24hKwh,\n ecsJourKwh,\n offJourFaiblePrev,\n forecastSource\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 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 clamp(v, min, max) {\n return Math.min(Math.max(v, min), max);\n}\n\nfunction to2(n) {\n return String(n).padStart(2, '0');\n}\n\nfunction getParisNowParts() {\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())\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 cleanStateText(v) {\n const s = (v ?? '').toString().trim();\n const x = s.toLowerCase();\n return ['unknown', 'unavailable', 'none', 'null'].includes(x) ? '' : s;\n}\n\nconst cfg = {\n mobileNotifyDomain: 'notify',\n mobileNotifyService: 'mobile_app_iphone_16_de_tt',\n batteryUsableCapacityKwh: 17.5,\n sampleWindowMinMin: 2,\n sampleWindowMinMax: 15,\n energyByDemandKwh: {\n low: { green: 0.9, auto: 1.4, boost: 2.0 },\n normal: { green: 1.4, auto: 2.2, boost: 3.1 },\n high: { green: 2.2, auto: 3.4, boost: 4.6 },\n three_showers: { green: 3.0, auto: 4.4, boost: 5.8 }\n }\n};\n\nconst raw = $input.first()?.json ?? {};\nconst input = extractSnapshot(raw);\nconst now = getParisNowParts();\nconst nowMs = Date.now();\n\nconst soc = clamp(num(input.soc_percent, 0), 0, 100);\nconst batteryCurrentA = num(input.battery_current_a, 0);\nconst pvPowerW = Math.max(0, num(input.pv_power_w, 0));\nconst pvPowerAvg10mW = Math.max(0, num(input.pv_power_10m_avg_w, pvPowerW));\nconst houseAcPowerW = Math.max(0, num(input.house_ac_power_w, 0));\nconst gridPowerW = num(input.grid_power_w, 0);\nconst gridIsOn = low(input.grid_state || 'off') === 'on';\nconst acInAvailable = num(input.victron_acin1_available, 0) === 1;\nconst gridAvailable = gridIsOn || acInAvailable;\nconst todayRemainingKwh = Math.max(0, num(input.today_remaining_kwh, 0));\nconst tomorrowKwh = Math.max(0, num(input.tomorrow_kwh, 0));\nconst advisorEnabled = low(input.advisor_enabled || 'off') === 'on';\nconst notificationsEnabled = low(input.notifications_enabled || 'off') === 'on';\n\nconst hotWaterDemandRaw = low(input.hot_water_demand || 'normal');\nconst hotWaterDemand = ['low', 'normal', 'high', 'three_showers'].includes(hotWaterDemandRaw)\n ? hotWaterDemandRaw\n : 'normal';\n\nconst greenPowerW = Math.max(0, num(input.green_power_w, 620));\nconst autoPowerW = Math.max(0, num(input.auto_power_w, 1200));\nconst boostPowerW = Math.max(0, num(input.boost_power_w, 2000));\nconst strongForecastKwh = Math.max(0, num(input.strong_forecast_kwh, 18));\nconst mediumForecastKwh = Math.max(0, num(input.medium_forecast_kwh, 10));\nconst strongPvNowW = Math.max(0, num(input.strong_pv_now_w, 2500));\nconst mediumPvNowW = Math.max(0, num(input.medium_pv_now_w, 1500));\nconst boostMinSocStrongForecastPct = clamp(num(input.morning_boost_min_soc_strong_day, 35), 0, 100);\nconst boostMinSocMediumForecastPct = clamp(num(input.boost_min_soc_medium_day, 50), 0, 100);\nconst endOfDaySocGuard = clamp(num(input.end_of_day_soc_guard, 85), 0, 100);\nconst endOfDaySocTarget = clamp(num(input.end_of_day_soc_target, 85), 0, 100);\nconst baseLoadRemainingKwh = Math.max(0, num(input.base_load_remaining_kwh, 0));\nconst flexibleSafetyMarginKwh = Math.max(0, num(input.flexible_safety_margin_kwh, 0));\nconst acLoadsMaxSafeW = Math.max(0, num(input.ac_loads_max_safe_w, 8000));\nconst lastSignature = cleanStateText(input.last_signature);\n// Jour faible (07/10/2026) : decision du bilan, session reseau HC engagee,\n// energie du chauffe-eau sur 24 h (service d'historisation), propriete de la coupure.\nconst ecsNuit = low(input.ecs_nuit) === 'on';\nconst hcSessionCommitted = low(input.hc_session_committed) === 'on';\nconst ecs24hKwh = num(input.ecs_24h_kwh, NaN);\nconst ecsJourKwh = num(input.ecs_jour_kwh, NaN);\nconst offJourFaiblePrev = low(input.off_jour_faible_prev) === 'on';\nconst forecastSource = cleanStateText(input.forecast_source) || 'forecast_solar';\n// Fenetres HC (« HH:MM:SS » des input_datetime du controleur), en minutes du jour.\nconst hhmm = (v) => { const m = cleanStateText(v).match(/^(\\d{1,2}):(\\d{2})/); return m ? Number(m[1]) * 60 + Number(m[2]) : null; };\nconst hcWindows = [\n { nom: 'midi', debut: hhmm(input.hc_midday_start), fin: hhmm(input.hc_midday_stop), douche: '20 h' },\n { nom: 'nuit', debut: hhmm(input.hc_night_start), fin: hhmm(input.hc_night_stop), douche: '06 h' },\n].filter((w) => w.debut !== null && w.fin !== null);\n\nreturn [{\n json: {\n meta: {\n workflow: 'water_heater_advisor',\n architecture: 'ecs_6nodes_simple_early_boost',\n node: 'ecs_normalize_inputs',\n timestamp_paris: now.stamp\n },\n cfg,\n now,\n nowMs,\n input,\n normalized: {\n soc,\n batteryCurrentA,\n pvPowerW,\n pvPowerAvg10mW,\n houseAcPowerW,\n gridPowerW,\n gridIsOn,\n acInAvailable,\n gridAvailable,\n todayRemainingKwh,\n tomorrowKwh,\n advisorEnabled,\n notificationsEnabled,\n hotWaterDemand,\n greenPowerW,\n autoPowerW,\n boostPowerW,\n strongForecastKwh,\n mediumForecastKwh,\n strongPvNowW,\n mediumPvNowW,\n boostMinSocStrongForecastPct,\n boostMinSocMediumForecastPct,\n endOfDaySocGuard,\n endOfDaySocTarget,\n baseLoadRemainingKwh,\n flexibleSafetyMarginKwh,\n acLoadsMaxSafeW,\n lastSignature,\n ecsNuit,\n hcSessionCommitted,\n ecs24hKwh,\n ecsJourKwh,\n offJourFaiblePrev,\n forecastSource,\n hcWindows\n }\n }\n}];" }, "id": "1226082d-222c-4837-99d3-35094bd1c4f1", "name": "ECS Normalize Inputs", @@ -61,7 +61,7 @@ }, { "parameters": { - "jsCode": "function regimeRank(regime) {\n switch (regime) {\n case 'off': return 0;\n case 'hp_low': return 1;\n case 'resistive_mid': return 2;\n case 'boost_high': return 3;\n default: return -1;\n }\n}\n\nfunction allowedRankForMode(mode) {\n switch (mode) {\n case 'off': return 0;\n case 'green': return 1;\n case 'auto': return 2;\n case 'boost': return 3;\n default: return 0;\n }\n}\n\nfunction round1(v) {\n return Math.round(v * 10) / 10;\n}\n\nconst root = $input.first()?.json ?? {};\nconst now = root.now ?? {};\nconst nowMs = root.nowMs ?? null;\nconst normalized = root.normalized ?? {};\nconst observation = root.observation ?? {};\nconst energyModel = root.energyModel ?? {};\nconst baseDecision = root.baseDecision ?? {};\n\nlet {\n recommendedMode,\n adviceColor,\n state,\n reason,\n baseRecommendedMode,\n baseAdviceColor,\n baseState\n} = baseDecision;\n\nreason = Array.isArray(reason) ? [...reason] : [];\n\nconst {\n advisorEnabled,\n soc,\n batteryCurrentA,\n pvPowerAvg10mW,\n houseAcPowerW,\n todayRemainingKwh,\n tomorrowKwh,\n baseLoadRemainingKwh,\n flexibleSafetyMarginKwh,\n acLoadsMaxSafeW,\n hotWaterDemand,\n gridIsOn,\n acInAvailable,\n gridAvailable\n} = normalized;\n\nconst {\n evcsPriorityActive,\n energyPriorityReason,\n waterHeaterSwitchOn,\n observedPowerW,\n observedRegime,\n mismatchSinceAt,\n mismatchSinceMsPrev,\n forcedOffByAdvisorPrev,\n forcedOffReasonPrev,\n forcedOffAtPrev,\n restoreSurplusW,\n modeMismatchDelayMin,\n sampleDurationMin,\n energyDeltaKwh,\n sampleIsUsable\n} = observation;\n\nconst {\n targetSoc,\n energyNeededToReachTargetKwh,\n greenEnergyKwh,\n autoEnergyKwh,\n boostEnergyKwh,\n projectedGreenSoc,\n projectedAutoSoc,\n projectedBoostSoc,\n greenBudgetMarginKwh,\n autoBudgetMarginKwh,\n boostBudgetMarginKwh,\n forecastStrength,\n netPvHeadroomW,\n pvCoversHouseNow,\n pvStartedWell,\n greenAllowed,\n autoAllowed,\n boostAllowed,\n batteryNotDrainingHard\n} = energyModel;\n\nif (advisorEnabled && evcsPriorityActive) {\n if (recommendedMode === 'boost' || recommendedMode === 'auto') {\n if (greenAllowed && batteryNotDrainingHard) {\n recommendedMode = 'green';\n adviceColor = 'orange';\n state = 'evcs_priority_green_cap';\n reason.push(`Priorité EVCS active : chauffe-eau plafonné à GREEN (${energyPriorityReason || 'raison non précisée'})`);\n } else {\n recommendedMode = 'off';\n adviceColor = 'red';\n state = 'evcs_priority_off_cap';\n reason.push(`Priorité EVCS active : chauffe-eau déconseillé (${energyPriorityReason || 'raison non précisée'})`);\n }\n } else if (recommendedMode === 'green' && (!greenAllowed || !batteryNotDrainingHard)) {\n recommendedMode = 'off';\n adviceColor = 'red';\n state = 'evcs_priority_green_blocked';\n reason.push(`Priorité EVCS active : GREEN devient lui-même trop coûteux (${energyPriorityReason || 'raison non précisée'})`);\n }\n}\n\nconst selectedLoadKwh =\n recommendedMode === 'green' ? greenEnergyKwh :\n recommendedMode === 'auto' ? autoEnergyKwh :\n recommendedMode === 'boost' ? boostEnergyKwh :\n 0;\n\nconst selectedProjectedSoc =\n recommendedMode === 'green' ? projectedGreenSoc :\n recommendedMode === 'auto' ? projectedAutoSoc :\n recommendedMode === 'boost' ? projectedBoostSoc :\n soc;\n\nconst selectedBudgetMarginKwh =\n recommendedMode === 'green' ? greenBudgetMarginKwh :\n recommendedMode === 'auto' ? autoBudgetMarginKwh :\n recommendedMode === 'boost' ? boostBudgetMarginKwh :\n todayRemainingKwh - baseLoadRemainingKwh - flexibleSafetyMarginKwh - energyNeededToReachTargetKwh;\n\nconst legacyAdviceOk = recommendedMode !== 'off';\n\nconst observedRank = regimeRank(observedRegime);\nconst allowedRank = allowedRankForMode(recommendedMode);\n\nconst mismatchActive =\n waterHeaterSwitchOn &&\n observedRank >= 0 &&\n allowedRank >= 0 &&\n observedRank > allowedRank;\n\nlet mismatchSeverity = 'none';\nif (mismatchActive) {\n if (recommendedMode === 'off') {\n mismatchSeverity = 'critical';\n } else if (observedRank - allowedRank >= 2) {\n mismatchSeverity = 'high';\n } else {\n mismatchSeverity = 'medium';\n }\n}\n\nconst mismatchSinceMsEffective = mismatchActive ? (mismatchSinceMsPrev ?? nowMs) : null;\nconst mismatchSinceAtEffective = mismatchActive ? (mismatchSinceMsPrev ? mismatchSinceAt : now.stamp) : '';\nconst mismatchDurationMin = mismatchActive && mismatchSinceMsEffective !== null ? Math.max(0, (nowMs - mismatchSinceMsEffective) / 60000) : 0;\n\nlet pendingUserAction = '';\nif (mismatchActive) {\n if (recommendedMode === 'off') {\n pendingUserAction = evcsPriorityActive\n ? 'Priorité EVCS : coupe temporairement le chauffe-eau ou laisse-le hors tension'\n : 'Le chauffe-eau consomme alors qu\\'il est déconseillé : coupe-le ou attends une meilleure fenêtre';\n } else if (recommendedMode === 'green') {\n pendingUserAction = observedRegime === 'boost_high'\n ? 'Passe le chauffe-eau en GREEN si possible, il semble en régime BOOST/résistance forte'\n : 'Passe le chauffe-eau en GREEN si possible, il semble plus gourmand que prévu';\n } else if (recommendedMode === 'auto') {\n pendingUserAction = 'Le chauffe-eau semble trop gourmand pour AUTO : repasse-le en AUTO modéré ou GREEN si possible';\n }\n}\n\nconst firstMismatchRun = mismatchActive && (mismatchSinceMsPrev === null || mismatchDurationMin < 1);\n\nconst realConflictNow =\n evcsPriorityActive &&\n (\n batteryCurrentA < -1 ||\n pvPowerAvg10mW < restoreSurplusW ||\n houseAcPowerW > acLoadsMaxSafeW ||\n recommendedMode === 'off' ||\n observedRegime === 'boost_high' ||\n (recommendedMode === 'green' && observedRegime === 'resistive_mid')\n );\n\nconst forceOffNow =\n advisorEnabled &&\n waterHeaterSwitchOn &&\n !forcedOffByAdvisorPrev &&\n mismatchActive &&\n mismatchDurationMin >= modeMismatchDelayMin &&\n realConflictNow;\n\nconst restoreEnergyOk =\n pvPowerAvg10mW >= restoreSurplusW &&\n batteryCurrentA >= -1 &&\n houseAcPowerW <= acLoadsMaxSafeW;\n\nconst restoreNow =\n advisorEnabled &&\n forcedOffByAdvisorPrev &&\n !waterHeaterSwitchOn &&\n (\n (!evcsPriorityActive && recommendedMode !== 'off') ||\n restoreEnergyOk\n );\n\nlet forcedOffByAdvisor = forcedOffByAdvisorPrev;\nlet forcedOffReason = forcedOffReasonPrev;\nlet forcedOffAt = forcedOffAtPrev;\nlet protectionState = 'none';\nlet powerSwitchAction = 'none';\nlet powerSwitchDesiredOn = waterHeaterSwitchOn;\n\nif (forceOffNow) {\n powerSwitchAction = 'turn_off';\n powerSwitchDesiredOn = false;\n forcedOffByAdvisor = true;\n forcedOffReason = `Coupure temporaire ECS: priorité EVCS + régime ${observedRegime} trop gourmand`;\n forcedOffAt = now.stamp;\n protectionState = 'forced_off_now_evcs_priority';\n state = 'forced_off_now_evcs_priority';\n adviceColor = 'red';\n reason.push(forcedOffReason);\n}\nelse if (restoreNow) {\n powerSwitchAction = 'turn_on';\n powerSwitchDesiredOn = true;\n forcedOffByAdvisor = false;\n forcedOffReason = '';\n protectionState = 'restored_after_evcs_priority';\n state = 'restored_after_evcs_priority';\n reason.push('Remise sous tension ECS : conflit EVCS terminé ou surplus redevenu suffisant');\n}\nelse if (forcedOffByAdvisorPrev && !waterHeaterSwitchOn) {\n protectionState = 'forced_off_wait_restore';\n state = 'forced_off_wait_restore';\n reason.push(`ECS temporairement coupé par l'advisor (${forcedOffReasonPrev || 'raison non précisée'})`);\n}\nelse if (mismatchActive && evcsPriorityActive) {\n protectionState = 'watch_mismatch_evcs_priority';\n state = 'watch_mismatch_evcs_priority';\n reason.push(`Écart de régime ECS sous priorité EVCS depuis ${round1(mismatchDurationMin)} min`);\n}\n\nif (!forceOffNow && !restoreNow && forcedOffByAdvisorPrev && waterHeaterSwitchOn) {\n forcedOffByAdvisor = false;\n forcedOffReason = '';\n}\n\n// =====================================================\n// JOUR FAIBLE : EAU CHAUDE EN HC DE NUIT (07/10/2026, option b du proprietaire)\n// =====================================================\n// Le bilan decide une fois par jour (input_boolean.evcs_balance_ecs_nuit) : si\n// le solaire prevu ne remplit pas la batterie pour la nuit, EDF sera ferme de\n// toute facon -> le chauffe-eau est COUPE le jour et chauffe pendant une\n// session reseau HC engagee (contacteur ferme), au tarif creux, jamais sur la\n// batterie. Securite eau chaude : moins de ECS_EAU_FROIDE_KWH en 24 h -> il\n// chauffe quand meme. Energie 24 h illisible -> aucune restriction (le\n// comportement d'avant revient, jamais un ballon froid par panne de capteur).\n// Jour normal : le chauffe-eau coupe par cette regle est remis sous tension.\nconst ECS_EAU_FROIDE_KWH = 1.5;\n// Douches a 06:00 et 20:00 tous les jours (proprietaire, 07/10) : couper toute\n// la journee laisserait l'eau froide a 20 h. Un jour faible, il chauffe donc\n// AUSSI sur un vrai surplus solaire (jamais sur la batterie), et a partir de\n// 16 h s'il n'a presque pas chauffe depuis 07:24 (securite de la douche du soir).\nconst ECS_SOIR_DEBUT_H = 16;\nconst ECS_SOIR_FIN_H = 20;\nconst {\n ecsNuit = false, hcSessionCommitted = false, ecs24hKwh = NaN, ecsJourKwh = NaN,\n offJourFaiblePrev = false, pvPowerW = 0, autoPowerW = 1200,\n} = normalized;\nlet offJourFaible = false;\nlet jourFaibleState = 'none';\nif (advisorEnabled && ecsNuit) {\n const energieConnue = Number.isFinite(ecs24hKwh);\n const eauFroide = energieConnue && ecs24hKwh < ECS_EAU_FROIDE_KWH;\n const heuresCreusesEdf = hcSessionCommitted && gridIsOn;\n // Surplus reel : sous tension, la production couvre la maison (chauffe-eau\n // compris) ; hors tension, il reste de quoi l'alimenter. Reseau ouvert.\n const solaireCouvre = !gridIsOn && (waterHeaterSwitchOn\n ? pvPowerW >= houseAcPowerW\n : pvPowerW - houseAcPowerW >= autoPowerW);\n const heure = Number.isFinite(now.hour) ? now.hour : -1;\n const securiteSoir = heure >= ECS_SOIR_DEBUT_H && heure < ECS_SOIR_FIN_H\n && !(Number.isFinite(ecsJourKwh) && ecsJourKwh >= ECS_EAU_FROIDE_KWH);\n const veutChauffer = !energieConnue || heuresCreusesEdf || eauFroide || solaireCouvre || securiteSoir;\n offJourFaible = true;\n jourFaibleState = !energieConnue ? 'jour_faible_energie_illisible'\n : heuresCreusesEdf ? 'jour_faible_chauffe_hc_edf'\n : eauFroide ? 'jour_faible_eau_froide'\n : securiteSoir ? 'jour_faible_securite_soir'\n : solaireCouvre ? 'jour_faible_surplus_solaire'\n : 'jour_faible_coupe';\n if (veutChauffer !== waterHeaterSwitchOn) {\n powerSwitchAction = veutChauffer ? 'turn_on' : 'turn_off';\n powerSwitchDesiredOn = veutChauffer;\n } else {\n powerSwitchAction = 'none';\n powerSwitchDesiredOn = waterHeaterSwitchOn;\n }\n forcedOffByAdvisor = false;\n forcedOffReason = '';\n protectionState = jourFaibleState;\n state = jourFaibleState;\n reason.push(\n jourFaibleState === 'jour_faible_chauffe_hc_edf' ? 'Jour faible : eau chaude en heures creuses sur EDF (session reseau engagee)'\n : jourFaibleState === 'jour_faible_eau_froide' ? `Jour faible mais seulement ${round1(ecs24hKwh)} kWh en 24 h : chauffe pour garantir l eau chaude`\n : jourFaibleState === 'jour_faible_energie_illisible' ? 'Jour faible, energie 24 h illisible : aucune restriction'\n : jourFaibleState === 'jour_faible_securite_soir' ? 'Jour faible : chauffe pour la douche de 20 h (peu chauffe depuis 07:24)'\n : jourFaibleState === 'jour_faible_surplus_solaire' ? 'Jour faible : surplus solaire reel, il chauffe sans toucher la batterie'\n : 'Jour faible : chauffe-eau coupe, il chauffera en heures creuses sur EDF'\n );\n} else if (advisorEnabled && offJourFaiblePrev && !waterHeaterSwitchOn) {\n powerSwitchAction = 'turn_on';\n powerSwitchDesiredOn = true;\n jourFaibleState = 'jour_normal_remise_sous_tension';\n protectionState = jourFaibleState;\n state = jourFaibleState;\n reason.push('Jour normal : chauffe-eau remis sous tension (il chauffe au solaire)');\n}\n\nreturn [{\n json: {\n ...root,\n meta: {\n ...(root.meta ?? {}),\n node: 'ecs_final_arbitration_and_protection'\n },\n finalDecision: {\n recommendedMode,\n adviceColor,\n state,\n reason,\n baseRecommendedMode,\n baseAdviceColor,\n baseState,\n selectedLoadKwh,\n selectedProjectedSoc,\n selectedBudgetMarginKwh,\n legacyAdviceOk,\n mismatchActive,\n mismatchSeverity,\n mismatchSinceAtEffective,\n mismatchSinceMsEffective,\n mismatchDurationMin,\n pendingUserAction,\n firstMismatchRun,\n realConflictNow,\n forceOffNow,\n restoreEnergyOk,\n restoreNow,\n forcedOffByAdvisor,\n forcedOffReason,\n forcedOffAt,\n protectionState,\n powerSwitchAction,\n powerSwitchDesiredOn,\n offJourFaible,\n jourFaibleState\n }\n }\n}];" + "jsCode": "function regimeRank(regime) {\n switch (regime) {\n case 'off': return 0;\n case 'hp_low': return 1;\n case 'resistive_mid': return 2;\n case 'boost_high': return 3;\n default: return -1;\n }\n}\n\nfunction allowedRankForMode(mode) {\n switch (mode) {\n case 'off': return 0;\n case 'green': return 1;\n case 'auto': return 2;\n case 'boost': return 3;\n default: return 0;\n }\n}\n\nfunction round1(v) {\n return Math.round(v * 10) / 10;\n}\n\nconst root = $input.first()?.json ?? {};\nconst now = root.now ?? {};\nconst nowMs = root.nowMs ?? null;\nconst normalized = root.normalized ?? {};\nconst observation = root.observation ?? {};\nconst energyModel = root.energyModel ?? {};\nconst baseDecision = root.baseDecision ?? {};\n\nlet {\n recommendedMode,\n adviceColor,\n state,\n reason,\n baseRecommendedMode,\n baseAdviceColor,\n baseState\n} = baseDecision;\n\nreason = Array.isArray(reason) ? [...reason] : [];\n\nconst {\n advisorEnabled,\n soc,\n batteryCurrentA,\n pvPowerAvg10mW,\n houseAcPowerW,\n todayRemainingKwh,\n tomorrowKwh,\n baseLoadRemainingKwh,\n flexibleSafetyMarginKwh,\n acLoadsMaxSafeW,\n hotWaterDemand,\n gridIsOn,\n acInAvailable,\n gridAvailable\n} = normalized;\n\nconst {\n evcsPriorityActive,\n energyPriorityReason,\n waterHeaterSwitchOn,\n observedPowerW,\n observedRegime,\n mismatchSinceAt,\n mismatchSinceMsPrev,\n forcedOffByAdvisorPrev,\n forcedOffReasonPrev,\n forcedOffAtPrev,\n restoreSurplusW,\n modeMismatchDelayMin,\n sampleDurationMin,\n energyDeltaKwh,\n sampleIsUsable\n} = observation;\n\nconst {\n targetSoc,\n energyNeededToReachTargetKwh,\n greenEnergyKwh,\n autoEnergyKwh,\n boostEnergyKwh,\n projectedGreenSoc,\n projectedAutoSoc,\n projectedBoostSoc,\n greenBudgetMarginKwh,\n autoBudgetMarginKwh,\n boostBudgetMarginKwh,\n forecastStrength,\n netPvHeadroomW,\n pvCoversHouseNow,\n pvStartedWell,\n greenAllowed,\n autoAllowed,\n boostAllowed,\n batteryNotDrainingHard\n} = energyModel;\n\nif (advisorEnabled && evcsPriorityActive) {\n if (recommendedMode === 'boost' || recommendedMode === 'auto') {\n if (greenAllowed && batteryNotDrainingHard) {\n recommendedMode = 'green';\n adviceColor = 'orange';\n state = 'evcs_priority_green_cap';\n reason.push(`Priorité EVCS active : chauffe-eau plafonné à GREEN (${energyPriorityReason || 'raison non précisée'})`);\n } else {\n recommendedMode = 'off';\n adviceColor = 'red';\n state = 'evcs_priority_off_cap';\n reason.push(`Priorité EVCS active : chauffe-eau déconseillé (${energyPriorityReason || 'raison non précisée'})`);\n }\n } else if (recommendedMode === 'green' && (!greenAllowed || !batteryNotDrainingHard)) {\n recommendedMode = 'off';\n adviceColor = 'red';\n state = 'evcs_priority_green_blocked';\n reason.push(`Priorité EVCS active : GREEN devient lui-même trop coûteux (${energyPriorityReason || 'raison non précisée'})`);\n }\n}\n\nconst selectedLoadKwh =\n recommendedMode === 'green' ? greenEnergyKwh :\n recommendedMode === 'auto' ? autoEnergyKwh :\n recommendedMode === 'boost' ? boostEnergyKwh :\n 0;\n\nconst selectedProjectedSoc =\n recommendedMode === 'green' ? projectedGreenSoc :\n recommendedMode === 'auto' ? projectedAutoSoc :\n recommendedMode === 'boost' ? projectedBoostSoc :\n soc;\n\nconst selectedBudgetMarginKwh =\n recommendedMode === 'green' ? greenBudgetMarginKwh :\n recommendedMode === 'auto' ? autoBudgetMarginKwh :\n recommendedMode === 'boost' ? boostBudgetMarginKwh :\n todayRemainingKwh - baseLoadRemainingKwh - flexibleSafetyMarginKwh - energyNeededToReachTargetKwh;\n\nconst legacyAdviceOk = recommendedMode !== 'off';\n\nconst observedRank = regimeRank(observedRegime);\nconst allowedRank = allowedRankForMode(recommendedMode);\n\nconst mismatchActive =\n waterHeaterSwitchOn &&\n observedRank >= 0 &&\n allowedRank >= 0 &&\n observedRank > allowedRank;\n\nlet mismatchSeverity = 'none';\nif (mismatchActive) {\n if (recommendedMode === 'off') {\n mismatchSeverity = 'critical';\n } else if (observedRank - allowedRank >= 2) {\n mismatchSeverity = 'high';\n } else {\n mismatchSeverity = 'medium';\n }\n}\n\nconst mismatchSinceMsEffective = mismatchActive ? (mismatchSinceMsPrev ?? nowMs) : null;\nconst mismatchSinceAtEffective = mismatchActive ? (mismatchSinceMsPrev ? mismatchSinceAt : now.stamp) : '';\nconst mismatchDurationMin = mismatchActive && mismatchSinceMsEffective !== null ? Math.max(0, (nowMs - mismatchSinceMsEffective) / 60000) : 0;\n\nlet pendingUserAction = '';\nif (mismatchActive) {\n if (recommendedMode === 'off') {\n pendingUserAction = evcsPriorityActive\n ? 'Priorité EVCS : coupe temporairement le chauffe-eau ou laisse-le hors tension'\n : 'Le chauffe-eau consomme alors qu\\'il est déconseillé : coupe-le ou attends une meilleure fenêtre';\n } else if (recommendedMode === 'green') {\n pendingUserAction = observedRegime === 'boost_high'\n ? 'Passe le chauffe-eau en GREEN si possible, il semble en régime BOOST/résistance forte'\n : 'Passe le chauffe-eau en GREEN si possible, il semble plus gourmand que prévu';\n } else if (recommendedMode === 'auto') {\n pendingUserAction = 'Le chauffe-eau semble trop gourmand pour AUTO : repasse-le en AUTO modéré ou GREEN si possible';\n }\n}\n\nconst firstMismatchRun = mismatchActive && (mismatchSinceMsPrev === null || mismatchDurationMin < 1);\n\nconst realConflictNow =\n evcsPriorityActive &&\n (\n batteryCurrentA < -1 ||\n pvPowerAvg10mW < restoreSurplusW ||\n houseAcPowerW > acLoadsMaxSafeW ||\n recommendedMode === 'off' ||\n observedRegime === 'boost_high' ||\n (recommendedMode === 'green' && observedRegime === 'resistive_mid')\n );\n\nconst forceOffNow =\n advisorEnabled &&\n waterHeaterSwitchOn &&\n !forcedOffByAdvisorPrev &&\n mismatchActive &&\n mismatchDurationMin >= modeMismatchDelayMin &&\n realConflictNow;\n\nconst restoreEnergyOk =\n pvPowerAvg10mW >= restoreSurplusW &&\n batteryCurrentA >= -1 &&\n houseAcPowerW <= acLoadsMaxSafeW;\n\nconst restoreNow =\n advisorEnabled &&\n forcedOffByAdvisorPrev &&\n !waterHeaterSwitchOn &&\n (\n (!evcsPriorityActive && recommendedMode !== 'off') ||\n restoreEnergyOk\n );\n\nlet forcedOffByAdvisor = forcedOffByAdvisorPrev;\nlet forcedOffReason = forcedOffReasonPrev;\nlet forcedOffAt = forcedOffAtPrev;\nlet protectionState = 'none';\nlet powerSwitchAction = 'none';\nlet powerSwitchDesiredOn = waterHeaterSwitchOn;\n\nif (forceOffNow) {\n powerSwitchAction = 'turn_off';\n powerSwitchDesiredOn = false;\n forcedOffByAdvisor = true;\n forcedOffReason = `Coupure temporaire ECS: priorité EVCS + régime ${observedRegime} trop gourmand`;\n forcedOffAt = now.stamp;\n protectionState = 'forced_off_now_evcs_priority';\n state = 'forced_off_now_evcs_priority';\n adviceColor = 'red';\n reason.push(forcedOffReason);\n}\nelse if (restoreNow) {\n powerSwitchAction = 'turn_on';\n powerSwitchDesiredOn = true;\n forcedOffByAdvisor = false;\n forcedOffReason = '';\n protectionState = 'restored_after_evcs_priority';\n state = 'restored_after_evcs_priority';\n reason.push('Remise sous tension ECS : conflit EVCS terminé ou surplus redevenu suffisant');\n}\nelse if (forcedOffByAdvisorPrev && !waterHeaterSwitchOn) {\n protectionState = 'forced_off_wait_restore';\n state = 'forced_off_wait_restore';\n reason.push(`ECS temporairement coupé par l'advisor (${forcedOffReasonPrev || 'raison non précisée'})`);\n}\nelse if (mismatchActive && evcsPriorityActive) {\n protectionState = 'watch_mismatch_evcs_priority';\n state = 'watch_mismatch_evcs_priority';\n reason.push(`Écart de régime ECS sous priorité EVCS depuis ${round1(mismatchDurationMin)} min`);\n}\n\nif (!forceOffNow && !restoreNow && forcedOffByAdvisorPrev && waterHeaterSwitchOn) {\n forcedOffByAdvisor = false;\n forcedOffReason = '';\n}\n\n// =====================================================\n// JOUR FAIBLE : EAU CHAUDE EN HC DE NUIT (07/10/2026, option b du proprietaire)\n// =====================================================\n// Le bilan decide une fois par jour (input_boolean.evcs_balance_ecs_nuit) : si\n// le solaire prevu ne remplit pas la batterie pour la nuit, EDF sera ferme de\n// toute facon -> le chauffe-eau est COUPE le jour et chauffe pendant une\n// session reseau HC engagee (contacteur ferme), au tarif creux, jamais sur la\n// batterie. Securite eau chaude : moins de ECS_EAU_FROIDE_KWH en 24 h -> il\n// chauffe quand meme. Energie 24 h illisible -> aucune restriction (le\n// comportement d'avant revient, jamais un ballon froid par panne de capteur).\n// Jour normal : le chauffe-eau coupe par cette regle est remis sous tension.\nconst ECS_EAU_FROIDE_KWH = 1.5;\n// Douches a 06:00 et 20:00 tous les jours (proprietaire, 07/10) : couper toute\n// la journee laisserait l'eau froide a 20 h. Un jour faible, il chauffe donc\n// AUSSI sur un vrai surplus solaire (jamais sur la batterie), et a partir de\n// 16 h s'il n'a presque pas chauffe depuis 07:24 (securite de la douche du soir).\nconst ECS_SOIR_DEBUT_H = 16;\nconst ECS_SOIR_FIN_H = 20;\nconst {\n ecsNuit = false, hcSessionCommitted = false, ecs24hKwh = NaN, ecsJourKwh = NaN,\n offJourFaiblePrev = false, pvPowerW = 0, autoPowerW = 1200,\n} = normalized;\nlet offJourFaible = false;\nlet jourFaibleState = 'none';\nif (advisorEnabled && ecsNuit) {\n const energieConnue = Number.isFinite(ecs24hKwh);\n const eauFroide = energieConnue && ecs24hKwh < ECS_EAU_FROIDE_KWH;\n const heuresCreusesEdf = hcSessionCommitted && gridIsOn;\n // Surplus reel : sous tension, la production couvre la maison (chauffe-eau\n // compris) ; hors tension, il reste de quoi l'alimenter. Reseau ouvert.\n const solaireCouvre = !gridIsOn && (waterHeaterSwitchOn\n ? pvPowerW >= houseAcPowerW\n : pvPowerW - houseAcPowerW >= autoPowerW);\n const heure = Number.isFinite(now.hour) ? now.hour : -1;\n const securiteSoir = heure >= ECS_SOIR_DEBUT_H && heure < ECS_SOIR_FIN_H\n && !(Number.isFinite(ecsJourKwh) && ecsJourKwh >= ECS_EAU_FROIDE_KWH);\n const veutChauffer = !energieConnue || heuresCreusesEdf || eauFroide || solaireCouvre || securiteSoir;\n offJourFaible = true;\n jourFaibleState = !energieConnue ? 'jour_faible_energie_illisible'\n : heuresCreusesEdf ? 'jour_faible_chauffe_hc_edf'\n : eauFroide ? 'jour_faible_eau_froide'\n : securiteSoir ? 'jour_faible_securite_soir'\n : solaireCouvre ? 'jour_faible_surplus_solaire'\n : 'jour_faible_coupe';\n if (veutChauffer !== waterHeaterSwitchOn) {\n powerSwitchAction = veutChauffer ? 'turn_on' : 'turn_off';\n powerSwitchDesiredOn = veutChauffer;\n } else {\n powerSwitchAction = 'none';\n powerSwitchDesiredOn = waterHeaterSwitchOn;\n }\n forcedOffByAdvisor = false;\n forcedOffReason = '';\n protectionState = jourFaibleState;\n state = jourFaibleState;\n reason.push(\n jourFaibleState === 'jour_faible_chauffe_hc_edf' ? 'Jour faible : eau chaude en heures creuses sur EDF (session reseau engagee)'\n : jourFaibleState === 'jour_faible_eau_froide' ? `Jour faible mais seulement ${round1(ecs24hKwh)} kWh en 24 h : chauffe pour garantir l eau chaude`\n : jourFaibleState === 'jour_faible_energie_illisible' ? 'Jour faible, energie 24 h illisible : aucune restriction'\n : jourFaibleState === 'jour_faible_securite_soir' ? 'Jour faible : chauffe pour la douche de 20 h (peu chauffe depuis 07:24)'\n : jourFaibleState === 'jour_faible_surplus_solaire' ? 'Jour faible : surplus solaire reel, il chauffe sans toucher la batterie'\n : 'Jour faible : chauffe-eau coupe, il chauffera en heures creuses sur EDF'\n );\n} else if (advisorEnabled && offJourFaiblePrev && !waterHeaterSwitchOn) {\n powerSwitchAction = 'turn_on';\n powerSwitchDesiredOn = true;\n jourFaibleState = 'jour_normal_remise_sous_tension';\n protectionState = jourFaibleState;\n state = jourFaibleState;\n reason.push('Jour normal : chauffe-eau remis sous tension (il chauffe au solaire)');\n}\n\n// =====================================================\n// ALERTE « PASSE EN BOOST » (07/10/2026, proprietaire)\n// =====================================================\n// En heures creuses sur EDF, le ballon doit consommer AU MAXIMUM toute la\n// fenetre : en AUTO il reste sur la pompe a chaleur (lent, 55 °C) et la douche\n// ne sera pas a 65 °C (20 h apres la HC de midi, 06 h apres celle de nuit).\n// Si, 15 min apres le debut de la fenetre, session engagee, contacteur ferme et\n// ballon sous tension, il ne tire que la puissance de la pompe a chaleur\n// (regime hp_low), on le SIGNALE : input_text.evcs_alerte_chauffe_eau (JSON,\n// `since` = debut de la fenetre -> un seul message par fenetre, envoye par le\n// concierge OpenClaw). 0 W = thermostat satisfait : pas d'alerte.\nconst ALERTE_DELAI_MIN = 15;\nconst minNow = Number.isFinite(now.minNow) ? now.minNow : null;\nconst fenetreHc = (normalized.hcWindows || []).find((w) => minNow !== null && (\n w.debut <= w.fin ? (minNow >= w.debut && minNow < w.fin) : (minNow >= w.debut || minNow < w.fin)));\nconst depuisDebutMin = fenetreHc ? ((minNow - fenetreHc.debut + 1440) % 1440) : null;\nconst alerteBoost = !!fenetreHc && depuisDebutMin >= ALERTE_DELAI_MIN\n && hcSessionCommitted && gridIsOn && waterHeaterSwitchOn && observedRegime === 'hp_low';\nlet alerteChauffeEau = '';\nif (alerteBoost) {\n const h = String(Math.floor(fenetreHc.debut / 60)).padStart(2, '0') + ':' + String(fenetreHc.debut % 60).padStart(2, '0');\n const jourFenetre = (fenetreHc.debut > fenetreHc.fin && minNow < fenetreHc.fin)\n ? new Date(nowMs - 86400000).toLocaleDateString('sv-SE', { timeZone: 'Europe/Paris' }) : now.date;\n alerteChauffeEau = JSON.stringify({\n active: true, since: `${jourFenetre} ${h}`, fenetre: fenetreHc.nom,\n puissance_w: Math.round(observedPowerW),\n message: `🚿 Heures creuses EDF : le chauffe-eau ne tire que ${Math.round(observedPowerW)} W (pompe a chaleur). Passe-le en BOOST pour avoir 65 °C a ${fenetreHc.douche}.`,\n });\n}\n\nreturn [{\n json: {\n ...root,\n meta: {\n ...(root.meta ?? {}),\n node: 'ecs_final_arbitration_and_protection'\n },\n finalDecision: {\n recommendedMode,\n adviceColor,\n state,\n reason,\n baseRecommendedMode,\n baseAdviceColor,\n baseState,\n selectedLoadKwh,\n selectedProjectedSoc,\n selectedBudgetMarginKwh,\n legacyAdviceOk,\n mismatchActive,\n mismatchSeverity,\n mismatchSinceAtEffective,\n mismatchSinceMsEffective,\n mismatchDurationMin,\n pendingUserAction,\n firstMismatchRun,\n realConflictNow,\n forceOffNow,\n restoreEnergyOk,\n restoreNow,\n forcedOffByAdvisor,\n forcedOffReason,\n forcedOffAt,\n protectionState,\n powerSwitchAction,\n powerSwitchDesiredOn,\n offJourFaible,\n jourFaibleState,\n alerteChauffeEau\n }\n }\n}];" }, "id": "994b3242-616d-46e7-83ed-d236a117d15c", "name": "ECS Final Arbitration & Protection", @@ -74,7 +74,7 @@ }, { "parameters": { - "jsCode": "function round1(v) {\n return Math.round(v * 10) / 10;\n}\n\nfunction round2(v) {\n return Math.round(v * 100) / 100;\n}\n\nfunction safeText(v, max = 255) {\n return (v ?? '').toString().slice(0, max);\n}\n\nfunction boolService(flag) {\n return flag ? 'turn_on' : 'turn_off';\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\nconst root = $input.first()?.json ?? {};\nconst cfg = root.cfg ?? {};\nconst now = root.now ?? {};\nconst input = root.input ?? {};\nconst normalized = root.normalized ?? {};\nconst observation = root.observation ?? {};\nconst energyModel = root.energyModel ?? {};\nconst finalDecision = root.finalDecision ?? {};\n\nconst {\n soc,\n batteryCurrentA,\n pvPowerW,\n pvPowerAvg10mW,\n houseAcPowerW,\n gridPowerW,\n gridIsOn,\n acInAvailable,\n gridAvailable,\n todayRemainingKwh,\n tomorrowKwh,\n advisorEnabled,\n notificationsEnabled,\n hotWaterDemand,\n greenPowerW,\n autoPowerW,\n boostPowerW,\n strongForecastKwh,\n mediumForecastKwh,\n strongPvNowW,\n mediumPvNowW,\n boostMinSocStrongForecastPct,\n boostMinSocMediumForecastPct,\n endOfDaySocGuard,\n endOfDaySocTarget,\n baseLoadRemainingKwh,\n flexibleSafetyMarginKwh,\n acLoadsMaxSafeW,\n lastSignature\n} = normalized;\n\nconst {\n evcsPriorityActive,\n energyPriorityMode,\n energyPriorityReason,\n evcsPriorityUntilAt,\n waterHeaterSwitchOn,\n waterHeaterEnergyTotalKwh,\n restoreSurplusW,\n modeMismatchDelayMin,\n sampleDurationMin,\n energyDeltaKwh,\n sampleIsUsable,\n observedPowerW,\n observedRegime\n} = observation;\n\nconst {\n targetSoc,\n batteryCapacityKwh,\n greenEnergyKwh,\n autoEnergyKwh,\n boostEnergyKwh,\n energyNeededToReachTargetKwh,\n projectedGreenSoc,\n projectedAutoSoc,\n projectedBoostSoc,\n greenBudgetMarginKwh,\n autoBudgetMarginKwh,\n boostBudgetMarginKwh,\n forecastStrength,\n strongForecast,\n mediumForecast,\n strongPvNow,\n mediumPvNow,\n netPvHeadroomW,\n pvCoversHouseNow,\n pvStartedWell,\n acSafe,\n batteryNotDrainingHard,\n greenAllowed,\n autoAllowed,\n boostAllowedByBudget,\n boostAllowed,\n earlyBoostStrongReady,\n earlyBoostMediumReady,\n autoReady,\n greenReady\n} = energyModel;\n\nlet {\n recommendedMode,\n adviceColor,\n state,\n reason,\n baseRecommendedMode,\n baseAdviceColor,\n baseState,\n selectedLoadKwh,\n selectedProjectedSoc,\n selectedBudgetMarginKwh,\n legacyAdviceOk,\n mismatchActive,\n mismatchSeverity,\n mismatchSinceAtEffective,\n mismatchSinceMsEffective,\n mismatchDurationMin,\n pendingUserAction,\n firstMismatchRun,\n realConflictNow,\n forceOffNow,\n restoreEnergyOk,\n restoreNow,\n forcedOffByAdvisor,\n forcedOffReason,\n forcedOffAt,\n protectionState,\n powerSwitchAction,\n powerSwitchDesiredOn,\n offJourFaible = false\n} = finalDecision;\n\nreason = Array.isArray(reason) ? [...reason] : [];\n\nconst forcedOffAtSafe = forcedOffAt && parseParisStampToMs(forcedOffAt) !== null ? forcedOffAt : '';\nconst forcedOffReasonSafe = forcedOffByAdvisor ? safeText(forcedOffReason, 255) : '';\nconst pendingUserActionSafe = safeText(pendingUserAction, 255);\n\nif (reason.length === 0) {\n if (recommendedMode === 'boost') reason.push('Boost recommandé');\n else if (recommendedMode === 'auto') reason.push('Mode auto recommandé');\n else if (recommendedMode === 'green') reason.push('Mode green recommandé');\n else reason.push('Chauffe-eau déconseillé pour le moment');\n}\n\nreason.push(`Demande ECS: ${hotWaterDemand}`);\nreason.push(`Conseil final: ${recommendedMode}`);\nreason.push(`Conseil initial: ${baseRecommendedMode}`);\nreason.push(`Couleur conseil: ${adviceColor}`);\nreason.push(`Forecast du jour: ${round1(todayRemainingKwh)} kWh`);\nreason.push(`Production demain: ${round1(tomorrowKwh)} kWh`);\nreason.push(`Classe forecast: ${forecastStrength}`);\nreason.push(`PV actuel: ${Math.round(pvPowerW)} W | PV10: ${Math.round(pvPowerAvg10mW)} W`);\nreason.push(`Charges AC maison: ${Math.round(houseAcPowerW)} W`);\nreason.push(`Surplus net estimé: ${Math.round(netPvHeadroomW)} W`);\nreason.push(`PV couvre maison: ${pvCoversHouseNow ? 'oui' : 'non'} | PV bien démarré: ${pvStartedWell ? 'oui' : 'non'}`);\nreason.push(`Batterie: SOC ${round1(soc)}% | courant ${round1(batteryCurrentA)} A`);\nreason.push(`Réseau: cmd=${gridIsOn ? 'ON' : 'OFF'} | acin=${acInAvailable ? 'OK' : 'KO'} | dispo=${gridAvailable ? 'oui' : 'non'}`);\nreason.push(`Target fin de jour: ${round1(targetSoc)}%`);\nreason.push(`Base load restante: ${round1(baseLoadRemainingKwh)} kWh`);\nreason.push(`Marge sécurité: ${round1(flexibleSafetyMarginKwh)} kWh`);\nreason.push(`Green ${round1(greenEnergyKwh)}kWh → SOC fin ${round1(projectedGreenSoc)}% | marge ${round1(greenBudgetMarginKwh)}kWh`);\nreason.push(`Auto ${round1(autoEnergyKwh)}kWh → SOC fin ${round1(projectedAutoSoc)}% | marge ${round1(autoBudgetMarginKwh)}kWh`);\nreason.push(`Boost ${round1(boostEnergyKwh)}kWh → SOC fin ${round1(projectedBoostSoc)}% | marge ${round1(boostBudgetMarginKwh)}kWh`);\nreason.push(`Régime observé: ${observedRegime} | puissance ≈ ${Math.round(observedPowerW)} W | delta ${sampleIsUsable ? round2(energyDeltaKwh) : 'n/a'} kWh/${sampleIsUsable ? round1(sampleDurationMin) : 'n/a'} min`);\nreason.push(`Prio EVCS: ${evcsPriorityActive ? 'ON' : 'OFF'}${energyPriorityReason ? ` | ${energyPriorityReason}` : ''}`);\nreason.push(`Mismatch: ${mismatchActive ? 'oui' : 'non'} | sévérité=${mismatchSeverity} | durée=${round1(mismatchDurationMin)} min`);\nreason.push(`Protection ECS: ${protectionState}`);\nif (pendingUserActionSafe) reason.push(`Action attendue: ${pendingUserActionSafe}`);\n\nconst shortReason = reason.join(' | ').slice(0, 255);\n\nconst signature = [\n now.date,\n `d=${hotWaterDemand}`,\n `m=${recommendedMode}`,\n `base=${baseRecommendedMode}`,\n `c=${adviceColor}`,\n `fs=${strongForecast ? '1' : '0'}`,\n `fm=${mediumForecast ? '1' : '0'}`,\n `pb=${pvStartedWell ? '1' : '0'}`,\n `soc=${round1(soc)}`,\n `pv=${Math.round(pvPowerW)}`,\n `pv10=${Math.round(pvPowerAvg10mW)}`,\n `npv=${Math.round(netPvHeadroomW)}`,\n `obs=${observedRegime}`,\n `prio=${evcsPriorityActive ? '1' : '0'}`,\n `cut=${forcedOffByAdvisor ? '1' : '0'}`\n].join('|');\n\nconst signatureChanged = signature !== lastSignature;\n\nlet notifyWanted = false;\nlet notifyTitle = 'Water Heater Advisor';\nlet notifyMessage = '';\n\nif (notificationsEnabled) {\n if (forceOffNow) {\n notifyWanted = true;\n notifyTitle = 'ECS coupé temporairement';\n notifyMessage = forcedOffReasonSafe || 'Le chauffe-eau a été coupé temporairement';\n } else if (restoreNow) {\n notifyWanted = true;\n notifyTitle = 'ECS remis sous tension';\n notifyMessage = 'Le chauffe-eau est réalimenté car la priorité EVCS est terminée ou le surplus est redevenu suffisant.';\n } else if (firstMismatchRun && pendingUserActionSafe) {\n notifyWanted = true;\n notifyTitle = 'Action utilisateur ECS conseillée';\n notifyMessage = pendingUserActionSafe;\n } else if (signatureChanged) {\n notifyWanted = true;\n notifyTitle = `Water Heater Advisor: ${recommendedMode.toUpperCase()} / ${adviceColor.toUpperCase()}`;\n notifyMessage = `Demande=${hotWaterDemand} | régime=${observedRegime} | SOC fin estimé=${round1(selectedProjectedSoc)}% | marge=${round1(selectedBudgetMarginKwh)} kWh`;\n }\n}\nconst BUDGET_MARGIN_MIN = -50;\nconst BUDGET_MARGIN_MAX = 50;\nconst result = {\n timestamp_paris: now.stamp,\n inputs: {\n soc_percent: soc,\n battery_current_a: batteryCurrentA,\n pv_power_w: pvPowerW,\n pv_power_10m_avg_w: pvPowerAvg10mW,\n house_ac_power_w: houseAcPowerW,\n grid_power_w: gridPowerW,\n grid_state: gridIsOn ? 'on' : 'off',\n victron_acin1_available: acInAvailable ? 1 : 0,\n today_remaining_kwh: round2(todayRemainingKwh),\n tomorrow_kwh: round2(tomorrowKwh),\n advisor_enabled: advisorEnabled,\n notifications_enabled: notificationsEnabled,\n hot_water_demand: hotWaterDemand,\n green_power_w: greenPowerW,\n auto_power_w: autoPowerW,\n boost_power_w: boostPowerW,\n strong_forecast_kwh: strongForecastKwh,\n medium_forecast_kwh: mediumForecastKwh,\n strong_pv_now_w: strongPvNowW,\n medium_pv_now_w: mediumPvNowW,\n boost_min_soc_strong_forecast_pct: boostMinSocStrongForecastPct,\n boost_min_soc_medium_forecast_pct: boostMinSocMediumForecastPct,\n end_of_day_soc_guard: endOfDaySocGuard,\n end_of_day_soc_target: endOfDaySocTarget,\n base_load_remaining_kwh: baseLoadRemainingKwh,\n flexible_safety_margin_kwh: flexibleSafetyMarginKwh,\n ac_loads_max_safe_w: acLoadsMaxSafeW,\n water_heater_switch: waterHeaterSwitchOn,\n water_heater_energy_total_kwh: round2(waterHeaterEnergyTotalKwh),\n last_signature: lastSignature || null,\n evcs_priority_active: evcsPriorityActive,\n energy_priority_mode: energyPriorityMode,\n energy_priority_reason: energyPriorityReason || null,\n evcs_priority_until: evcsPriorityUntilAt || null\n },\n computed: {\n forecast_strength: forecastStrength,\n strong_forecast: strongForecast,\n medium_forecast: mediumForecast,\n strong_pv_now: strongPvNow,\n medium_pv_now: mediumPvNow,\n net_pv_headroom_w: Math.round(netPvHeadroomW),\n pv_covers_house_now: pvCoversHouseNow,\n pv_started_well: pvStartedWell,\n battery_usable_capacity_kwh_internal: batteryCapacityKwh,\n energy_needed_to_reach_target_kwh: round2(energyNeededToReachTargetKwh),\n green_energy_kwh: round2(greenEnergyKwh),\n auto_energy_kwh: round2(autoEnergyKwh),\n boost_energy_kwh: round2(boostEnergyKwh),\n projected_green_soc: round1(projectedGreenSoc),\n projected_auto_soc: round1(projectedAutoSoc),\n projected_boost_soc: round1(projectedBoostSoc),\n green_budget_margin_kwh: round2(greenBudgetMarginKwh),\n auto_budget_margin_kwh: round2(autoBudgetMarginKwh),\n boost_budget_margin_kwh: round2(boostBudgetMarginKwh),\n ac_safe: acSafe,\n battery_not_draining_hard: batteryNotDrainingHard,\n green_allowed: greenAllowed,\n auto_allowed: autoAllowed,\n boost_allowed_by_budget: boostAllowedByBudget,\n boost_allowed: boostAllowed,\n early_boost_strong_ready: earlyBoostStrongReady,\n early_boost_medium_ready: earlyBoostMediumReady,\n auto_ready: autoReady,\n green_ready: greenReady,\n base_recommended_mode: baseRecommendedMode,\n base_advice_color: baseAdviceColor,\n base_state: baseState,\n observed_power_w: Math.round(observedPowerW),\n observed_regime: observedRegime,\n sample_duration_min: sampleIsUsable ? round1(sampleDurationMin) : null,\n energy_delta_kwh: sampleIsUsable ? round2(energyDeltaKwh) : null,\n mismatch_active: mismatchActive,\n mismatch_severity: mismatchSeverity,\n mismatch_duration_min: round1(mismatchDurationMin),\n evcs_priority_active: evcsPriorityActive,\n protection_state: protectionState,\n real_conflict_now: realConflictNow,\n restore_energy_ok: restoreEnergyOk,\n restore_surplus_w: restoreSurplusW,\n mode_mismatch_delay_min: modeMismatchDelayMin\n },\n decision: {\n recommended_mode: recommendedMode,\n advice_color: adviceColor,\n state,\n legacy_advice_ok: legacyAdviceOk,\n selected_load_kwh: round2(selectedLoadKwh),\n projected_end_of_day_soc: round1(selectedProjectedSoc),\n budget_margin_kwh: round2(selectedBudgetMarginKwh),\n observed_power_w: Math.round(observedPowerW),\n observed_regime: observedRegime,\n mismatch_active: mismatchActive,\n mismatch_severity: mismatchSeverity,\n mismatch_duration_min: round1(mismatchDurationMin),\n pending_user_action: pendingUserActionSafe,\n power_switch_action: powerSwitchAction,\n power_switch_desired_on: powerSwitchDesiredOn,\n force_off_now: forceOffNow,\n restore_now: restoreNow,\n forced_off_by_advisor: forcedOffByAdvisor,\n forced_off_reason: forcedOffReasonSafe,\n forced_off_at: forcedOffByAdvisor ? forcedOffAtSafe || null : null,\n signature,\n signature_changed: signatureChanged,\n should_notify: notifyWanted,\n reason,\n short_reason: shortReason\n },\n ha_service_calls: {\n helper_recommended_mode: {\n domain: 'input_select',\n service: 'select_option',\n data: {\n entity_id: 'input_select.victron_water_heater_recommended_mode',\n option: recommendedMode\n }\n },\n helper_advice_color: {\n domain: 'input_select',\n service: 'select_option',\n data: {\n entity_id: 'input_select.victron_water_heater_advice_color',\n option: adviceColor\n }\n },\n helper_state: {\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.victron_water_heater_advice_state',\n value: state\n }\n },\n helper_reason: {\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.victron_water_heater_advice_reason',\n value: shortReason || 'Aucune raison'\n }\n },\n helper_signature: {\n execute: signatureChanged,\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.victron_water_heater_advice_last_signature',\n value: signature\n }\n },\n helper_projected_soc: {\n domain: 'input_number',\n service: 'set_value',\n data: {\n entity_id: 'input_number.victron_water_heater_projected_end_of_day_soc',\n value: round1(selectedProjectedSoc)\n }\n },\nhelper_budget_margin: {\n domain: 'input_number',\n service: 'set_value',\n data: {\n entity_id: 'input_number.victron_water_heater_budget_margin_kwh',\n value: Math.max(BUDGET_MARGIN_MIN, Math.min(BUDGET_MARGIN_MAX, round2(selectedBudgetMarginKwh)))\n }\n},\n helper_load_kwh: {\n domain: 'input_number',\n service: 'set_value',\n data: {\n entity_id: 'input_number.victron_load_water_heater_kwh',\n value: round2(selectedLoadKwh)\n }\n },\n helper_legacy_advice_ok: {\n domain: 'input_boolean',\n service: boolService(legacyAdviceOk),\n data: {\n entity_id: 'input_boolean.victron_advice_water_heater_ok'\n }\n },\n helper_green_allowed: {\n domain: 'input_boolean',\n service: boolService(greenAllowed),\n data: {\n entity_id: 'input_boolean.victron_water_heater_green_allowed'\n }\n },\n helper_auto_allowed: {\n domain: 'input_boolean',\n service: boolService(autoAllowed),\n data: {\n entity_id: 'input_boolean.victron_water_heater_auto_allowed'\n }\n },\n helper_boost_allowed: {\n domain: 'input_boolean',\n service: boolService(boostAllowed),\n data: {\n entity_id: 'input_boolean.victron_water_heater_boost_allowed'\n }\n },\n helper_observed_power: {\n domain: 'input_number',\n service: 'set_value',\n data: {\n entity_id: 'input_number.victron_water_heater_observed_power_w',\n value: Math.round(observedPowerW)\n }\n },\n helper_observed_regime: {\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.victron_water_heater_observed_regime',\n value: observedRegime\n }\n },\n helper_pending_user_action: {\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.victron_water_heater_pending_user_action',\n value: pendingUserActionSafe\n }\n },\n helper_mismatch_since: {\n execute: mismatchActive && !!mismatchSinceAtEffective,\n domain: 'input_datetime',\n service: 'set_datetime',\n data: {\n entity_id: 'input_datetime.victron_water_heater_mode_mismatch_since',\n datetime: mismatchSinceAtEffective\n }\n },\n helper_energy_prev_kwh: {\n domain: 'input_number',\n service: 'set_value',\n data: {\n entity_id: 'input_number.victron_water_heater_energy_total_prev_kwh',\n value: round2(waterHeaterEnergyTotalKwh)\n }\n },\n helper_energy_prev_at: {\n domain: 'input_datetime',\n service: 'set_datetime',\n data: {\n entity_id: 'input_datetime.victron_water_heater_energy_prev_at',\n datetime: now.stamp\n }\n },\n helper_forced_off_by_advisor: {\n domain: 'input_boolean',\n service: boolService(forcedOffByAdvisor),\n data: {\n entity_id: 'input_boolean.victron_water_heater_forced_off_by_advisor'\n }\n },\n helper_forced_off_reason: {\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.victron_water_heater_forced_off_reason',\n value: forcedOffReasonSafe\n }\n },\n helper_forced_off_at: {\n execute: forceOffNow && !!forcedOffAtSafe,\n domain: 'input_datetime',\n service: 'set_datetime',\n data: {\n entity_id: 'input_datetime.victron_water_heater_forced_off_at',\n datetime: forcedOffAtSafe\n }\n },\n // Jour faible (07/10/2026) : la coupure appartient a la regle « eau chaude en HC de nuit ».\n helper_off_jour_faible: {\n domain: 'input_boolean',\n service: boolService(offJourFaible),\n data: {\n entity_id: 'input_boolean.victron_water_heater_off_jour_faible'\n }\n },\n water_heater_power_switch: {\n execute: powerSwitchAction !== 'none',\n domain: 'switch',\n service: powerSwitchDesiredOn ? 'turn_on' : 'turn_off',\n data: {\n entity_id: 'switch.tt_chauffe_eau_pleumeur_switch'\n }\n },\n logbook: {\n execute: signatureChanged || forceOffNow || restoreNow,\n domain: 'logbook',\n service: 'log',\n data: {\n name: 'Water Heater Advisor',\n message: `${recommendedMode}/${adviceColor} | regime=${observedRegime} | prioEVCS=${evcsPriorityActive ? 'on' : 'off'} | cut=${forcedOffByAdvisor ? 'on' : 'off'} | ${shortReason}`,\n entity_id: 'switch.tt_chauffe_eau_pleumeur_switch'\n }\n },\n mobile_notification: {\n execute: notifyWanted,\n domain: cfg.mobileNotifyDomain,\n service: cfg.mobileNotifyService,\n data: {\n title: notifyTitle,\n message: notifyMessage,\n data: {\n push: {\n 'interruption-level': 'time-sensitive',\n sound: { name: 'default' }\n },\n ttl: 0,\n priority: 'high'\n }\n }\n }\n }\n};\n\nreturn [{ json: result }];" + "jsCode": "function round1(v) {\n return Math.round(v * 10) / 10;\n}\n\nfunction round2(v) {\n return Math.round(v * 100) / 100;\n}\n\nfunction safeText(v, max = 255) {\n return (v ?? '').toString().slice(0, max);\n}\n\nfunction boolService(flag) {\n return flag ? 'turn_on' : 'turn_off';\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\nconst root = $input.first()?.json ?? {};\nconst cfg = root.cfg ?? {};\nconst now = root.now ?? {};\nconst input = root.input ?? {};\nconst normalized = root.normalized ?? {};\nconst observation = root.observation ?? {};\nconst energyModel = root.energyModel ?? {};\nconst finalDecision = root.finalDecision ?? {};\n\nconst {\n soc,\n batteryCurrentA,\n pvPowerW,\n pvPowerAvg10mW,\n houseAcPowerW,\n gridPowerW,\n gridIsOn,\n acInAvailable,\n gridAvailable,\n todayRemainingKwh,\n tomorrowKwh,\n advisorEnabled,\n notificationsEnabled,\n hotWaterDemand,\n greenPowerW,\n autoPowerW,\n boostPowerW,\n strongForecastKwh,\n mediumForecastKwh,\n strongPvNowW,\n mediumPvNowW,\n boostMinSocStrongForecastPct,\n boostMinSocMediumForecastPct,\n endOfDaySocGuard,\n endOfDaySocTarget,\n baseLoadRemainingKwh,\n flexibleSafetyMarginKwh,\n acLoadsMaxSafeW,\n lastSignature\n} = normalized;\n\nconst {\n evcsPriorityActive,\n energyPriorityMode,\n energyPriorityReason,\n evcsPriorityUntilAt,\n waterHeaterSwitchOn,\n waterHeaterEnergyTotalKwh,\n restoreSurplusW,\n modeMismatchDelayMin,\n sampleDurationMin,\n energyDeltaKwh,\n sampleIsUsable,\n observedPowerW,\n observedRegime\n} = observation;\n\nconst {\n targetSoc,\n batteryCapacityKwh,\n greenEnergyKwh,\n autoEnergyKwh,\n boostEnergyKwh,\n energyNeededToReachTargetKwh,\n projectedGreenSoc,\n projectedAutoSoc,\n projectedBoostSoc,\n greenBudgetMarginKwh,\n autoBudgetMarginKwh,\n boostBudgetMarginKwh,\n forecastStrength,\n strongForecast,\n mediumForecast,\n strongPvNow,\n mediumPvNow,\n netPvHeadroomW,\n pvCoversHouseNow,\n pvStartedWell,\n acSafe,\n batteryNotDrainingHard,\n greenAllowed,\n autoAllowed,\n boostAllowedByBudget,\n boostAllowed,\n earlyBoostStrongReady,\n earlyBoostMediumReady,\n autoReady,\n greenReady\n} = energyModel;\n\nlet {\n recommendedMode,\n adviceColor,\n state,\n reason,\n baseRecommendedMode,\n baseAdviceColor,\n baseState,\n selectedLoadKwh,\n selectedProjectedSoc,\n selectedBudgetMarginKwh,\n legacyAdviceOk,\n mismatchActive,\n mismatchSeverity,\n mismatchSinceAtEffective,\n mismatchSinceMsEffective,\n mismatchDurationMin,\n pendingUserAction,\n firstMismatchRun,\n realConflictNow,\n forceOffNow,\n restoreEnergyOk,\n restoreNow,\n forcedOffByAdvisor,\n forcedOffReason,\n forcedOffAt,\n protectionState,\n powerSwitchAction,\n powerSwitchDesiredOn,\n offJourFaible = false,\n alerteChauffeEau = ''\n} = finalDecision;\n\nreason = Array.isArray(reason) ? [...reason] : [];\n\nconst forcedOffAtSafe = forcedOffAt && parseParisStampToMs(forcedOffAt) !== null ? forcedOffAt : '';\nconst forcedOffReasonSafe = forcedOffByAdvisor ? safeText(forcedOffReason, 255) : '';\nconst pendingUserActionSafe = safeText(pendingUserAction, 255);\n\nif (reason.length === 0) {\n if (recommendedMode === 'boost') reason.push('Boost recommandé');\n else if (recommendedMode === 'auto') reason.push('Mode auto recommandé');\n else if (recommendedMode === 'green') reason.push('Mode green recommandé');\n else reason.push('Chauffe-eau déconseillé pour le moment');\n}\n\nreason.push(`Demande ECS: ${hotWaterDemand}`);\nreason.push(`Conseil final: ${recommendedMode}`);\nreason.push(`Conseil initial: ${baseRecommendedMode}`);\nreason.push(`Couleur conseil: ${adviceColor}`);\nreason.push(`Forecast du jour: ${round1(todayRemainingKwh)} kWh`);\nreason.push(`Production demain: ${round1(tomorrowKwh)} kWh`);\nreason.push(`Classe forecast: ${forecastStrength}`);\nreason.push(`PV actuel: ${Math.round(pvPowerW)} W | PV10: ${Math.round(pvPowerAvg10mW)} W`);\nreason.push(`Charges AC maison: ${Math.round(houseAcPowerW)} W`);\nreason.push(`Surplus net estimé: ${Math.round(netPvHeadroomW)} W`);\nreason.push(`PV couvre maison: ${pvCoversHouseNow ? 'oui' : 'non'} | PV bien démarré: ${pvStartedWell ? 'oui' : 'non'}`);\nreason.push(`Batterie: SOC ${round1(soc)}% | courant ${round1(batteryCurrentA)} A`);\nreason.push(`Réseau: cmd=${gridIsOn ? 'ON' : 'OFF'} | acin=${acInAvailable ? 'OK' : 'KO'} | dispo=${gridAvailable ? 'oui' : 'non'}`);\nreason.push(`Target fin de jour: ${round1(targetSoc)}%`);\nreason.push(`Base load restante: ${round1(baseLoadRemainingKwh)} kWh`);\nreason.push(`Marge sécurité: ${round1(flexibleSafetyMarginKwh)} kWh`);\nreason.push(`Green ${round1(greenEnergyKwh)}kWh → SOC fin ${round1(projectedGreenSoc)}% | marge ${round1(greenBudgetMarginKwh)}kWh`);\nreason.push(`Auto ${round1(autoEnergyKwh)}kWh → SOC fin ${round1(projectedAutoSoc)}% | marge ${round1(autoBudgetMarginKwh)}kWh`);\nreason.push(`Boost ${round1(boostEnergyKwh)}kWh → SOC fin ${round1(projectedBoostSoc)}% | marge ${round1(boostBudgetMarginKwh)}kWh`);\nreason.push(`Régime observé: ${observedRegime} | puissance ≈ ${Math.round(observedPowerW)} W | delta ${sampleIsUsable ? round2(energyDeltaKwh) : 'n/a'} kWh/${sampleIsUsable ? round1(sampleDurationMin) : 'n/a'} min`);\nreason.push(`Prio EVCS: ${evcsPriorityActive ? 'ON' : 'OFF'}${energyPriorityReason ? ` | ${energyPriorityReason}` : ''}`);\nreason.push(`Mismatch: ${mismatchActive ? 'oui' : 'non'} | sévérité=${mismatchSeverity} | durée=${round1(mismatchDurationMin)} min`);\nreason.push(`Protection ECS: ${protectionState}`);\nif (pendingUserActionSafe) reason.push(`Action attendue: ${pendingUserActionSafe}`);\n\nconst shortReason = reason.join(' | ').slice(0, 255);\n\nconst signature = [\n now.date,\n `d=${hotWaterDemand}`,\n `m=${recommendedMode}`,\n `base=${baseRecommendedMode}`,\n `c=${adviceColor}`,\n `fs=${strongForecast ? '1' : '0'}`,\n `fm=${mediumForecast ? '1' : '0'}`,\n `pb=${pvStartedWell ? '1' : '0'}`,\n `soc=${round1(soc)}`,\n `pv=${Math.round(pvPowerW)}`,\n `pv10=${Math.round(pvPowerAvg10mW)}`,\n `npv=${Math.round(netPvHeadroomW)}`,\n `obs=${observedRegime}`,\n `prio=${evcsPriorityActive ? '1' : '0'}`,\n `cut=${forcedOffByAdvisor ? '1' : '0'}`\n].join('|');\n\nconst signatureChanged = signature !== lastSignature;\n\nlet notifyWanted = false;\nlet notifyTitle = 'Water Heater Advisor';\nlet notifyMessage = '';\n\nif (notificationsEnabled) {\n if (forceOffNow) {\n notifyWanted = true;\n notifyTitle = 'ECS coupé temporairement';\n notifyMessage = forcedOffReasonSafe || 'Le chauffe-eau a été coupé temporairement';\n } else if (restoreNow) {\n notifyWanted = true;\n notifyTitle = 'ECS remis sous tension';\n notifyMessage = 'Le chauffe-eau est réalimenté car la priorité EVCS est terminée ou le surplus est redevenu suffisant.';\n } else if (firstMismatchRun && pendingUserActionSafe) {\n notifyWanted = true;\n notifyTitle = 'Action utilisateur ECS conseillée';\n notifyMessage = pendingUserActionSafe;\n } else if (signatureChanged) {\n notifyWanted = true;\n notifyTitle = `Water Heater Advisor: ${recommendedMode.toUpperCase()} / ${adviceColor.toUpperCase()}`;\n notifyMessage = `Demande=${hotWaterDemand} | régime=${observedRegime} | SOC fin estimé=${round1(selectedProjectedSoc)}% | marge=${round1(selectedBudgetMarginKwh)} kWh`;\n }\n}\nconst BUDGET_MARGIN_MIN = -50;\nconst BUDGET_MARGIN_MAX = 50;\nconst result = {\n timestamp_paris: now.stamp,\n inputs: {\n soc_percent: soc,\n battery_current_a: batteryCurrentA,\n pv_power_w: pvPowerW,\n pv_power_10m_avg_w: pvPowerAvg10mW,\n house_ac_power_w: houseAcPowerW,\n grid_power_w: gridPowerW,\n grid_state: gridIsOn ? 'on' : 'off',\n victron_acin1_available: acInAvailable ? 1 : 0,\n today_remaining_kwh: round2(todayRemainingKwh),\n tomorrow_kwh: round2(tomorrowKwh),\n advisor_enabled: advisorEnabled,\n notifications_enabled: notificationsEnabled,\n hot_water_demand: hotWaterDemand,\n green_power_w: greenPowerW,\n auto_power_w: autoPowerW,\n boost_power_w: boostPowerW,\n strong_forecast_kwh: strongForecastKwh,\n medium_forecast_kwh: mediumForecastKwh,\n strong_pv_now_w: strongPvNowW,\n medium_pv_now_w: mediumPvNowW,\n boost_min_soc_strong_forecast_pct: boostMinSocStrongForecastPct,\n boost_min_soc_medium_forecast_pct: boostMinSocMediumForecastPct,\n end_of_day_soc_guard: endOfDaySocGuard,\n end_of_day_soc_target: endOfDaySocTarget,\n base_load_remaining_kwh: baseLoadRemainingKwh,\n flexible_safety_margin_kwh: flexibleSafetyMarginKwh,\n ac_loads_max_safe_w: acLoadsMaxSafeW,\n water_heater_switch: waterHeaterSwitchOn,\n water_heater_energy_total_kwh: round2(waterHeaterEnergyTotalKwh),\n last_signature: lastSignature || null,\n evcs_priority_active: evcsPriorityActive,\n energy_priority_mode: energyPriorityMode,\n energy_priority_reason: energyPriorityReason || null,\n evcs_priority_until: evcsPriorityUntilAt || null\n },\n computed: {\n forecast_strength: forecastStrength,\n strong_forecast: strongForecast,\n medium_forecast: mediumForecast,\n strong_pv_now: strongPvNow,\n medium_pv_now: mediumPvNow,\n net_pv_headroom_w: Math.round(netPvHeadroomW),\n pv_covers_house_now: pvCoversHouseNow,\n pv_started_well: pvStartedWell,\n battery_usable_capacity_kwh_internal: batteryCapacityKwh,\n energy_needed_to_reach_target_kwh: round2(energyNeededToReachTargetKwh),\n green_energy_kwh: round2(greenEnergyKwh),\n auto_energy_kwh: round2(autoEnergyKwh),\n boost_energy_kwh: round2(boostEnergyKwh),\n projected_green_soc: round1(projectedGreenSoc),\n projected_auto_soc: round1(projectedAutoSoc),\n projected_boost_soc: round1(projectedBoostSoc),\n green_budget_margin_kwh: round2(greenBudgetMarginKwh),\n auto_budget_margin_kwh: round2(autoBudgetMarginKwh),\n boost_budget_margin_kwh: round2(boostBudgetMarginKwh),\n ac_safe: acSafe,\n battery_not_draining_hard: batteryNotDrainingHard,\n green_allowed: greenAllowed,\n auto_allowed: autoAllowed,\n boost_allowed_by_budget: boostAllowedByBudget,\n boost_allowed: boostAllowed,\n early_boost_strong_ready: earlyBoostStrongReady,\n early_boost_medium_ready: earlyBoostMediumReady,\n auto_ready: autoReady,\n green_ready: greenReady,\n base_recommended_mode: baseRecommendedMode,\n base_advice_color: baseAdviceColor,\n base_state: baseState,\n observed_power_w: Math.round(observedPowerW),\n observed_regime: observedRegime,\n sample_duration_min: sampleIsUsable ? round1(sampleDurationMin) : null,\n energy_delta_kwh: sampleIsUsable ? round2(energyDeltaKwh) : null,\n mismatch_active: mismatchActive,\n mismatch_severity: mismatchSeverity,\n mismatch_duration_min: round1(mismatchDurationMin),\n evcs_priority_active: evcsPriorityActive,\n protection_state: protectionState,\n real_conflict_now: realConflictNow,\n restore_energy_ok: restoreEnergyOk,\n restore_surplus_w: restoreSurplusW,\n mode_mismatch_delay_min: modeMismatchDelayMin\n },\n decision: {\n recommended_mode: recommendedMode,\n advice_color: adviceColor,\n state,\n legacy_advice_ok: legacyAdviceOk,\n selected_load_kwh: round2(selectedLoadKwh),\n projected_end_of_day_soc: round1(selectedProjectedSoc),\n budget_margin_kwh: round2(selectedBudgetMarginKwh),\n observed_power_w: Math.round(observedPowerW),\n observed_regime: observedRegime,\n mismatch_active: mismatchActive,\n mismatch_severity: mismatchSeverity,\n mismatch_duration_min: round1(mismatchDurationMin),\n pending_user_action: pendingUserActionSafe,\n power_switch_action: powerSwitchAction,\n power_switch_desired_on: powerSwitchDesiredOn,\n force_off_now: forceOffNow,\n restore_now: restoreNow,\n forced_off_by_advisor: forcedOffByAdvisor,\n forced_off_reason: forcedOffReasonSafe,\n forced_off_at: forcedOffByAdvisor ? forcedOffAtSafe || null : null,\n signature,\n signature_changed: signatureChanged,\n should_notify: notifyWanted,\n reason,\n short_reason: shortReason\n },\n ha_service_calls: {\n helper_recommended_mode: {\n domain: 'input_select',\n service: 'select_option',\n data: {\n entity_id: 'input_select.victron_water_heater_recommended_mode',\n option: recommendedMode\n }\n },\n helper_advice_color: {\n domain: 'input_select',\n service: 'select_option',\n data: {\n entity_id: 'input_select.victron_water_heater_advice_color',\n option: adviceColor\n }\n },\n helper_state: {\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.victron_water_heater_advice_state',\n value: state\n }\n },\n helper_reason: {\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.victron_water_heater_advice_reason',\n value: shortReason || 'Aucune raison'\n }\n },\n helper_signature: {\n execute: signatureChanged,\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.victron_water_heater_advice_last_signature',\n value: signature\n }\n },\n helper_projected_soc: {\n domain: 'input_number',\n service: 'set_value',\n data: {\n entity_id: 'input_number.victron_water_heater_projected_end_of_day_soc',\n value: round1(selectedProjectedSoc)\n }\n },\nhelper_budget_margin: {\n domain: 'input_number',\n service: 'set_value',\n data: {\n entity_id: 'input_number.victron_water_heater_budget_margin_kwh',\n value: Math.max(BUDGET_MARGIN_MIN, Math.min(BUDGET_MARGIN_MAX, round2(selectedBudgetMarginKwh)))\n }\n},\n helper_load_kwh: {\n domain: 'input_number',\n service: 'set_value',\n data: {\n entity_id: 'input_number.victron_load_water_heater_kwh',\n value: round2(selectedLoadKwh)\n }\n },\n helper_legacy_advice_ok: {\n domain: 'input_boolean',\n service: boolService(legacyAdviceOk),\n data: {\n entity_id: 'input_boolean.victron_advice_water_heater_ok'\n }\n },\n helper_green_allowed: {\n domain: 'input_boolean',\n service: boolService(greenAllowed),\n data: {\n entity_id: 'input_boolean.victron_water_heater_green_allowed'\n }\n },\n helper_auto_allowed: {\n domain: 'input_boolean',\n service: boolService(autoAllowed),\n data: {\n entity_id: 'input_boolean.victron_water_heater_auto_allowed'\n }\n },\n helper_boost_allowed: {\n domain: 'input_boolean',\n service: boolService(boostAllowed),\n data: {\n entity_id: 'input_boolean.victron_water_heater_boost_allowed'\n }\n },\n helper_observed_power: {\n domain: 'input_number',\n service: 'set_value',\n data: {\n entity_id: 'input_number.victron_water_heater_observed_power_w',\n value: Math.round(observedPowerW)\n }\n },\n helper_observed_regime: {\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.victron_water_heater_observed_regime',\n value: observedRegime\n }\n },\n helper_pending_user_action: {\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.victron_water_heater_pending_user_action',\n value: pendingUserActionSafe\n }\n },\n helper_mismatch_since: {\n execute: mismatchActive && !!mismatchSinceAtEffective,\n domain: 'input_datetime',\n service: 'set_datetime',\n data: {\n entity_id: 'input_datetime.victron_water_heater_mode_mismatch_since',\n datetime: mismatchSinceAtEffective\n }\n },\n helper_energy_prev_kwh: {\n domain: 'input_number',\n service: 'set_value',\n data: {\n entity_id: 'input_number.victron_water_heater_energy_total_prev_kwh',\n value: round2(waterHeaterEnergyTotalKwh)\n }\n },\n helper_energy_prev_at: {\n domain: 'input_datetime',\n service: 'set_datetime',\n data: {\n entity_id: 'input_datetime.victron_water_heater_energy_prev_at',\n datetime: now.stamp\n }\n },\n helper_forced_off_by_advisor: {\n domain: 'input_boolean',\n service: boolService(forcedOffByAdvisor),\n data: {\n entity_id: 'input_boolean.victron_water_heater_forced_off_by_advisor'\n }\n },\n helper_forced_off_reason: {\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.victron_water_heater_forced_off_reason',\n value: forcedOffReasonSafe\n }\n },\n helper_forced_off_at: {\n execute: forceOffNow && !!forcedOffAtSafe,\n domain: 'input_datetime',\n service: 'set_datetime',\n data: {\n entity_id: 'input_datetime.victron_water_heater_forced_off_at',\n datetime: forcedOffAtSafe\n }\n },\n // Alerte « passe en BOOST » (07/10/2026), lue par le concierge OpenClaw.\n helper_alerte_chauffe_eau: {\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.evcs_alerte_chauffe_eau',\n value: alerteChauffeEau\n }\n },\n // Jour faible (07/10/2026) : la coupure appartient a la regle « eau chaude en HC de nuit ».\n helper_off_jour_faible: {\n domain: 'input_boolean',\n service: boolService(offJourFaible),\n data: {\n entity_id: 'input_boolean.victron_water_heater_off_jour_faible'\n }\n },\n water_heater_power_switch: {\n execute: powerSwitchAction !== 'none',\n domain: 'switch',\n service: powerSwitchDesiredOn ? 'turn_on' : 'turn_off',\n data: {\n entity_id: 'switch.tt_chauffe_eau_pleumeur_switch'\n }\n },\n logbook: {\n execute: signatureChanged || forceOffNow || restoreNow,\n domain: 'logbook',\n service: 'log',\n data: {\n name: 'Water Heater Advisor',\n message: `${recommendedMode}/${adviceColor} | regime=${observedRegime} | prioEVCS=${evcsPriorityActive ? 'on' : 'off'} | cut=${forcedOffByAdvisor ? 'on' : 'off'} | ${shortReason}`,\n entity_id: 'switch.tt_chauffe_eau_pleumeur_switch'\n }\n },\n mobile_notification: {\n execute: notifyWanted,\n domain: cfg.mobileNotifyDomain,\n service: cfg.mobileNotifyService,\n data: {\n title: notifyTitle,\n message: notifyMessage,\n data: {\n push: {\n 'interruption-level': 'time-sensitive',\n sound: { name: 'default' }\n },\n ttl: 0,\n priority: 'high'\n }\n }\n }\n }\n};\n\nreturn [{ json: result }];" }, "id": "481de0f8-22a8-4aae-b47d-493098383c6c", "name": "ECS Output Builder", @@ -167,7 +167,7 @@ "parameters": [ { "name": "template", - "value": "{#- 07/10/2026 : prevision lue comme le bilan (input_select.evcs_balance_forecast_source).\n VRM : reste du jour = heures restantes de la courbe horaire (heure en cours au\n prorata), demain = somme de la courbe de demain. Courbe absente ou\n malformee -> repli sur Forecast.Solar. -#}\n{%- set src = states('input_select.evcs_balance_forecast_source') -%}\n{%- set vt = states('input_text.evcs_balance_vrm_today').split(',') -%}\n{%- set vd = states('input_text.evcs_balance_vrm_tomorrow').split(',') -%}\n{%- set p = namespace(reste=0, demain=0, ok=(src == 'vrm' and vt | length == 24 and vd | length == 24)) -%}\n{%- if p.ok -%}\n {%- for i in range(now().hour, 24) -%}\n {%- set v = vt[i] | float(0) -%}\n {%- set p.reste = p.reste + (v * (1 - now().minute / 60) if i == now().hour else v) -%}\n {%- endfor -%}\n {%- for v in vd -%}{%- set p.demain = p.demain + (v | float(0)) -%}{%- endfor -%}\n{%- endif -%}\n{{ {\n \"soc_percent\": states(\"sensor.victron_battery_soc\"),\n \"battery_current_a\": states(\"sensor.victron_battery_current\"),\n \"pv_power_w\": states(\"sensor.victron_pv_power_system\"),\n \"pv_power_10m_avg_w\": states(\"sensor.victron_pv_power_10m_avg\"),\n \"house_ac_power_w\": states(\"sensor.victron_consumption_power_l1\"),\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 \"today_remaining_kwh\": (p.reste | round(2)) if p.ok else states(\"sensor.energy_production_today_remaining\"),\n \"tomorrow_kwh\": (p.demain | round(2)) if p.ok else states(\"sensor.energy_production_tomorrow\"),\n \"forecast_source\": \"vrm\" if p.ok else \"forecast_solar\",\n\n \"ecs_nuit\": states(\"input_boolean.evcs_balance_ecs_nuit\"),\n \"ecs_nuit_jour\": states(\"input_text.evcs_balance_ecs_nuit_jour\"),\n \"hc_session_committed\": states(\"input_boolean.victron_evcs_night_grid_session_committed\"),\n \"ecs_24h_kwh\": states(\"sensor.evcs_chauffe_eau_24h_kwh\"),\n \"ecs_jour_kwh\": states(\"sensor.evcs_chauffe_eau_jour_kwh\"),\n \"off_jour_faible_prev\": states(\"input_boolean.victron_water_heater_off_jour_faible\"),\n\n \"advisor_enabled\": states(\"input_boolean.victron_water_heater_advisor_enabled\"),\n \"notifications_enabled\": states(\"input_boolean.victron_water_heater_notifications\"),\n\n \"hot_water_demand\": states(\"input_select.victron_water_heater_hot_water_demand\"),\n \"morning_boost_priority_enabled\": states(\"input_boolean.victron_water_heater_morning_boost_priority_enabled\"),\n \"morning_boost_latest_time\": states(\"input_datetime.victron_water_heater_morning_boost_latest_time\"),\n \"late_red_time\": states(\"input_datetime.victron_water_heater_late_red_time\"),\n\n \"green_power_w\": states(\"input_number.victron_water_heater_green_power_w\"),\n \"auto_power_w\": states(\"input_number.victron_water_heater_auto_power_w\"),\n \"boost_power_w\": states(\"input_number.victron_water_heater_boost_power_w\"),\n\n \"strong_forecast_kwh\": states(\"input_number.victron_water_heater_strong_forecast_kwh\"),\n \"medium_forecast_kwh\": states(\"input_number.victron_water_heater_medium_forecast_kwh\"),\n \"strong_pv_now_w\": states(\"input_number.victron_water_heater_strong_pv_now_w\"),\n \"medium_pv_now_w\": states(\"input_number.victron_water_heater_medium_pv_now_w\"),\n\n \"morning_boost_min_soc_strong_day\": states(\"input_number.victron_water_heater_morning_boost_min_soc_strong_day\"),\n \"boost_min_soc_medium_day\": states(\"input_number.victron_water_heater_boost_min_soc_medium_day\"),\n \"end_of_day_soc_guard\": states(\"input_number.victron_water_heater_end_of_day_soc_guard\"),\n\n \"end_of_day_soc_target\": states(\"input_number.victron_end_of_day_soc_target\"),\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 \"ac_loads_max_safe_w\": states(\"input_number.victron_ac_loads_max_safe_w\"),\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 \"last_signature\": states(\"input_text.victron_water_heater_advice_last_signature\"),\n\n \"evcs_priority_active\": states(\"input_boolean.victron_evcs_priority_active\"),\n \"energy_priority_mode\": states(\"input_select.victron_energy_priority_mode\"),\n \"energy_priority_reason\": states(\"input_text.victron_energy_priority_reason\"),\n \"evcs_priority_until\": states(\"input_datetime.victron_evcs_priority_until\"),\n\n \"water_heater_switch\": states(\"switch.tt_chauffe_eau_pleumeur_switch\"),\n \"water_heater_energy_total_kwh\": states(\"sensor.tt_chauffe_eau_pleumeur_energie_totale\"),\n\n \"energy_total_prev_kwh\": states(\"input_number.victron_water_heater_energy_total_prev_kwh\"),\n \"energy_prev_at\": states(\"input_datetime.victron_water_heater_energy_prev_at\"),\n\n \"observed_regime_helper\": states(\"input_text.victron_water_heater_observed_regime\"),\n \"observed_power_helper_w\": states(\"input_number.victron_water_heater_observed_power_w\"),\n\n \"mode_mismatch_since\": states(\"input_datetime.victron_water_heater_mode_mismatch_since\"),\n\n \"forced_off_by_advisor\": states(\"input_boolean.victron_water_heater_forced_off_by_advisor\"),\n \"forced_off_reason\": states(\"input_text.victron_water_heater_forced_off_reason\"),\n \"forced_off_at\": states(\"input_datetime.victron_water_heater_forced_off_at\"),\n\n \"pending_user_action_prev\": states(\"input_text.victron_water_heater_pending_user_action\"),\n\n \"restore_surplus_w\": states(\"input_number.victron_water_heater_restore_surplus_w\"),\n \"mode_mismatch_delay_min\": states(\"input_number.victron_water_heater_mode_mismatch_delay_min\")\n} | tojson }}\n" + "value": "{#- 07/10/2026 : prevision lue comme le bilan (input_select.evcs_balance_forecast_source).\n VRM : reste du jour = heures restantes de la courbe horaire (heure en cours au\n prorata), demain = somme de la courbe de demain. Courbe absente ou\n malformee -> repli sur Forecast.Solar. -#}\n{%- set src = states('input_select.evcs_balance_forecast_source') -%}\n{%- set vt = states('input_text.evcs_balance_vrm_today').split(',') -%}\n{%- set vd = states('input_text.evcs_balance_vrm_tomorrow').split(',') -%}\n{%- set p = namespace(reste=0, demain=0, ok=(src == 'vrm' and vt | length == 24 and vd | length == 24)) -%}\n{%- if p.ok -%}\n {%- for i in range(now().hour, 24) -%}\n {%- set v = vt[i] | float(0) -%}\n {%- set p.reste = p.reste + (v * (1 - now().minute / 60) if i == now().hour else v) -%}\n {%- endfor -%}\n {%- for v in vd -%}{%- set p.demain = p.demain + (v | float(0)) -%}{%- endfor -%}\n{%- endif -%}\n{{ {\n \"soc_percent\": states(\"sensor.victron_battery_soc\"),\n \"battery_current_a\": states(\"sensor.victron_battery_current\"),\n \"pv_power_w\": states(\"sensor.victron_pv_power_system\"),\n \"pv_power_10m_avg_w\": states(\"sensor.victron_pv_power_10m_avg\"),\n \"house_ac_power_w\": states(\"sensor.victron_consumption_power_l1\"),\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 \"today_remaining_kwh\": (p.reste | round(2)) if p.ok else states(\"sensor.energy_production_today_remaining\"),\n \"tomorrow_kwh\": (p.demain | round(2)) if p.ok else states(\"sensor.energy_production_tomorrow\"),\n \"forecast_source\": \"vrm\" if p.ok else \"forecast_solar\",\n\n \"ecs_nuit\": states(\"input_boolean.evcs_balance_ecs_nuit\"),\n \"ecs_nuit_jour\": states(\"input_text.evcs_balance_ecs_nuit_jour\"),\n \"hc_session_committed\": states(\"input_boolean.victron_evcs_night_grid_session_committed\"),\n \"ecs_24h_kwh\": states(\"sensor.evcs_chauffe_eau_24h_kwh\"),\n \"ecs_jour_kwh\": states(\"sensor.evcs_chauffe_eau_jour_kwh\"),\n \"off_jour_faible_prev\": states(\"input_boolean.victron_water_heater_off_jour_faible\"),\n \"hc_midday_start\": states(\"input_datetime.victron_evcs_hc_midday_start\"),\n \"hc_midday_stop\": states(\"input_datetime.victron_evcs_hc_midday_stop\"),\n \"hc_night_start\": states(\"input_datetime.victron_evcs_hc_night_start\"),\n \"hc_night_stop\": states(\"input_datetime.victron_evcs_hc_night_stop\"),\n\n \"advisor_enabled\": states(\"input_boolean.victron_water_heater_advisor_enabled\"),\n \"notifications_enabled\": states(\"input_boolean.victron_water_heater_notifications\"),\n\n \"hot_water_demand\": states(\"input_select.victron_water_heater_hot_water_demand\"),\n \"morning_boost_priority_enabled\": states(\"input_boolean.victron_water_heater_morning_boost_priority_enabled\"),\n \"morning_boost_latest_time\": states(\"input_datetime.victron_water_heater_morning_boost_latest_time\"),\n \"late_red_time\": states(\"input_datetime.victron_water_heater_late_red_time\"),\n\n \"green_power_w\": states(\"input_number.victron_water_heater_green_power_w\"),\n \"auto_power_w\": states(\"input_number.victron_water_heater_auto_power_w\"),\n \"boost_power_w\": states(\"input_number.victron_water_heater_boost_power_w\"),\n\n \"strong_forecast_kwh\": states(\"input_number.victron_water_heater_strong_forecast_kwh\"),\n \"medium_forecast_kwh\": states(\"input_number.victron_water_heater_medium_forecast_kwh\"),\n \"strong_pv_now_w\": states(\"input_number.victron_water_heater_strong_pv_now_w\"),\n \"medium_pv_now_w\": states(\"input_number.victron_water_heater_medium_pv_now_w\"),\n\n \"morning_boost_min_soc_strong_day\": states(\"input_number.victron_water_heater_morning_boost_min_soc_strong_day\"),\n \"boost_min_soc_medium_day\": states(\"input_number.victron_water_heater_boost_min_soc_medium_day\"),\n \"end_of_day_soc_guard\": states(\"input_number.victron_water_heater_end_of_day_soc_guard\"),\n\n \"end_of_day_soc_target\": states(\"input_number.victron_end_of_day_soc_target\"),\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 \"ac_loads_max_safe_w\": states(\"input_number.victron_ac_loads_max_safe_w\"),\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 \"last_signature\": states(\"input_text.victron_water_heater_advice_last_signature\"),\n\n \"evcs_priority_active\": states(\"input_boolean.victron_evcs_priority_active\"),\n \"energy_priority_mode\": states(\"input_select.victron_energy_priority_mode\"),\n \"energy_priority_reason\": states(\"input_text.victron_energy_priority_reason\"),\n \"evcs_priority_until\": states(\"input_datetime.victron_evcs_priority_until\"),\n\n \"water_heater_switch\": states(\"switch.tt_chauffe_eau_pleumeur_switch\"),\n \"water_heater_energy_total_kwh\": states(\"sensor.tt_chauffe_eau_pleumeur_energie_totale\"),\n\n \"energy_total_prev_kwh\": states(\"input_number.victron_water_heater_energy_total_prev_kwh\"),\n \"energy_prev_at\": states(\"input_datetime.victron_water_heater_energy_prev_at\"),\n\n \"observed_regime_helper\": states(\"input_text.victron_water_heater_observed_regime\"),\n \"observed_power_helper_w\": states(\"input_number.victron_water_heater_observed_power_w\"),\n\n \"mode_mismatch_since\": states(\"input_datetime.victron_water_heater_mode_mismatch_since\"),\n\n \"forced_off_by_advisor\": states(\"input_boolean.victron_water_heater_forced_off_by_advisor\"),\n \"forced_off_reason\": states(\"input_text.victron_water_heater_forced_off_reason\"),\n \"forced_off_at\": states(\"input_datetime.victron_water_heater_forced_off_at\"),\n\n \"pending_user_action_prev\": states(\"input_text.victron_water_heater_pending_user_action\"),\n\n \"restore_surplus_w\": states(\"input_number.victron_water_heater_restore_surplus_w\"),\n \"mode_mismatch_delay_min\": states(\"input_number.victron_water_heater_mode_mismatch_delay_min\")\n} | tojson }}\n" } ] },