From 8709e436dfd9d185dde4398a2874df65b74dc2dc Mon Sep 17 00:00:00 2001 From: Tallagrand Thierry Date: Fri, 18 Sep 2026 02:11:56 +0200 Subject: [PATCH] La consigne reseau se dimensionne sur le deficit du bilan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Elle empruntait le plafond de la borne — `maison + physicalCapA x V` — qui n a aucune relation physique avec ce dont la batterie a besoin. Qu il tombe juste etait un accident : le 17/09 a 13:51, voiture partie depuis cinq minutes, consigne a 7032 W pour une maison a 933 W. Elle additionne desormais des besoins NOMMES : la maison toujours, la voiture seulement si un vehicule est branche, la batterie seulement dans une fenetre d heures creuses — recharger la batterie sur le reseau n a de sens economique que la. Hors fenetre le terme vaut zero, donc la division par un temps qui tend vers zero disparait par construction et non par garde. MAX ET NON ADDITION. La consigne est une AUTORISATION, pas une prediction : le fichier le dit deja — « la consigne dit ce que le reseau a le DROIT de servir, DVCC dit ce que la batterie a le droit de prendre ». Additionner deux autorisations independantes n est pas plus honnete, seulement plus permissif. Mesure : avec l addition, `consigne-session-engagee-couvre-la-maison` passe de 5595 a 6341 W — une autorisation en plus sur le chemin dominant (85 nuits sur 88 ont un vehicule) pour corriger un chemin emprunte zero fois en treize jours. LE TERME BATTERIE EST BORNE PAR DVCC, PAS PAR LE BMS NI PAR LE TEMPS. Le BMS declare 250 A, soit 12,2 kW : au-dessus de GRID_SETPOINT_MAX_W, il ne bornerait rien. Et un plancher d horizon ne borne que le diviseur : en fin de fenetre (14 min, manque 5,672 kWh) la division rend 17 kW, ecretes a 10 000, soit PIRE que les 5437 empruntes qu on remplace. Avec DVCC le terme vaut 1538 W et la consigne 2380 W. Corollaire : le plancher d horizon devient sans objet, il ne reste qu une garde d une minute contre la division par zero. Le manque vient de `evcs_balance_grid_energy_needed_kwh` et non de `energy_to_cover_kwh` : la premiere est le MANQUE, nulle des que la batterie couvre et convertie par la courbe mesuree par morceaux ; la seconde est l energie que la batterie devra FOURNIR et ne tombe jamais a zero tant qu il reste de la nuit — 11,96 kWh annonces le 17/09 a 20:00 avec un SOC de 91 %. Le plancher nuage suit la voiture et PAS la batterie : il couvre l exposition creee par le courant du vehicule ; une recharge batterie qui ralentit quand le solaire s effondre ne coupe personne. INVARIANT TENU PAR LA FORME : `maison + max(...)` ne peut jamais descendre sous la maison, quel que soit DVCC. La regle du 01/09 et du 04/09 est donc garantie par construction. Toute lecture manquante retombe sur la formule d avant. Golden 102 -> 104. `nuit-sans-vehicule-session-engage` passe de 5437 a 2380 et recoit le manque explicitement, sans quoi il heritait du zero de la fixture et decrivait un etat impossible (SOC 20 pour un requis de 59,1 avec batterie couverte). Deux scenarios ajoutes : la fin de fenetre bornee par DVCC — celui qui protege contre la regression de 10 kW — et le contrefactuel a vehicule branche qui epingle le choix du max. Mutations : plafond DVCC retire -> 2 echecs dont la fin de fenetre ; max remplace par une addition -> le contrefactuel seul ; bilan ignore -> les deux scenarios pilotes par le bilan. Les autres suites sont intactes (hysteresis 24, ACM 20, advisor, bilan 48). ⚠️ RIEN N EST POUSSE VERS n8n : la regle du depot impose la validation du proprietaire avant tout PUT. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01GwpFpy2aQ2rJok6tNQSbVj --- src/nodes/HA_SERVICE_CALLS_OUTPUT.js | 112 +++++++++++++++++++- src/nodes/HA_SNAPSHOT_TEMPLATE.json.tpl | 2 + src/nodes/INPUT_PARSE.js | 20 ++++ src/nodes/WINDOWS_SOURCEV2.js | 25 +++++ tests/golden/fixture-snapshot.json | 4 +- tests/golden/scenarios.mjs | 72 +++++++++++++ workflows/evcs-surplus-controller-v2.6.json | 8 +- 7 files changed, 235 insertions(+), 8 deletions(-) diff --git a/src/nodes/HA_SERVICE_CALLS_OUTPUT.js b/src/nodes/HA_SERVICE_CALLS_OUTPUT.js index c2a2f3e..0ad912d 100644 --- a/src/nodes/HA_SERVICE_CALLS_OUTPUT.js +++ b/src/nodes/HA_SERVICE_CALLS_OUTPUT.js @@ -359,8 +359,96 @@ const gridSessionActive = night.nightGridSessionCommitted === true ); +// ===================================================== +// DIMENSIONNEMENT SUR LE DEFICIT DU BILAN (18/09/2026) +// ===================================================== +// Ce que la consigne remplace : `maison + plafond de la borne`, ou le plafond +// de la borne n'a AUCUNE relation physique avec ce dont la batterie a besoin. +// Qu'il tombe juste est un accident — releve du 17/09 a 13:51, voiture partie +// depuis cinq minutes, consigne a 7032 W pour une maison a 933 W. +// +// La consigne additionne desormais des besoins NOMMES : +// maison toujours ; +// voiture seulement si un vehicule est branche ; +// batterie seulement dans une fenetre d'heures creuses. +// +// ⚠️ MAX ET NON ADDITION, et ce n'est pas de la prudence. La consigne est une +// AUTORISATION, pas une prediction de consommation — le commentaire de la bande +// asymetrique le dit plus bas : « la consigne dit ce que le reseau a le DROIT +// de servir, DVCC dit ce que la batterie a le droit de prendre ». Additionner +// deux autorisations independantes n'est pas plus honnete, seulement plus +// permissif. Mesure a l'appui : avec l'addition, +// `consigne-session-engagee-couvre-la-maison` passe de 5595 a 6341 W, soit +// 746 W d'autorisation en plus sur une nuit engagee qui fonctionne deja — une +// regression sur le chemin DOMINANT pour corriger un chemin emprunte zero fois +// en treize jours. +// +// ⚠️ ET LE PLAFOND DU TERME BATTERIE EST DVCC, PAS LE BMS. Le BMS declare +// 250 A, soit 12,2 kW a 49 V : au-dessus de GRID_SETPOINT_MAX_W, donc il ne +// bornerait rien. Mesure : en fin de fenetre (14 min restantes, manque +// 5,672 kWh) la division rendait 17 kW, ecretes a 10 000 — PIRE que les 5437 +// d'aujourd'hui. Avec DVCC le terme vaut 1538 W et la consigne 2380 W. +// Composer les deux leviers, ce n'est pas les confondre : le reseau n'est +// autorise a servir la batterie qu'a hauteur de ce que la batterie est +// autorisee a prendre. +// +// Corollaire : aucun plancher d'horizon n'est necessaire. Un horizon qui tend +// vers zero donne `min(enorme, DVCC) = DVCC`. Il ne reste qu'une garde d'une +// minute, qui est de l'arithmetique defensive et non une constante calibree. +// +// INVARIANT TENU PAR LA FORME : `maison + max(...)` ne peut jamais descendre +// sous la maison, quel que soit DVCC. La regle du 01/09 et du 04/09 — « tant +// que le contacteur est ferme A NOTRE DEMANDE, la consigne doit couvrir la +// maison » — est donc garantie par construction. A DVCC nul, le plafond vaut +// exactement la maison. +const SETPOINT_BALANCE_MAX_AGE_MIN = 30; +const setpointBalanceAgeMin = Number.isFinite(params.balanceLastRunMs) + ? (nowMs - params.balanceLastRunMs) / 60000 + : null; + +// Le manque, pas l'energie a couvrir : voir le commentaire d'INPUT_PARSE. +const balanceUsableForSetpoint = + params.balanceStatusOk && + Number.isFinite(params.balanceGridEnergyNeededKwh) && + setpointBalanceAgeMin !== null && + setpointBalanceAgeMin >= -5 && + setpointBalanceAgeMin <= SETPOINT_BALANCE_MAX_AGE_MIN; + +const dvccChargeLimitW = + Number.isFinite(params.dvccChargeLimitA) && params.dvccChargeLimitA >= 0 + ? params.dvccChargeLimitA * derived.batteryVoltageRef + : null; + +// Toute lecture manquante retombe sur la formule d'avant : degradation vers +// l'existant, jamais pire. +const setpointFromBalance = balanceUsableForSetpoint && dvccChargeLimitW !== null; + +const evcsSetpointShareW = parsed.evcsVehicleConnected + ? derived.physicalCapA * derived.acVoltageRef + : 0; + +// Recharger la batterie sur le reseau n'a de sens economique QUE dans une +// fenetre creuse. Hors fenetre le terme vaut zero — ce n'est pas un repli +// faute d'horizon, c'est le comportement voulu, et le probleme de la division +// par un temps qui tend vers zero disparait par construction. +const hcMinutesLeft = Number.isFinite(windows.minutesRemainingInHcWindow) + ? windows.minutesRemainingInHcWindow + : null; + +const batterySetpointShareW = + setpointFromBalance && hcMinutesLeft !== null && params.balanceGridEnergyNeededKwh > 0 + ? Math.min( + (params.balanceGridEnergyNeededKwh * 1000) / Math.max(hcMinutesLeft / 60, 1 / 60), + dvccChargeLimitW + ) + : 0; + const gridSetpointCeilingW = clamp( - Math.round(derived.houseNonEvcsW + derived.physicalCapA * derived.acVoltageRef), + Math.round( + setpointFromBalance + ? derived.houseNonEvcsW + Math.max(evcsSetpointShareW, batterySetpointShareW) + : derived.houseNonEvcsW + derived.physicalCapA * derived.acVoltageRef + ), params.gridSetpointIdleW, GRID_SETPOINT_MAX_W ); @@ -431,8 +519,16 @@ const batterySurgeBudgetW = Math.max( 0, Math.abs(batteryPolicy.batteryDischargeHardDynamicA) * derived.batteryVoltageRef * 0.8 ); -const gridSetpointLoadW = - derived.houseNonEvcsW + derived.physicalCapA * derived.acVoltageRef; +// ⚠️ LE PLANCHER NUAGE SUIT LA VOITURE, PAS LA BATTERIE. Il existe pour que la +// consigne ne descende pas au point ou la perte totale du solaire depasserait +// ce que la batterie sait encaisser — et le commentaire ci-dessus dit pourquoi : +// « c'est le courant de la voiture qui cree l'exposition ». Une batterie qu'on +// recharge depuis le reseau n'est PAS une exposition : si le solaire s'effondre, +// la recharge ralentit et personne ne se fait couper. L'y inclure remonterait le +// plancher sans aucune contrepartie de securite, et contre l'objectif. +const gridSetpointLoadW = setpointFromBalance + ? derived.houseNonEvcsW + evcsSetpointShareW + : derived.houseNonEvcsW + derived.physicalCapA * derived.acVoltageRef; const gridSetpointCloudFloorW = Math.round( Math.max(0, gridSetpointLoadW - batterySurgeBudgetW) ); @@ -736,6 +832,16 @@ return [{ in_midday_window: windows.inMiddayWindow, in_evening_window: windows.inEveningWindow, + // D'ou vient la consigne reseau. Sans cette trace, un repli est + // SILENCIEUX : la consigne reprend la formule d'avant sans que rien ne le + // dise, et c'est precisement l'information qui manquera le jour ou elle se + // comportera mal. `bilan` = dimensionnee sur le manque, `heritee` = repli. + setpoint_source: setpointFromBalance ? 'bilan' : 'heritee', + setpoint_battery_share_w: Math.round(batterySetpointShareW), + setpoint_evcs_share_w: Math.round(evcsSetpointShareW), + dvcc_limit_w: dvccChargeLimitW === null ? null : Math.round(dvccChargeLimitW), + hc_minutes_left: hcMinutesLeft, + departure_priority_active: context.departurePriorityActive, minutes_until_departure: lifecycle.minutesUntilDeparture, diff --git a/src/nodes/HA_SNAPSHOT_TEMPLATE.json.tpl b/src/nodes/HA_SNAPSHOT_TEMPLATE.json.tpl index 50d083d..5c0a8f1 100644 --- a/src/nodes/HA_SNAPSHOT_TEMPLATE.json.tpl +++ b/src/nodes/HA_SNAPSHOT_TEMPLATE.json.tpl @@ -147,6 +147,8 @@ "night_battery_done_prev": states("input_boolean.victron_evcs_night_battery_done"), "balance_required_soc": states("input_number.evcs_balance_required_soc"), "balance_energy_to_cover_kwh": states("input_number.evcs_balance_energy_to_cover_kwh"), + "balance_grid_energy_needed_kwh": states("input_number.evcs_balance_grid_energy_needed_kwh"), + "dvcc_max_charge_current_a": states("sensor.victron_max_charge_current_setting"), "balance_status": states("input_text.evcs_balance_status"), "balance_last_run": states("input_text.evcs_balance_last_run"), "allow_grid_hc_midday": states("input_boolean.victron_evcs_allow_grid_hc_midday"), diff --git a/src/nodes/INPUT_PARSE.js b/src/nodes/INPUT_PARSE.js index 80eb9f0..68c5d9e 100644 --- a/src/nodes/INPUT_PARSE.js +++ b/src/nodes/INPUT_PARSE.js @@ -478,6 +478,26 @@ const params = { nightBatteryChargeEnabled: low(input.night_battery_charge_enabled || 'off') === 'on', nightBatteryDonePrev: low(input.night_battery_done_prev || 'off') === 'on', balanceEnergyToCoverKwh: num(input.balance_energy_to_cover_kwh, NaN), + // ⚠️ NE PAS CONFONDRE LES DEUX GRANDEURS DU BILAN. + // `balanceEnergyToCoverKwh` est l'energie que la batterie devra FOURNIR + // jusqu'au croisement PV ; elle sert a CALCULER la cible + // (`requiredSocPct = socForEnergy(energyToCoverKwh, floor)`) et ne tombe + // jamais a zero tant qu'il reste de la nuit a traverser, meme batterie + // pleine. Releve du 17/09 a 20:00 : 11,96 kWh « a couvrir » avec un SOC de + // 91 %. La prendre pour un deficit ferait reclamer du reseau toute la nuit. + // + // `balanceGridEnergyNeededKwh` est le MANQUE : + // `socMargin < 0 ? energyBetweenSoc(soc, requiredSoc) : 0` (BALANCE.js l.234) + // — nul des que la batterie couvre, et converti par la COURBE par morceaux + // mesuree le 03/09 (0,097 kWh/point sous le genou de 33 %, 0,169 au-dessus). + // C'est donc la seule des deux qui soit juste sous le genou, c'est-a-dire au + // SOC ou l'on decide de fermer le contacteur. + balanceGridEnergyNeededKwh: num(input.balance_grid_energy_needed_kwh, NaN), + // Ce que l'Auto Charge Manager autorise la batterie a PRENDRE (retour de + // lecture de DVCC). A ne pas confondre avec `bms_max_charge_current_a`, qui + // est le maximum tolere par le BMS — 250 A, soit plus de 12 kW, donc + // au-dessus de GRID_SETPOINT_MAX_W : il ne bornerait rien. + dvccChargeLimitA: num(input.dvcc_max_charge_current_a, NaN), balanceStatusOk: cleanText(input.balance_status).toLowerCase() === 'ok', balanceLastRunMs: parseRunStampToMs(cleanText(input.balance_last_run)), diff --git a/src/nodes/WINDOWS_SOURCEV2.js b/src/nodes/WINDOWS_SOURCEV2.js index 107ecf7..d63f035 100644 --- a/src/nodes/WINDOWS_SOURCEV2.js +++ b/src/nodes/WINDOWS_SOURCEV2.js @@ -15,6 +15,23 @@ function isWithinWindow(nowMin, startMin, stopMin) { return nowMin >= startMin || nowMin < stopMin; } +// Temps restant dans une fenetre, en minutes, ou null si on n'y est pas. +// +// ⚠️ IL VIT ICI, A COTE DE `isWithinWindow`, ET PAS DANS LE NOEUD QUI L'UTILISE. +// Ce qui serait duplique ailleurs n'est pas l'analyse des heures — INPUT_PARSE +// les a deja converties en minutes — c'est la LOGIQUE D'APPARTENANCE, et en +// particulier le traitement du passage de minuit. Une seconde interpretation +// divergerait le jour ou une borne bouge, et elles ont deja bouge une fois +// (01:30 -> 01:24 le 09/07/2026). +function minutesRemainingInWindow(nowMin, startMin, stopMin) { + if (!isWithinWindow(nowMin, startMin, stopMin)) return null; + if (startMin < stopMin) return stopMin - nowMin; + // Fenetre a cheval sur minuit : avant minuit il reste la fin de la nuit EN + // PLUS du reste de la journee. Atteignable par les valeurs de repli + // d'INPUT_PARSE (23:00 -> 06:00) si les helpers d'horaire sont illisibles. + return nowMin >= startMin ? stopMin + 1440 - nowMin : stopMin - nowMin; +} + function clamp(v, min, max) { return Math.min(Math.max(v, min), max); } @@ -1031,6 +1048,14 @@ return [{ inMorningWindow, inMiddayWindow, inEveningWindow, + // Temps restant dans la fenetre d'HEURES CREUSES courante (nuit ou midi), + // null hors de toute fenetre creuse. Consomme par le dimensionnement de + // la consigne reseau : recharger la batterie sur le reseau n'a de sens + // economique que la-dedans, donc `null` y vaut « pas de part batterie » + // sans test supplementaire. + minutesRemainingInHcWindow: + minutesRemainingInWindow(now.minNow, params.hcNightStartMin, params.hcNightStopMin) + ?? minutesRemainingInWindow(now.minNow, params.hcMiddayStartMin, params.hcMiddayStopMin), }, derived: { diff --git a/tests/golden/fixture-snapshot.json b/tests/golden/fixture-snapshot.json index f8a6440..f1d7030 100644 --- a/tests/golden/fixture-snapshot.json +++ b/tests/golden/fixture-snapshot.json @@ -171,5 +171,7 @@ "vebus_overload": "off", "vebus_state": "9", "victron_acin1_available": "0", - "victron_evcs_strategy": "auto_solar_plus" + "victron_evcs_strategy": "auto_solar_plus", + "balance_grid_energy_needed_kwh": "0.0", + "dvcc_max_charge_current_a": "30" } diff --git a/tests/golden/scenarios.mjs b/tests/golden/scenarios.mjs index 870ebfa..59bb140 100644 --- a/tests/golden/scenarios.mjs +++ b/tests/golden/scenarios.mjs @@ -2322,12 +2322,84 @@ export const scenarios = [ grid_setpoint_w: '700', balance_required_soc: '59.1', balance_energy_to_cover_kwh: '3.13', balance_status: 'ok', balance_last_run: '08/08 03:58', + // Le MANQUE, pose explicitement : sans lui le scenario heriterait du 0 de + // la fixture, ce qui decrirait une batterie couverte alors qu'il declare + // un SOC de 20 % pour un requis de 59,1 % — un etat impossible. + // Valeur calculee avec la courbe mesuree, parc de reference (7 modules, + // echelle 1) : 13 points sous le genou de 33 % a 0,097 = 1,261 kWh, puis + // 26,1 points a 0,169 = 4,411 kWh, soit 5,672 kWh. Ce scenario est le seul + // du corpus dont le deficit TRAVERSE le genou. + balance_grid_energy_needed_kwh: '5.672', }, expect: { profile: 'night_hc', takeoverNeeded: true, takeoverHelperService: 'turn_on', sessionCommittedService: 'turn_on', + // 18/09/2026 : 5437 -> 2380. Les 5437 valaient 612 de maison + 4825 + // empruntes au plafond d'une borne ou aucun vehicule n'est branche. La + // nouvelle valeur vaut 612 + le manque reel borne par DVCC (30 A, soit + // 1538 W a la tension de la fixture), plus la marge d'ecriture d'un pas + // de courant. ⚠️ Attendu calcule sur la FIXTURE et jamais sur la tension + // du jour : le recalculer sur l'installation vivante rendrait la suite + // rouge un matin ou la batterie est a 49 V sans que rien ne soit casse. + gridSetpointW: 2380, + }, + }, + { + id: 'consigne-bilan-fin-de-fenetre-bornee-par-dvcc', + label: "CONSIGNE 18/09 — meme manque a 07:10, 14 min de fenetre : c'est DVCC qui borne, pas le temps", + time: '2026-08-08T07:10:00+02:00', + overrides: { + evcs_vehicle_connected: 'off', evcs_device_connected: 'on', + grid_state: 'on', victron_acin1_available: '1', + soc_percent: '20', battery_current_a: '0', + pv_power_w: '0', pv_power_10m_avg_w: '0', pv_trend_w: '0', + house_ac_power_w: '612', vebus_ac_out_l1_power_w: '612', + evcs_ac_power_w: '0', + grid_setpoint_w: '700', + balance_required_soc: '59.1', balance_energy_to_cover_kwh: '3.13', + balance_status: 'ok', balance_last_run: '08/08 07:08', + balance_grid_energy_needed_kwh: '5.672', + }, + expect: { + profile: 'night_hc', + // ⚠️ LE SCENARIO QUI PROTEGE CONTRE LA REGRESSION LA PLUS GRAVE de cette + // conception. 5,672 kWh a combler en 14 minutes font 17 kW : avec un + // simple plancher d'horizon, la consigne saturait sur GRID_SETPOINT_MAX_W + // (10 000 W), soit PIRE que les 5437 du plafond emprunte qu'on remplace. + // Le plafond DVCC ramene le terme a 1538 W, et la consigne a la meme + // valeur qu'a 04:00 — parce que ce qui borne n'est pas le temps qui + // reste, c'est ce que la batterie a le droit de prendre. + // Le plafond BMS n'aurait rien borne : 250 A a 49 V font 12,2 kW, + // au-dessus des 10 000 de l'ecretage. + gridSetpointW: 2380, + }, + }, + { + id: 'consigne-bilan-max-la-voiture-domine', + label: 'CONTREFACTUEL 18/09 — meme nuit, meme manque, mais VEHICULE BRANCHE : le max retient la voiture, pas la somme', + time: '2026-08-08T04:00:00+02:00', + overrides: { + evcs_vehicle_connected: 'on', evcs_device_connected: 'on', + grid_state: 'on', victron_acin1_available: '1', + soc_percent: '20', battery_current_a: '0', + pv_power_w: '0', pv_power_10m_avg_w: '0', pv_trend_w: '0', + house_ac_power_w: '612', vebus_ac_out_l1_power_w: '612', + evcs_ac_power_w: '0', + grid_setpoint_w: '700', + balance_required_soc: '59.1', balance_energy_to_cover_kwh: '3.13', + balance_status: 'ok', balance_last_run: '08/08 03:58', + balance_grid_energy_needed_kwh: '5.672', + }, + expect: { + profile: 'night_hc', + // Terme voiture 4825 W contre terme batterie 1538 W : le `max` retient la + // voiture, et la consigne vaut ce qu'elle vaut aujourd'hui. Avec une + // ADDITION elle monterait de 1538 W de plus — une autorisation en plus sur + // le chemin DOMINANT (85 nuits sur 88 ont un vehicule branche) pour + // corriger un chemin emprunte zero fois. C'est ce scenario qui epingle le + // choix du max, et il doit rester a la valeur historique. gridSetpointW: 5437, }, }, diff --git a/workflows/evcs-surplus-controller-v2.6.json b/workflows/evcs-surplus-controller-v2.6.json index 96f30e0..24d97a1 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 // 16,9 kWh mesures sur SEPT modules (§41), soit 2,414 kWh par module. Le parc\n // ayant change deux fois en une semaine, le nombre de modules se LIT au lieu\n // d'etre code : `input_number.evcs_balance_modules_en_service`, le meme helper\n // que le bilan energetique — les deux decrivent forcement le meme parc.\n // Absent ou illisible => 7, donc aucun changement tant que personne ne l'ecrit.\n batteryUsableCapacityKwh: 2.414 * (() => {\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 // Energie deja livree par la session EN COURS (kWh). Le capteur de la borne\n // s'appelle \"last_session_energy\" mais il suit la session courante et repart\n // de zero a chaque nouvelle connexion (verifie le 07/09/2026 : 0 -> 0,1 -> 0,2\n // ... pendant la charge). Sert a juger la credibilite d'un statut \"charged\".\n evcsSessionEnergyKwh: Math.max(0, num(input.evcs_session_energy_kwh, 0)),\n evcsChargingSwitchOn: low(input.evcs_charging_switch || 'off') === 'on',\n evcsAutoStartSwitchOn: low(input.evcs_auto_start_switch || 'off') === 'on',\n\n controllerEnabled: low(input.controller_enabled || 'off') === 'on',\n notificationsEnabled: low(input.notifications_enabled || 'off') === 'on',\n hardStopEnabledLegacy: low(input.hard_stop_enabled || 'off') === 'on',\n chargePermissionNow: low(input.charge_permission_now || 'off') === 'on',\n controlLatch: low(input.control_latch || 'off') === 'on',\n departurePriorityEnabled: low(input.departure_priority_enabled || 'off') === 'on',\n forecastModeEnabled: low(input.forecast_mode_enabled || 'off') === 'on',\n\n evcsPriorityManualOverride: low(input.evcs_priority_manual_override || 'off') === 'on',\n evcsPriorityManualMode: cleanText(input.evcs_priority_manual_mode || 'normal', 'normal').toLowerCase(),\n evcsPriorityManualReason: cleanText(input.evcs_priority_manual_reason),\n\n vebusOverload: low(input.vebus_overload || 'off') === 'on',\n vebusL1Overload: low(input.vebus_l1_overload || 'off') === 'on',\n vebusLowBattery: low(input.vebus_low_battery || 'off') === 'on',\n vebusL1LowBattery: low(input.vebus_l1_low_battery || 'off') === 'on',\n\n vebusAcOutPowerW: Math.max(0, num(input.vebus_ac_out_l1_power_w, 0)),\n vebusAcOutCurrentA: Math.max(0, num(input.vebus_ac_out_l1_current_a, 0)),\n vebusAcOutVoltageV: num(input.vebus_ac_out_l1_voltage_v, CFG.nominalAcVoltageV),\n\n vebusDcVoltageV: num(input.vebus_dc_voltage_v, NaN),\n vebusDcCurrentA: num(input.vebus_dc_current_a, NaN),\n vebusDcPowerW: num(input.vebus_dc_power_w, NaN),\n\n vebusState: num(input.vebus_state, NaN),\n vebusMainState: num(input.vebus_main_state, NaN),\n};\n\nparsed.evcsModeIsAuto = low(parsed.evcsModeRaw).includes('auto');\n\n// =====================================================\n// PARAMETERS\n// =====================================================\nconst params = {\n userStrategyRaw: input.victron_evcs_strategy || 'auto_solar',\n\n targetCurrentAutoA: clamp(num(input.target_current_auto_a, 6), 0, 32),\n pvStartMinW: Math.max(0, num(input.pv_start_min_w, 1800)),\n pvHoldMinW: Math.max(0, num(input.pv_hold_min_w, 1200)),\n houseAcHeadroomW: Math.max(0, num(input.house_ac_headroom_w, 1200)),\n\n batteryDischargeSoftA: num(input.battery_discharge_soft_a, -5),\n batteryDischargeHardA: num(input.battery_discharge_hard_a, -15),\n batteryReserveUpA: Math.max(0, num(input.battery_reserve_up_a, 8)),\n batteryNeutralBandA: Math.max(0, num(input.battery_neutral_band_a, 3)),\n\n socStartMin: clamp(num(input.soc_start_min, 60), 0, 100),\n socStopMin: clamp(num(input.soc_stop_min, 35), 0, 100),\n\n currentMinA: clamp(num(input.current_min_a, 6), 6, 32),\n currentMaxAutoA: clamp(\n num(input.current_max_auto_a, 16),\n clamp(num(input.current_min_a, 6), 6, 32),\n 32\n ),\n gridCurrentMaxA: clamp(num(input.grid_current_max_a, 20), 6, 32),\n currentStepUpA: clamp(num(input.current_step_up_a, 1), 1, 5),\n currentStepDownA: clamp(num(input.current_step_down_a, 2), 1, 8),\n\n currentChangeCooldownSeconds: Math.max(5, num(input.current_change_cooldown_seconds, 60)),\n minRunMinutes: Math.max(0, num(input.min_run_minutes, 3)),\n minStopMinutes: Math.max(0, num(input.min_stop_minutes, 10)),\n\n requiredEnergyKwh: Math.max(0, num(input.required_energy_kwh, 0)),\n socComfortTarget: clamp(num(input.soc_comfort_target, 85), 0, 100),\n endOfDaySocGuard: clamp(num(input.end_of_day_soc_guard, 85), 0, 100),\n baseLoadRemainingKwh: Math.max(0, num(input.base_load_remaining_kwh, 0)),\n flexibleSafetyMarginKwh: Math.max(0, num(input.flexible_safety_margin_kwh, 0)),\n endOfDaySocTarget: clamp(num(input.end_of_day_soc_target, 85), 0, 100),\n\n acLoadsMaxSafeW: Math.max(0, num(input.ac_loads_max_safe_w, 8500)),\n batteryFullSoc: clamp(num(input.battery_full_soc, 90), 0, 100),\n batteryFullForecastMinKwh: Math.max(0, num(input.battery_full_forecast_min_kwh, 8)),\n forecastCommitSocFloor: clamp(num(input.forecast_commit_soc_floor, 35), 0, 100),\n forecastCommitMinKwh: Math.max(0, num(input.forecast_commit_min_kwh, 25)),\n\n cloudDebtPrevKwh: Math.max(0, num(input.cloud_debt_kwh, 0)),\n cloudDebtMaxKwh: Math.max(0.05, num(input.cloud_debt_max_kwh, 1.2)),\n cloudToleranceMinutes: Math.max(1, num(input.cloud_tolerance_minutes, 20)),\n\n // P9 (09/08/2026) : deux véhicules alternent sur la borne.\n // Le sélecteur input_select.victron_evcs_vehicle choisit l'heure de départ\n // effective : VW = victron_evcs_departure_time (~08:50),\n // JEEP = victron_evcs_departure_time_jeep (~06:30).\n selectedVehicle: cleanText(input.evcs_vehicle, 'VW'),\n departureTimeMin:\n low(input.evcs_vehicle || 'vw') === 'jeep'\n ? parseTimeToMinutes(\n input.departure_time_jeep,\n parseTimeToMinutes(input.departure_time, null)\n )\n : parseTimeToMinutes(input.departure_time, null),\n allowGridDepartureUrgent: low(input.allow_grid_departure_urgent || 'off') === 'on',\n dayStartTimeMin: parseTimeToMinutes(input.day_start_time, 7 * 60 + 30),\n dayStopTimeMin: parseTimeToMinutes(input.day_stop_time, 20 * 60 + 30),\n\n hcNightStartMin: parseTimeToMinutes(input.hc_night_start_time, 23 * 60),\n hcNightStopMin: parseTimeToMinutes(input.hc_night_stop_time, 6 * 60),\n morningStartMin: parseTimeToMinutes(\n input.morning_start_time,\n parseTimeToMinutes(input.day_start_time, 7 * 60 + 30)\n ),\n morningStopMin: parseTimeToMinutes(input.morning_stop_time, 11 * 60),\n hcMiddayStartMin: parseTimeToMinutes(input.hc_midday_start_time, 12 * 60 + 21),\n hcMiddayStopMin: parseTimeToMinutes(input.hc_midday_stop_time, 14 * 60 + 21),\n eveningStartMin: parseTimeToMinutes(input.evening_start_time, 17 * 60),\n eveningStopMin: parseTimeToMinutes(\n input.evening_stop_time,\n parseTimeToMinutes(input.day_stop_time, 20 * 60 + 30)\n ),\n\n 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 // P12 — charge nuit sur batterie. Le plancher est le SOC requis PRUDENT (p80)\n // et non le p50 : la marge est mesuree, pas inventee. On descend la voiture\n // jusqu'a ce que la batterie n'ait plus que ce qu'il lui faut pour tenir\n // jusqu'au croisement PV.\n balanceRequiredSocPrudentPct: num(input.balance_required_soc_prudent, NaN),\n nightBatteryChargeEnabled: low(input.night_battery_charge_enabled || 'off') === 'on',\n nightBatteryDonePrev: low(input.night_battery_done_prev || 'off') === 'on',\n balanceEnergyToCoverKwh: num(input.balance_energy_to_cover_kwh, NaN),\n 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 // 16,9 kWh mesures sur SEPT modules (§41), soit 2,414 kWh par module. Le parc\n // ayant change deux fois en une semaine, le nombre de modules se LIT au lieu\n // d'etre code : `input_number.evcs_balance_modules_en_service`, le meme helper\n // que le bilan energetique — les deux decrivent forcement le meme parc.\n // Absent ou illisible => 7, donc aucun changement tant que personne ne l'ecrit.\n batteryUsableCapacityKwh: 2.414 * (() => {\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 // Energie deja livree par la session EN COURS (kWh). Le capteur de la borne\n // s'appelle \"last_session_energy\" mais il suit la session courante et repart\n // de zero a chaque nouvelle connexion (verifie le 07/09/2026 : 0 -> 0,1 -> 0,2\n // ... pendant la charge). Sert a juger la credibilite d'un statut \"charged\".\n evcsSessionEnergyKwh: Math.max(0, num(input.evcs_session_energy_kwh, 0)),\n evcsChargingSwitchOn: low(input.evcs_charging_switch || 'off') === 'on',\n evcsAutoStartSwitchOn: low(input.evcs_auto_start_switch || 'off') === 'on',\n\n controllerEnabled: low(input.controller_enabled || 'off') === 'on',\n notificationsEnabled: low(input.notifications_enabled || 'off') === 'on',\n hardStopEnabledLegacy: low(input.hard_stop_enabled || 'off') === 'on',\n chargePermissionNow: low(input.charge_permission_now || 'off') === 'on',\n controlLatch: low(input.control_latch || 'off') === 'on',\n departurePriorityEnabled: low(input.departure_priority_enabled || 'off') === 'on',\n forecastModeEnabled: low(input.forecast_mode_enabled || 'off') === 'on',\n\n evcsPriorityManualOverride: low(input.evcs_priority_manual_override || 'off') === 'on',\n evcsPriorityManualMode: cleanText(input.evcs_priority_manual_mode || 'normal', 'normal').toLowerCase(),\n evcsPriorityManualReason: cleanText(input.evcs_priority_manual_reason),\n\n vebusOverload: low(input.vebus_overload || 'off') === 'on',\n vebusL1Overload: low(input.vebus_l1_overload || 'off') === 'on',\n vebusLowBattery: low(input.vebus_low_battery || 'off') === 'on',\n vebusL1LowBattery: low(input.vebus_l1_low_battery || 'off') === 'on',\n\n vebusAcOutPowerW: Math.max(0, num(input.vebus_ac_out_l1_power_w, 0)),\n vebusAcOutCurrentA: Math.max(0, num(input.vebus_ac_out_l1_current_a, 0)),\n vebusAcOutVoltageV: num(input.vebus_ac_out_l1_voltage_v, CFG.nominalAcVoltageV),\n\n vebusDcVoltageV: num(input.vebus_dc_voltage_v, NaN),\n vebusDcCurrentA: num(input.vebus_dc_current_a, NaN),\n vebusDcPowerW: num(input.vebus_dc_power_w, NaN),\n\n vebusState: num(input.vebus_state, NaN),\n vebusMainState: num(input.vebus_main_state, NaN),\n};\n\nparsed.evcsModeIsAuto = low(parsed.evcsModeRaw).includes('auto');\n\n// =====================================================\n// PARAMETERS\n// =====================================================\nconst params = {\n userStrategyRaw: input.victron_evcs_strategy || 'auto_solar',\n\n targetCurrentAutoA: clamp(num(input.target_current_auto_a, 6), 0, 32),\n pvStartMinW: Math.max(0, num(input.pv_start_min_w, 1800)),\n pvHoldMinW: Math.max(0, num(input.pv_hold_min_w, 1200)),\n houseAcHeadroomW: Math.max(0, num(input.house_ac_headroom_w, 1200)),\n\n batteryDischargeSoftA: num(input.battery_discharge_soft_a, -5),\n batteryDischargeHardA: num(input.battery_discharge_hard_a, -15),\n batteryReserveUpA: Math.max(0, num(input.battery_reserve_up_a, 8)),\n batteryNeutralBandA: Math.max(0, num(input.battery_neutral_band_a, 3)),\n\n socStartMin: clamp(num(input.soc_start_min, 60), 0, 100),\n socStopMin: clamp(num(input.soc_stop_min, 35), 0, 100),\n\n currentMinA: clamp(num(input.current_min_a, 6), 6, 32),\n currentMaxAutoA: clamp(\n num(input.current_max_auto_a, 16),\n clamp(num(input.current_min_a, 6), 6, 32),\n 32\n ),\n gridCurrentMaxA: clamp(num(input.grid_current_max_a, 20), 6, 32),\n currentStepUpA: clamp(num(input.current_step_up_a, 1), 1, 5),\n currentStepDownA: clamp(num(input.current_step_down_a, 2), 1, 8),\n\n currentChangeCooldownSeconds: Math.max(5, num(input.current_change_cooldown_seconds, 60)),\n minRunMinutes: Math.max(0, num(input.min_run_minutes, 3)),\n minStopMinutes: Math.max(0, num(input.min_stop_minutes, 10)),\n\n requiredEnergyKwh: Math.max(0, num(input.required_energy_kwh, 0)),\n socComfortTarget: clamp(num(input.soc_comfort_target, 85), 0, 100),\n endOfDaySocGuard: clamp(num(input.end_of_day_soc_guard, 85), 0, 100),\n baseLoadRemainingKwh: Math.max(0, num(input.base_load_remaining_kwh, 0)),\n flexibleSafetyMarginKwh: Math.max(0, num(input.flexible_safety_margin_kwh, 0)),\n endOfDaySocTarget: clamp(num(input.end_of_day_soc_target, 85), 0, 100),\n\n acLoadsMaxSafeW: Math.max(0, num(input.ac_loads_max_safe_w, 8500)),\n batteryFullSoc: clamp(num(input.battery_full_soc, 90), 0, 100),\n batteryFullForecastMinKwh: Math.max(0, num(input.battery_full_forecast_min_kwh, 8)),\n forecastCommitSocFloor: clamp(num(input.forecast_commit_soc_floor, 35), 0, 100),\n forecastCommitMinKwh: Math.max(0, num(input.forecast_commit_min_kwh, 25)),\n\n cloudDebtPrevKwh: Math.max(0, num(input.cloud_debt_kwh, 0)),\n cloudDebtMaxKwh: Math.max(0.05, num(input.cloud_debt_max_kwh, 1.2)),\n cloudToleranceMinutes: Math.max(1, num(input.cloud_tolerance_minutes, 20)),\n\n // P9 (09/08/2026) : deux véhicules alternent sur la borne.\n // Le sélecteur input_select.victron_evcs_vehicle choisit l'heure de départ\n // effective : VW = victron_evcs_departure_time (~08:50),\n // JEEP = victron_evcs_departure_time_jeep (~06:30).\n selectedVehicle: cleanText(input.evcs_vehicle, 'VW'),\n departureTimeMin:\n low(input.evcs_vehicle || 'vw') === 'jeep'\n ? parseTimeToMinutes(\n input.departure_time_jeep,\n parseTimeToMinutes(input.departure_time, null)\n )\n : parseTimeToMinutes(input.departure_time, null),\n allowGridDepartureUrgent: low(input.allow_grid_departure_urgent || 'off') === 'on',\n dayStartTimeMin: parseTimeToMinutes(input.day_start_time, 7 * 60 + 30),\n dayStopTimeMin: parseTimeToMinutes(input.day_stop_time, 20 * 60 + 30),\n\n hcNightStartMin: parseTimeToMinutes(input.hc_night_start_time, 23 * 60),\n hcNightStopMin: parseTimeToMinutes(input.hc_night_stop_time, 6 * 60),\n morningStartMin: parseTimeToMinutes(\n input.morning_start_time,\n parseTimeToMinutes(input.day_start_time, 7 * 60 + 30)\n ),\n morningStopMin: parseTimeToMinutes(input.morning_stop_time, 11 * 60),\n hcMiddayStartMin: parseTimeToMinutes(input.hc_midday_start_time, 12 * 60 + 21),\n hcMiddayStopMin: parseTimeToMinutes(input.hc_midday_stop_time, 14 * 60 + 21),\n eveningStartMin: parseTimeToMinutes(input.evening_start_time, 17 * 60),\n eveningStopMin: parseTimeToMinutes(\n input.evening_stop_time,\n parseTimeToMinutes(input.day_stop_time, 20 * 60 + 30)\n ),\n\n 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 // P12 — charge nuit sur batterie. Le plancher est le SOC requis PRUDENT (p80)\n // et non le p50 : la marge est mesuree, pas inventee. On descend la voiture\n // jusqu'a ce que la batterie n'ait plus que ce qu'il lui faut pour tenir\n // jusqu'au croisement PV.\n balanceRequiredSocPrudentPct: num(input.balance_required_soc_prudent, NaN),\n nightBatteryChargeEnabled: low(input.night_battery_charge_enabled || 'off') === 'on',\n nightBatteryDonePrev: low(input.night_battery_done_prev || 'off') === 'on',\n balanceEnergyToCoverKwh: num(input.balance_energy_to_cover_kwh, NaN),\n // ⚠️ NE PAS CONFONDRE LES DEUX GRANDEURS DU BILAN.\n // `balanceEnergyToCoverKwh` est l'energie que la batterie devra FOURNIR\n // jusqu'au croisement PV ; elle sert a CALCULER la cible\n // (`requiredSocPct = socForEnergy(energyToCoverKwh, floor)`) et ne tombe\n // jamais a zero tant qu'il reste de la nuit a traverser, meme batterie\n // pleine. Releve du 17/09 a 20:00 : 11,96 kWh « a couvrir » avec un SOC de\n // 91 %. La prendre pour un deficit ferait reclamer du reseau toute la nuit.\n //\n // `balanceGridEnergyNeededKwh` est le MANQUE :\n // `socMargin < 0 ? energyBetweenSoc(soc, requiredSoc) : 0` (BALANCE.js l.234)\n // — nul des que la batterie couvre, et converti par la COURBE par morceaux\n // mesuree le 03/09 (0,097 kWh/point sous le genou de 33 %, 0,169 au-dessus).\n // C'est donc la seule des deux qui soit juste sous le genou, c'est-a-dire au\n // SOC ou l'on decide de fermer le contacteur.\n balanceGridEnergyNeededKwh: num(input.balance_grid_energy_needed_kwh, NaN),\n // Ce que l'Auto Charge Manager autorise la batterie a PRENDRE (retour de\n // lecture de DVCC). A ne pas confondre avec `bms_max_charge_current_a`, qui\n // est le maximum tolere par le BMS — 250 A, soit plus de 12 kW, donc\n // au-dessus de GRID_SETPOINT_MAX_W : il ne bornerait rien.\n dvccChargeLimitA: num(input.dvcc_max_charge_current_a, NaN),\n balanceStatusOk: cleanText(input.balance_status).toLowerCase() === 'ok',\n balanceLastRunMs: parseRunStampToMs(cleanText(input.balance_last_run)),\n\n // Duree de la fenetre d'etablissement. Valeur mesuree le 31/08/2026 : le\n // transitoire de fermeture dure ~150 s (import 1985 -> 25 W et 2600 -> 52 W,\n // deux campagnes), on prend 180 s de marge. Reglable sans redeploiement via\n // input_number.evcs_grid_settle_seconds ; absent => 180.\n gridSettleSeconds: clamp(num(input.grid_settle_seconds, 180), 0, 900),\n\n // Consigne réseau au repos. 50 W est le défaut Victron (légèrement positif\n // pour ne jamais injecter), pas une valeur choisie ici.\n // CONSIGNE DE REPOS : 200 W et non plus 50 (04/09/2026).\n // 50 W etait le defaut Victron. Mesure sur la nuit du 03-04/09, 12 074\n // echantillons du point de raccordement : 17 % sont NEGATIFS, mediane -18 W,\n // excursions jusqu'a -356 W, episodes de 35 s au plus. Ce n'est pas de\n // l'injection — la batterie se dechargeait a 744 W au meme moment — c'est le\n // regulateur qui chasse autour d'une cible trop proche de zero. A 200 W le\n // point de fonctionnement reste positif sur l'essentiel des excursions.\n // ⚠️ Ne supprime pas les pointes les plus fortes : une excursion de -356 W\n // passerait encore sous zero. Reduit leur nombre, ne les annule pas.\n // CONSIGNE DE REPOS : 50 -> 200 (04/09) -> 700 (06/09).\n //\n // Elle ne s'applique que contacteur FERME sans session reseau EVCS — donc\n // apres une coupure sur SOC bas, ou apres une fermeture par la regle de\n // protection du parc. Contacteur ouvert, elle n'a aucun effet.\n //\n // A 200 W, une plaque chauffante qui cycle fait passer le point de\n // fonctionnement sous zero : mesure du 06/09 sur la fenetre 12:07-12:22,\n // 9 % des echantillons negatifs, minimum -477 W, mediane des negatifs\n // -146 W. Le regulateur ne rattrape pas assez vite un delestage brutal de\n // ~1 kW. A 700 W toute la courbe monte de 500 W : minimum +23 W, plus aucune\n // excursion negative sur cette fenetre.\n //\n // Le surcout est faible et va dans le bon sens : les DEUX seules raisons de\n // fermer le contacteur sans session EVCS sont « la batterie est a son\n // plancher » et « la maison demande plus que le parc ne sait donner ». Dans\n // les deux cas on VEUT que le reseau contribue plus que 200 W.\n //\n // ⚠️ Ne corrige PAS les gros a-coups. Le 06/09 a 13:41:40, le debranchement\n // du vehicule a supprime 2,2 kW d'un coup alors que la batterie, a 93 % de\n // SOC, n'absorbait rien et que le MPPT produisait 4 kW : -2 819 W pendant\n // plus de deux minutes, le temps que le systeme bride la production. Aucune\n // consigne n'absorbe cela. Ce qui l'evite est la correction du meme jour sur\n // la regle de protection (elle porte desormais sur `maison - PV`) : avec une\n // demande sur le parc de 741 W, le contacteur n'aurait pas ete ferme, et\n // sans reseau il n'y a rien ou injecter.\n gridSetpointIdleW: clamp(num(input.grid_setpoint_idle_w, 700), 0, 1500),\n\n probeEnabled: low(input.probe_enabled || 'off') === 'on',\n probeMinutes: Math.max(1, num(input.probe_minutes, 3)),\n probeRetryMinutes: Math.max(5, num(input.probe_retry_minutes, 45)),\n probeCurrentA: clamp(\n num(input.probe_current_a, clamp(num(input.current_min_a, 10), 6, 32)),\n clamp(num(input.current_min_a, 10), 6, 32),\n clamp(num(input.current_max_auto_a, 16), clamp(num(input.current_min_a, 10), 6, 32), 32)\n ),\n\n gridBatterySoftA: num(input.grid_battery_soft_a, -3),\n gridBatteryHardA: num(input.grid_battery_hard_a, -6),\n\n morningProbePvAvgMinW: Math.max(0, num(input.morning_probe_pv_avg_min_w, 1500)),\n morningProbePvTrendMinW: num(input.morning_probe_pv_trend_min_w, 0),\n middayProbePvAvgMinW: Math.max(0, num(input.midday_probe_pv_avg_min_w, 1200)),\n\n morningSocMin: clamp(num(input.morning_soc_min, 35), 0, 100),\n middaySocMin: clamp(num(input.midday_soc_min, 75), 0, 100),\n eveningSocStartMin: clamp(num(input.evening_soc_start_min, 85), 0, 100),\n eveningSocStopMin: clamp(num(input.evening_soc_stop_min, 80), 0, 100),\n\n morningBatteryMaxDischargeA: num(input.morning_battery_max_discharge_a, -20),\n middayBatteryMaxDischargeA: num(input.midday_battery_max_discharge_a, -40),\n eveningBatteryMaxDischargeA: num(input.evening_battery_max_discharge_a, -30),\n\n // =====================================================\n // EVCS NIGHT ENGINE — NEW PARAMETERS\n // =====================================================\n evcsNightBatteryHardFloorPct: clamp(\n num(input.evcs_night_battery_hard_floor_pct, 22),\n 0,\n 100\n ),\n\n evcsNightStrongForecastKwh: Math.max(\n 0,\n num(input.evcs_night_strong_forecast_kwh, 18)\n ),\n\n evcsNightMediumForecastKwh: Math.max(\n 0,\n num(input.evcs_night_medium_forecast_kwh, 10)\n ),\n\n evcsNightTargetStrongForecastPct: clamp(\n num(input.evcs_night_target_strong_forecast_pct, 35),\n 0,\n 100\n ),\n\n evcsNightTargetMediumForecastPct: clamp(\n num(input.evcs_night_target_medium_forecast_pct, 55),\n 0,\n 100\n ),\n\n evcsNightTargetWeakForecastPct: clamp(\n num(input.evcs_night_target_weak_forecast_pct, 85),\n 0,\n 100\n ),\n\n evcsNightBatteryRecoveryPowerKw: Math.max(\n 0.1,\n num(input.evcs_night_battery_recovery_power_kw, 2.5)\n ),\n\n evcsNightDepartureSafetyMin: Math.max(\n 0,\n num(input.evcs_night_departure_safety_min, 30)\n ),\n};\n\n// =====================================================\n// HISTORY / PERSISTED HELPERS\n// =====================================================\nconst nightSessionStartSocRaw = num(\n input.evcs_night_session_start_soc ?? input.night_session_start_soc,\n NaN\n);\n\nconst nightSessionStartAtRaw = cleanText(\n input.evcs_night_session_start_at ?? input.night_session_start_at\n);\n\n// =====================================================\n// VEHICLE DEMAND FILTER HISTORY RAW INPUTS\n// =====================================================\nconst evcsNoDemandSinceAtRaw = cleanText(input.evcs_no_demand_since_at);\nconst evcsNoDemandFilterEvalAtRaw = cleanText(input.evcs_no_demand_filter_eval_at);\n\nconst history = {\n lastBlockReason: cleanText(input.last_block_reason, 'none') || 'none',\n blockedUntilAt: cleanText(input.blocked_until),\n probeStartedAt: cleanText(input.probe_started_at),\n lastStartAt: cleanText(input.last_start_at),\n lastStopAt: cleanText(input.last_stop_at),\n lastCurrentChangeAt: cleanText(input.last_current_change_at),\n lastSignature: cleanText(input.last_signature),\n cloudDebtStartedAt: cleanText(input.cloud_debt_started_at),\n evcsPriorityManualUntilAt: cleanText(input.evcs_priority_manual_until),\n\n blockedUntilMs: parseParisStampToMs(cleanText(input.blocked_until)),\n probeStartedMs: parseParisStampToMs(cleanText(input.probe_started_at)),\n lastStartMs: parseParisStampToMs(cleanText(input.last_start_at)),\n lastStopMs: parseParisStampToMs(cleanText(input.last_stop_at)),\n lastCurrentChangeMs: parseParisStampToMs(cleanText(input.last_current_change_at)),\n cloudDebtStartedMsPrev: parseParisStampToMs(cleanText(input.cloud_debt_started_at)),\n\n // DEPASSEMENT SOUPLE BATTERIE (01/09/2026) : horodatage du debut du\n // depassement en cours, ecrit par HA_SERVICE_CALLS_OUTPUT. Vide/illisible\n // => null => aucun depassement en cours, comportement de repli sur.\n nightGridSessionCommittedPrev:\n (input.night_grid_session_committed_prev ?? 'off').toString().trim().toLowerCase() === 'on',\n batterySoftBreachAt: cleanText(input.battery_soft_breach_at),\n batterySoftBreachMsPrev: parseParisStampToMs(cleanText(input.battery_soft_breach_at)),\n evcsPriorityManualUntilMs: parseParisStampToMs(cleanText(input.evcs_priority_manual_until)),\n\n // =====================================================\n // EVCS NIGHT SESSION MEMORY\n // =====================================================\n nightSessionStartSocPct: Number.isFinite(nightSessionStartSocRaw)\n ? clamp(nightSessionStartSocRaw, 0, 100)\n : null,\n\n nightSessionStartAt: nightSessionStartAtRaw || '',\n nightSessionStartMs: parseParisStampToMs(nightSessionStartAtRaw),\n\n // =====================================================\n // VEHICLE DEMAND FILTER PERSISTENCE\n // =====================================================\n evcsDeliveryRatioEma: num(input.evcs_delivery_ratio_ema, NaN),\n evcsDeliveryNoiseEma: num(input.evcs_delivery_noise_ema, NaN),\n evcsNoDemandScoreEma: num(input.evcs_no_demand_score_ema, NaN),\n evcsNoDemandSemanticActive: low(input.evcs_no_demand_semantic_active || 'off') === 'on',\n\n evcsNoDemandSinceAt: evcsNoDemandSinceAtRaw || '',\n evcsNoDemandSinceMs: parseParisStampToMs(evcsNoDemandSinceAtRaw),\n\n evcsNoDemandFilterEvalAt: evcsNoDemandFilterEvalAtRaw || '',\n evcsNoDemandFilterEvalMs: parseParisStampToMs(evcsNoDemandFilterEvalAtRaw),\n};\n\n// =====================================================\n// MISE A L'ECHELLE DU PARC (12/09/2026)\n// =====================================================\n// Les bandes ci-dessous sont en AMPERES ABSOLUS, et elles decrivent un parc\n// de 4 modules -- celui sur lequel elles sont aujourd'hui en vigueur et\n// observees justes (l'arret dur du 11/09 a mordu au bon endroit, 87 A pour\n// une limite de 70). Avec quatre US5000 en plus, le parc triple : garder ces\n// ampere-la briderait le nouveau parc au niveau de l'ancien.\n//\n// On les met donc a l'echelle du parc DECLARE — le meme helper que la\n// capacite utile et que le bilan energetique, exprime en equivalents\n// US2000C (un US5000 en vaut DEUX). Une seule valeur a tenir a jour, et les\n// trois consommateurs restent forcement d'accord entre eux.\n//\n// ⚠️ On ne met PAS a l'echelle sur la limite BMS lue en direct : elle\n// s'effondre quand une cellule monte en tension (04/09 : plafond de charge\n// tombe de 150 a 20 A en cinq secondes). Une regle de confort ne doit pas\n// suivre un signal aussi nerveux. En revanche le BMS PLAFONNE : on ne\n// s'autorise jamais plus que ce que le parc declare accepter a l'instant.\n//\n// Parc absent ou illisible => echelle 1 et plafond inactif, donc comportement\n// d'aujourd'hui strictement inchange.\nconst parkScale = (() => {\n const n = Number(input.modules_en_service);\n return Number.isFinite(n) && n >= 1 && n <= 24 ? n / 4 : 1;\n})();\nconst bmsDischargeCapA = (() => {\n const n = Number(input.bms_max_discharge_current_a);\n return Number.isFinite(n) && n > 0 ? n : Infinity;\n})();\nconst bmsChargeCapA = (() => {\n const n = Number(input.bms_max_charge_current_a);\n return Number.isFinite(n) && n > 0 ? n : Infinity;\n})();\n// Decharge : valeurs NEGATIVES, donc le plafond est un `Math.max`.\nconst scaleDischargeA = v => Math.max(-bmsDischargeCapA, v * parkScale);\nconst scaleChargeA = v => Math.min(bmsChargeCapA, Math.max(0, v) * parkScale);\n\n// =====================================================\n// BATTERY TUNING\n// =====================================================\nconst batteryTune = {\n global: {\n houseSpikeGraceMinutes: Math.max(0, num(input.house_spike_grace_minutes, 10)),\n solarPotentialGraceMinutes: Math.max(0, num(input.solar_potential_grace_minutes, 12)),\n stepUpCooldownSeconds: Math.max(5, num(input.step_up_cooldown_seconds, params.currentChangeCooldownSeconds)),\n stepDownCooldownSeconds: Math.max(5, num(input.step_down_cooldown_seconds, params.currentChangeCooldownSeconds)),\n forceModeAutoRevertMinutes: Math.max(0, num(input.force_mode_auto_revert_minutes, 120)),\n overloadStepDownA: Math.max(1, num(input.overload_step_down_a, 2)),\n overloadHoldSeconds: Math.max(5, num(input.overload_hold_seconds, 30)),\n overloadStopAfterSeconds: Math.max(10, num(input.overload_stop_after_seconds, 120)),\n },\n\n bands: {\n gt90: {\n dischargeSoftA: scaleDischargeA(num(input.batt_discharge_soft_gt90_a, -50)),\n dischargeHardA: scaleDischargeA(num(input.batt_discharge_hard_gt90_a, -100)),\n chargeReserveA: scaleChargeA(num(input.batt_charge_reserve_gt90_a, 5)),\n softToleranceMin: Math.max(0, num(input.batt_soft_tol_gt90_min, 12)),\n hardToleranceSec: Math.max(0, num(input.batt_hard_tol_gt90_sec, 20)),\n recoveryMin: Math.max(0, num(input.batt_recovery_gt90_min, 6)),\n },\n\n '80_90': {\n dischargeSoftA: scaleDischargeA(num(input.batt_discharge_soft_80_90_a, -40)),\n dischargeHardA: scaleDischargeA(num(input.batt_discharge_hard_80_90_a, -80)),\n chargeReserveA: scaleChargeA(num(input.batt_charge_reserve_80_90_a, 10)),\n softToleranceMin: Math.max(0, num(input.batt_soft_tol_80_90_min, 10)),\n hardToleranceSec: Math.max(0, num(input.batt_hard_tol_80_90_sec, 20)),\n recoveryMin: Math.max(0, num(input.batt_recovery_80_90_min, 6)),\n },\n\n '60_80': {\n dischargeSoftA: scaleDischargeA(num(input.batt_discharge_soft_60_80_a, -20)),\n dischargeHardA: scaleDischargeA(num(input.batt_discharge_hard_60_80_a, -50)),\n chargeReserveA: scaleChargeA(num(input.batt_charge_reserve_60_80_a, 40)),\n softToleranceMin: Math.max(0, num(input.batt_soft_tol_60_80_min, 6)),\n hardToleranceSec: Math.max(0, num(input.batt_hard_tol_60_80_sec, 15)),\n recoveryMin: Math.max(0, num(input.batt_recovery_60_80_min, 8)),\n },\n\n '40_60': {\n dischargeSoftA: scaleDischargeA(num(input.batt_discharge_soft_40_60_a, -12)),\n dischargeHardA: scaleDischargeA(num(input.batt_discharge_hard_40_60_a, -30)),\n chargeReserveA: scaleChargeA(num(input.batt_charge_reserve_40_60_a, 70)),\n softToleranceMin: Math.max(0, num(input.batt_soft_tol_40_60_min, 4)),\n hardToleranceSec: Math.max(0, num(input.batt_hard_tol_40_60_sec, 10)),\n recoveryMin: Math.max(0, num(input.batt_recovery_40_60_min, 10)),\n },\n\n '25_40': {\n dischargeSoftA: scaleDischargeA(num(input.batt_discharge_soft_25_40_a, -5)),\n dischargeHardA: scaleDischargeA(num(input.batt_discharge_hard_25_40_a, -15)),\n chargeReserveA: scaleChargeA(num(input.batt_charge_reserve_25_40_a, 110)),\n softToleranceMin: Math.max(0, num(input.batt_soft_tol_25_40_min, 2)),\n hardToleranceSec: Math.max(0, num(input.batt_hard_tol_25_40_sec, 5)),\n recoveryMin: Math.max(0, num(input.batt_recovery_25_40_min, 12)),\n },\n\n lt25: {\n dischargeSoftA: scaleDischargeA(num(input.batt_discharge_soft_lt25_a, -3)),\n dischargeHardA: scaleDischargeA(num(input.batt_discharge_hard_lt25_a, -8)),\n chargeReserveA: scaleChargeA(num(input.batt_charge_reserve_lt25_a, 150)),\n softToleranceMin: Math.max(0, num(input.batt_soft_tol_lt25_min, 1)),\n hardToleranceSec: Math.max(0, num(input.batt_hard_tol_lt25_sec, 2)),\n recoveryMin: Math.max(0, num(input.batt_recovery_lt25_min, 15)),\n },\n },\n};\n\n// =====================================================\n// OPTIONAL DEBUG / SUMMARY\n// =====================================================\nconst evcsNightConfig = {\n batteryHardFloorPct: params.evcsNightBatteryHardFloorPct,\n strongForecastKwh: params.evcsNightStrongForecastKwh,\n mediumForecastKwh: params.evcsNightMediumForecastKwh,\n targetStrongForecastPct: params.evcsNightTargetStrongForecastPct,\n targetMediumForecastPct: params.evcsNightTargetMediumForecastPct,\n targetWeakForecastPct: params.evcsNightTargetWeakForecastPct,\n batteryRecoveryPowerKw: params.evcsNightBatteryRecoveryPowerKw,\n departureSafetyMin: params.evcsNightDepartureSafetyMin,\n sessionStartSocPct: history.nightSessionStartSocPct,\n sessionStartAt: history.nightSessionStartAt || null,\n};\n\nconst vehicleDemandHistoryDebug = {\n evcsDeliveryRatioEma: finiteOrNull(history.evcsDeliveryRatioEma),\n evcsDeliveryNoiseEma: finiteOrNull(history.evcsDeliveryNoiseEma),\n evcsNoDemandScoreEma: finiteOrNull(history.evcsNoDemandScoreEma),\n evcsNoDemandSemanticActive: history.evcsNoDemandSemanticActive,\n evcsNoDemandSinceAt: history.evcsNoDemandSinceAt || null,\n evcsNoDemandSinceMs: finiteOrNull(history.evcsNoDemandSinceMs),\n evcsNoDemandFilterEvalAt: history.evcsNoDemandFilterEvalAt || null,\n evcsNoDemandFilterEvalMs: finiteOrNull(history.evcsNoDemandFilterEvalMs),\n};\n\n// =====================================================\n// OUTPUT\n// =====================================================\nreturn [{\n json: {\n raw,\n input,\n nowMs,\n now,\n cfg: CFG,\n parsed,\n params,\n history,\n batteryTune,\n evcsNightConfig,\n vehicleDemandHistoryDebug,\n }\n}];\n" }, "type": "n8n-nodes-base.code", "typeVersion": 2, @@ -107,7 +107,7 @@ }, { "parameters": { - "jsCode": "// Bornes de la consigne de puissance reseau. Le maximum est celui declare par\n// l'entite elle-meme (number.airtria_mpii_v1_ac_power_setpoint_l1, max 10000 W),\n// pas un chiffre choisi ici. La bande morte evite de reecrire la meme valeur\n// toutes les 30 s sans empecher la correction d'une derive externe.\nconst GRID_SETPOINT_MAX_W = 10000;\nconst GRID_SETPOINT_DEADBAND_W = 100;\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 round3(v) {\n return Math.round(v * 1000) / 1000;\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 formatParisStampFromMs(ms) {\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 return `${parts.year}-${parts.month}-${parts.day} ${to2(hour)}:${parts.minute}:${parts.second}`;\n}\n\nconst prev = $input.first().json;\n\nconst {\n input,\n now,\n nowMs,\n cfg,\n parsed,\n params,\n history,\n strategy,\n windows,\n lifecycle,\n context,\n derived,\n batteryTune,\n batteryPolicy,\n locks,\n cloud,\n modes,\n safeguards,\n conditions,\n decision,\n nightEvcs,\n vehicleDemandFilter,\n} = prev;\n\n// =====================================================\n// SAFE ACCESS NIGHT EVCS\n// =====================================================\nconst night = nightEvcs ?? {\n nightSessionActive: false,\n nightSessionNeedsCapture: false,\n nightSessionNeedsClear: false,\n nightSessionStartSocPct: null,\n nightSessionStartAt: '',\n batteryHardFloorPct: null,\n batteryMorningTargetPct: null,\n maxBatteryDischargePoints: null,\n gridTakeoverTriggerPct: null,\n batteryRecoveryUrgent: false,\n nightGridTakeoverNeeded: false,\n nightPreferredSourceMode: 'blocked',\n};\n\n// =====================================================\n// SAFE ACCESS VEHICLE DEMAND FILTER\n// =====================================================\nconst vdf = vehicleDemandFilter ?? {\n dtSec: null,\n commandCurrentA: null,\n realCurrentA: null,\n expectedPowerW: null,\n realPowerW: null,\n currentDeliveryRatioRaw: null,\n powerDeliveryRatioRaw: null,\n deliveryRatioRaw: null,\n deliveryRatioEma: null,\n deliveryNoiseEma: null,\n adaptiveQuietBand: null,\n noDemandEvidenceRaw: null,\n sessionElapsedSec: null,\n sessionArmFactor: null,\n commandFactor: null,\n sourceDeliverableForNoDemand: false,\n evcsSessionArmedForNoDemand: false,\n noDemandScoreRaw: null,\n noDemandScoreEma: null,\n noDemandScoreOn: null,\n noDemandScoreOff: null,\n evcsVehicleNoDemandSemantic: false,\n evcsNoDemandSinceMs: null,\n evcsNoDemandSinceMinutes: null,\n nextHistory: {\n evcsDeliveryRatioEma: null,\n evcsDeliveryNoiseEma: null,\n evcsNoDemandScoreEma: null,\n evcsNoDemandSemanticActive: false,\n evcsNoDemandSinceMs: null,\n evcsNoDemandFilterEvalMs: null\n }\n};\n\n// =====================================================\n// FINAL ACTIONS\n// =====================================================\nlet desiredCurrentA = decision.desiredCurrentA;\n\ndesiredCurrentA = decision.desiredCharging\n ? clamp(\n desiredCurrentA,\n params.currentMinA,\n Math.max(params.currentMinA, derived.physicalCapA || params.currentMinA)\n )\n : params.currentMinA;\n\nconst chargingAction =\n decision.desiredCharging === parsed.evcsChargingSwitchOn\n ? 'none'\n : decision.desiredCharging\n ? 'turn_on'\n : 'turn_off';\n\nconst permissionAction =\n decision.desiredPermission === parsed.chargePermissionNow\n ? 'none'\n : decision.desiredPermission\n ? 'turn_on'\n : 'turn_off';\n\nconst latchAction =\n decision.desiredLatch === parsed.controlLatch\n ? 'none'\n : decision.desiredLatch\n ? 'turn_on'\n : 'turn_off';\n\nconst autoStartAction =\n parsed.controllerEnabled &&\n parsed.evcsVehicleConnected &&\n parsed.evcsAutoStartSwitchOn\n ? 'turn_off'\n : 'none';\n\nconst currentAction =\n decision.desiredCharging &&\n (\n !Number.isFinite(derived.currentReferenceA) ||\n Math.abs(desiredCurrentA - derived.currentReferenceA) >= 0.5\n )\n ? 'set'\n : 'none';\n\nconst setLastStartAt = chargingAction === 'turn_on';\nconst setLastStopAt = chargingAction === 'turn_off';\nconst setLastCurrentChangeAt = currentAction === 'set';\n\n// =====================================================\n// CLEAN BLOCKING LOGIC\n// =====================================================\nconst blockedUntilToWrite =\n decision.nextBlockReason === 'none'\n ? now.stamp\n : decision.nextBlockedUntilStamp;\n\nconst needsBlockReasonUpdate =\n decision.nextBlockReason !== history.lastBlockReason;\n\n// =====================================================\n// GLOBAL EVCS PRIORITY\n// AUTO + OVERRIDE MANUEL\n// =====================================================\nconst forceWindowMinutes = Math.max(\n 30,\n batteryTune?.global?.forceModeAutoRevertMinutes ?? 120\n);\n\nconst activeChargeIntent =\n decision.desiredCharging ||\n ['start', 'hold', 'step_up', 'step_down', 'step_down_fast'].includes(decision.currentDecision);\n\nconst evcsVehicleNotRequestingPowerState =\n decision.state === 'vehicle_full_or_paused' ||\n decision.currentDecision === 'stop_vehicle_full_or_paused';\n\n// -------------------------\n// PRIORITÉ AUTO\n// -------------------------\nlet autoEvcsPriorityActive = false;\nlet autoEnergyPriorityMode = 'normal';\nlet autoEnergyPriorityReason = 'Aucune priorité EVCS';\nlet autoEvcsPriorityUntil = now.stamp;\n\nif (\n parsed.evcsVehicleConnected &&\n !strategy.isStopMode &&\n !evcsVehicleNotRequestingPowerState\n) {\n if (strategy.forceChargeRequested || strategy.forceGridRequested) {\n autoEvcsPriorityActive = true;\n autoEnergyPriorityMode = 'evcs_priority';\n autoEnergyPriorityReason =\n strategy.forceGridRequested\n ? 'EVCS prioritaire : force_grid_now'\n : 'EVCS prioritaire : force_charge_now';\n autoEvcsPriorityUntil = formatParisStampFromMs(nowMs + forceWindowMinutes * 60000);\n }\n\n else if (night.nightSessionActive) {\n autoEvcsPriorityActive = true;\n autoEnergyPriorityMode = 'evcs_priority';\n autoEnergyPriorityReason =\n night.batteryRecoveryUrgent\n ? 'EVCS prioritaire : session nuit + urgence départ'\n : night.nightGridTakeoverNeeded\n ? 'EVCS prioritaire : session nuit + takeover réseau'\n : 'EVCS prioritaire : session nuit active';\n\n autoEvcsPriorityUntil =\n Number.isFinite(lifecycle.minutesUntilDeparture)\n ? formatParisStampFromMs(\n nowMs + Math.max(1, lifecycle.minutesUntilDeparture) * 60000\n )\n : formatParisStampFromMs(nowMs + forceWindowMinutes * 60000);\n }\n\n else if (context.departurePriorityActive && Number.isFinite(lifecycle.minutesUntilDeparture)) {\n autoEvcsPriorityActive = true;\n autoEnergyPriorityMode = 'evcs_priority';\n autoEnergyPriorityReason = `EVCS prioritaire : départ dans ${Math.round(lifecycle.minutesUntilDeparture)} min`;\n autoEvcsPriorityUntil = formatParisStampFromMs(\n nowMs + Math.max(1, lifecycle.minutesUntilDeparture) * 60000\n );\n }\n\n else if (activeChargeIntent && context.profile === 'midday_hc_or_solar') {\n autoEvcsPriorityActive = true;\n autoEnergyPriorityMode = 'evcs_priority';\n autoEnergyPriorityReason = 'EVCS prioritaire : recharge active fenêtre midi';\n autoEvcsPriorityUntil = formatParisStampFromMs(nowMs + 90 * 60000);\n }\n}\n\n// -------------------------\n// OVERRIDE MANUEL\n// -------------------------\nconst manualPriorityOverrideActive =\n parsed.evcsPriorityManualOverride &&\n parsed.evcsPriorityManualMode === 'evcs_priority' &&\n history.evcsPriorityManualUntilMs !== null &&\n history.evcsPriorityManualUntilMs > nowMs + 1000;\n\nlet evcsPriorityActive = autoEvcsPriorityActive;\nlet energyPriorityMode = autoEnergyPriorityMode;\nlet energyPriorityReason = autoEnergyPriorityReason;\nlet evcsPriorityUntil = autoEvcsPriorityUntil;\nlet evcsPrioritySource = 'auto';\n\nif (manualPriorityOverrideActive && !evcsVehicleNotRequestingPowerState) {\n evcsPriorityActive = true;\n energyPriorityMode = 'evcs_priority';\n energyPriorityReason =\n parsed.evcsPriorityManualReason || 'EVCS prioritaire : override manuel';\n evcsPriorityUntil =\n history.evcsPriorityManualUntilAt || autoEvcsPriorityUntil;\n evcsPrioritySource = 'manual';\n}\n\n// =====================================================\n// REASONS / LABELS / SIGNATURE\n// =====================================================\nconst reason = [...decision.reason];\n\nreason.push(`Stratégie=${strategy.userStrategy}`);\nreason.push(`Profil=${context.profile}`);\nreason.push(`Source=${context.sourceMode}`);\nreason.push(\n `Mode=${\n modes.batteryFullHarvestMode\n ? 'battery_full_harvest_mode'\n : modes.forecastCommitMode\n ? 'forecast_commit_mode'\n : 'battery_priority_mode'\n }`\n);\nreason.push(`SOC=${round1(parsed.soc)}%`);\nreason.push(`Batt=${round1(parsed.batteryCurrentA)}A`);\nreason.push(`PV=${Math.round(parsed.pvPowerW)}W`);\nreason.push(`PV10=${Math.round(parsed.pvPowerAvg10mW)}W`);\n// =====================================================\n// CONSIGNE DE PUISSANCE RESEAU (01/09/2026)\n// =====================================================\n// Fermer le contacteur ne suffit PAS. Au-dessus du SOC minimal, l'ESS en\n// autoconsommation ne preleve que `ac_power_setpoint` et laisse la batterie\n// porter tout le reste : nuit du 01/09, contacteur ferme, consigne 50 W,\n// batterie a -803 W pour 761 W de maison, voiture arretee par le garde\n// \"batterie trop sollicitee\". La consigne est donc le levier qui rend une\n// session reseau REELLE, et il doit appartenir au controleur, pas rester un\n// reglage manuel permanent (celui-ci provoquait la substitution du solaire\n// par le reseau en journee, cf. HANDOFF 27/28).\n//\n// La valeur n'est pas une constante : c'est le besoin mesure du moment, la\n// maison telle qu'elle consomme plus la voiture au courant que le controleur\n// s'autorise. Le plafond joue le role de marge : quand la voiture n'est pas a\n// son plafond, la consigne est deja genereuse, et l'exces ne peut pas partir\n// dans la batterie (DVCC tenue a 2 A par l'Auto Charge Manager pendant un\n// takeover). Hors session, retour a la valeur de repos.\n// ⚠️ LA SESSION ENGAGEE COMPTE AUSSI (04/09/2026).\n// Nuit du 03-04/09 : la session reseau unique a tenu le contacteur toute la\n// nuit — une seule bascule, comme voulu. Mais a 02:50, la voiture finie, le\n// sourceMode est passe a 'blocked' et la consigne est retombee de 5078 W a\n// 50 W. Contacteur ferme, reseau disponible, heures creuses, et l'ESS ne\n// demandait que 50 W pendant que la maison tirait 770 W SUR LA BATTERIE.\n// Trois heures et demie, SOC 44 % -> 17 %, 2,60 kWh pris a la batterie quand\n// le reseau etait la et bon marche.\n//\n// C'est le defaut du 01/09 revenu ailleurs : fermer le contacteur ne suffit\n// pas, la consigne doit demander la puissance. J'avais conditionne la consigne\n// au sourceMode, puis cree la veille une NOUVELLE raison de garder le\n// contacteur ferme — l'engagement de session — sans la brancher ici. La\n// session tenait le contacteur, pas l'energie.\n//\n// Regle : tant que le contacteur est ferme A NOTRE DEMANDE, la consigne doit\n// couvrir la maison. Peu importe laquelle des deux raisons le tient.\nconst gridSessionActive =\n parsed.acInAvailable &&\n (\n context.sourceMode === 'grid_forced' ||\n context.sourceMode === 'grid_hc_allowed' ||\n night.nightGridSessionCommitted === true\n );\n\nconst gridSetpointCeilingW = clamp(\n Math.round(derived.houseNonEvcsW + derived.physicalCapA * derived.acVoltageRef),\n params.gridSetpointIdleW,\n GRID_SETPOINT_MAX_W\n);\n\nconst gridSetpointCurrentW = Number.isFinite(parsed.gridSetpointW)\n ? parsed.gridSetpointW\n : null;\n\n// =====================================================\n// CREDIT SOLAIRE SUR LA CONSIGNE (11/09/2026)\n// =====================================================\n// Defaut mesure ce jour-la : BOOST 28 A en plein soleil, consigne calculee a\n// 8242 W (maison 1802 + 28 A x 230). L'ESS n'a qu'un moyen de prelever 8,2 kW\n// quand le soleil en donne 4 : brider les MPPT. Releve 17:02 -- PV 121 W,\n// reseau 7697 W, batterie 0 A, SOC 96 %. La production etait refusee, et par\n// NOTRE consigne. Le commentaire ci-dessus (\"une consigne trop HAUTE ne coute\n// rien\") a ete ecrit la nuit du 01/09, ou la PV valait zero : il n'y avait\n// alors rien a evincer. En plein jour il est faux.\n//\n// ⚠️ ON NE PEUT PAS SOUSTRAIRE LA PV MESUREE. Elle vaut 121 W *a cause* de\n// cette consigne : mesure censuree, la soustraire ne bouge rien et le systeme\n// reste sur son mauvais equilibre. Preuve le meme jour a 17:22 -- consigne\n// descendue A LA MAIN a 2280 W : PV 4758 W en 50 secondes, batterie +9 A. La\n// ressource etait la, seule la consigne l'evincait.\n//\n// On DESCEND donc, et on laisse la physique repondre. Deux signaux, tous deux\n// deja lus et tous deux INCENSURABLES :\n// - les chargeurs declarent eux-memes s'ils se BRIDENT (mpptLimitedCount).\n// `off` ne compte pas : la nuit ils sont tous `off` et il n'y a rien a\n// liberer -- c'est ce qui garantit que le comportement de nuit (§31, §57)\n// est strictement inchange ;\n// - le courant batterie dit si on est descendu trop bas, via la politique\n// par bande de SOC deja calibree (batterySoftBreachNow, §35).\n// La valeur courante vit dans le GX : l'etat est deja persiste, aucun helper\n// nouveau n'est necessaire.\n//\n// PLANCHER : la consigne ne descend JAMAIS sous la maison hors voiture. C'est\n// la garantie du 01/09 et du 04/09 -- le reseau, quand le contacteur est ferme\n// a notre demande, couvre la maison. On ne libere que la part VOITURE, qui est\n// exactement celle qui evince le solaire. Le solaire pourrait souvent couvrir\n// la maison en plus, mais l'affirmer demanderait ses propres scenarios.\n// ⚠️ SECOND PLANCHER : LE NUAGE (ajoute 11/09 18:05, apres incident).\n// Premiere version de la descente : consigne tombee a 1281 W alors que le\n// solaire portait tout (PV 6058 W, reseau 1163 W, voiture a 28 A). Un nuage a\n// fait chuter la PV de 4,6 kW en une minute ; le reseau etant plafonne a\n// 1281 W, la difference est tombee entierement sur la batterie -- 87 A pour\n// une limite dure de 70 A -> `battery_current_hard_stop`, charge coupee.\n//\n// Sans injection, la production peut s'effondrer de plusieurs kW en quelques\n// secondes. Une boucle a 30 s ne peut pas rattraper cela : il faut que le\n// reseau soit DEJA autorise a prendre le relais. La consigne ne descend donc\n// jamais au point ou la perte totale du solaire depasserait ce que la batterie\n// sait encaisser.\n//\n// Le budget vient de la politique batterie deja calibree par bande de SOC\n// (§35), pas d'une constante nouvelle, et on garde 20 % de marge sous la\n// limite DURE -- c'est elle qui coupe la charge, et une coupure est plus\n// couteuse que quelques centaines de watts de solaire non captes.\n//\n// Contrepartie assumee, et elle est reelle : voiture a 28 A, la descente est\n// nettement moins profonde qu'a 10 A. C'est proportionne -- c'est le courant\n// de la voiture qui cree l'exposition. Et c'est la regle du proprietaire :\n// protéger le materiel prime sur l'economie de kilowattheures.\n// `derived.batteryVoltageRef` est deja la tension retenue en amont (mesure\n// vebus, repli 51,2 V) : meme grandeur que celle qui sert a convertir le\n// courant batterie en puissance dans BATTERY_CLOUD_LOCKS.\nconst batterySurgeBudgetW = Math.max(\n 0,\n Math.abs(batteryPolicy.batteryDischargeHardDynamicA) * derived.batteryVoltageRef * 0.8\n);\nconst gridSetpointLoadW =\n derived.houseNonEvcsW + derived.physicalCapA * derived.acVoltageRef;\nconst gridSetpointCloudFloorW = Math.round(\n Math.max(0, gridSetpointLoadW - batterySurgeBudgetW)\n);\n\nconst gridSetpointHouseFloorW = clamp(\n Math.max(Math.round(derived.houseNonEvcsW), gridSetpointCloudFloorW),\n params.gridSetpointIdleW,\n GRID_SETPOINT_MAX_W\n);\n\nconst solarResourceRefused =\n parsed.mpptReadableCount > 0 && parsed.mpptLimitedCount > 0;\n\n// Pendant la fenetre d'etablissement (§29) les mesures PV et batterie ne\n// decrivent aucun regime permanent : on ne descend pas sur elles.\nconst gridSetpointDescendOk =\n gridSessionActive &&\n solarResourceRefused &&\n !batteryPolicy.batterySoftBreachNow &&\n !derived.gridSettling &&\n gridSetpointCurrentW !== null;\n\n// ⚠️ LE PLAFOND EST UN PLAFOND, PAS UNE CIBLE (corrige 11/09 17:52).\n// Premiere version de ce correctif, en production 12 minutes : la consigne\n// descendait bien a 2241 W et la PV remontait a 7074 W -- puis, tous les\n// chargeurs repassant `mppt_active`, la condition de descente devenait fausse,\n// le besoin revenait au plafond et la consigne etait REECRITE a 7100 W. La PV\n// s'effondrait a 1785 W, les chargeurs se bridaient, on redescendait.\n// Oscillateur de periode ~5 min. Releve : 17:40:30 -> 2241 / 17:47:19 -> 7100\n// / 17:47:28 PV 1785 / 17:48:30 -> 7368.\n//\n// Une fois la consigne descendue, on la TIENT tant que le solaire produit. Ce\n// n'est pas un etat a memoriser : la valeur courante est dans le GX, et le\n// plancher maison la fait remonter d'elle-meme si la maison grossit.\n//\n// La nuit, les chargeurs sont `off` (ils ne produisent pas) : on retombe sur le\n// comportement historique -- la consigne monte au plafond pour que le reseau\n// porte maison et voiture (§31, §57). C'est cette distinction qui empeche le\n// « on tient » de casser les nuits HC.\nconst solarProducing =\n parsed.mpptReadableCount > 0 && parsed.mpptAtMaxPowerCount > 0;\n\nlet gridSetpointNeedW;\nlet gridSetpointDescending = false;\n\nif (!gridSessionActive) {\n gridSetpointNeedW = params.gridSetpointIdleW;\n} else if (solarProducing && !batteryPolicy.batterySoftBreachNow && gridSetpointCurrentW !== null) {\n // On tient la valeur acquise, bornee par la maison et par le plafond.\n gridSetpointNeedW = clamp(\n Math.min(gridSetpointCurrentW, gridSetpointCeilingW),\n gridSetpointHouseFloorW,\n gridSetpointCeilingW\n );\n} else {\n gridSetpointNeedW = gridSetpointCeilingW;\n}\n\nif (gridSetpointDescendOk) {\n // Un chargeur bride PROUVE qu'il reste de la ressource : on peut descendre\n // franchement. Une descente trop forte est rattrapee au cycle suivant (30 s\n // ou la batterie porte l'ecart, soit quelques dizaines de Wh), alors qu'une\n // descente trop timide laisse des kW de solaire refuses pendant dix minutes.\n const minStepW = Math.max(\n GRID_SETPOINT_DEADBAND_W,\n Math.round(params.currentStepUpA * derived.acVoltageRef)\n );\n const baseW = Math.min(gridSetpointCurrentW, gridSetpointCeilingW);\n const gapW = baseW - gridSetpointHouseFloorW;\n if (gapW > 0) {\n const stepW = Math.max(minStepW, Math.round(gapW / 2));\n gridSetpointNeedW = Math.max(gridSetpointHouseFloorW, baseW - stepW);\n gridSetpointDescending = true;\n }\n}\n\n// La bande est ASYMETRIQUE, et c'est le point. Une consigne trop BASSE fait\n// porter la difference par la batterie : c'est le defaut de la nuit du 01/09,\n// il se corrige immediatement. Une consigne trop HAUTE ne coute rien : l'ESS\n// ne preleve que ce qu'il peut placer, et le courant de charge de la batterie\n// est tenu par ailleurs (DVCC, Auto Charge Manager). Deux leviers, deux roles\n// -- la consigne dit ce que le reseau a le DROIT de servir, DVCC dit ce que la\n// batterie a le droit de prendre. Ne pas confondre les deux.\n//\n// On ecrit donc avec une marge d'un pas de courant EVCS (grandeur du systeme,\n// pas une constante inventee) pour que le bruit de la maison ne rende pas la\n// consigne insuffisante au cycle suivant, et on ne redescend que si elle est\n// devenue franchement inutile. Sans cela la boucle 30 s reecrivait a chaque\n// cycle en poursuivant le bruit.\nconst gridSetpointSlackW = gridSessionActive && !gridSetpointDescending\n ? Math.max(GRID_SETPOINT_DEADBAND_W, Math.round(params.currentStepUpA * derived.acVoltageRef))\n : 0;\n\nconst gridSetpointTargetW = clamp(\n gridSetpointNeedW + gridSetpointSlackW,\n 0,\n GRID_SETPOINT_MAX_W\n);\n\nconst gridSetpointNeedsWrite =\n gridSetpointCurrentW === null ||\n gridSetpointCurrentW < gridSetpointNeedW ||\n gridSetpointCurrentW > gridSetpointNeedW + 2 * gridSetpointSlackW + GRID_SETPOINT_DEADBAND_W;\n\nreason.push(`Grid=${parsed.gridAvailable ? 'on' : 'off'} (${Math.round(parsed.gridPowerW)}W)`);\n// Trace de la fenetre d'etablissement : sans elle, une lecture du logbook\n// pendant le transitoire donne l'impression que le systeme decide n'importe\n// quoi. Il decide, au contraire, de ne PAS decider sur ces mesures.\nif (derived.gridSettling) {\n reason.push(`ETABLISSEMENT reseau ${Math.round(parsed.gridTransitionAgeSec)}s/${params.gridSettleSeconds}s (mesures PV/batterie non fiables)`);\n}\nif (gridSessionActive) {\n reason.push(\n gridSetpointDescending\n ? `Consigne reseau=${gridSetpointTargetW}W (CREDIT SOLAIRE, ${parsed.mpptLimitedCount}/${parsed.mpptReadableCount} brides)`\n : `Consigne reseau=${gridSetpointTargetW}W`\n );\n}\n// Visible dans le logbook uniquement pendant un depassement souple : c'est le\n// seul moment ou l'information sert, et la raison est tronquee a 255 caracteres.\nif (batteryPolicy.batterySoftBreachNow) {\n reason.push(\n `Batt souple ${round1(batteryPolicy.batterySoftBreachMinutes)}/${round1(batteryPolicy.batterySoftToleranceMinutes)}min`\n );\n}\n// Idem : visible seulement quand le bilan energetique DURCIT le plancher du\n// soir. Sans cette trace, un blocage de charge un soir de SOC 94 % est\n// incomprehensible a la lecture du logbook.\nif (safeguards.eveningFloorFromBalance) {\n reason.push(\n `Plancher soir=${round1(safeguards.eveningSocStartFloorPct)}% (BILAN, ${round1(safeguards.balanceEnergyToCoverKwh)}kWh a couvrir)`\n );\n}\nreason.push(`Cap=${derived.physicalCapA}A`);\nreason.push(`Ref=${round1(derived.currentReferenceA)}A`);\nreason.push(`Target=${round1(desiredCurrentA)}A`);\nreason.push(`Debt=${round2(cloud.cloudDebtNextKwh)}kWh`);\nreason.push(`Vehicle=${parsed.evcsVehicleConnected ? 'on' : 'off'}`);\nreason.push(`EVCS mode=${parsed.evcsModeRaw}`);\nreason.push(`VE.Bus AC=${Math.round(parsed.vebusAcOutPowerW)}W`);\nreason.push(\n `VE.Bus DC=${\n Number.isFinite(parsed.vebusDcVoltageV)\n ? round1(parsed.vebusDcVoltageV)\n : 'n/a'\n }V`\n);\nreason.push(`PrioEVCS=${evcsPriorityActive ? 'on' : 'off'}(${evcsPrioritySource})`);\nreason.push(`VDF delivery=${Number.isFinite(vdf.deliveryRatioEma) ? round3(vdf.deliveryRatioEma) : 'na'}`);\nreason.push(`VDF noise=${Number.isFinite(vdf.deliveryNoiseEma) ? round3(vdf.deliveryNoiseEma) : 'na'}`);\nreason.push(`VDF score=${Number.isFinite(vdf.noDemandScoreEma) ? round3(vdf.noDemandScoreEma) : 'na'}`);\nreason.push(`VDF noDemand=${vdf.evcsVehicleNoDemandSemantic ? 'on' : 'off'}`);\n\nif (night.nightSessionActive) {\n reason.push(`NightTarget=${round1(night.batteryMorningTargetPct ?? 0)}%`);\n reason.push(`NightTrigger=${round1(night.gridTakeoverTriggerPct ?? 0)}%`);\n\n if (Number.isFinite(night.nightSessionStartSocPct)) {\n reason.push(`StartSOC=${round1(night.nightSessionStartSocPct)}%`);\n reason.push(`AllowDrop=${round1(night.maxBatteryDischargePoints ?? 0)}pts`);\n }\n\n reason.push(`NightPreferred=${night.nightPreferredSourceMode ?? 'blocked'}`);\n reason.push(`NightTakeover=${night.nightGridTakeoverNeeded ? 'on' : 'off'}`);\n reason.push(`NightUrgent=${night.batteryRecoveryUrgent ? 'on' : 'off'}`);\n}\n\nconst shortReason = reason.join(' | ').slice(0, 255);\n\nconst sourceModeLabel =\n context.sourceMode === 'blocked' ? 'Bloqué' :\n context.sourceMode === 'solar_only' ? 'Solaire' :\n context.sourceMode === 'solar_potential' ? 'Potentiel solaire' :\n context.sourceMode === 'hybrid_probe' ? 'Probe hybride' :\n context.sourceMode === 'grid_hc_allowed' ? 'Réseau HC autorisé' :\n context.sourceMode === 'grid_forced' ? 'Réseau forcé' :\n context.sourceMode === 'battery_assist_night' ? 'Assistance batterie nuit' :\n context.sourceMode;\n\nconst signature = [\n decision.state,\n `profile=${context.profile}`,\n `source=${context.sourceMode}`,\n `chg=${decision.desiredCharging ? '1' : '0'}`,\n `perm=${decision.desiredPermission ? '1' : '0'}`,\n `l=${decision.desiredLatch ? '1' : '0'}`,\n `cur=${round1(desiredCurrentA)}`,\n `block=${decision.nextBlockReason}`,\n `stop=${decision.mustStop ? '1' : '0'}`,\n `prio=${evcsPriorityActive ? '1' : '0'}`,\n `night=${night.nightSessionActive ? '1' : '0'}`,\n `ntrg=${Number.isFinite(night.gridTakeoverTriggerPct) ? round1(night.gridTakeoverTriggerPct) : 'na'}`,\n `nurg=${night.batteryRecoveryUrgent ? '1' : '0'}`,\n `vdf=${vdf.evcsVehicleNoDemandSemantic ? '1' : '0'}`,\n `vdfs=${Number.isFinite(vdf.noDemandScoreEma) ? round3(vdf.noDemandScoreEma) : 'na'}`\n].join('|');\n\nconst signatureChanged = signature !== history.lastSignature;\n\n// =====================================================\n// NOTIFICATIONS\n// =====================================================\nlet notifyWanted = false;\nlet notifyTitle = 'Victron EVCS Surplus';\nlet notifyMessage = shortReason;\n\nif (parsed.notificationsEnabled && signatureChanged) {\n if (decision.currentDecision === 'start') {\n notifyWanted = true;\n notifyTitle = '⚡ EVCS démarrage';\n } else if (decision.currentDecision === 'step_up') {\n notifyWanted = true;\n notifyTitle = '⬆️ EVCS hausse courant';\n } else if (\n decision.currentDecision === 'step_down' ||\n decision.currentDecision === 'step_down_fast'\n ) {\n notifyWanted = true;\n notifyTitle = '⬇️ EVCS baisse courant';\n } else if (\n decision.mustStop ||\n decision.currentDecision === 'stop' ||\n decision.currentDecision === 'stop_hard' ||\n decision.currentDecision === 'stop_power_cap'\n ) {\n notifyWanted = true;\n notifyTitle = '⛔ EVCS arrêt';\n } else if (decision.state === 'mode_auto_conflict') {\n notifyWanted = true;\n notifyTitle = '⚠️ EVCS mode Auto';\n } else if (decision.state === 'vehicle_full_or_paused') {\n notifyWanted = true;\n notifyTitle = '🚗 EVCS véhicule plein ou en pause';\n }\n}\n\n// =====================================================\n// TIMESTAMPS TO WRITE\n// =====================================================\nconst cloudDebtStartedAtToWrite =\n cloud.cloudDebtStartedMs\n ? formatParisStampFromMs(cloud.cloudDebtStartedMs)\n : now.stamp;\n\n// DEPASSEMENT SOUPLE BATTERIE (01/09/2026) : chaine vide quand il n'y a pas\n// de depassement en cours. Le helper n'est reecrit que sur changement reel\n// (debut ou fin), pas a chaque cycle : la valeur du debut doit RESTER figee\n// pendant toute la duree du depassement, sinon le compteur ne monte jamais.\nconst batterySoftBreachAtToWrite =\n batteryPolicy.batterySoftBreachStartedMs !== null &&\n batteryPolicy.batterySoftBreachStartedMs !== undefined\n ? formatParisStampFromMs(batteryPolicy.batterySoftBreachStartedMs)\n : '';\n\nconst setBatterySoftBreachAt =\n batterySoftBreachAtToWrite !== (history.batterySoftBreachAt || '');\n\nconst probeStartedAtToWrite = now.stamp;\nconst lastStartAtToWrite = now.stamp;\nconst lastStopAtToWrite = now.stamp;\nconst lastCurrentChangeAtToWrite = now.stamp;\n\n// =====================================================\n// NIGHT SESSION MEMORY\n// =====================================================\nconst nightSessionStartSocToWrite =\n decision.clearNightSessionStart\n ? ''\n : decision.setNightSessionStart\n ? String(round1(parsed.soc))\n : (\n Number.isFinite(history.nightSessionStartSocPct)\n ? String(round1(history.nightSessionStartSocPct))\n : ''\n );\n\nconst nightSessionStartAtToWrite =\n decision.clearNightSessionStart\n ? ''\n : decision.setNightSessionStart\n ? now.stamp\n : (history.nightSessionStartAt || '');\n\n// =====================================================\n// OUTPUT\n// =====================================================\nreturn [{\n json: {\n timestamp_paris: now.stamp,\n inputs: input,\n\n computed: {\n operating_mode: context.operatingMode,\n profile: context.profile,\n source_mode: context.sourceMode,\n source_mode_label: sourceModeLabel,\n user_strategy: strategy.userStrategy,\n\n in_night_hc_window: windows.inNightHcWindow,\n in_morning_window: windows.inMorningWindow,\n in_midday_window: windows.inMiddayWindow,\n in_evening_window: windows.inEveningWindow,\n\n departure_priority_active: context.departurePriorityActive,\n minutes_until_departure: lifecycle.minutesUntilDeparture,\n\n battery_priority_mode: modes.batteryPriorityMode,\n battery_full_harvest_mode: modes.batteryFullHarvestMode,\n forecast_commit_mode: modes.forecastCommitMode,\n\n physical_cap_current_a: derived.physicalCapA,\n can_run_at_min_current: derived.canRunAtMinCurrent,\n\n forecast_buffer_kwh: round2(derived.forecastBufferKwh),\n projected_end_soc_after_charge: round1(derived.projectedEndSocAfterCharge),\n\n cloud_debt_next_kwh: round2(cloud.cloudDebtNextKwh),\n cloud_debt_duration_minutes: round1(cloud.cloudDebtDurationMinutes),\n cloud_debt_active: cloud.cloudDebtActive,\n cloud_tolerance_ok: cloud.cloudToleranceOk,\n\n start_blocked_by_reason: locks.startBlockedByReason,\n\n probe_window_active: conditions.probeWindowActive,\n probe_success_now: conditions.probeSuccessNow,\n probe_failed_now: conditions.probeFailedNow,\n\n any_vebus_overload: derived.anyVebusOverload,\n any_vebus_low_battery: derived.anyVebusLowBattery,\n vebus_ac_out_soft_reached: derived.vebusAcOutSoftReached,\n vebus_ac_out_hard_reached: derived.vebusAcOutHardReached,\n vebus_dc_voltage_soft_low: derived.vebusDcVoltageSoftLow,\n vebus_dc_voltage_hard_low: derived.vebusDcVoltageHardLow,\n\n soc_band: batteryPolicy.socBand,\n batt_discharge_soft_dynamic_a: round1(batteryPolicy.batteryDischargeSoftDynamicA),\n batt_discharge_hard_dynamic_a: round1(batteryPolicy.batteryDischargeHardDynamicA),\n batt_charge_reserve_dynamic_a: round1(batteryPolicy.batteryChargeReserveDynamicA),\n batt_soft_tolerance_min: round1(batteryPolicy.batterySoftToleranceMinutes),\n batt_hard_tolerance_sec: round1(batteryPolicy.batteryHardToleranceSeconds),\n batt_recovery_min: round1(batteryPolicy.batteryRecoveryMinutes),\n grid_battery_soft_dynamic_a: round1(batteryPolicy.gridBatterySoftDynamicA),\n grid_battery_hard_dynamic_a: round1(batteryPolicy.gridBatteryHardDynamicA),\n house_spike_detected: batteryPolicy.houseSpikeDetected,\n solar_potential_context: batteryPolicy.solarPotentialContext,\n\n batt_soft_breach_now: !!batteryPolicy.batterySoftBreachNow,\n batt_soft_breach_minutes: round1(batteryPolicy.batterySoftBreachMinutes || 0),\n batt_soft_discharge_hold_ok: batteryPolicy.batterySoftDischargeHoldOk !== false,\n\n evcs_priority_active: evcsPriorityActive,\n energy_priority_mode: energyPriorityMode,\n energy_priority_reason: energyPriorityReason,\n evcs_priority_until: evcsPriorityUntil,\n evcs_priority_source: evcsPrioritySource,\n evcs_priority_manual_override_active: manualPriorityOverrideActive,\n\n night_session_active: night.nightSessionActive,\n night_session_needs_capture: !!night.nightSessionNeedsCapture,\n night_session_needs_clear: !!night.nightSessionNeedsClear,\n night_session_start_soc_pct: Number.isFinite(night.nightSessionStartSocPct)\n ? round1(night.nightSessionStartSocPct)\n : null,\n night_session_start_at: night.nightSessionStartAt || null,\n\n battery_hard_floor_pct: Number.isFinite(night.batteryHardFloorPct)\n ? round1(night.batteryHardFloorPct)\n : null,\n battery_morning_target_pct: Number.isFinite(night.batteryMorningTargetPct)\n ? round1(night.batteryMorningTargetPct)\n : null,\n max_battery_discharge_points: Number.isFinite(night.maxBatteryDischargePoints)\n ? round1(night.maxBatteryDischargePoints)\n : null,\n grid_takeover_trigger_pct: Number.isFinite(night.gridTakeoverTriggerPct)\n ? round1(night.gridTakeoverTriggerPct)\n : null,\n\n battery_recovery_urgent: !!night.batteryRecoveryUrgent,\n night_grid_takeover_needed: !!night.nightGridTakeoverNeeded,\n night_preferred_source_mode: night.nightPreferredSourceMode || 'blocked',\n\n evcs_delivery_ratio_ema: Number.isFinite(vdf.deliveryRatioEma) ? round3(vdf.deliveryRatioEma) : null,\n evcs_delivery_noise_ema: Number.isFinite(vdf.deliveryNoiseEma) ? round3(vdf.deliveryNoiseEma) : null,\n evcs_no_demand_score_ema: Number.isFinite(vdf.noDemandScoreEma) ? round3(vdf.noDemandScoreEma) : null,\n evcs_vehicle_no_demand_semantic: !!vdf.evcsVehicleNoDemandSemantic,\n evcs_no_demand_since_minutes: Number.isFinite(vdf.evcsNoDemandSinceMinutes) ? round3(vdf.evcsNoDemandSinceMinutes) : null,\n evcs_source_deliverable_for_no_demand: !!vdf.sourceDeliverableForNoDemand,\n evcs_session_armed_for_no_demand: !!vdf.evcsSessionArmedForNoDemand,\n evcs_no_demand_score_on: Number.isFinite(vdf.noDemandScoreOn) ? round3(vdf.noDemandScoreOn) : null,\n evcs_no_demand_score_off: Number.isFinite(vdf.noDemandScoreOff) ? round3(vdf.noDemandScoreOff) : null\n },\n\n decision: {\n state: decision.state,\n current_decision: decision.currentDecision,\n desired_charging: decision.desiredCharging,\n desired_permission: decision.desiredPermission,\n desired_latch: decision.desiredLatch,\n desired_current_a: round1(desiredCurrentA),\n\n charging_action: chargingAction,\n permission_action: permissionAction,\n latch_action: latchAction,\n current_action: currentAction,\n auto_start_action: autoStartAction,\n\n set_last_start_at: setLastStartAt,\n set_last_stop_at: setLastStopAt,\n set_last_current_change_at: setLastCurrentChangeAt,\n set_cloud_debt_started_at: decision.setCloudDebtStartedAt,\n set_probe_started_at: decision.setProbeStartedAt,\n\n set_night_session_start: !!decision.setNightSessionStart,\n clear_night_session_start: !!decision.clearNightSessionStart,\n\n set_block_reason: needsBlockReasonUpdate || !parsed.controllerEnabled,\n block_reason: decision.nextBlockReason,\n\n set_blocked_until: true,\n blocked_until: blockedUntilToWrite,\n\n dynamic_ok: decision.dynamicOk,\n must_stop: decision.mustStop,\n\n short_reason: shortReason,\n reason,\n signature,\n signature_changed: signatureChanged,\n\n should_notify: notifyWanted,\n notify_title: notifyTitle,\n notify_message: notifyMessage,\n\n evcs_priority_active: evcsPriorityActive,\n energy_priority_mode: energyPriorityMode,\n energy_priority_reason: energyPriorityReason,\n evcs_priority_until: evcsPriorityUntil,\n evcs_priority_source: evcsPrioritySource\n },\n\n ha_service_calls: {\n helper_state: {\n execute: true,\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.victron_evcs_surplus_state',\n value: decision.state\n }\n },\n\n helper_current_decision: {\n execute: true,\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.victron_evcs_surplus_current_decision',\n value: decision.currentDecision\n }\n },\n\n helper_reason: {\n execute: true,\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.victron_evcs_surplus_reason',\n value: shortReason\n }\n },\n\n helper_operating_mode: {\n execute: true,\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.victron_evcs_operating_mode',\n value: context.operatingMode\n }\n },\n\n helper_source_mode: {\n execute: true,\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.victron_evcs_source_mode',\n value: sourceModeLabel\n }\n },\n\n helper_target_current: {\n execute: true,\n domain: 'input_number',\n service: 'set_value',\n data: {\n entity_id: 'input_number.victron_evcs_target_current_auto',\n value: round1(desiredCurrentA)\n }\n },\n\n helper_budget_remaining: {\n execute: true,\n domain: 'input_number',\n service: 'set_value',\n data: {\n entity_id: 'input_number.victron_evcs_budget_remaining_kwh',\n value: round2(derived.forecastBufferKwh)\n }\n },\n\n helper_projected_soc: {\n execute: true,\n domain: 'input_number',\n service: 'set_value',\n data: {\n entity_id: 'input_number.victron_evcs_projected_end_soc_after_charge',\n value: round1(derived.projectedEndSocAfterCharge)\n }\n },\n\n helper_cloud_debt: {\n execute: true,\n domain: 'input_number',\n service: 'set_value',\n data: {\n entity_id: 'input_number.victron_evcs_cloud_debt_kwh',\n value: round2(cloud.cloudDebtNextKwh)\n }\n },\n\n helper_dynamic_ok: {\n execute: true,\n domain: 'input_boolean',\n service: decision.dynamicOk ? 'turn_on' : 'turn_off',\n data: {\n entity_id: 'input_boolean.victron_evcs_dynamic_ok'\n }\n },\n\n helper_must_stop: {\n execute: true,\n domain: 'input_boolean',\n service: decision.mustStop ? 'turn_on' : 'turn_off',\n data: {\n entity_id: 'input_boolean.victron_evcs_must_stop'\n }\n },\n\n helper_cloud_debt_started_at: {\n execute: true,\n domain: 'input_datetime',\n service: 'set_datetime',\n data: {\n entity_id: 'input_datetime.victron_evcs_cloud_debt_started_at',\n datetime: cloudDebtStartedAtToWrite\n }\n },\n\n helper_battery_soft_breach_at: {\n execute: setBatterySoftBreachAt,\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.victron_evcs_battery_soft_breach_at',\n value: batterySoftBreachAtToWrite\n }\n },\n\n helper_last_current_change_at: {\n execute: setLastCurrentChangeAt,\n domain: 'input_datetime',\n service: 'set_datetime',\n data: {\n entity_id: 'input_datetime.victron_evcs_last_current_change_at',\n datetime: lastCurrentChangeAtToWrite\n }\n },\n\n helper_last_start_at: {\n execute: setLastStartAt,\n domain: 'input_datetime',\n service: 'set_datetime',\n data: {\n entity_id: 'input_datetime.victron_evcs_last_start_at',\n datetime: lastStartAtToWrite\n }\n },\n\n helper_last_stop_at: {\n execute: setLastStopAt,\n domain: 'input_datetime',\n service: 'set_datetime',\n data: {\n entity_id: 'input_datetime.victron_evcs_last_stop_at',\n datetime: lastStopAtToWrite\n }\n },\n\n helper_signature: {\n execute: true,\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.victron_evcs_control_last_signature',\n value: signature\n }\n },\n\n helper_block_reason: {\n execute: needsBlockReasonUpdate || !parsed.controllerEnabled,\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.victron_evcs_last_block_reason',\n value: decision.nextBlockReason\n }\n },\n\n helper_blocked_until: {\n execute: true,\n domain: 'input_datetime',\n service: 'set_datetime',\n data: {\n entity_id: 'input_datetime.victron_evcs_blocked_until',\n datetime: blockedUntilToWrite\n }\n },\n\n helper_probe_started_at: {\n execute: decision.setProbeStartedAt,\n domain: 'input_datetime',\n service: 'set_datetime',\n data: {\n entity_id: 'input_datetime.victron_evcs_probe_started_at',\n datetime: probeStartedAtToWrite\n }\n },\n\n helper_evcs_priority_active: {\n execute: true,\n domain: 'input_boolean',\n service: evcsPriorityActive ? 'turn_on' : 'turn_off',\n data: {\n entity_id: 'input_boolean.victron_evcs_priority_active'\n }\n },\n\n helper_energy_priority_mode: {\n execute: true,\n domain: 'input_select',\n service: 'select_option',\n data: {\n entity_id: 'input_select.victron_energy_priority_mode',\n option: energyPriorityMode\n }\n },\n\n helper_energy_priority_reason: {\n execute: true,\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.victron_energy_priority_reason',\n value: energyPriorityReason\n }\n },\n\n helper_evcs_priority_until: {\n execute: true,\n domain: 'input_datetime',\n service: 'set_datetime',\n data: {\n entity_id: 'input_datetime.victron_evcs_priority_until',\n datetime: evcsPriorityUntil\n }\n },\n\n helper_night_target_soc: {\n execute: true,\n domain: 'input_number',\n service: 'set_value',\n data: {\n entity_id: 'input_number.victron_evcs_night_target_soc',\n value: round1(\n Number.isFinite(night.batteryMorningTargetPct)\n ? night.batteryMorningTargetPct\n : 0\n )\n }\n },\n\n helper_night_grid_trigger_soc: {\n execute: true,\n domain: 'input_number',\n service: 'set_value',\n data: {\n entity_id: 'input_number.victron_evcs_night_grid_trigger_soc',\n value: round1(\n Number.isFinite(night.gridTakeoverTriggerPct)\n ? night.gridTakeoverTriggerPct\n : 0\n )\n }\n },\n\n helper_night_preferred_source_mode: {\n execute: true,\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.victron_evcs_night_preferred_source_mode',\n value: night.nightPreferredSourceMode || 'blocked'\n }\n },\n\n // SESSION RESEAU UNIQUE (03/09/2026) : ce bit est ce qui rend la session\n // UNIQUE. Sans persistance, l'engagement serait reevalue a chaque cycle\n // et le systeme retomberait dans une bande — donc dans un oscillateur.\n // Ecrit uniquement sur changement reel.\n helper_night_grid_session_committed: {\n execute: night.nightGridSessionCommitted !== history.nightGridSessionCommittedPrev,\n domain: 'input_boolean',\n service: night.nightGridSessionCommitted ? 'turn_on' : 'turn_off',\n data: {\n entity_id: 'input_boolean.victron_evcs_night_grid_session_committed'\n }\n },\n\n // P12 — LE VERROU ANTI-OSCILLATEUR.\n //\n // Le plancher de surplus (`required_soc_prudent`) DESCEND pendant la nuit,\n // et le SOC aussi. Mesure du 15/09 : voiture en charge, le SOC descend de\n // 7,1 pt/h et le plancher de 3,7 ; voiture ARRETEE, le SOC ne descend plus\n // que de 2,6 pt/h quand le plancher continue a 3,7. Le surplus\n // REAPPARAITRAIT donc tout seul et la charge repartirait — cinquieme\n // oscillateur du projet, par construction.\n //\n // Une seule passe de surplus par session de nuit, comme la recovery\n // batterie depuis le 27/08. Ecrit uniquement sur changement reel.\n helper_night_battery_done: {\n execute: !!night.nightBatterySurplusExhausted !== !!params.nightBatteryDonePrev,\n domain: 'input_boolean',\n service: night.nightBatterySurplusExhausted ? 'turn_on' : 'turn_off',\n data: {\n entity_id: 'input_boolean.victron_evcs_night_battery_done'\n }\n },\n\n helper_night_grid_takeover_needed: {\n execute: true,\n domain: 'input_boolean',\n // P9 : la fenêtre \"départ imminent\" (jour) tient aussi le helper\n // takeover → override hystérésis + maintien batterie ACM pendant\n // la charge réseau pré-départ.\n service: (night.nightGridTakeoverNeeded || night.departureUrgentActive)\n ? 'turn_on'\n : 'turn_off',\n data: {\n entity_id: 'input_boolean.victron_evcs_night_grid_takeover_needed'\n }\n },\n\n helper_night_battery_recovery_urgent: {\n execute: true,\n domain: 'input_boolean',\n service: night.batteryRecoveryUrgent ? 'turn_on' : 'turn_off',\n data: {\n entity_id: 'input_boolean.victron_evcs_night_battery_recovery_urgent'\n }\n },\n\n helper_night_session_active: {\n execute: true,\n domain: 'input_boolean',\n service: night.nightSessionActive ? 'turn_on' : 'turn_off',\n data: {\n entity_id: 'input_boolean.victron_evcs_night_session_active'\n }\n },\n\n helper_night_session_start_soc: {\n execute: decision.setNightSessionStart || decision.clearNightSessionStart,\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.victron_evcs_night_session_start_soc',\n value: nightSessionStartSocToWrite\n }\n },\n\n helper_night_session_start_at: {\n execute: decision.setNightSessionStart || decision.clearNightSessionStart,\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.victron_evcs_night_session_start_at',\n value: nightSessionStartAtToWrite\n }\n },\n\n // =================================================\n // VEHICLE DEMAND FILTER HISTORY PERSISTENCE\n // =================================================\n helper_evcs_delivery_ratio_ema: {\n execute: Number.isFinite(vdf?.nextHistory?.evcsDeliveryRatioEma),\n domain: 'input_number',\n service: 'set_value',\n data: {\n entity_id: 'input_number.victron_evcs_delivery_ratio_ema',\n value: round3(vdf.nextHistory.evcsDeliveryRatioEma)\n }\n },\n\n helper_evcs_delivery_noise_ema: {\n execute: Number.isFinite(vdf?.nextHistory?.evcsDeliveryNoiseEma),\n domain: 'input_number',\n service: 'set_value',\n data: {\n entity_id: 'input_number.victron_evcs_delivery_noise_ema',\n value: round3(vdf.nextHistory.evcsDeliveryNoiseEma)\n }\n },\n\n helper_evcs_no_demand_score_ema: {\n execute: Number.isFinite(vdf?.nextHistory?.evcsNoDemandScoreEma),\n domain: 'input_number',\n service: 'set_value',\n data: {\n entity_id: 'input_number.victron_evcs_no_demand_score_ema',\n value: round3(vdf.nextHistory.evcsNoDemandScoreEma)\n }\n },\n\n helper_evcs_no_demand_semantic_active: {\n execute: true,\n domain: 'input_boolean',\n service: vdf?.nextHistory?.evcsNoDemandSemanticActive ? 'turn_on' : 'turn_off',\n data: {\n entity_id: 'input_boolean.victron_evcs_no_demand_semantic_active'\n }\n },\n\n helper_evcs_no_demand_since_at: {\n execute: Number.isFinite(vdf?.nextHistory?.evcsNoDemandSinceMs),\n domain: 'input_datetime',\n service: 'set_datetime',\n data: {\n entity_id: 'input_datetime.victron_evcs_no_demand_since_at',\n datetime: formatParisStampFromMs(vdf.nextHistory.evcsNoDemandSinceMs)\n }\n },\n\n helper_evcs_no_demand_filter_eval_at: {\n execute: Number.isFinite(vdf?.nextHistory?.evcsNoDemandFilterEvalMs),\n domain: 'input_datetime',\n service: 'set_datetime',\n data: {\n entity_id: 'input_datetime.victron_evcs_no_demand_filter_eval_at',\n datetime: formatParisStampFromMs(vdf.nextHistory.evcsNoDemandFilterEvalMs)\n }\n },\n\n // =================================================\n // PHYSICAL EDF GRID TAKEOVER\n // =================================================\n edf_general_ok_on: {\n execute:\n ['grid_forced', 'grid_hc_allowed'].includes(context.sourceMode) &&\n !parsed.gridAvailable,\n domain: 'switch',\n service: 'turn_on',\n data: {\n entity_id: 'switch.edf_general_ok_switch'\n }\n },\n\n grid_power_setpoint: {\n execute: gridSetpointNeedsWrite,\n domain: 'number',\n service: 'set_value',\n data: {\n entity_id: 'number.gx_device_ac_power_setpoint',\n value: gridSetpointTargetW\n }\n },\n\n evcs_auto_start_off: {\n execute: autoStartAction === 'turn_off',\n domain: 'switch',\n service: 'turn_off',\n data: {\n entity_id: 'switch.victron_evcs_auto_start'\n }\n },\n\n evcs_charging: {\n execute: chargingAction !== 'none',\n domain: 'switch',\n service: decision.desiredCharging ? 'turn_on' : 'turn_off',\n data: {\n entity_id: 'switch.victron_evcs_charging'\n }\n },\n\n helper_permission: {\n execute: permissionAction !== 'none',\n domain: 'input_boolean',\n service: decision.desiredPermission ? 'turn_on' : 'turn_off',\n data: {\n entity_id: 'input_boolean.victron_evcs_charge_permission_now'\n }\n },\n\n helper_latch: {\n execute: latchAction !== 'none',\n domain: 'input_boolean',\n service: decision.desiredLatch ? 'turn_on' : 'turn_off',\n data: {\n entity_id: 'input_boolean.victron_evcs_control_latch'\n }\n },\n\n evcs_set_current: {\n execute: currentAction === 'set',\n domain: 'number',\n service: 'set_value',\n data: {\n entity_id: 'number.victron_evcs_set_current_command',\n value: round1(desiredCurrentA)\n }\n },\n\n logbook: {\n execute: signatureChanged,\n domain: 'logbook',\n service: 'log',\n data: {\n name: 'Victron EVCS Surplus',\n message: shortReason\n }\n },\n\n mobile_notification: {\n execute: notifyWanted,\n domain: cfg.mobileNotifyDomain,\n service: cfg.mobileNotifyService,\n data: {\n title: notifyTitle,\n message: notifyMessage || shortReason\n }\n }\n }\n }\n}];\n" + "jsCode": "// Bornes de la consigne de puissance reseau. Le maximum est celui declare par\n// l'entite elle-meme (number.airtria_mpii_v1_ac_power_setpoint_l1, max 10000 W),\n// pas un chiffre choisi ici. La bande morte evite de reecrire la meme valeur\n// toutes les 30 s sans empecher la correction d'une derive externe.\nconst GRID_SETPOINT_MAX_W = 10000;\nconst GRID_SETPOINT_DEADBAND_W = 100;\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 round3(v) {\n return Math.round(v * 1000) / 1000;\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 formatParisStampFromMs(ms) {\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 return `${parts.year}-${parts.month}-${parts.day} ${to2(hour)}:${parts.minute}:${parts.second}`;\n}\n\nconst prev = $input.first().json;\n\nconst {\n input,\n now,\n nowMs,\n cfg,\n parsed,\n params,\n history,\n strategy,\n windows,\n lifecycle,\n context,\n derived,\n batteryTune,\n batteryPolicy,\n locks,\n cloud,\n modes,\n safeguards,\n conditions,\n decision,\n nightEvcs,\n vehicleDemandFilter,\n} = prev;\n\n// =====================================================\n// SAFE ACCESS NIGHT EVCS\n// =====================================================\nconst night = nightEvcs ?? {\n nightSessionActive: false,\n nightSessionNeedsCapture: false,\n nightSessionNeedsClear: false,\n nightSessionStartSocPct: null,\n nightSessionStartAt: '',\n batteryHardFloorPct: null,\n batteryMorningTargetPct: null,\n maxBatteryDischargePoints: null,\n gridTakeoverTriggerPct: null,\n batteryRecoveryUrgent: false,\n nightGridTakeoverNeeded: false,\n nightPreferredSourceMode: 'blocked',\n};\n\n// =====================================================\n// SAFE ACCESS VEHICLE DEMAND FILTER\n// =====================================================\nconst vdf = vehicleDemandFilter ?? {\n dtSec: null,\n commandCurrentA: null,\n realCurrentA: null,\n expectedPowerW: null,\n realPowerW: null,\n currentDeliveryRatioRaw: null,\n powerDeliveryRatioRaw: null,\n deliveryRatioRaw: null,\n deliveryRatioEma: null,\n deliveryNoiseEma: null,\n adaptiveQuietBand: null,\n noDemandEvidenceRaw: null,\n sessionElapsedSec: null,\n sessionArmFactor: null,\n commandFactor: null,\n sourceDeliverableForNoDemand: false,\n evcsSessionArmedForNoDemand: false,\n noDemandScoreRaw: null,\n noDemandScoreEma: null,\n noDemandScoreOn: null,\n noDemandScoreOff: null,\n evcsVehicleNoDemandSemantic: false,\n evcsNoDemandSinceMs: null,\n evcsNoDemandSinceMinutes: null,\n nextHistory: {\n evcsDeliveryRatioEma: null,\n evcsDeliveryNoiseEma: null,\n evcsNoDemandScoreEma: null,\n evcsNoDemandSemanticActive: false,\n evcsNoDemandSinceMs: null,\n evcsNoDemandFilterEvalMs: null\n }\n};\n\n// =====================================================\n// FINAL ACTIONS\n// =====================================================\nlet desiredCurrentA = decision.desiredCurrentA;\n\ndesiredCurrentA = decision.desiredCharging\n ? clamp(\n desiredCurrentA,\n params.currentMinA,\n Math.max(params.currentMinA, derived.physicalCapA || params.currentMinA)\n )\n : params.currentMinA;\n\nconst chargingAction =\n decision.desiredCharging === parsed.evcsChargingSwitchOn\n ? 'none'\n : decision.desiredCharging\n ? 'turn_on'\n : 'turn_off';\n\nconst permissionAction =\n decision.desiredPermission === parsed.chargePermissionNow\n ? 'none'\n : decision.desiredPermission\n ? 'turn_on'\n : 'turn_off';\n\nconst latchAction =\n decision.desiredLatch === parsed.controlLatch\n ? 'none'\n : decision.desiredLatch\n ? 'turn_on'\n : 'turn_off';\n\nconst autoStartAction =\n parsed.controllerEnabled &&\n parsed.evcsVehicleConnected &&\n parsed.evcsAutoStartSwitchOn\n ? 'turn_off'\n : 'none';\n\nconst currentAction =\n decision.desiredCharging &&\n (\n !Number.isFinite(derived.currentReferenceA) ||\n Math.abs(desiredCurrentA - derived.currentReferenceA) >= 0.5\n )\n ? 'set'\n : 'none';\n\nconst setLastStartAt = chargingAction === 'turn_on';\nconst setLastStopAt = chargingAction === 'turn_off';\nconst setLastCurrentChangeAt = currentAction === 'set';\n\n// =====================================================\n// CLEAN BLOCKING LOGIC\n// =====================================================\nconst blockedUntilToWrite =\n decision.nextBlockReason === 'none'\n ? now.stamp\n : decision.nextBlockedUntilStamp;\n\nconst needsBlockReasonUpdate =\n decision.nextBlockReason !== history.lastBlockReason;\n\n// =====================================================\n// GLOBAL EVCS PRIORITY\n// AUTO + OVERRIDE MANUEL\n// =====================================================\nconst forceWindowMinutes = Math.max(\n 30,\n batteryTune?.global?.forceModeAutoRevertMinutes ?? 120\n);\n\nconst activeChargeIntent =\n decision.desiredCharging ||\n ['start', 'hold', 'step_up', 'step_down', 'step_down_fast'].includes(decision.currentDecision);\n\nconst evcsVehicleNotRequestingPowerState =\n decision.state === 'vehicle_full_or_paused' ||\n decision.currentDecision === 'stop_vehicle_full_or_paused';\n\n// -------------------------\n// PRIORITÉ AUTO\n// -------------------------\nlet autoEvcsPriorityActive = false;\nlet autoEnergyPriorityMode = 'normal';\nlet autoEnergyPriorityReason = 'Aucune priorité EVCS';\nlet autoEvcsPriorityUntil = now.stamp;\n\nif (\n parsed.evcsVehicleConnected &&\n !strategy.isStopMode &&\n !evcsVehicleNotRequestingPowerState\n) {\n if (strategy.forceChargeRequested || strategy.forceGridRequested) {\n autoEvcsPriorityActive = true;\n autoEnergyPriorityMode = 'evcs_priority';\n autoEnergyPriorityReason =\n strategy.forceGridRequested\n ? 'EVCS prioritaire : force_grid_now'\n : 'EVCS prioritaire : force_charge_now';\n autoEvcsPriorityUntil = formatParisStampFromMs(nowMs + forceWindowMinutes * 60000);\n }\n\n else if (night.nightSessionActive) {\n autoEvcsPriorityActive = true;\n autoEnergyPriorityMode = 'evcs_priority';\n autoEnergyPriorityReason =\n night.batteryRecoveryUrgent\n ? 'EVCS prioritaire : session nuit + urgence départ'\n : night.nightGridTakeoverNeeded\n ? 'EVCS prioritaire : session nuit + takeover réseau'\n : 'EVCS prioritaire : session nuit active';\n\n autoEvcsPriorityUntil =\n Number.isFinite(lifecycle.minutesUntilDeparture)\n ? formatParisStampFromMs(\n nowMs + Math.max(1, lifecycle.minutesUntilDeparture) * 60000\n )\n : formatParisStampFromMs(nowMs + forceWindowMinutes * 60000);\n }\n\n else if (context.departurePriorityActive && Number.isFinite(lifecycle.minutesUntilDeparture)) {\n autoEvcsPriorityActive = true;\n autoEnergyPriorityMode = 'evcs_priority';\n autoEnergyPriorityReason = `EVCS prioritaire : départ dans ${Math.round(lifecycle.minutesUntilDeparture)} min`;\n autoEvcsPriorityUntil = formatParisStampFromMs(\n nowMs + Math.max(1, lifecycle.minutesUntilDeparture) * 60000\n );\n }\n\n else if (activeChargeIntent && context.profile === 'midday_hc_or_solar') {\n autoEvcsPriorityActive = true;\n autoEnergyPriorityMode = 'evcs_priority';\n autoEnergyPriorityReason = 'EVCS prioritaire : recharge active fenêtre midi';\n autoEvcsPriorityUntil = formatParisStampFromMs(nowMs + 90 * 60000);\n }\n}\n\n// -------------------------\n// OVERRIDE MANUEL\n// -------------------------\nconst manualPriorityOverrideActive =\n parsed.evcsPriorityManualOverride &&\n parsed.evcsPriorityManualMode === 'evcs_priority' &&\n history.evcsPriorityManualUntilMs !== null &&\n history.evcsPriorityManualUntilMs > nowMs + 1000;\n\nlet evcsPriorityActive = autoEvcsPriorityActive;\nlet energyPriorityMode = autoEnergyPriorityMode;\nlet energyPriorityReason = autoEnergyPriorityReason;\nlet evcsPriorityUntil = autoEvcsPriorityUntil;\nlet evcsPrioritySource = 'auto';\n\nif (manualPriorityOverrideActive && !evcsVehicleNotRequestingPowerState) {\n evcsPriorityActive = true;\n energyPriorityMode = 'evcs_priority';\n energyPriorityReason =\n parsed.evcsPriorityManualReason || 'EVCS prioritaire : override manuel';\n evcsPriorityUntil =\n history.evcsPriorityManualUntilAt || autoEvcsPriorityUntil;\n evcsPrioritySource = 'manual';\n}\n\n// =====================================================\n// REASONS / LABELS / SIGNATURE\n// =====================================================\nconst reason = [...decision.reason];\n\nreason.push(`Stratégie=${strategy.userStrategy}`);\nreason.push(`Profil=${context.profile}`);\nreason.push(`Source=${context.sourceMode}`);\nreason.push(\n `Mode=${\n modes.batteryFullHarvestMode\n ? 'battery_full_harvest_mode'\n : modes.forecastCommitMode\n ? 'forecast_commit_mode'\n : 'battery_priority_mode'\n }`\n);\nreason.push(`SOC=${round1(parsed.soc)}%`);\nreason.push(`Batt=${round1(parsed.batteryCurrentA)}A`);\nreason.push(`PV=${Math.round(parsed.pvPowerW)}W`);\nreason.push(`PV10=${Math.round(parsed.pvPowerAvg10mW)}W`);\n// =====================================================\n// CONSIGNE DE PUISSANCE RESEAU (01/09/2026)\n// =====================================================\n// Fermer le contacteur ne suffit PAS. Au-dessus du SOC minimal, l'ESS en\n// autoconsommation ne preleve que `ac_power_setpoint` et laisse la batterie\n// porter tout le reste : nuit du 01/09, contacteur ferme, consigne 50 W,\n// batterie a -803 W pour 761 W de maison, voiture arretee par le garde\n// \"batterie trop sollicitee\". La consigne est donc le levier qui rend une\n// session reseau REELLE, et il doit appartenir au controleur, pas rester un\n// reglage manuel permanent (celui-ci provoquait la substitution du solaire\n// par le reseau en journee, cf. HANDOFF 27/28).\n//\n// La valeur n'est pas une constante : c'est le besoin mesure du moment, la\n// maison telle qu'elle consomme plus la voiture au courant que le controleur\n// s'autorise. Le plafond joue le role de marge : quand la voiture n'est pas a\n// son plafond, la consigne est deja genereuse, et l'exces ne peut pas partir\n// dans la batterie (DVCC tenue a 2 A par l'Auto Charge Manager pendant un\n// takeover). Hors session, retour a la valeur de repos.\n// ⚠️ LA SESSION ENGAGEE COMPTE AUSSI (04/09/2026).\n// Nuit du 03-04/09 : la session reseau unique a tenu le contacteur toute la\n// nuit — une seule bascule, comme voulu. Mais a 02:50, la voiture finie, le\n// sourceMode est passe a 'blocked' et la consigne est retombee de 5078 W a\n// 50 W. Contacteur ferme, reseau disponible, heures creuses, et l'ESS ne\n// demandait que 50 W pendant que la maison tirait 770 W SUR LA BATTERIE.\n// Trois heures et demie, SOC 44 % -> 17 %, 2,60 kWh pris a la batterie quand\n// le reseau etait la et bon marche.\n//\n// C'est le defaut du 01/09 revenu ailleurs : fermer le contacteur ne suffit\n// pas, la consigne doit demander la puissance. J'avais conditionne la consigne\n// au sourceMode, puis cree la veille une NOUVELLE raison de garder le\n// contacteur ferme — l'engagement de session — sans la brancher ici. La\n// session tenait le contacteur, pas l'energie.\n//\n// Regle : tant que le contacteur est ferme A NOTRE DEMANDE, la consigne doit\n// couvrir la maison. Peu importe laquelle des deux raisons le tient.\nconst gridSessionActive =\n parsed.acInAvailable &&\n (\n context.sourceMode === 'grid_forced' ||\n context.sourceMode === 'grid_hc_allowed' ||\n night.nightGridSessionCommitted === true\n );\n\n// =====================================================\n// DIMENSIONNEMENT SUR LE DEFICIT DU BILAN (18/09/2026)\n// =====================================================\n// Ce que la consigne remplace : `maison + plafond de la borne`, ou le plafond\n// de la borne n'a AUCUNE relation physique avec ce dont la batterie a besoin.\n// Qu'il tombe juste est un accident — releve du 17/09 a 13:51, voiture partie\n// depuis cinq minutes, consigne a 7032 W pour une maison a 933 W.\n//\n// La consigne additionne desormais des besoins NOMMES :\n// maison toujours ;\n// voiture seulement si un vehicule est branche ;\n// batterie seulement dans une fenetre d'heures creuses.\n//\n// ⚠️ MAX ET NON ADDITION, et ce n'est pas de la prudence. La consigne est une\n// AUTORISATION, pas une prediction de consommation — le commentaire de la bande\n// asymetrique le dit plus bas : « la consigne dit ce que le reseau a le DROIT\n// de servir, DVCC dit ce que la batterie a le droit de prendre ». Additionner\n// deux autorisations independantes n'est pas plus honnete, seulement plus\n// permissif. Mesure a l'appui : avec l'addition,\n// `consigne-session-engagee-couvre-la-maison` passe de 5595 a 6341 W, soit\n// 746 W d'autorisation en plus sur une nuit engagee qui fonctionne deja — une\n// regression sur le chemin DOMINANT pour corriger un chemin emprunte zero fois\n// en treize jours.\n//\n// ⚠️ ET LE PLAFOND DU TERME BATTERIE EST DVCC, PAS LE BMS. Le BMS declare\n// 250 A, soit 12,2 kW a 49 V : au-dessus de GRID_SETPOINT_MAX_W, donc il ne\n// bornerait rien. Mesure : en fin de fenetre (14 min restantes, manque\n// 5,672 kWh) la division rendait 17 kW, ecretes a 10 000 — PIRE que les 5437\n// d'aujourd'hui. Avec DVCC le terme vaut 1538 W et la consigne 2380 W.\n// Composer les deux leviers, ce n'est pas les confondre : le reseau n'est\n// autorise a servir la batterie qu'a hauteur de ce que la batterie est\n// autorisee a prendre.\n//\n// Corollaire : aucun plancher d'horizon n'est necessaire. Un horizon qui tend\n// vers zero donne `min(enorme, DVCC) = DVCC`. Il ne reste qu'une garde d'une\n// minute, qui est de l'arithmetique defensive et non une constante calibree.\n//\n// INVARIANT TENU PAR LA FORME : `maison + max(...)` ne peut jamais descendre\n// sous la maison, quel que soit DVCC. La regle du 01/09 et du 04/09 — « tant\n// que le contacteur est ferme A NOTRE DEMANDE, la consigne doit couvrir la\n// maison » — est donc garantie par construction. A DVCC nul, le plafond vaut\n// exactement la maison.\nconst SETPOINT_BALANCE_MAX_AGE_MIN = 30;\nconst setpointBalanceAgeMin = Number.isFinite(params.balanceLastRunMs)\n ? (nowMs - params.balanceLastRunMs) / 60000\n : null;\n\n// Le manque, pas l'energie a couvrir : voir le commentaire d'INPUT_PARSE.\nconst balanceUsableForSetpoint =\n params.balanceStatusOk &&\n Number.isFinite(params.balanceGridEnergyNeededKwh) &&\n setpointBalanceAgeMin !== null &&\n setpointBalanceAgeMin >= -5 &&\n setpointBalanceAgeMin <= SETPOINT_BALANCE_MAX_AGE_MIN;\n\nconst dvccChargeLimitW =\n Number.isFinite(params.dvccChargeLimitA) && params.dvccChargeLimitA >= 0\n ? params.dvccChargeLimitA * derived.batteryVoltageRef\n : null;\n\n// Toute lecture manquante retombe sur la formule d'avant : degradation vers\n// l'existant, jamais pire.\nconst setpointFromBalance = balanceUsableForSetpoint && dvccChargeLimitW !== null;\n\nconst evcsSetpointShareW = parsed.evcsVehicleConnected\n ? derived.physicalCapA * derived.acVoltageRef\n : 0;\n\n// Recharger la batterie sur le reseau n'a de sens economique QUE dans une\n// fenetre creuse. Hors fenetre le terme vaut zero — ce n'est pas un repli\n// faute d'horizon, c'est le comportement voulu, et le probleme de la division\n// par un temps qui tend vers zero disparait par construction.\nconst hcMinutesLeft = Number.isFinite(windows.minutesRemainingInHcWindow)\n ? windows.minutesRemainingInHcWindow\n : null;\n\nconst batterySetpointShareW =\n setpointFromBalance && hcMinutesLeft !== null && params.balanceGridEnergyNeededKwh > 0\n ? Math.min(\n (params.balanceGridEnergyNeededKwh * 1000) / Math.max(hcMinutesLeft / 60, 1 / 60),\n dvccChargeLimitW\n )\n : 0;\n\nconst gridSetpointCeilingW = clamp(\n Math.round(\n setpointFromBalance\n ? derived.houseNonEvcsW + Math.max(evcsSetpointShareW, batterySetpointShareW)\n : derived.houseNonEvcsW + derived.physicalCapA * derived.acVoltageRef\n ),\n params.gridSetpointIdleW,\n GRID_SETPOINT_MAX_W\n);\n\nconst gridSetpointCurrentW = Number.isFinite(parsed.gridSetpointW)\n ? parsed.gridSetpointW\n : null;\n\n// =====================================================\n// CREDIT SOLAIRE SUR LA CONSIGNE (11/09/2026)\n// =====================================================\n// Defaut mesure ce jour-la : BOOST 28 A en plein soleil, consigne calculee a\n// 8242 W (maison 1802 + 28 A x 230). L'ESS n'a qu'un moyen de prelever 8,2 kW\n// quand le soleil en donne 4 : brider les MPPT. Releve 17:02 -- PV 121 W,\n// reseau 7697 W, batterie 0 A, SOC 96 %. La production etait refusee, et par\n// NOTRE consigne. Le commentaire ci-dessus (\"une consigne trop HAUTE ne coute\n// rien\") a ete ecrit la nuit du 01/09, ou la PV valait zero : il n'y avait\n// alors rien a evincer. En plein jour il est faux.\n//\n// ⚠️ ON NE PEUT PAS SOUSTRAIRE LA PV MESUREE. Elle vaut 121 W *a cause* de\n// cette consigne : mesure censuree, la soustraire ne bouge rien et le systeme\n// reste sur son mauvais equilibre. Preuve le meme jour a 17:22 -- consigne\n// descendue A LA MAIN a 2280 W : PV 4758 W en 50 secondes, batterie +9 A. La\n// ressource etait la, seule la consigne l'evincait.\n//\n// On DESCEND donc, et on laisse la physique repondre. Deux signaux, tous deux\n// deja lus et tous deux INCENSURABLES :\n// - les chargeurs declarent eux-memes s'ils se BRIDENT (mpptLimitedCount).\n// `off` ne compte pas : la nuit ils sont tous `off` et il n'y a rien a\n// liberer -- c'est ce qui garantit que le comportement de nuit (§31, §57)\n// est strictement inchange ;\n// - le courant batterie dit si on est descendu trop bas, via la politique\n// par bande de SOC deja calibree (batterySoftBreachNow, §35).\n// La valeur courante vit dans le GX : l'etat est deja persiste, aucun helper\n// nouveau n'est necessaire.\n//\n// PLANCHER : la consigne ne descend JAMAIS sous la maison hors voiture. C'est\n// la garantie du 01/09 et du 04/09 -- le reseau, quand le contacteur est ferme\n// a notre demande, couvre la maison. On ne libere que la part VOITURE, qui est\n// exactement celle qui evince le solaire. Le solaire pourrait souvent couvrir\n// la maison en plus, mais l'affirmer demanderait ses propres scenarios.\n// ⚠️ SECOND PLANCHER : LE NUAGE (ajoute 11/09 18:05, apres incident).\n// Premiere version de la descente : consigne tombee a 1281 W alors que le\n// solaire portait tout (PV 6058 W, reseau 1163 W, voiture a 28 A). Un nuage a\n// fait chuter la PV de 4,6 kW en une minute ; le reseau etant plafonne a\n// 1281 W, la difference est tombee entierement sur la batterie -- 87 A pour\n// une limite dure de 70 A -> `battery_current_hard_stop`, charge coupee.\n//\n// Sans injection, la production peut s'effondrer de plusieurs kW en quelques\n// secondes. Une boucle a 30 s ne peut pas rattraper cela : il faut que le\n// reseau soit DEJA autorise a prendre le relais. La consigne ne descend donc\n// jamais au point ou la perte totale du solaire depasserait ce que la batterie\n// sait encaisser.\n//\n// Le budget vient de la politique batterie deja calibree par bande de SOC\n// (§35), pas d'une constante nouvelle, et on garde 20 % de marge sous la\n// limite DURE -- c'est elle qui coupe la charge, et une coupure est plus\n// couteuse que quelques centaines de watts de solaire non captes.\n//\n// Contrepartie assumee, et elle est reelle : voiture a 28 A, la descente est\n// nettement moins profonde qu'a 10 A. C'est proportionne -- c'est le courant\n// de la voiture qui cree l'exposition. Et c'est la regle du proprietaire :\n// protéger le materiel prime sur l'economie de kilowattheures.\n// `derived.batteryVoltageRef` est deja la tension retenue en amont (mesure\n// vebus, repli 51,2 V) : meme grandeur que celle qui sert a convertir le\n// courant batterie en puissance dans BATTERY_CLOUD_LOCKS.\nconst batterySurgeBudgetW = Math.max(\n 0,\n Math.abs(batteryPolicy.batteryDischargeHardDynamicA) * derived.batteryVoltageRef * 0.8\n);\n// ⚠️ LE PLANCHER NUAGE SUIT LA VOITURE, PAS LA BATTERIE. Il existe pour que la\n// consigne ne descende pas au point ou la perte totale du solaire depasserait\n// ce que la batterie sait encaisser — et le commentaire ci-dessus dit pourquoi :\n// « c'est le courant de la voiture qui cree l'exposition ». Une batterie qu'on\n// recharge depuis le reseau n'est PAS une exposition : si le solaire s'effondre,\n// la recharge ralentit et personne ne se fait couper. L'y inclure remonterait le\n// plancher sans aucune contrepartie de securite, et contre l'objectif.\nconst gridSetpointLoadW = setpointFromBalance\n ? derived.houseNonEvcsW + evcsSetpointShareW\n : derived.houseNonEvcsW + derived.physicalCapA * derived.acVoltageRef;\nconst gridSetpointCloudFloorW = Math.round(\n Math.max(0, gridSetpointLoadW - batterySurgeBudgetW)\n);\n\nconst gridSetpointHouseFloorW = clamp(\n Math.max(Math.round(derived.houseNonEvcsW), gridSetpointCloudFloorW),\n params.gridSetpointIdleW,\n GRID_SETPOINT_MAX_W\n);\n\nconst solarResourceRefused =\n parsed.mpptReadableCount > 0 && parsed.mpptLimitedCount > 0;\n\n// Pendant la fenetre d'etablissement (§29) les mesures PV et batterie ne\n// decrivent aucun regime permanent : on ne descend pas sur elles.\nconst gridSetpointDescendOk =\n gridSessionActive &&\n solarResourceRefused &&\n !batteryPolicy.batterySoftBreachNow &&\n !derived.gridSettling &&\n gridSetpointCurrentW !== null;\n\n// ⚠️ LE PLAFOND EST UN PLAFOND, PAS UNE CIBLE (corrige 11/09 17:52).\n// Premiere version de ce correctif, en production 12 minutes : la consigne\n// descendait bien a 2241 W et la PV remontait a 7074 W -- puis, tous les\n// chargeurs repassant `mppt_active`, la condition de descente devenait fausse,\n// le besoin revenait au plafond et la consigne etait REECRITE a 7100 W. La PV\n// s'effondrait a 1785 W, les chargeurs se bridaient, on redescendait.\n// Oscillateur de periode ~5 min. Releve : 17:40:30 -> 2241 / 17:47:19 -> 7100\n// / 17:47:28 PV 1785 / 17:48:30 -> 7368.\n//\n// Une fois la consigne descendue, on la TIENT tant que le solaire produit. Ce\n// n'est pas un etat a memoriser : la valeur courante est dans le GX, et le\n// plancher maison la fait remonter d'elle-meme si la maison grossit.\n//\n// La nuit, les chargeurs sont `off` (ils ne produisent pas) : on retombe sur le\n// comportement historique -- la consigne monte au plafond pour que le reseau\n// porte maison et voiture (§31, §57). C'est cette distinction qui empeche le\n// « on tient » de casser les nuits HC.\nconst solarProducing =\n parsed.mpptReadableCount > 0 && parsed.mpptAtMaxPowerCount > 0;\n\nlet gridSetpointNeedW;\nlet gridSetpointDescending = false;\n\nif (!gridSessionActive) {\n gridSetpointNeedW = params.gridSetpointIdleW;\n} else if (solarProducing && !batteryPolicy.batterySoftBreachNow && gridSetpointCurrentW !== null) {\n // On tient la valeur acquise, bornee par la maison et par le plafond.\n gridSetpointNeedW = clamp(\n Math.min(gridSetpointCurrentW, gridSetpointCeilingW),\n gridSetpointHouseFloorW,\n gridSetpointCeilingW\n );\n} else {\n gridSetpointNeedW = gridSetpointCeilingW;\n}\n\nif (gridSetpointDescendOk) {\n // Un chargeur bride PROUVE qu'il reste de la ressource : on peut descendre\n // franchement. Une descente trop forte est rattrapee au cycle suivant (30 s\n // ou la batterie porte l'ecart, soit quelques dizaines de Wh), alors qu'une\n // descente trop timide laisse des kW de solaire refuses pendant dix minutes.\n const minStepW = Math.max(\n GRID_SETPOINT_DEADBAND_W,\n Math.round(params.currentStepUpA * derived.acVoltageRef)\n );\n const baseW = Math.min(gridSetpointCurrentW, gridSetpointCeilingW);\n const gapW = baseW - gridSetpointHouseFloorW;\n if (gapW > 0) {\n const stepW = Math.max(minStepW, Math.round(gapW / 2));\n gridSetpointNeedW = Math.max(gridSetpointHouseFloorW, baseW - stepW);\n gridSetpointDescending = true;\n }\n}\n\n// La bande est ASYMETRIQUE, et c'est le point. Une consigne trop BASSE fait\n// porter la difference par la batterie : c'est le defaut de la nuit du 01/09,\n// il se corrige immediatement. Une consigne trop HAUTE ne coute rien : l'ESS\n// ne preleve que ce qu'il peut placer, et le courant de charge de la batterie\n// est tenu par ailleurs (DVCC, Auto Charge Manager). Deux leviers, deux roles\n// -- la consigne dit ce que le reseau a le DROIT de servir, DVCC dit ce que la\n// batterie a le droit de prendre. Ne pas confondre les deux.\n//\n// On ecrit donc avec une marge d'un pas de courant EVCS (grandeur du systeme,\n// pas une constante inventee) pour que le bruit de la maison ne rende pas la\n// consigne insuffisante au cycle suivant, et on ne redescend que si elle est\n// devenue franchement inutile. Sans cela la boucle 30 s reecrivait a chaque\n// cycle en poursuivant le bruit.\nconst gridSetpointSlackW = gridSessionActive && !gridSetpointDescending\n ? Math.max(GRID_SETPOINT_DEADBAND_W, Math.round(params.currentStepUpA * derived.acVoltageRef))\n : 0;\n\nconst gridSetpointTargetW = clamp(\n gridSetpointNeedW + gridSetpointSlackW,\n 0,\n GRID_SETPOINT_MAX_W\n);\n\nconst gridSetpointNeedsWrite =\n gridSetpointCurrentW === null ||\n gridSetpointCurrentW < gridSetpointNeedW ||\n gridSetpointCurrentW > gridSetpointNeedW + 2 * gridSetpointSlackW + GRID_SETPOINT_DEADBAND_W;\n\nreason.push(`Grid=${parsed.gridAvailable ? 'on' : 'off'} (${Math.round(parsed.gridPowerW)}W)`);\n// Trace de la fenetre d'etablissement : sans elle, une lecture du logbook\n// pendant le transitoire donne l'impression que le systeme decide n'importe\n// quoi. Il decide, au contraire, de ne PAS decider sur ces mesures.\nif (derived.gridSettling) {\n reason.push(`ETABLISSEMENT reseau ${Math.round(parsed.gridTransitionAgeSec)}s/${params.gridSettleSeconds}s (mesures PV/batterie non fiables)`);\n}\nif (gridSessionActive) {\n reason.push(\n gridSetpointDescending\n ? `Consigne reseau=${gridSetpointTargetW}W (CREDIT SOLAIRE, ${parsed.mpptLimitedCount}/${parsed.mpptReadableCount} brides)`\n : `Consigne reseau=${gridSetpointTargetW}W`\n );\n}\n// Visible dans le logbook uniquement pendant un depassement souple : c'est le\n// seul moment ou l'information sert, et la raison est tronquee a 255 caracteres.\nif (batteryPolicy.batterySoftBreachNow) {\n reason.push(\n `Batt souple ${round1(batteryPolicy.batterySoftBreachMinutes)}/${round1(batteryPolicy.batterySoftToleranceMinutes)}min`\n );\n}\n// Idem : visible seulement quand le bilan energetique DURCIT le plancher du\n// soir. Sans cette trace, un blocage de charge un soir de SOC 94 % est\n// incomprehensible a la lecture du logbook.\nif (safeguards.eveningFloorFromBalance) {\n reason.push(\n `Plancher soir=${round1(safeguards.eveningSocStartFloorPct)}% (BILAN, ${round1(safeguards.balanceEnergyToCoverKwh)}kWh a couvrir)`\n );\n}\nreason.push(`Cap=${derived.physicalCapA}A`);\nreason.push(`Ref=${round1(derived.currentReferenceA)}A`);\nreason.push(`Target=${round1(desiredCurrentA)}A`);\nreason.push(`Debt=${round2(cloud.cloudDebtNextKwh)}kWh`);\nreason.push(`Vehicle=${parsed.evcsVehicleConnected ? 'on' : 'off'}`);\nreason.push(`EVCS mode=${parsed.evcsModeRaw}`);\nreason.push(`VE.Bus AC=${Math.round(parsed.vebusAcOutPowerW)}W`);\nreason.push(\n `VE.Bus DC=${\n Number.isFinite(parsed.vebusDcVoltageV)\n ? round1(parsed.vebusDcVoltageV)\n : 'n/a'\n }V`\n);\nreason.push(`PrioEVCS=${evcsPriorityActive ? 'on' : 'off'}(${evcsPrioritySource})`);\nreason.push(`VDF delivery=${Number.isFinite(vdf.deliveryRatioEma) ? round3(vdf.deliveryRatioEma) : 'na'}`);\nreason.push(`VDF noise=${Number.isFinite(vdf.deliveryNoiseEma) ? round3(vdf.deliveryNoiseEma) : 'na'}`);\nreason.push(`VDF score=${Number.isFinite(vdf.noDemandScoreEma) ? round3(vdf.noDemandScoreEma) : 'na'}`);\nreason.push(`VDF noDemand=${vdf.evcsVehicleNoDemandSemantic ? 'on' : 'off'}`);\n\nif (night.nightSessionActive) {\n reason.push(`NightTarget=${round1(night.batteryMorningTargetPct ?? 0)}%`);\n reason.push(`NightTrigger=${round1(night.gridTakeoverTriggerPct ?? 0)}%`);\n\n if (Number.isFinite(night.nightSessionStartSocPct)) {\n reason.push(`StartSOC=${round1(night.nightSessionStartSocPct)}%`);\n reason.push(`AllowDrop=${round1(night.maxBatteryDischargePoints ?? 0)}pts`);\n }\n\n reason.push(`NightPreferred=${night.nightPreferredSourceMode ?? 'blocked'}`);\n reason.push(`NightTakeover=${night.nightGridTakeoverNeeded ? 'on' : 'off'}`);\n reason.push(`NightUrgent=${night.batteryRecoveryUrgent ? 'on' : 'off'}`);\n}\n\nconst shortReason = reason.join(' | ').slice(0, 255);\n\nconst sourceModeLabel =\n context.sourceMode === 'blocked' ? 'Bloqué' :\n context.sourceMode === 'solar_only' ? 'Solaire' :\n context.sourceMode === 'solar_potential' ? 'Potentiel solaire' :\n context.sourceMode === 'hybrid_probe' ? 'Probe hybride' :\n context.sourceMode === 'grid_hc_allowed' ? 'Réseau HC autorisé' :\n context.sourceMode === 'grid_forced' ? 'Réseau forcé' :\n context.sourceMode === 'battery_assist_night' ? 'Assistance batterie nuit' :\n context.sourceMode;\n\nconst signature = [\n decision.state,\n `profile=${context.profile}`,\n `source=${context.sourceMode}`,\n `chg=${decision.desiredCharging ? '1' : '0'}`,\n `perm=${decision.desiredPermission ? '1' : '0'}`,\n `l=${decision.desiredLatch ? '1' : '0'}`,\n `cur=${round1(desiredCurrentA)}`,\n `block=${decision.nextBlockReason}`,\n `stop=${decision.mustStop ? '1' : '0'}`,\n `prio=${evcsPriorityActive ? '1' : '0'}`,\n `night=${night.nightSessionActive ? '1' : '0'}`,\n `ntrg=${Number.isFinite(night.gridTakeoverTriggerPct) ? round1(night.gridTakeoverTriggerPct) : 'na'}`,\n `nurg=${night.batteryRecoveryUrgent ? '1' : '0'}`,\n `vdf=${vdf.evcsVehicleNoDemandSemantic ? '1' : '0'}`,\n `vdfs=${Number.isFinite(vdf.noDemandScoreEma) ? round3(vdf.noDemandScoreEma) : 'na'}`\n].join('|');\n\nconst signatureChanged = signature !== history.lastSignature;\n\n// =====================================================\n// NOTIFICATIONS\n// =====================================================\nlet notifyWanted = false;\nlet notifyTitle = 'Victron EVCS Surplus';\nlet notifyMessage = shortReason;\n\nif (parsed.notificationsEnabled && signatureChanged) {\n if (decision.currentDecision === 'start') {\n notifyWanted = true;\n notifyTitle = '⚡ EVCS démarrage';\n } else if (decision.currentDecision === 'step_up') {\n notifyWanted = true;\n notifyTitle = '⬆️ EVCS hausse courant';\n } else if (\n decision.currentDecision === 'step_down' ||\n decision.currentDecision === 'step_down_fast'\n ) {\n notifyWanted = true;\n notifyTitle = '⬇️ EVCS baisse courant';\n } else if (\n decision.mustStop ||\n decision.currentDecision === 'stop' ||\n decision.currentDecision === 'stop_hard' ||\n decision.currentDecision === 'stop_power_cap'\n ) {\n notifyWanted = true;\n notifyTitle = '⛔ EVCS arrêt';\n } else if (decision.state === 'mode_auto_conflict') {\n notifyWanted = true;\n notifyTitle = '⚠️ EVCS mode Auto';\n } else if (decision.state === 'vehicle_full_or_paused') {\n notifyWanted = true;\n notifyTitle = '🚗 EVCS véhicule plein ou en pause';\n }\n}\n\n// =====================================================\n// TIMESTAMPS TO WRITE\n// =====================================================\nconst cloudDebtStartedAtToWrite =\n cloud.cloudDebtStartedMs\n ? formatParisStampFromMs(cloud.cloudDebtStartedMs)\n : now.stamp;\n\n// DEPASSEMENT SOUPLE BATTERIE (01/09/2026) : chaine vide quand il n'y a pas\n// de depassement en cours. Le helper n'est reecrit que sur changement reel\n// (debut ou fin), pas a chaque cycle : la valeur du debut doit RESTER figee\n// pendant toute la duree du depassement, sinon le compteur ne monte jamais.\nconst batterySoftBreachAtToWrite =\n batteryPolicy.batterySoftBreachStartedMs !== null &&\n batteryPolicy.batterySoftBreachStartedMs !== undefined\n ? formatParisStampFromMs(batteryPolicy.batterySoftBreachStartedMs)\n : '';\n\nconst setBatterySoftBreachAt =\n batterySoftBreachAtToWrite !== (history.batterySoftBreachAt || '');\n\nconst probeStartedAtToWrite = now.stamp;\nconst lastStartAtToWrite = now.stamp;\nconst lastStopAtToWrite = now.stamp;\nconst lastCurrentChangeAtToWrite = now.stamp;\n\n// =====================================================\n// NIGHT SESSION MEMORY\n// =====================================================\nconst nightSessionStartSocToWrite =\n decision.clearNightSessionStart\n ? ''\n : decision.setNightSessionStart\n ? String(round1(parsed.soc))\n : (\n Number.isFinite(history.nightSessionStartSocPct)\n ? String(round1(history.nightSessionStartSocPct))\n : ''\n );\n\nconst nightSessionStartAtToWrite =\n decision.clearNightSessionStart\n ? ''\n : decision.setNightSessionStart\n ? now.stamp\n : (history.nightSessionStartAt || '');\n\n// =====================================================\n// OUTPUT\n// =====================================================\nreturn [{\n json: {\n timestamp_paris: now.stamp,\n inputs: input,\n\n computed: {\n operating_mode: context.operatingMode,\n profile: context.profile,\n source_mode: context.sourceMode,\n source_mode_label: sourceModeLabel,\n user_strategy: strategy.userStrategy,\n\n in_night_hc_window: windows.inNightHcWindow,\n in_morning_window: windows.inMorningWindow,\n in_midday_window: windows.inMiddayWindow,\n in_evening_window: windows.inEveningWindow,\n\n // D'ou vient la consigne reseau. Sans cette trace, un repli est\n // SILENCIEUX : la consigne reprend la formule d'avant sans que rien ne le\n // dise, et c'est precisement l'information qui manquera le jour ou elle se\n // comportera mal. `bilan` = dimensionnee sur le manque, `heritee` = repli.\n setpoint_source: setpointFromBalance ? 'bilan' : 'heritee',\n setpoint_battery_share_w: Math.round(batterySetpointShareW),\n setpoint_evcs_share_w: Math.round(evcsSetpointShareW),\n dvcc_limit_w: dvccChargeLimitW === null ? null : Math.round(dvccChargeLimitW),\n hc_minutes_left: hcMinutesLeft,\n\n departure_priority_active: context.departurePriorityActive,\n minutes_until_departure: lifecycle.minutesUntilDeparture,\n\n battery_priority_mode: modes.batteryPriorityMode,\n battery_full_harvest_mode: modes.batteryFullHarvestMode,\n forecast_commit_mode: modes.forecastCommitMode,\n\n physical_cap_current_a: derived.physicalCapA,\n can_run_at_min_current: derived.canRunAtMinCurrent,\n\n forecast_buffer_kwh: round2(derived.forecastBufferKwh),\n projected_end_soc_after_charge: round1(derived.projectedEndSocAfterCharge),\n\n cloud_debt_next_kwh: round2(cloud.cloudDebtNextKwh),\n cloud_debt_duration_minutes: round1(cloud.cloudDebtDurationMinutes),\n cloud_debt_active: cloud.cloudDebtActive,\n cloud_tolerance_ok: cloud.cloudToleranceOk,\n\n start_blocked_by_reason: locks.startBlockedByReason,\n\n probe_window_active: conditions.probeWindowActive,\n probe_success_now: conditions.probeSuccessNow,\n probe_failed_now: conditions.probeFailedNow,\n\n any_vebus_overload: derived.anyVebusOverload,\n any_vebus_low_battery: derived.anyVebusLowBattery,\n vebus_ac_out_soft_reached: derived.vebusAcOutSoftReached,\n vebus_ac_out_hard_reached: derived.vebusAcOutHardReached,\n vebus_dc_voltage_soft_low: derived.vebusDcVoltageSoftLow,\n vebus_dc_voltage_hard_low: derived.vebusDcVoltageHardLow,\n\n soc_band: batteryPolicy.socBand,\n batt_discharge_soft_dynamic_a: round1(batteryPolicy.batteryDischargeSoftDynamicA),\n batt_discharge_hard_dynamic_a: round1(batteryPolicy.batteryDischargeHardDynamicA),\n batt_charge_reserve_dynamic_a: round1(batteryPolicy.batteryChargeReserveDynamicA),\n batt_soft_tolerance_min: round1(batteryPolicy.batterySoftToleranceMinutes),\n batt_hard_tolerance_sec: round1(batteryPolicy.batteryHardToleranceSeconds),\n batt_recovery_min: round1(batteryPolicy.batteryRecoveryMinutes),\n grid_battery_soft_dynamic_a: round1(batteryPolicy.gridBatterySoftDynamicA),\n grid_battery_hard_dynamic_a: round1(batteryPolicy.gridBatteryHardDynamicA),\n house_spike_detected: batteryPolicy.houseSpikeDetected,\n solar_potential_context: batteryPolicy.solarPotentialContext,\n\n batt_soft_breach_now: !!batteryPolicy.batterySoftBreachNow,\n batt_soft_breach_minutes: round1(batteryPolicy.batterySoftBreachMinutes || 0),\n batt_soft_discharge_hold_ok: batteryPolicy.batterySoftDischargeHoldOk !== false,\n\n evcs_priority_active: evcsPriorityActive,\n energy_priority_mode: energyPriorityMode,\n energy_priority_reason: energyPriorityReason,\n evcs_priority_until: evcsPriorityUntil,\n evcs_priority_source: evcsPrioritySource,\n evcs_priority_manual_override_active: manualPriorityOverrideActive,\n\n night_session_active: night.nightSessionActive,\n night_session_needs_capture: !!night.nightSessionNeedsCapture,\n night_session_needs_clear: !!night.nightSessionNeedsClear,\n night_session_start_soc_pct: Number.isFinite(night.nightSessionStartSocPct)\n ? round1(night.nightSessionStartSocPct)\n : null,\n night_session_start_at: night.nightSessionStartAt || null,\n\n battery_hard_floor_pct: Number.isFinite(night.batteryHardFloorPct)\n ? round1(night.batteryHardFloorPct)\n : null,\n battery_morning_target_pct: Number.isFinite(night.batteryMorningTargetPct)\n ? round1(night.batteryMorningTargetPct)\n : null,\n max_battery_discharge_points: Number.isFinite(night.maxBatteryDischargePoints)\n ? round1(night.maxBatteryDischargePoints)\n : null,\n grid_takeover_trigger_pct: Number.isFinite(night.gridTakeoverTriggerPct)\n ? round1(night.gridTakeoverTriggerPct)\n : null,\n\n battery_recovery_urgent: !!night.batteryRecoveryUrgent,\n night_grid_takeover_needed: !!night.nightGridTakeoverNeeded,\n night_preferred_source_mode: night.nightPreferredSourceMode || 'blocked',\n\n evcs_delivery_ratio_ema: Number.isFinite(vdf.deliveryRatioEma) ? round3(vdf.deliveryRatioEma) : null,\n evcs_delivery_noise_ema: Number.isFinite(vdf.deliveryNoiseEma) ? round3(vdf.deliveryNoiseEma) : null,\n evcs_no_demand_score_ema: Number.isFinite(vdf.noDemandScoreEma) ? round3(vdf.noDemandScoreEma) : null,\n evcs_vehicle_no_demand_semantic: !!vdf.evcsVehicleNoDemandSemantic,\n evcs_no_demand_since_minutes: Number.isFinite(vdf.evcsNoDemandSinceMinutes) ? round3(vdf.evcsNoDemandSinceMinutes) : null,\n evcs_source_deliverable_for_no_demand: !!vdf.sourceDeliverableForNoDemand,\n evcs_session_armed_for_no_demand: !!vdf.evcsSessionArmedForNoDemand,\n evcs_no_demand_score_on: Number.isFinite(vdf.noDemandScoreOn) ? round3(vdf.noDemandScoreOn) : null,\n evcs_no_demand_score_off: Number.isFinite(vdf.noDemandScoreOff) ? round3(vdf.noDemandScoreOff) : null\n },\n\n decision: {\n state: decision.state,\n current_decision: decision.currentDecision,\n desired_charging: decision.desiredCharging,\n desired_permission: decision.desiredPermission,\n desired_latch: decision.desiredLatch,\n desired_current_a: round1(desiredCurrentA),\n\n charging_action: chargingAction,\n permission_action: permissionAction,\n latch_action: latchAction,\n current_action: currentAction,\n auto_start_action: autoStartAction,\n\n set_last_start_at: setLastStartAt,\n set_last_stop_at: setLastStopAt,\n set_last_current_change_at: setLastCurrentChangeAt,\n set_cloud_debt_started_at: decision.setCloudDebtStartedAt,\n set_probe_started_at: decision.setProbeStartedAt,\n\n set_night_session_start: !!decision.setNightSessionStart,\n clear_night_session_start: !!decision.clearNightSessionStart,\n\n set_block_reason: needsBlockReasonUpdate || !parsed.controllerEnabled,\n block_reason: decision.nextBlockReason,\n\n set_blocked_until: true,\n blocked_until: blockedUntilToWrite,\n\n dynamic_ok: decision.dynamicOk,\n must_stop: decision.mustStop,\n\n short_reason: shortReason,\n reason,\n signature,\n signature_changed: signatureChanged,\n\n should_notify: notifyWanted,\n notify_title: notifyTitle,\n notify_message: notifyMessage,\n\n evcs_priority_active: evcsPriorityActive,\n energy_priority_mode: energyPriorityMode,\n energy_priority_reason: energyPriorityReason,\n evcs_priority_until: evcsPriorityUntil,\n evcs_priority_source: evcsPrioritySource\n },\n\n ha_service_calls: {\n helper_state: {\n execute: true,\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.victron_evcs_surplus_state',\n value: decision.state\n }\n },\n\n helper_current_decision: {\n execute: true,\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.victron_evcs_surplus_current_decision',\n value: decision.currentDecision\n }\n },\n\n helper_reason: {\n execute: true,\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.victron_evcs_surplus_reason',\n value: shortReason\n }\n },\n\n helper_operating_mode: {\n execute: true,\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.victron_evcs_operating_mode',\n value: context.operatingMode\n }\n },\n\n helper_source_mode: {\n execute: true,\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.victron_evcs_source_mode',\n value: sourceModeLabel\n }\n },\n\n helper_target_current: {\n execute: true,\n domain: 'input_number',\n service: 'set_value',\n data: {\n entity_id: 'input_number.victron_evcs_target_current_auto',\n value: round1(desiredCurrentA)\n }\n },\n\n helper_budget_remaining: {\n execute: true,\n domain: 'input_number',\n service: 'set_value',\n data: {\n entity_id: 'input_number.victron_evcs_budget_remaining_kwh',\n value: round2(derived.forecastBufferKwh)\n }\n },\n\n helper_projected_soc: {\n execute: true,\n domain: 'input_number',\n service: 'set_value',\n data: {\n entity_id: 'input_number.victron_evcs_projected_end_soc_after_charge',\n value: round1(derived.projectedEndSocAfterCharge)\n }\n },\n\n helper_cloud_debt: {\n execute: true,\n domain: 'input_number',\n service: 'set_value',\n data: {\n entity_id: 'input_number.victron_evcs_cloud_debt_kwh',\n value: round2(cloud.cloudDebtNextKwh)\n }\n },\n\n helper_dynamic_ok: {\n execute: true,\n domain: 'input_boolean',\n service: decision.dynamicOk ? 'turn_on' : 'turn_off',\n data: {\n entity_id: 'input_boolean.victron_evcs_dynamic_ok'\n }\n },\n\n helper_must_stop: {\n execute: true,\n domain: 'input_boolean',\n service: decision.mustStop ? 'turn_on' : 'turn_off',\n data: {\n entity_id: 'input_boolean.victron_evcs_must_stop'\n }\n },\n\n helper_cloud_debt_started_at: {\n execute: true,\n domain: 'input_datetime',\n service: 'set_datetime',\n data: {\n entity_id: 'input_datetime.victron_evcs_cloud_debt_started_at',\n datetime: cloudDebtStartedAtToWrite\n }\n },\n\n helper_battery_soft_breach_at: {\n execute: setBatterySoftBreachAt,\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.victron_evcs_battery_soft_breach_at',\n value: batterySoftBreachAtToWrite\n }\n },\n\n helper_last_current_change_at: {\n execute: setLastCurrentChangeAt,\n domain: 'input_datetime',\n service: 'set_datetime',\n data: {\n entity_id: 'input_datetime.victron_evcs_last_current_change_at',\n datetime: lastCurrentChangeAtToWrite\n }\n },\n\n helper_last_start_at: {\n execute: setLastStartAt,\n domain: 'input_datetime',\n service: 'set_datetime',\n data: {\n entity_id: 'input_datetime.victron_evcs_last_start_at',\n datetime: lastStartAtToWrite\n }\n },\n\n helper_last_stop_at: {\n execute: setLastStopAt,\n domain: 'input_datetime',\n service: 'set_datetime',\n data: {\n entity_id: 'input_datetime.victron_evcs_last_stop_at',\n datetime: lastStopAtToWrite\n }\n },\n\n helper_signature: {\n execute: true,\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.victron_evcs_control_last_signature',\n value: signature\n }\n },\n\n helper_block_reason: {\n execute: needsBlockReasonUpdate || !parsed.controllerEnabled,\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.victron_evcs_last_block_reason',\n value: decision.nextBlockReason\n }\n },\n\n helper_blocked_until: {\n execute: true,\n domain: 'input_datetime',\n service: 'set_datetime',\n data: {\n entity_id: 'input_datetime.victron_evcs_blocked_until',\n datetime: blockedUntilToWrite\n }\n },\n\n helper_probe_started_at: {\n execute: decision.setProbeStartedAt,\n domain: 'input_datetime',\n service: 'set_datetime',\n data: {\n entity_id: 'input_datetime.victron_evcs_probe_started_at',\n datetime: probeStartedAtToWrite\n }\n },\n\n helper_evcs_priority_active: {\n execute: true,\n domain: 'input_boolean',\n service: evcsPriorityActive ? 'turn_on' : 'turn_off',\n data: {\n entity_id: 'input_boolean.victron_evcs_priority_active'\n }\n },\n\n helper_energy_priority_mode: {\n execute: true,\n domain: 'input_select',\n service: 'select_option',\n data: {\n entity_id: 'input_select.victron_energy_priority_mode',\n option: energyPriorityMode\n }\n },\n\n helper_energy_priority_reason: {\n execute: true,\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.victron_energy_priority_reason',\n value: energyPriorityReason\n }\n },\n\n helper_evcs_priority_until: {\n execute: true,\n domain: 'input_datetime',\n service: 'set_datetime',\n data: {\n entity_id: 'input_datetime.victron_evcs_priority_until',\n datetime: evcsPriorityUntil\n }\n },\n\n helper_night_target_soc: {\n execute: true,\n domain: 'input_number',\n service: 'set_value',\n data: {\n entity_id: 'input_number.victron_evcs_night_target_soc',\n value: round1(\n Number.isFinite(night.batteryMorningTargetPct)\n ? night.batteryMorningTargetPct\n : 0\n )\n }\n },\n\n helper_night_grid_trigger_soc: {\n execute: true,\n domain: 'input_number',\n service: 'set_value',\n data: {\n entity_id: 'input_number.victron_evcs_night_grid_trigger_soc',\n value: round1(\n Number.isFinite(night.gridTakeoverTriggerPct)\n ? night.gridTakeoverTriggerPct\n : 0\n )\n }\n },\n\n helper_night_preferred_source_mode: {\n execute: true,\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.victron_evcs_night_preferred_source_mode',\n value: night.nightPreferredSourceMode || 'blocked'\n }\n },\n\n // SESSION RESEAU UNIQUE (03/09/2026) : ce bit est ce qui rend la session\n // UNIQUE. Sans persistance, l'engagement serait reevalue a chaque cycle\n // et le systeme retomberait dans une bande — donc dans un oscillateur.\n // Ecrit uniquement sur changement reel.\n helper_night_grid_session_committed: {\n execute: night.nightGridSessionCommitted !== history.nightGridSessionCommittedPrev,\n domain: 'input_boolean',\n service: night.nightGridSessionCommitted ? 'turn_on' : 'turn_off',\n data: {\n entity_id: 'input_boolean.victron_evcs_night_grid_session_committed'\n }\n },\n\n // P12 — LE VERROU ANTI-OSCILLATEUR.\n //\n // Le plancher de surplus (`required_soc_prudent`) DESCEND pendant la nuit,\n // et le SOC aussi. Mesure du 15/09 : voiture en charge, le SOC descend de\n // 7,1 pt/h et le plancher de 3,7 ; voiture ARRETEE, le SOC ne descend plus\n // que de 2,6 pt/h quand le plancher continue a 3,7. Le surplus\n // REAPPARAITRAIT donc tout seul et la charge repartirait — cinquieme\n // oscillateur du projet, par construction.\n //\n // Une seule passe de surplus par session de nuit, comme la recovery\n // batterie depuis le 27/08. Ecrit uniquement sur changement reel.\n helper_night_battery_done: {\n execute: !!night.nightBatterySurplusExhausted !== !!params.nightBatteryDonePrev,\n domain: 'input_boolean',\n service: night.nightBatterySurplusExhausted ? 'turn_on' : 'turn_off',\n data: {\n entity_id: 'input_boolean.victron_evcs_night_battery_done'\n }\n },\n\n helper_night_grid_takeover_needed: {\n execute: true,\n domain: 'input_boolean',\n // P9 : la fenêtre \"départ imminent\" (jour) tient aussi le helper\n // takeover → override hystérésis + maintien batterie ACM pendant\n // la charge réseau pré-départ.\n service: (night.nightGridTakeoverNeeded || night.departureUrgentActive)\n ? 'turn_on'\n : 'turn_off',\n data: {\n entity_id: 'input_boolean.victron_evcs_night_grid_takeover_needed'\n }\n },\n\n helper_night_battery_recovery_urgent: {\n execute: true,\n domain: 'input_boolean',\n service: night.batteryRecoveryUrgent ? 'turn_on' : 'turn_off',\n data: {\n entity_id: 'input_boolean.victron_evcs_night_battery_recovery_urgent'\n }\n },\n\n helper_night_session_active: {\n execute: true,\n domain: 'input_boolean',\n service: night.nightSessionActive ? 'turn_on' : 'turn_off',\n data: {\n entity_id: 'input_boolean.victron_evcs_night_session_active'\n }\n },\n\n helper_night_session_start_soc: {\n execute: decision.setNightSessionStart || decision.clearNightSessionStart,\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.victron_evcs_night_session_start_soc',\n value: nightSessionStartSocToWrite\n }\n },\n\n helper_night_session_start_at: {\n execute: decision.setNightSessionStart || decision.clearNightSessionStart,\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.victron_evcs_night_session_start_at',\n value: nightSessionStartAtToWrite\n }\n },\n\n // =================================================\n // VEHICLE DEMAND FILTER HISTORY PERSISTENCE\n // =================================================\n helper_evcs_delivery_ratio_ema: {\n execute: Number.isFinite(vdf?.nextHistory?.evcsDeliveryRatioEma),\n domain: 'input_number',\n service: 'set_value',\n data: {\n entity_id: 'input_number.victron_evcs_delivery_ratio_ema',\n value: round3(vdf.nextHistory.evcsDeliveryRatioEma)\n }\n },\n\n helper_evcs_delivery_noise_ema: {\n execute: Number.isFinite(vdf?.nextHistory?.evcsDeliveryNoiseEma),\n domain: 'input_number',\n service: 'set_value',\n data: {\n entity_id: 'input_number.victron_evcs_delivery_noise_ema',\n value: round3(vdf.nextHistory.evcsDeliveryNoiseEma)\n }\n },\n\n helper_evcs_no_demand_score_ema: {\n execute: Number.isFinite(vdf?.nextHistory?.evcsNoDemandScoreEma),\n domain: 'input_number',\n service: 'set_value',\n data: {\n entity_id: 'input_number.victron_evcs_no_demand_score_ema',\n value: round3(vdf.nextHistory.evcsNoDemandScoreEma)\n }\n },\n\n helper_evcs_no_demand_semantic_active: {\n execute: true,\n domain: 'input_boolean',\n service: vdf?.nextHistory?.evcsNoDemandSemanticActive ? 'turn_on' : 'turn_off',\n data: {\n entity_id: 'input_boolean.victron_evcs_no_demand_semantic_active'\n }\n },\n\n helper_evcs_no_demand_since_at: {\n execute: Number.isFinite(vdf?.nextHistory?.evcsNoDemandSinceMs),\n domain: 'input_datetime',\n service: 'set_datetime',\n data: {\n entity_id: 'input_datetime.victron_evcs_no_demand_since_at',\n datetime: formatParisStampFromMs(vdf.nextHistory.evcsNoDemandSinceMs)\n }\n },\n\n helper_evcs_no_demand_filter_eval_at: {\n execute: Number.isFinite(vdf?.nextHistory?.evcsNoDemandFilterEvalMs),\n domain: 'input_datetime',\n service: 'set_datetime',\n data: {\n entity_id: 'input_datetime.victron_evcs_no_demand_filter_eval_at',\n datetime: formatParisStampFromMs(vdf.nextHistory.evcsNoDemandFilterEvalMs)\n }\n },\n\n // =================================================\n // PHYSICAL EDF GRID TAKEOVER\n // =================================================\n edf_general_ok_on: {\n execute:\n ['grid_forced', 'grid_hc_allowed'].includes(context.sourceMode) &&\n !parsed.gridAvailable,\n domain: 'switch',\n service: 'turn_on',\n data: {\n entity_id: 'switch.edf_general_ok_switch'\n }\n },\n\n grid_power_setpoint: {\n execute: gridSetpointNeedsWrite,\n domain: 'number',\n service: 'set_value',\n data: {\n entity_id: 'number.gx_device_ac_power_setpoint',\n value: gridSetpointTargetW\n }\n },\n\n evcs_auto_start_off: {\n execute: autoStartAction === 'turn_off',\n domain: 'switch',\n service: 'turn_off',\n data: {\n entity_id: 'switch.victron_evcs_auto_start'\n }\n },\n\n evcs_charging: {\n execute: chargingAction !== 'none',\n domain: 'switch',\n service: decision.desiredCharging ? 'turn_on' : 'turn_off',\n data: {\n entity_id: 'switch.victron_evcs_charging'\n }\n },\n\n helper_permission: {\n execute: permissionAction !== 'none',\n domain: 'input_boolean',\n service: decision.desiredPermission ? 'turn_on' : 'turn_off',\n data: {\n entity_id: 'input_boolean.victron_evcs_charge_permission_now'\n }\n },\n\n helper_latch: {\n execute: latchAction !== 'none',\n domain: 'input_boolean',\n service: decision.desiredLatch ? 'turn_on' : 'turn_off',\n data: {\n entity_id: 'input_boolean.victron_evcs_control_latch'\n }\n },\n\n evcs_set_current: {\n execute: currentAction === 'set',\n domain: 'number',\n service: 'set_value',\n data: {\n entity_id: 'number.victron_evcs_set_current_command',\n value: round1(desiredCurrentA)\n }\n },\n\n logbook: {\n execute: signatureChanged,\n domain: 'logbook',\n service: 'log',\n data: {\n name: 'Victron EVCS Surplus',\n message: shortReason\n }\n },\n\n mobile_notification: {\n execute: notifyWanted,\n domain: cfg.mobileNotifyDomain,\n service: cfg.mobileNotifyService,\n data: {\n title: notifyTitle,\n message: notifyMessage || shortReason\n }\n }\n }\n }\n}];\n" }, "type": "n8n-nodes-base.code", "typeVersion": 2, @@ -142,7 +142,7 @@ "parameters": [ { "name": "template", - "value": "{{ {\n \"soc_percent\": states(\"sensor.victron_battery_soc\"),\n \"battery_current_a\": states(\"sensor.victron_battery_current\"),\n\n \"pv_power_w\": states(\"sensor.victron_pv_power_system\"),\n \"pv_power_10m_avg_w\": states(\"sensor.victron_pv_power_10m_avg\"),\n \"pv_trend_w\": states(\"sensor.victron_pv_power_trend_w\"),\n\n \"mppt_operation_modes\": states.sensor\n | selectattr('entity_id','search','mppt')\n | selectattr('entity_id','search','operation_mode')\n | map(attribute='state') | join(','),\n\n \"house_ac_power_w\": states(\"sensor.victron_consumption_power_l1\"),\n \"house_ac_power_prev_w\": states(\"sensor.victron_consumption_power_l1\"),\n\n \"grid_power_w\": states(\"sensor.victron_grid_power_l1\"),\n \"grid_state\": states(\"switch.edf_general_ok_switch\"),\n \"victron_acin1_available\": states(\"sensor.victron_ac_in1_available\"),\n\n \n \"acin1_last_changed_ts\": as_timestamp(states.sensor.victron_ac_in1_available.last_changed, 0) if states.sensor.victron_ac_in1_available is not none else 0,\n \"grid_switch_last_changed_ts\": as_timestamp(states.switch.edf_general_ok_switch.last_changed, 0) if states.switch.edf_general_ok_switch is not none else 0,\n \"grid_settle_seconds\": states(\"input_number.evcs_grid_settle_seconds\"),\n \"grid_setpoint_w\": states(\"number.gx_device_ac_power_setpoint\"),\n \"grid_setpoint_idle_w\": states(\"input_number.evcs_grid_setpoint_idle_w\"),\n\n \"today_remaining_kwh\": states(\"sensor.energy_production_today_remaining\"),\n \"tomorrow_kwh\": states(\"sensor.energy_production_tomorrow\"),\n\n \"evcs_device_connected\": states(\"binary_sensor.victron_evcs_device_connected\"),\n \"evcs_vehicle_connected\": states(\"binary_sensor.victron_evcs_vehicle_connected\"),\n \"evcs_status_raw\": states(\"sensor.victron_evcs_status_raw\"),\n \"evcs_mode_sensor\": states(\"sensor.victron_evcs_mode\"),\n \"evcs_mode_fallback\": states(\"input_text.sensor_victron_evcs_mode\"),\n \"evcs_set_current_a\": states(\"sensor.victron_evcs_set_current\"),\n \"evcs_command_current_a\": states(\"number.victron_evcs_set_current_command\"),\n \"evcs_current_a\": states(\"sensor.victron_evcs_current\"),\n \"evcs_ac_power_w\": states(\"sensor.victron_evcs_ac_power\"),\n \"evcs_charging_time\": states(\"sensor.victron_evcs_charging_time\"),\n \"evcs_session_energy_kwh\": states(\"sensor.evcs_hq2239dnt7p_last_session_energy\"),\n \"evcs_charging_switch\": states(\"switch.victron_evcs_charging\"),\n \"evcs_auto_start_switch\": states(\"switch.victron_evcs_auto_start\"),\n\n \"controller_enabled\": states(\"input_boolean.victron_evcs_surplus_auto_enabled\"),\n \"notifications_enabled\": states(\"input_boolean.victron_evcs_surplus_notifications\"),\n \"hard_stop_enabled\": states(\"input_boolean.victron_evcs_hard_stop_enabled\"),\n \"charge_permission_now\": states(\"input_boolean.victron_evcs_charge_permission_now\"),\n \"control_latch\": states(\"input_boolean.victron_evcs_control_latch\"),\n \"departure_priority_enabled\": states(\"input_boolean.victron_evcs_departure_priority_enabled\"),\n \"forecast_mode_enabled\": states(\"input_boolean.victron_evcs_forecast_mode_enabled\"),\n\n \"target_current_auto_a\": states(\"input_number.victron_evcs_target_current_auto\"),\n \"budget_remaining_kwh\": states(\"input_number.victron_evcs_budget_remaining_kwh\"),\n \"projected_end_soc_after_charge\": states(\"input_number.victron_evcs_projected_end_soc_after_charge\"),\n\n \"pv_start_min_w\": states(\"input_number.victron_evcs_pv_start_min_w\"),\n \"pv_hold_min_w\": states(\"input_number.victron_evcs_pv_hold_min_w\"),\n \"house_ac_headroom_w\": states(\"input_number.victron_evcs_house_ac_headroom_w\"),\n \"battery_discharge_soft_a\": states(\"input_number.victron_evcs_battery_discharge_soft_a\"),\n \"battery_discharge_hard_a\": states(\"input_number.victron_evcs_battery_discharge_hard_a\"),\n \"battery_reserve_up_a\": states(\"input_number.victron_evcs_battery_reserve_up_a\"),\n \"battery_neutral_band_a\": states(\"input_number.victron_evcs_battery_neutral_band_a\"),\n \"soc_start_min\": states(\"input_number.victron_evcs_soc_start_min\"),\n \"modules_en_service\": states(\"input_number.evcs_balance_modules_en_service\"),\n\n \"bms_max_discharge_current_a\": states(\"sensor.airtria_8_us2000c_maximum_allowed_discharge_current\"),\n \"bms_max_charge_current_a\": states(\"sensor.airtria_8_us2000c_maximum_allowed_charge_current\"),\n \"soc_stop_min\": states(\"input_number.victron_evcs_soc_stop_min\"),\n \"current_min_a\": states(\"input_number.victron_evcs_current_min_a\"),\n \"current_max_auto_a\": states(\"input_number.victron_evcs_current_max_auto_a\"),\n \"grid_current_max_a\": states(\"input_number.evcs_plafond_courant_edf_on\"),\n \"current_step_up_a\": states(\"input_number.victron_evcs_current_step_up_a\"),\n \"current_step_down_a\": states(\"input_number.victron_evcs_current_step_down_a\"),\n \"current_change_cooldown_seconds\": states(\"input_number.victron_evcs_current_change_cooldown_seconds\"),\n \"min_run_minutes\": states(\"input_number.victron_evcs_min_run_minutes\"),\n \"min_stop_minutes\": states(\"input_number.victron_evcs_min_stop_minutes\"),\n\n \"required_energy_kwh\": states(\"input_number.victron_evcs_required_energy_kwh\"),\n \"soc_comfort_target\": states(\"input_number.victron_evcs_soc_comfort_target\"),\n \"end_of_day_soc_guard\": states(\"input_number.victron_evcs_end_of_day_soc_guard\"),\n\n \"base_load_remaining_kwh\": states(\"input_number.victron_base_load_remaining_kwh\"),\n \"flexible_safety_margin_kwh\": states(\"input_number.victron_flexible_safety_margin_kwh\"),\n \"end_of_day_soc_target\": states(\"input_number.victron_end_of_day_soc_target\"),\n \"ac_loads_max_safe_w\": states(\"input_number.victron_ac_loads_max_safe_w\"),\n\n \"battery_full_soc\": states(\"input_number.victron_evcs_battery_full_soc\"),\n \"battery_full_forecast_min_kwh\": states(\"input_number.victron_evcs_battery_full_forecast_min_kwh\"),\n \"forecast_commit_soc_floor\": states(\"input_number.victron_evcs_forecast_commit_soc_floor\"),\n \"forecast_commit_min_kwh\": states(\"input_number.victron_evcs_forecast_commit_min_kwh\"),\n\n \"cloud_debt_kwh\": states(\"input_number.victron_evcs_cloud_debt_kwh\"),\n \"cloud_debt_max_kwh\": states(\"input_number.victron_evcs_cloud_debt_max_kwh\"),\n \"cloud_tolerance_minutes\": states(\"input_number.victron_evcs_cloud_tolerance_minutes\"),\n \"cloud_debt_started_at\": states(\"input_datetime.victron_evcs_cloud_debt_started_at\"),\n \"battery_soft_breach_at\": states(\"input_text.victron_evcs_battery_soft_breach_at\"),\n \"operating_mode_helper\": states(\"input_text.victron_evcs_operating_mode\"),\n\n \"departure_time\": states(\"input_datetime.victron_evcs_departure_time\"),\n \"departure_time_jeep\": states(\"input_datetime.victron_evcs_departure_time_jeep\"),\n \"evcs_vehicle\": states(\"input_select.victron_evcs_vehicle\"),\n \"allow_grid_departure_urgent\": states(\"input_boolean.victron_evcs_allow_grid_departure_urgent\"),\n \"last_start_at\": states(\"input_datetime.victron_evcs_last_start_at\"),\n \"last_stop_at\": states(\"input_datetime.victron_evcs_last_stop_at\"),\n \"last_current_change_at\": states(\"input_datetime.victron_evcs_last_current_change_at\"),\n\n \"day_start_time\": states(\"input_datetime.victron_flexible_day_start_time\"),\n \"day_stop_time\": states(\"input_datetime.victron_flexible_day_stop_time\"),\n\n \"vebus_overload\": states(\"binary_sensor.victron_ve_bus_overload\"),\n \"vebus_l1_overload\": states(\"binary_sensor.victron_ve_bus_l1_overload\"),\n \"vebus_low_battery\": states(\"binary_sensor.victron_ve_bus_low_battery\"),\n \"vebus_l1_low_battery\": states(\"binary_sensor.victron_ve_bus_l1_low_battery\"),\n\n \"vebus_ac_out_l1_power_w\": states(\"sensor.victron_ve_bus_ac_out_l1_power\"),\n \"vebus_ac_out_l1_current_a\": states(\"sensor.victron_ve_bus_ac_out_l1_current\"),\n \"vebus_ac_out_l1_voltage_v\": states(\"sensor.victron_ve_bus_ac_out_l1_voltage\"),\n\n \"vebus_dc_voltage_v\": states(\"sensor.victron_ve_bus_dc_voltage\"),\n \"vebus_dc_current_a\": states(\"sensor.victron_ve_bus_dc_current\"),\n \"vebus_dc_power_w\": states(\"sensor.victron_ve_bus_dc_power_system\"),\n\n \"vebus_state\": states(\"sensor.victron_ve_bus_state\"),\n \"vebus_main_state\": states(\"sensor.victron_ve_bus_main_state\"),\n\n \"last_signature\": states(\"input_text.victron_evcs_control_last_signature\"),\n\n \"hc_night_start_time\": states(\"input_datetime.victron_evcs_hc_night_start\"),\n \"hc_night_stop_time\": states(\"input_datetime.victron_evcs_hc_night_stop\"),\n \"morning_start_time\": states(\"input_datetime.victron_evcs_morning_start\"),\n \"morning_stop_time\": states(\"input_datetime.victron_evcs_morning_stop\"),\n \"hc_midday_start_time\": states(\"input_datetime.victron_evcs_hc_midday_start\"),\n \"hc_midday_stop_time\": states(\"input_datetime.victron_evcs_hc_midday_stop\"),\n \"evening_start_time\": states(\"input_datetime.victron_evcs_evening_start\"),\n \"evening_stop_time\": states(\"input_datetime.victron_evcs_evening_stop\"),\n\n \"allow_grid_hc_night\": states(\"input_boolean.victron_evcs_allow_grid_hc_night\"),\n \"night_takeover_prev\": states(\"input_boolean.victron_evcs_night_grid_takeover_needed\"),\n \"night_grid_session_committed_prev\": states(\"input_boolean.victron_evcs_night_grid_session_committed\"),\n \"prev_surplus_state\": states(\"input_text.victron_evcs_surplus_state\"),\n \"evcs_night_session_start_soc\": states(\"input_text.victron_evcs_night_session_start_soc\"),\n \"evcs_night_session_start_at\": states(\"input_text.victron_evcs_night_session_start_at\"),\n\n \"balance_required_soc_prudent\": states(\"input_number.evcs_balance_required_soc_prudent\"),\n \"night_battery_charge_enabled\": states(\"input_boolean.victron_evcs_night_battery_charge\"),\n \"night_battery_done_prev\": states(\"input_boolean.victron_evcs_night_battery_done\"),\n \"balance_required_soc\": states(\"input_number.evcs_balance_required_soc\"),\n \"balance_energy_to_cover_kwh\": states(\"input_number.evcs_balance_energy_to_cover_kwh\"),\n \"balance_status\": states(\"input_text.evcs_balance_status\"),\n \"balance_last_run\": states(\"input_text.evcs_balance_last_run\"),\n \"allow_grid_hc_midday\": states(\"input_boolean.victron_evcs_allow_grid_hc_midday\"),\n \"probe_enabled\": states(\"input_boolean.victron_evcs_probe_enabled\"),\n\n \"probe_minutes\": states(\"input_number.victron_evcs_probe_minutes\"),\n \"probe_retry_minutes\": states(\"input_number.victron_evcs_probe_retry_minutes\"),\n \"probe_current_a\": states(\"input_number.victron_evcs_probe_current_a\"),\n\n \"grid_battery_soft_a\": states(\"input_number.victron_evcs_grid_battery_soft_a\"),\n \"grid_battery_hard_a\": states(\"input_number.victron_evcs_grid_battery_hard_a\"),\n\n \"morning_probe_pv_avg_min_w\": states(\"input_number.victron_evcs_morning_probe_pv_avg_min_w\"),\n \"morning_probe_pv_trend_min_w\": states(\"input_number.victron_evcs_morning_probe_pv_trend_min_w\"),\n \"midday_probe_pv_avg_min_w\": states(\"input_number.victron_evcs_midday_probe_pv_avg_min_w\"),\n \"afternoon_probe_pv_avg_min_w\": states(\"input_number.victron_evcs_afternoon_probe_pv_avg_min_w\"),\n \"afternoon_probe_soc_min\": states(\"input_number.victron_evcs_afternoon_probe_soc_min\"),\n \"afternoon_battery_max_discharge_a\": states(\"input_number.victron_evcs_afternoon_battery_max_discharge_a\"),\n \"morning_soc_min\": states(\"input_number.victron_evcs_morning_soc_min\"),\n \"midday_soc_min\": states(\"input_number.victron_evcs_midday_soc_min\"),\n \"evening_soc_start_min\": states(\"input_number.victron_evcs_evening_soc_start_min\"),\n \"evening_soc_stop_min\": states(\"input_number.victron_evcs_evening_soc_stop_min\"),\n\n \"morning_battery_max_discharge_a\": states(\"input_number.victron_evcs_morning_battery_max_discharge_a\"),\n \"midday_battery_max_discharge_a\": states(\"input_number.victron_evcs_midday_battery_max_discharge_a\"),\n \"evening_battery_max_discharge_a\": states(\"input_number.victron_evcs_evening_battery_max_discharge_a\"),\n\n \"last_block_reason\": states(\"input_text.victron_evcs_last_block_reason\"),\n \"blocked_until\": states(\"input_datetime.victron_evcs_blocked_until\"),\n \"probe_started_at\": states(\"input_datetime.victron_evcs_probe_started_at\"),\n\n \"victron_evcs_strategy\": states(\"input_select.victron_evcs_strategy\"),\n \"evcs_priority_manual_override\": states(\"input_boolean.victron_evcs_priority_manual_override\"),\n \"evcs_priority_manual_mode\": states(\"input_select.victron_evcs_priority_manual_mode\"),\n \"evcs_priority_manual_reason\": states(\"input_text.victron_evcs_priority_manual_reason\"),\n \"evcs_priority_manual_until\": states(\"input_datetime.victron_evcs_priority_manual_until\"),\n\n \"house_spike_grace_minutes\": states(\"input_number.victron_evcs_house_spike_grace_minutes\"),\n \"solar_potential_grace_minutes\": states(\"input_number.victron_evcs_solar_potential_grace_minutes\"),\n \"step_up_cooldown_seconds\": states(\"input_number.victron_evcs_step_up_cooldown_seconds\"),\n \"step_down_cooldown_seconds\": states(\"input_number.victron_evcs_step_down_cooldown_seconds\"),\n \"force_mode_auto_revert_minutes\": states(\"input_number.victron_evcs_force_mode_auto_revert_minutes\"),\n\n \"overload_step_down_a\": states(\"input_number.victron_evcs_overload_step_down_a\"),\n \"overload_hold_seconds\": states(\"input_number.victron_evcs_overload_hold_seconds\"),\n \"overload_stop_after_seconds\": states(\"input_number.victron_evcs_overload_stop_after_seconds\"),\n\n \"batt_discharge_soft_gt90_a\": states(\"input_number.victron_evcs_batt_discharge_soft_gt90_a\"),\n \"batt_discharge_hard_gt90_a\": states(\"input_number.victron_evcs_batt_discharge_hard_gt90_a\"),\n \"batt_charge_reserve_gt90_a\": states(\"input_number.victron_evcs_batt_charge_reserve_gt90_a\"),\n \"batt_soft_tol_gt90_min\": states(\"input_number.victron_evcs_batt_soft_tol_gt90_min\"),\n \"batt_hard_tol_gt90_sec\": states(\"input_number.victron_evcs_batt_hard_tol_gt90_sec\"),\n \"batt_recovery_gt90_min\": states(\"input_number.victron_evcs_batt_recovery_gt90_min\"),\n\n \"batt_discharge_soft_80_90_a\": states(\"input_number.victron_evcs_batt_discharge_soft_80_90_a\"),\n \"batt_discharge_hard_80_90_a\": states(\"input_number.victron_evcs_batt_discharge_hard_80_90_a\"),\n \"batt_charge_reserve_80_90_a\": states(\"input_number.victron_evcs_batt_charge_reserve_80_90_a\"),\n \"batt_soft_tol_80_90_min\": states(\"input_number.victron_evcs_batt_soft_tol_80_90_min\"),\n \"batt_hard_tol_80_90_sec\": states(\"input_number.victron_evcs_batt_hard_tol_80_90_sec\"),\n \"batt_recovery_80_90_min\": states(\"input_number.victron_evcs_batt_recovery_80_90_min\"),\n\n \"batt_discharge_soft_60_80_a\": states(\"input_number.victron_evcs_batt_discharge_soft_60_80_a\"),\n \"batt_discharge_hard_60_80_a\": states(\"input_number.victron_evcs_batt_discharge_hard_60_80_a\"),\n \"batt_charge_reserve_60_80_a\": states(\"input_number.victron_evcs_batt_charge_reserve_60_80_a\"),\n \"batt_soft_tol_60_80_min\": states(\"input_number.victron_evcs_batt_soft_tol_60_80_min\"),\n \"batt_hard_tol_60_80_sec\": states(\"input_number.victron_evcs_batt_hard_tol_60_80_sec\"),\n \"batt_recovery_60_80_min\": states(\"input_number.victron_evcs_batt_recovery_60_80_min\"),\n\n \"batt_discharge_soft_40_60_a\": states(\"input_number.victron_evcs_batt_discharge_soft_40_60_a\"),\n \"batt_discharge_hard_40_60_a\": states(\"input_number.victron_evcs_batt_discharge_hard_40_60_a\"),\n \"batt_charge_reserve_40_60_a\": states(\"input_number.victron_evcs_batt_charge_reserve_40_60_a\"),\n \"batt_soft_tol_40_60_min\": states(\"input_number.victron_evcs_batt_soft_tol_40_60_min\"),\n \"batt_hard_tol_40_60_sec\": states(\"input_number.victron_evcs_batt_hard_tol_40_60_sec\"),\n \"batt_recovery_40_60_min\": states(\"input_number.victron_evcs_batt_recovery_40_60_min\"),\n\n \"batt_discharge_soft_25_40_a\": states(\"input_number.victron_evcs_batt_discharge_soft_25_40_a\"),\n \"batt_discharge_hard_25_40_a\": states(\"input_number.victron_evcs_batt_discharge_hard_25_40_a\"),\n \"batt_charge_reserve_25_40_a\": states(\"input_number.victron_evcs_batt_charge_reserve_25_40_a\"),\n \"batt_soft_tol_25_40_min\": states(\"input_number.victron_evcs_batt_soft_tol_25_40_min\"),\n \"batt_hard_tol_25_40_sec\": states(\"input_number.victron_evcs_batt_hard_tol_25_40_sec\"),\n \"batt_recovery_25_40_min\": states(\"input_number.victron_evcs_batt_recovery_25_40_min\"),\n\n \"batt_discharge_soft_lt25_a\": states(\"input_number.victron_evcs_batt_discharge_soft_lt25_a\"),\n \"batt_discharge_hard_lt25_a\": states(\"input_number.victron_evcs_batt_discharge_hard_lt25_a\"),\n \"batt_charge_reserve_lt25_a\": states(\"input_number.victron_evcs_batt_charge_reserve_lt25_a\"),\n \"batt_soft_tol_lt25_min\": states(\"input_number.victron_evcs_batt_soft_tol_lt25_min\"),\n \"batt_hard_tol_lt25_sec\": states(\"input_number.victron_evcs_batt_hard_tol_lt25_sec\"),\n \"batt_recovery_lt25_min\": states(\"input_number.victron_evcs_batt_recovery_lt25_min\"),\n\n \"evcs_delivery_ratio_ema\": states(\"input_number.victron_evcs_delivery_ratio_ema\"),\n \"evcs_delivery_noise_ema\": states(\"input_number.victron_evcs_delivery_noise_ema\"),\n \"evcs_no_demand_score_ema\": states(\"input_number.victron_evcs_no_demand_score_ema\"),\n \"evcs_no_demand_semantic_active\": states(\"input_boolean.victron_evcs_no_demand_semantic_active\"),\n \"evcs_no_demand_since_at\": states(\"input_datetime.victron_evcs_no_demand_since_at\"),\n \"evcs_no_demand_filter_eval_at\": states(\"input_datetime.victron_evcs_no_demand_filter_eval_at\")\n} | tojson }}\n" + "value": "{{ {\n \"soc_percent\": states(\"sensor.victron_battery_soc\"),\n \"battery_current_a\": states(\"sensor.victron_battery_current\"),\n\n \"pv_power_w\": states(\"sensor.victron_pv_power_system\"),\n \"pv_power_10m_avg_w\": states(\"sensor.victron_pv_power_10m_avg\"),\n \"pv_trend_w\": states(\"sensor.victron_pv_power_trend_w\"),\n\n \"mppt_operation_modes\": states.sensor\n | selectattr('entity_id','search','mppt')\n | selectattr('entity_id','search','operation_mode')\n | map(attribute='state') | join(','),\n\n \"house_ac_power_w\": states(\"sensor.victron_consumption_power_l1\"),\n \"house_ac_power_prev_w\": states(\"sensor.victron_consumption_power_l1\"),\n\n \"grid_power_w\": states(\"sensor.victron_grid_power_l1\"),\n \"grid_state\": states(\"switch.edf_general_ok_switch\"),\n \"victron_acin1_available\": states(\"sensor.victron_ac_in1_available\"),\n\n \n \"acin1_last_changed_ts\": as_timestamp(states.sensor.victron_ac_in1_available.last_changed, 0) if states.sensor.victron_ac_in1_available is not none else 0,\n \"grid_switch_last_changed_ts\": as_timestamp(states.switch.edf_general_ok_switch.last_changed, 0) if states.switch.edf_general_ok_switch is not none else 0,\n \"grid_settle_seconds\": states(\"input_number.evcs_grid_settle_seconds\"),\n \"grid_setpoint_w\": states(\"number.gx_device_ac_power_setpoint\"),\n \"grid_setpoint_idle_w\": states(\"input_number.evcs_grid_setpoint_idle_w\"),\n\n \"today_remaining_kwh\": states(\"sensor.energy_production_today_remaining\"),\n \"tomorrow_kwh\": states(\"sensor.energy_production_tomorrow\"),\n\n \"evcs_device_connected\": states(\"binary_sensor.victron_evcs_device_connected\"),\n \"evcs_vehicle_connected\": states(\"binary_sensor.victron_evcs_vehicle_connected\"),\n \"evcs_status_raw\": states(\"sensor.victron_evcs_status_raw\"),\n \"evcs_mode_sensor\": states(\"sensor.victron_evcs_mode\"),\n \"evcs_mode_fallback\": states(\"input_text.sensor_victron_evcs_mode\"),\n \"evcs_set_current_a\": states(\"sensor.victron_evcs_set_current\"),\n \"evcs_command_current_a\": states(\"number.victron_evcs_set_current_command\"),\n \"evcs_current_a\": states(\"sensor.victron_evcs_current\"),\n \"evcs_ac_power_w\": states(\"sensor.victron_evcs_ac_power\"),\n \"evcs_charging_time\": states(\"sensor.victron_evcs_charging_time\"),\n \"evcs_session_energy_kwh\": states(\"sensor.evcs_hq2239dnt7p_last_session_energy\"),\n \"evcs_charging_switch\": states(\"switch.victron_evcs_charging\"),\n \"evcs_auto_start_switch\": states(\"switch.victron_evcs_auto_start\"),\n\n \"controller_enabled\": states(\"input_boolean.victron_evcs_surplus_auto_enabled\"),\n \"notifications_enabled\": states(\"input_boolean.victron_evcs_surplus_notifications\"),\n \"hard_stop_enabled\": states(\"input_boolean.victron_evcs_hard_stop_enabled\"),\n \"charge_permission_now\": states(\"input_boolean.victron_evcs_charge_permission_now\"),\n \"control_latch\": states(\"input_boolean.victron_evcs_control_latch\"),\n \"departure_priority_enabled\": states(\"input_boolean.victron_evcs_departure_priority_enabled\"),\n \"forecast_mode_enabled\": states(\"input_boolean.victron_evcs_forecast_mode_enabled\"),\n\n \"target_current_auto_a\": states(\"input_number.victron_evcs_target_current_auto\"),\n \"budget_remaining_kwh\": states(\"input_number.victron_evcs_budget_remaining_kwh\"),\n \"projected_end_soc_after_charge\": states(\"input_number.victron_evcs_projected_end_soc_after_charge\"),\n\n \"pv_start_min_w\": states(\"input_number.victron_evcs_pv_start_min_w\"),\n \"pv_hold_min_w\": states(\"input_number.victron_evcs_pv_hold_min_w\"),\n \"house_ac_headroom_w\": states(\"input_number.victron_evcs_house_ac_headroom_w\"),\n \"battery_discharge_soft_a\": states(\"input_number.victron_evcs_battery_discharge_soft_a\"),\n \"battery_discharge_hard_a\": states(\"input_number.victron_evcs_battery_discharge_hard_a\"),\n \"battery_reserve_up_a\": states(\"input_number.victron_evcs_battery_reserve_up_a\"),\n \"battery_neutral_band_a\": states(\"input_number.victron_evcs_battery_neutral_band_a\"),\n \"soc_start_min\": states(\"input_number.victron_evcs_soc_start_min\"),\n \"modules_en_service\": states(\"input_number.evcs_balance_modules_en_service\"),\n\n \"bms_max_discharge_current_a\": states(\"sensor.airtria_8_us2000c_maximum_allowed_discharge_current\"),\n \"bms_max_charge_current_a\": states(\"sensor.airtria_8_us2000c_maximum_allowed_charge_current\"),\n \"soc_stop_min\": states(\"input_number.victron_evcs_soc_stop_min\"),\n \"current_min_a\": states(\"input_number.victron_evcs_current_min_a\"),\n \"current_max_auto_a\": states(\"input_number.victron_evcs_current_max_auto_a\"),\n \"grid_current_max_a\": states(\"input_number.evcs_plafond_courant_edf_on\"),\n \"current_step_up_a\": states(\"input_number.victron_evcs_current_step_up_a\"),\n \"current_step_down_a\": states(\"input_number.victron_evcs_current_step_down_a\"),\n \"current_change_cooldown_seconds\": states(\"input_number.victron_evcs_current_change_cooldown_seconds\"),\n \"min_run_minutes\": states(\"input_number.victron_evcs_min_run_minutes\"),\n \"min_stop_minutes\": states(\"input_number.victron_evcs_min_stop_minutes\"),\n\n \"required_energy_kwh\": states(\"input_number.victron_evcs_required_energy_kwh\"),\n \"soc_comfort_target\": states(\"input_number.victron_evcs_soc_comfort_target\"),\n \"end_of_day_soc_guard\": states(\"input_number.victron_evcs_end_of_day_soc_guard\"),\n\n \"base_load_remaining_kwh\": states(\"input_number.victron_base_load_remaining_kwh\"),\n \"flexible_safety_margin_kwh\": states(\"input_number.victron_flexible_safety_margin_kwh\"),\n \"end_of_day_soc_target\": states(\"input_number.victron_end_of_day_soc_target\"),\n \"ac_loads_max_safe_w\": states(\"input_number.victron_ac_loads_max_safe_w\"),\n\n \"battery_full_soc\": states(\"input_number.victron_evcs_battery_full_soc\"),\n \"battery_full_forecast_min_kwh\": states(\"input_number.victron_evcs_battery_full_forecast_min_kwh\"),\n \"forecast_commit_soc_floor\": states(\"input_number.victron_evcs_forecast_commit_soc_floor\"),\n \"forecast_commit_min_kwh\": states(\"input_number.victron_evcs_forecast_commit_min_kwh\"),\n\n \"cloud_debt_kwh\": states(\"input_number.victron_evcs_cloud_debt_kwh\"),\n \"cloud_debt_max_kwh\": states(\"input_number.victron_evcs_cloud_debt_max_kwh\"),\n \"cloud_tolerance_minutes\": states(\"input_number.victron_evcs_cloud_tolerance_minutes\"),\n \"cloud_debt_started_at\": states(\"input_datetime.victron_evcs_cloud_debt_started_at\"),\n \"battery_soft_breach_at\": states(\"input_text.victron_evcs_battery_soft_breach_at\"),\n \"operating_mode_helper\": states(\"input_text.victron_evcs_operating_mode\"),\n\n \"departure_time\": states(\"input_datetime.victron_evcs_departure_time\"),\n \"departure_time_jeep\": states(\"input_datetime.victron_evcs_departure_time_jeep\"),\n \"evcs_vehicle\": states(\"input_select.victron_evcs_vehicle\"),\n \"allow_grid_departure_urgent\": states(\"input_boolean.victron_evcs_allow_grid_departure_urgent\"),\n \"last_start_at\": states(\"input_datetime.victron_evcs_last_start_at\"),\n \"last_stop_at\": states(\"input_datetime.victron_evcs_last_stop_at\"),\n \"last_current_change_at\": states(\"input_datetime.victron_evcs_last_current_change_at\"),\n\n \"day_start_time\": states(\"input_datetime.victron_flexible_day_start_time\"),\n \"day_stop_time\": states(\"input_datetime.victron_flexible_day_stop_time\"),\n\n \"vebus_overload\": states(\"binary_sensor.victron_ve_bus_overload\"),\n \"vebus_l1_overload\": states(\"binary_sensor.victron_ve_bus_l1_overload\"),\n \"vebus_low_battery\": states(\"binary_sensor.victron_ve_bus_low_battery\"),\n \"vebus_l1_low_battery\": states(\"binary_sensor.victron_ve_bus_l1_low_battery\"),\n\n \"vebus_ac_out_l1_power_w\": states(\"sensor.victron_ve_bus_ac_out_l1_power\"),\n \"vebus_ac_out_l1_current_a\": states(\"sensor.victron_ve_bus_ac_out_l1_current\"),\n \"vebus_ac_out_l1_voltage_v\": states(\"sensor.victron_ve_bus_ac_out_l1_voltage\"),\n\n \"vebus_dc_voltage_v\": states(\"sensor.victron_ve_bus_dc_voltage\"),\n \"vebus_dc_current_a\": states(\"sensor.victron_ve_bus_dc_current\"),\n \"vebus_dc_power_w\": states(\"sensor.victron_ve_bus_dc_power_system\"),\n\n \"vebus_state\": states(\"sensor.victron_ve_bus_state\"),\n \"vebus_main_state\": states(\"sensor.victron_ve_bus_main_state\"),\n\n \"last_signature\": states(\"input_text.victron_evcs_control_last_signature\"),\n\n \"hc_night_start_time\": states(\"input_datetime.victron_evcs_hc_night_start\"),\n \"hc_night_stop_time\": states(\"input_datetime.victron_evcs_hc_night_stop\"),\n \"morning_start_time\": states(\"input_datetime.victron_evcs_morning_start\"),\n \"morning_stop_time\": states(\"input_datetime.victron_evcs_morning_stop\"),\n \"hc_midday_start_time\": states(\"input_datetime.victron_evcs_hc_midday_start\"),\n \"hc_midday_stop_time\": states(\"input_datetime.victron_evcs_hc_midday_stop\"),\n \"evening_start_time\": states(\"input_datetime.victron_evcs_evening_start\"),\n \"evening_stop_time\": states(\"input_datetime.victron_evcs_evening_stop\"),\n\n \"allow_grid_hc_night\": states(\"input_boolean.victron_evcs_allow_grid_hc_night\"),\n \"night_takeover_prev\": states(\"input_boolean.victron_evcs_night_grid_takeover_needed\"),\n \"night_grid_session_committed_prev\": states(\"input_boolean.victron_evcs_night_grid_session_committed\"),\n \"prev_surplus_state\": states(\"input_text.victron_evcs_surplus_state\"),\n \"evcs_night_session_start_soc\": states(\"input_text.victron_evcs_night_session_start_soc\"),\n \"evcs_night_session_start_at\": states(\"input_text.victron_evcs_night_session_start_at\"),\n\n \"balance_required_soc_prudent\": states(\"input_number.evcs_balance_required_soc_prudent\"),\n \"night_battery_charge_enabled\": states(\"input_boolean.victron_evcs_night_battery_charge\"),\n \"night_battery_done_prev\": states(\"input_boolean.victron_evcs_night_battery_done\"),\n \"balance_required_soc\": states(\"input_number.evcs_balance_required_soc\"),\n \"balance_energy_to_cover_kwh\": states(\"input_number.evcs_balance_energy_to_cover_kwh\"),\n \"balance_grid_energy_needed_kwh\": states(\"input_number.evcs_balance_grid_energy_needed_kwh\"),\n \"dvcc_max_charge_current_a\": states(\"sensor.victron_max_charge_current_setting\"),\n \"balance_status\": states(\"input_text.evcs_balance_status\"),\n \"balance_last_run\": states(\"input_text.evcs_balance_last_run\"),\n \"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\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\nconst batteryMorningTargetPct = balanceMorningTargetApplied\n ? Math.min(90, Math.max(batteryMorningTargetBasePct, 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\nconst batteryRecoveryUrgent =\n nightSessionActive &&\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;\nconst sessionFresh =\n parsed.evcsVehicleConnected &&\n Number.isFinite(parsed.evcsChargingTimeSec) &&\n parsed.evcsChargingTimeSec <= P11_FRESH_SESSION_MAX_SEC;\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.\nconst nightBatterySurplusExhausted =\n params.nightBatteryDonePrev ||\n (nightSessionActive && parsed.soc <= nightBatterySurplusFloorPct);\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.\nconst nightGridHcPreferred =\n nightSessionActive &&\n params.allowGridHcNight &&\n !vehicleChargeCompleted &&\n !nightBatterySurplusAvailable;\n\nconst batteryAssistAllowed =\n nightSessionActive &&\n !nightGridHcPreferred &&\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.\nconst nightGridSessionCommitted =\n profile === 'night_hc' &&\n (nightGridSessionUncovered || 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 : batteryAssistAllowed\n ? 'battery_assist_night'\n : (\n batteryBelowHardFloor ||\n batteryBelowMorningTarget ||\n batteryRecoveryUrgent\n )\n ? 'grid_forced'\n : 'grid_hc_allowed';\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 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 },\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 nightGridSessionCommitted,\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\nconst batteryMorningTargetPct = balanceMorningTargetApplied\n ? Math.min(90, Math.max(batteryMorningTargetBasePct, 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\nconst batteryRecoveryUrgent =\n nightSessionActive &&\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;\nconst sessionFresh =\n parsed.evcsVehicleConnected &&\n Number.isFinite(parsed.evcsChargingTimeSec) &&\n parsed.evcsChargingTimeSec <= P11_FRESH_SESSION_MAX_SEC;\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.\nconst nightBatterySurplusExhausted =\n params.nightBatteryDonePrev ||\n (nightSessionActive && parsed.soc <= nightBatterySurplusFloorPct);\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.\nconst nightGridHcPreferred =\n nightSessionActive &&\n params.allowGridHcNight &&\n !vehicleChargeCompleted &&\n !nightBatterySurplusAvailable;\n\nconst batteryAssistAllowed =\n nightSessionActive &&\n !nightGridHcPreferred &&\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.\nconst nightGridSessionCommitted =\n profile === 'night_hc' &&\n (nightGridSessionUncovered || 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 : batteryAssistAllowed\n ? 'battery_assist_night'\n : (\n batteryBelowHardFloor ||\n batteryBelowMorningTarget ||\n batteryRecoveryUrgent\n )\n ? 'grid_forced'\n : 'grid_hc_allowed';\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 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 nightGridSessionCommitted,\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,