From fc49e6c31afadbf6e547e5119c624ba2422ce370 Mon Sep 17 00:00:00 2001 From: Tallagrand Thierry Date: Mon, 31 Aug 2026 20:03:23 +0200 Subject: [PATCH] Sequence d ouverture : baisser le courant avant que le reseau parte Le plafond majore (28 A en BOOST) n est legitime que tant que le reseau alimente AC-IN : sinon les deux MultiPlus portent seuls la voiture plus la maison, le mecanisme du W-105. Personne ne baissait avant l ouverture -- c est l hysteresis qui ouvre, l EVCS l apprenait apres coup -- et quand il l apprenait, la reduction attendait le cooldown de courant et descendait d un pas a la fois. Correctif sans poignee de main ni helper nouveau : le plafond majore exige que l EVCS DEMANDE lui-meme le reseau. Sa demande tombe au cycle ou la fenetre se ferme (30 s), l hysteresis ne peut ouvrir qu ensuite (override disparu PUIS min_on, cycle 1 min) : l EVCS baisse toujours avant, mecaniquement. Second volet : un plafond n est pas une cible qu on approche. Reduction immediate et directe au plafond, sans cooldown. Le calcul du plafond a ete deplace apres sourceMode dans WINDOWS_SOURCEV2 ; rien entre les deux ne le lisait, verifie avant de bouger. Helper a 20 A par defaut : comportement inchange. Le correctif ne mord que sur le BOOST 28 A, le cas dangereux. Golden 48 -> 51, chaque cas apparie a son contrefactuel. Mutations des deux volets separement : 1 puis 2 echecs cibles, restauration 51/51. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01KKzkkVTG6KXnGXFFC7X9XJ --- HANDOFF.md | 75 ++++++++++++++ src/nodes/DECISION_ENGINEV2.js | 22 +++-- src/nodes/WINDOWS_SOURCEV2.js | 64 ++++++++---- tests/golden/scenarios.mjs | 103 ++++++++++++++++++++ workflows/evcs-surplus-controller-v2.6.json | 6 +- 5 files changed, 238 insertions(+), 32 deletions(-) diff --git a/HANDOFF.md b/HANDOFF.md index b2b5d15..2c2920e 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -1951,3 +1951,78 @@ restant verts puisqu'ils n'en dépendent pas. Restauration → 48/48. --- *Document mis à jour le : 31/08/2026 (~18:20) — fenêtre d'établissement déployée dans src/, golden 48/48, PUT n8n en attente de validation* + +## 30. Session du 31/08/2026 (suite) — Séquence d'ouverture du contacteur + +### Le défaut, en deux morceaux +Le plafond de courant EVCS majoré (P5, `input_number.evcs_plafond_courant_edf_on`, +jusqu'à **28 A en BOOST**) n'est légitime que tant que le réseau alimente AC-IN : +hors réseau, les deux MultiPlus portent seuls la voiture **plus** la maison. +C'est le mécanisme du W-105 déjà consigné (§3, maintenance du 09/07). + +1. **Personne ne baissait avant l'ouverture.** C'est l'hystérésis (cycle 1 min) + qui ouvre le contacteur ; le contrôleur EVCS ne l'apprenait qu'après coup, via + `parsed.acInAvailable`. Les deux automates n'ont aucune poignée de main. +2. **Et quand il l'apprenait, il ne baissait pas vite.** La branche + `charging_step_down_system_cap` de `DECISION_ENGINEV2` exigeait + `lifecycle.currentCooldownElapsed` — un compteur de **confort** — et ne + descendait que de `currentStepDownA` par cycle. De 28 A à 20 A : plusieurs + cooldowns. L'exposition ne se comptait donc pas en secondes mais en minutes. + +### Le correctif +**Aucune poignée de main entre les deux automates, aucun helper nouveau.** Le +plafond majoré exige désormais que **l'EVCS demande lui-même le réseau** : + +```js +const evcsGridCeilingAllowed = + parsed.acInAvailable && + (sourceMode === 'grid_forced' || sourceMode === 'grid_hc_allowed' || forceGridRequested); +``` + +L'ordonnancement devient mécanique : la demande de l'EVCS tombe au cycle où la +fenêtre se ferme (30 s), alors que l'hystérésis ne peut ouvrir qu'**ensuite** +(sa branche `turn_off_recovered` exige justement que l'override ait disparu, puis +min_on écoulé, sur un cycle d'une minute). L'EVCS baisse donc toujours avant, +sans que rien ne soit coordonné. + +⚠️ **Le calcul du plafond a été DÉPLACÉ après le calcul de `sourceMode`** dans +`WINDOWS_SOURCEV2` (juste avant `const operatingMode = profile;`). Vérifié avant +de bouger : `physicalCapA`, `canRunAtMinCurrent` et les quatre variables +intermédiaires n'étaient utilisés qu'à leur définition et dans l'export — rien +entre les deux ne les lisait. Ne pas les remonter. + +Second volet, dans `DECISION_ENGINEV2` : **un plafond n'est pas une cible qu'on +approche, c'est une limite qu'on respecte.** La réduction ne dépend plus du +cooldown et descend directement au plafond (`decision: 'step_down_fast'`, valeur +déjà utilisée par la branche surcharge). Réduire est toujours sûr ; c'est la +montée qui doit être temporisée, et elle l'est. + +### Portée réelle, et ce qui reste exposé +Avec le helper à sa valeur par défaut (20 A), **le comportement est strictement +inchangé** : le plafond valait déjà 20 A dans les deux branches. Le correctif ne +mord que sur le BOOST 28 A, c'est-à-dire exactement le cas dangereux. + +Reste irréductible : une ouverture **manuelle** (EDF URGENCE) ou une perte +physique du réseau ne se laissent pas anticiper. L'exposition y passe de +plusieurs minutes à **≤ 30 s plus le temps de rampe de la borne**, ce qui est le +minimum atteignable sans signal matériel. + +### Golden 48 → 51 +| scénario | attendu | +|---|---| +| `ouverture-plafond-tombe-hors-demande-reseau` (AC-IN présent, BOOST 28, matin solaire, cooldown NON écoulé) | `charging_step_down_system_cap` → **20 A immédiatement** | +| `ouverture-plafond-maintenu-en-demande-reseau` (CONTREFACTUEL, `force_grid_now`) | `charging_hold_grid` **28 A**, BOOST préservé | +| `ouverture-reseau-disparu-reduction-immediate` (AC-IN absent, cooldown non écoulé) | réduction immédiate à 20 A | + +Preuve par mutation, les deux volets séparément : +- plafond majoré reconditionné au seul `acInAvailable` → 1 échec ciblé ; +- réduction remise sous `currentCooldownElapsed` → 2 échecs ciblés ; +- restauration → 51/51. + +### Suite +3. priorité solaire pour la voiture à batterie pleine (coût marginal nul : + 6-7 trackers sur 7 bridés = ressource refusée) et interdiction de fermer le + contacteur pour charger une voiture en journée batterie pleine (~150 Wh). + +--- +*Document mis à jour le : 31/08/2026 (~19:00) — séquence d'ouverture corrigée, golden 51/51, PUT n8n en attente de validation* diff --git a/src/nodes/DECISION_ENGINEV2.js b/src/nodes/DECISION_ENGINEV2.js index b300150..5642c0b 100644 --- a/src/nodes/DECISION_ENGINEV2.js +++ b/src/nodes/DECISION_ENGINEV2.js @@ -834,18 +834,20 @@ else if (cloud.sessionObservedActive) { } } - else if ((derived.currentReferenceA > derived.physicalCapA) && lifecycle.currentCooldownElapsed) { + // SÉQUENCE D'OUVERTURE (31/08/2026) : un plafond n'est pas une cible qu'on + // approche, c'est une limite qu'on respecte. Cette réduction attendait le + // cooldown de courant — un compteur de confort — et ne descendait que d'un + // pas à la fois : de 28 A à 20 A elle prenait plusieurs cooldowns, pendant + // lesquels les onduleurs portaient un courant qu'ils n'ont plus le droit de + // porter (le plafond retombe à la disparition du réseau). Désormais : + // descente IMMÉDIATE et DIRECTE au plafond. Réduire est toujours sûr ; c'est + // la montée qui doit être temporisée, et elle l'est. + else if (derived.currentReferenceA > derived.physicalCapA) { setOn({ stateName: 'charging_step_down_system_cap', - decisionName: 'step_down', - currentA: Math.max( - params.currentMinA, - Math.min( - derived.currentReferenceA - Math.max(params.currentStepDownA, 2), - derived.physicalCapA - ) - ), - reasonText: 'Réduction pour respecter la capacité système', + decisionName: 'step_down_fast', + currentA: Math.max(params.currentMinA, derived.physicalCapA), + reasonText: `Réduction immédiate au plafond système ${derived.physicalCapA}A`, dynamic: true, mustStopFlag: false, }); diff --git a/src/nodes/WINDOWS_SOURCEV2.js b/src/nodes/WINDOWS_SOURCEV2.js index a69c4f3..800ebbc 100644 --- a/src/nodes/WINDOWS_SOURCEV2.js +++ b/src/nodes/WINDOWS_SOURCEV2.js @@ -117,25 +117,6 @@ const vebusNonEvcsW = Math.max(0, parsed.vebusAcOutPowerW - parsed.evcsAcPowerW) const totalVebusSoftCeilingW = Math.max(6500, params.acLoadsMaxSafeW || 8500); const totalVebusHardCeilingW = totalVebusSoftCeilingW + 400; -// P5 : plafond EVCS configurable quand le réseau alimente réellement AC-IN -// (input_number.evcs_plafond_courant_edf_on). Hors réseau : plafond câblé prudent. -const evcsDedicatedCapBaseW = Math.max(0, cfg.evcsDedicatedMaxW - cfg.systemReserveW); -const safeEvcsByDedicatedCapW = parsed.acInAvailable - ? Math.max(0, params.gridCurrentMaxA * acVoltageRef) - : evcsDedicatedCapBaseW; -const safeEvcsByVebusW = Math.max(0, totalVebusSoftCeilingW - vebusNonEvcsW - cfg.systemReserveW); -const safeEvcsCombinedW = Math.max(0, Math.min(safeEvcsByDedicatedCapW, safeEvcsByVebusW)); - -const systemCapCurrentA = Math.max(0, Math.floor(safeEvcsCombinedW / acVoltageRef)); -const physicalCapA = clamp( - systemCapCurrentA, - 0, - parsed.acInAvailable - ? Math.max(params.currentMaxAutoA, params.gridCurrentMaxA) - : params.currentMaxAutoA -); -const canRunAtMinCurrent = physicalCapA >= params.currentMinA; - // ===================================================== // FENETRE D'ETABLISSEMENT DU RESEAU (31/08/2026) // ===================================================== @@ -779,6 +760,50 @@ if (dayVehicleFullIdle && profile !== 'night_hc') { sourceMode = 'blocked'; } +// ===================================================== +// PLAFOND DE COURANT EVCS — calculé APRÈS sourceMode (31/08/2026) +// ===================================================== +// P5 : plafond majoré (input_number.evcs_plafond_courant_edf_on, jusqu'à 28 A +// en BOOST) quand le réseau alimente réellement AC-IN ; sinon plafond câblé +// prudent, celui que les deux MultiPlus peuvent porter SEULS. +// +// SÉQUENCE D'OUVERTURE (31/08/2026) — pourquoi ce bloc a été déplacé ici. +// Le plafond majoré n'est légitime que tant que le réseau est là. Or c'est +// l'hystérésis (cycle 1 min) qui ouvre le contacteur, et le contrôleur EVCS ne +// l'apprenait qu'après coup : pendant ce temps les deux onduleurs portaient +// seuls la voiture au courant majoré plus la maison — le mécanisme du W-105. +// +// Correctif sans poignée de main entre les deux automates : le plafond majoré +// exige désormais que l'EVCS DEMANDE lui-même le réseau. Sa demande tombe au +// cycle où la fenêtre se ferme (30 s), alors que l'hystérésis ne peut ouvrir +// qu'ensuite (override disparu ET min_on écoulé, cycle 1 min). L'EVCS baisse +// donc AVANT, mécaniquement, sans rien coordonner. +// Le calcul doit venir après sourceMode ; rien entre les deux ne l'utilisait. +const evcsGridCeilingAllowed = + parsed.acInAvailable && + ( + sourceMode === 'grid_forced' || + sourceMode === 'grid_hc_allowed' || + forceGridRequested + ); + +const evcsDedicatedCapBaseW = Math.max(0, cfg.evcsDedicatedMaxW - cfg.systemReserveW); +const safeEvcsByDedicatedCapW = evcsGridCeilingAllowed + ? Math.max(0, params.gridCurrentMaxA * acVoltageRef) + : evcsDedicatedCapBaseW; +const safeEvcsByVebusW = Math.max(0, totalVebusSoftCeilingW - vebusNonEvcsW - cfg.systemReserveW); +const safeEvcsCombinedW = Math.max(0, Math.min(safeEvcsByDedicatedCapW, safeEvcsByVebusW)); + +const systemCapCurrentA = Math.max(0, Math.floor(safeEvcsCombinedW / acVoltageRef)); +const physicalCapA = clamp( + systemCapCurrentA, + 0, + evcsGridCeilingAllowed + ? Math.max(params.currentMaxAutoA, params.gridCurrentMaxA) + : params.currentMaxAutoA +); +const canRunAtMinCurrent = physicalCapA >= params.currentMinA; + const operatingMode = profile; // ===================================================== @@ -827,6 +852,7 @@ return [{ systemCapCurrentA, physicalCapA, canRunAtMinCurrent, + evcsGridCeilingAllowed, gridSettling, gridTransitionAgeSec: parsed.gridTransitionAgeSec, endDayGuardSoc, diff --git a/tests/golden/scenarios.mjs b/tests/golden/scenarios.mjs index 5e9c87b..08b733b 100644 --- a/tests/golden/scenarios.mjs +++ b/tests/golden/scenarios.mjs @@ -1203,4 +1203,107 @@ export const scenarios = [ takeoverHelperService: "turn_off", }, }, + // ======= SÉQUENCE D'OUVERTURE DU CONTACTEUR (31/08/2026) ======= + // Le plafond majoré (28 A en BOOST) n'est légitime que tant que le réseau est + // là. C'est l'hystérésis qui ouvre, une minute après que la demande réseau de + // l'EVCS a disparu : l'EVCS doit donc baisser AVANT, de lui-même. + { + id: 'ouverture-plafond-tombe-hors-demande-reseau', + label: "Matin solaire, AC-IN encore présent mais l'EVCS ne demande plus le réseau : le plafond retombe à 20 A et la voiture est réduite IMMÉDIATEMENT", + time: MORNING, + overrides: { + evcs_vehicle_connected: 'on', evcs_device_connected: 'on', + evcs_charging_switch: 'on', control_latch: 'on', charge_permission_now: 'on', + last_start_at: '2026-08-08 08:00:00', + // BOOST EDF : plafond réseau 28 A, voiture effectivement à 28 A. + grid_current_max_a: '28', current_max_auto_a: '22', + evcs_current_a: '28', evcs_set_current_a: '28', evcs_command_current_a: '28', + evcs_ac_power_w: '6440', + house_ac_power_w: '8440', vebus_ac_out_l1_power_w: '8440', + grid_state: 'on', victron_acin1_available: '1', grid_power_w: '2000', + soc_percent: '60', pv_power_w: '4500', pv_power_10m_avg_w: '4200', + battery_current_a: '10', + // Cooldown de courant NON écoulé : la réduction ne doit pas l'attendre. + last_current_change_at: '2026-08-08 08:59:50', + acin1_last_changed_ts: String(MORNING_TS - 4000), + grid_switch_last_changed_ts: String(MORNING_TS - 4000), + }, + expect: { + profile: "morning_solar", + sourceMode: "solar_only", + state: "charging_step_down_system_cap", + decision: "step_down_fast", + desiredCharging: true, + currentA: 20, + takeoverNeeded: false, + edfOnAction: false, + takeoverHelperService: "turn_off", + }, + }, + { + id: 'ouverture-plafond-maintenu-en-demande-reseau', + label: "CONTREFACTUEL : force_grid_now, l'EVCS demande le réseau → le plafond BOOST 28 A est conservé, aucune réduction", + time: MORNING, + overrides: { + evcs_vehicle_connected: 'on', evcs_device_connected: 'on', + evcs_charging_switch: 'on', control_latch: 'on', charge_permission_now: 'on', + last_start_at: '2026-08-08 08:00:00', + // BOOST EDF : plafond réseau 28 A, voiture effectivement à 28 A. + grid_current_max_a: '28', current_max_auto_a: '22', + evcs_current_a: '28', evcs_set_current_a: '28', evcs_command_current_a: '28', + evcs_ac_power_w: '6440', + house_ac_power_w: '8440', vebus_ac_out_l1_power_w: '8440', + grid_state: 'on', victron_acin1_available: '1', grid_power_w: '2000', + victron_evcs_strategy: 'force_grid_now', + soc_percent: '60', pv_power_w: '4500', pv_power_10m_avg_w: '4200', + battery_current_a: '10', + last_current_change_at: '2026-08-08 08:59:50', + acin1_last_changed_ts: String(MORNING_TS - 4000), + grid_switch_last_changed_ts: String(MORNING_TS - 4000), + }, + expect: { + profile: "morning_solar", + sourceMode: "grid_forced", + state: "charging_hold_grid", + decision: "hold", + desiredCharging: true, + currentA: 28, + takeoverNeeded: false, + edfOnAction: false, + takeoverHelperService: "turn_off", + }, + }, + { + id: 'ouverture-reseau-disparu-reduction-immediate', + label: 'Réseau DISPARU : plafond câblé 20 A, voiture encore à 28 A, cooldown non écoulé → réduction immédiate, sans attendre le compteur de confort', + time: MORNING, + overrides: { + evcs_vehicle_connected: 'on', evcs_device_connected: 'on', + evcs_charging_switch: 'on', control_latch: 'on', charge_permission_now: 'on', + last_start_at: '2026-08-08 08:00:00', + // BOOST EDF : plafond réseau 28 A, voiture effectivement à 28 A. + grid_current_max_a: '28', current_max_auto_a: '22', + evcs_current_a: '28', evcs_set_current_a: '28', evcs_command_current_a: '28', + evcs_ac_power_w: '6440', + house_ac_power_w: '8440', vebus_ac_out_l1_power_w: '8440', + grid_state: 'on', victron_acin1_available: '1', grid_power_w: '2000', + grid_state: 'off', victron_acin1_available: '0', grid_power_w: '0', + soc_percent: '60', pv_power_w: '4500', pv_power_10m_avg_w: '4200', + battery_current_a: '10', + last_current_change_at: '2026-08-08 08:59:50', + acin1_last_changed_ts: String(MORNING_TS - 20), + grid_switch_last_changed_ts: String(MORNING_TS - 20), + }, + expect: { + profile: "morning_solar", + sourceMode: "solar_only", + state: "charging_step_down_system_cap", + decision: "step_down_fast", + desiredCharging: true, + currentA: 20, + takeoverNeeded: false, + edfOnAction: false, + takeoverHelperService: "turn_off", + }, + }, ]; diff --git a/workflows/evcs-surplus-controller-v2.6.json b/workflows/evcs-surplus-controller-v2.6.json index 2b63b11..3883921 100644 --- a/workflows/evcs-surplus-controller-v2.6.json +++ b/workflows/evcs-surplus-controller-v2.6.json @@ -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 \"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 {# Fenetre d'etablissement (31/08/2026) : a chaque fermeture du contacteur, le\n reseau reprend la charge de la maison et le solaire bascule vers la batterie\n -- ou se fait brider s'il n'y a plus de tampon. Pendant ~2 min 30, PV, import\n et courant batterie ne decrivent PAS un regime permanent. Sans ces horodatages\n la boucle de 30 s prend cinq decisions sur des mesures fausses. #}\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\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_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 \"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 \"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 \"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 \"advisor_apply_night_target\": states(\"input_boolean.evcs_advisor_apply_night_target\"),\n \"advisor_night_target_soc\": states(\"input_number.evcs_advisor_night_target_soc\"),\n \"advisor_status\": states(\"input_text.evcs_advisor_status\"),\n \"advisor_last_run\": states(\"input_text.evcs_advisor_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 \"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\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_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 \"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 \"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 \"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 \"advisor_apply_night_target\": states(\"input_boolean.evcs_advisor_apply_night_target\"),\n \"advisor_night_target_soc\": states(\"input_number.evcs_advisor_night_target_soc\"),\n \"advisor_status\": states(\"input_text.evcs_advisor_status\"),\n \"advisor_last_run\": states(\"input_text.evcs_advisor_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// P5 : plafond EVCS configurable quand le réseau alimente réellement AC-IN\n// (input_number.evcs_plafond_courant_edf_on). Hors réseau : plafond câblé prudent.\nconst evcsDedicatedCapBaseW = Math.max(0, cfg.evcsDedicatedMaxW - cfg.systemReserveW);\nconst safeEvcsByDedicatedCapW = parsed.acInAvailable\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 parsed.acInAvailable\n ? Math.max(params.currentMaxAutoA, params.gridCurrentMaxA)\n : params.currentMaxAutoA\n);\nconst canRunAtMinCurrent = physicalCapA >= params.currentMinA;\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// ADVISOR IA — PREMIER CÂBLAGE RÉEL (09/08/2026, HANDOFF §20)\n// La cible SOC matin suit la recommandation de l'advisor UNIQUEMENT si :\n// - toggle input_boolean.evcs_advisor_apply_night_target ON (rollback 1 clic)\n// - dernier run advisor en statut 'ok'\n// - recommandation fraîche (≤ 120 min, l'advisor tourne toutes les 15 min)\n// - valeur bornée [max(22, hard floor), 90]\n// Sinon : règles forecast historiques (comportement inchangé).\n// =====================================================\nconst ADVISOR_TARGET_MAX_AGE_MIN = 120;\nconst advisorTargetAgeMin = minutesSince(params.advisorLastRunMs, nowMs);\nconst advisorNightTargetApplied =\n params.advisorApplyNightTarget &&\n params.advisorStatusOk &&\n Number.isFinite(params.advisorNightTargetSocPct) &&\n advisorTargetAgeMin !== null &&\n advisorTargetAgeMin <= ADVISOR_TARGET_MAX_AGE_MIN;\n\nconst batteryMorningTargetPct =\n advisorNightTargetApplied\n ? clamp(\n params.advisorNightTargetSocPct,\n Math.max(22, batteryHardFloorPct),\n 90\n )\n : batteryMorningTargetRulePct;\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).\nconst evcsStatusCharged =\n !sessionFresh &&\n Number.parseFloat(parsed.evcsStatusRaw) === 3 &&\n Math.max(0, parsed.evcsAcPowerW || 0) <= 300;\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\nconst nightGridHcPreferred =\n nightSessionActive &&\n params.allowGridHcNight &&\n !vehicleChargeCompleted;\n\nconst batteryAssistAllowed =\n nightSessionActive &&\n !nightGridHcPreferred &&\n parsed.soc > batteryHardFloorPct &&\n parsed.soc > gridTakeoverTriggerPct &&\n !nightTakeoverLatched &&\n !batteryRecoveryUrgent;\n\nconst nightGridTakeoverNeeded =\n nightSessionActive &&\n (\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\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\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 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 batteryMorningTargetRulePct,\n advisorNightTargetApplied,\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 }\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\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// ADVISOR IA — PREMIER CÂBLAGE RÉEL (09/08/2026, HANDOFF §20)\n// La cible SOC matin suit la recommandation de l'advisor UNIQUEMENT si :\n// - toggle input_boolean.evcs_advisor_apply_night_target ON (rollback 1 clic)\n// - dernier run advisor en statut 'ok'\n// - recommandation fraîche (≤ 120 min, l'advisor tourne toutes les 15 min)\n// - valeur bornée [max(22, hard floor), 90]\n// Sinon : règles forecast historiques (comportement inchangé).\n// =====================================================\nconst ADVISOR_TARGET_MAX_AGE_MIN = 120;\nconst advisorTargetAgeMin = minutesSince(params.advisorLastRunMs, nowMs);\nconst advisorNightTargetApplied =\n params.advisorApplyNightTarget &&\n params.advisorStatusOk &&\n Number.isFinite(params.advisorNightTargetSocPct) &&\n advisorTargetAgeMin !== null &&\n advisorTargetAgeMin <= ADVISOR_TARGET_MAX_AGE_MIN;\n\nconst batteryMorningTargetPct =\n advisorNightTargetApplied\n ? clamp(\n params.advisorNightTargetSocPct,\n Math.max(22, batteryHardFloorPct),\n 90\n )\n : batteryMorningTargetRulePct;\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).\nconst evcsStatusCharged =\n !sessionFresh &&\n Number.parseFloat(parsed.evcsStatusRaw) === 3 &&\n Math.max(0, parsed.evcsAcPowerW || 0) <= 300;\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\nconst nightGridHcPreferred =\n nightSessionActive &&\n params.allowGridHcNight &&\n !vehicleChargeCompleted;\n\nconst batteryAssistAllowed =\n nightSessionActive &&\n !nightGridHcPreferred &&\n parsed.soc > batteryHardFloorPct &&\n parsed.soc > gridTakeoverTriggerPct &&\n !nightTakeoverLatched &&\n !batteryRecoveryUrgent;\n\nconst nightGridTakeoverNeeded =\n nightSessionActive &&\n (\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\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 batteryMorningTargetRulePct,\n advisorNightTargetApplied,\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 }\n }\n}];\n" }, "type": "n8n-nodes-base.code", "typeVersion": 2, @@ -178,7 +178,7 @@ }, { "parameters": { - "jsCode": "function 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 round3(v) {\n return Math.round(v * 1000) / 1000;\n}\n\nfunction to2(n) {\n return String(n).padStart(2, '0');\n}\n\nfunction finiteOr(v, fallback) {\n return Number.isFinite(v) ? v : fallback;\n}\n\nfunction sigmoid(x) {\n return 1 / (1 + Math.exp(-x));\n}\n\nfunction alphaFromTau(dtSec, tauSec) {\n const dt = Math.max(1, finiteOr(dtSec, 60));\n const tau = Math.max(1, finiteOr(tauSec, 60));\n return Math.min(1, Math.max(0.001, 1 - Math.exp(-dt / tau)));\n}\n\nfunction ema(prev, sample, alpha) {\n if (!Number.isFinite(sample)) return Number.isFinite(prev) ? prev : 0;\n if (!Number.isFinite(prev)) return sample;\n return prev + alpha * (sample - prev);\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 now,\n nowMs,\n parsed,\n params,\n history,\n strategy,\n lifecycle,\n context,\n derived,\n locks,\n cloud,\n modes,\n safeguards,\n conditions,\n batteryTune,\n nightEvcs,\n} = prev;\n\n// =====================================================\n// DECISION INIT\n// =====================================================\nlet desiredCharging = false;\nlet desiredPermission = false;\nlet desiredLatch = false;\nlet desiredCurrentA = params.currentMinA;\n\nlet state = 'idle';\nlet currentDecision = 'hold';\nlet dynamicOk = false;\nlet mustStop = false;\n\nlet reason = [];\n\nlet setCloudDebtStartedAt = false;\nlet setProbeStartedAt = false;\n\n// EVCS night session memory\nlet setNightSessionStart = false;\nlet clearNightSessionStart = false;\n\nlet notifyWanted = false;\nlet notifyTitle = 'Victron EVCS Surplus';\nlet notifyMessage = '';\n\nlet nextBlockReason = 'none';\nlet nextBlockedUntilStamp = now.stamp;\n\n// =====================================================\n// HELPERS\n// =====================================================\nfunction setOff({\n stateName,\n decisionName = 'stop',\n reasonText,\n dynamic = false,\n mustStopFlag = false,\n blockReason = 'none',\n blockedUntilStamp = now.stamp,\n}) {\n desiredCharging = false;\n desiredPermission = false;\n desiredLatch = false;\n desiredCurrentA = params.currentMinA;\n\n state = stateName;\n currentDecision = decisionName;\n dynamicOk = dynamic;\n mustStop = mustStopFlag;\n nextBlockReason = blockReason;\n nextBlockedUntilStamp = blockedUntilStamp;\n\n if (reasonText) reason.push(reasonText);\n}\n\nfunction setOn({\n stateName,\n decisionName = 'hold',\n currentA = params.currentMinA,\n reasonText,\n dynamic = true,\n mustStopFlag = false,\n}) {\n desiredCharging = true;\n desiredPermission = true;\n desiredLatch = true;\n desiredCurrentA = clamp(\n currentA,\n params.currentMinA,\n Math.max(params.currentMinA, derived.physicalCapA || params.currentMinA)\n );\n\n state = stateName;\n currentDecision = decisionName;\n dynamicOk = dynamic;\n mustStop = mustStopFlag;\n\n if (reasonText) reason.push(reasonText);\n}\n\n// =====================================================\n// SESSION MEMORY SIDE EFFECTS\n// =====================================================\nif (nightEvcs?.nightSessionNeedsCapture) {\n setNightSessionStart = true;\n}\n\nif (nightEvcs?.nightSessionNeedsClear) {\n clearNightSessionStart = true;\n}\n\n// =====================================================\n// MANUAL LOCAL CHARGE SEMANTICS\n// IMPORTANT:\n// force_charge_now = bypass fenêtres pour la charge locale,\n// sans imposer le réseau.\n// =====================================================\nconst manualChargeBypassWindows = !!context.manualChargeBypassWindows;\nconst manualChargeLocalRequested = !!context.manualChargeLocalRequested;\n\nconst manualLocalChargeAllowed =\n !!strategy.forceChargeRequested &&\n manualChargeBypassWindows &&\n manualChargeLocalRequested &&\n !safeguards.mustStopHard &&\n !!derived.canRunAtMinCurrent;\n\n// =====================================================\n// EVCS NUMERIC NO-DEMAND FILTER\n// Objectif:\n// détecter \"voiture branchée + session armée + commande claire\n// mais plus de demande réelle\" sans seuils fixes bruts.\n// IMPORTANT:\n// on n'utilise PAS le PV comme critère.\n// =====================================================\nconst prevDeliveryRatioEma = finiteOr(history.evcsDeliveryRatioEma, NaN);\nconst prevDeliveryNoiseEma = finiteOr(history.evcsDeliveryNoiseEma, NaN);\nconst prevNoDemandScoreEma = finiteOr(history.evcsNoDemandScoreEma, NaN);\nconst prevNoDemandSemanticActive = !!history.evcsNoDemandSemanticActive;\nconst prevNoDemandSinceMs = finiteOr(history.evcsNoDemandSinceMs, NaN);\nconst prevNoDemandFilterEvalMs = finiteOr(history.evcsNoDemandFilterEvalMs, NaN);\n\nconst noDemandDtSec =\n Number.isFinite(prevNoDemandFilterEvalMs) && Number.isFinite(nowMs)\n ? clamp((nowMs - prevNoDemandFilterEvalMs) / 1000, 1, 600)\n : 60;\n\n// Constantes dynamiques du filtre = temps de réponse du filtre,\n// pas des seuils métiers bruts.\nconst deliveryTauSec = Math.max(15, finiteOr(params.evcsDeliveryTauSec, 45));\nconst noiseTauSec = Math.max(deliveryTauSec, finiteOr(params.evcsDeliveryNoiseTauSec, 180));\nconst scoreTauSec = Math.max(20, finiteOr(params.evcsNoDemandScoreTauSec, 150));\nconst armTauSec = Math.max(20, finiteOr(params.evcsNoDemandArmTauSec, 180));\n\nconst alphaDelivery = alphaFromTau(noDemandDtSec, deliveryTauSec);\nconst alphaNoise = alphaFromTau(noDemandDtSec, noiseTauSec);\nconst alphaScore = alphaFromTau(noDemandDtSec, scoreTauSec);\n\nconst acVoltageRef = finiteOr(derived.acVoltageRef, 230);\n\nconst commandCurrentA = clamp(\n finiteOr(\n derived.currentReferenceA,\n finiteOr(\n parsed.evcsCommandCurrentA,\n finiteOr(parsed.evcsSetCurrentA, finiteOr(params.currentMinA, 6))\n )\n ),\n 0,\n 32\n);\n\nconst expectedPowerW = Math.max(1, commandCurrentA * acVoltageRef);\n\nconst realCurrentA = Math.abs(\n finiteOr(\n parsed.evcsCurrentRealA,\n acVoltageRef > 180\n ? finiteOr(parsed.evcsAcPowerW, 0) / acVoltageRef\n : 0\n )\n);\n\nconst realPowerW = Math.max(0, finiteOr(parsed.evcsAcPowerW, 0));\n\nconst currentDeliveryRatioRaw = clamp(\n realCurrentA / Math.max(commandCurrentA, 0.1),\n 0,\n 1.25\n);\n\nconst powerDeliveryRatioRaw = clamp(\n realPowerW / expectedPowerW,\n 0,\n 1.25\n);\n\nconst deliveryRatioRaw = clamp(\n Math.max(currentDeliveryRatioRaw, powerDeliveryRatioRaw),\n 0,\n 1\n);\n\nconst deliveryRatioEma = ema(prevDeliveryRatioEma, deliveryRatioRaw, alphaDelivery);\n\nconst deliveryResidualAbs = Math.abs(deliveryRatioRaw - deliveryRatioEma);\nconst deliveryNoiseEma = ema(prevDeliveryNoiseEma, deliveryResidualAbs, alphaNoise);\n\nconst adaptiveQuietBand = clamp(\n 0.02 + 3.0 * deliveryNoiseEma,\n 0.02,\n 0.35\n);\n\nconst noDemandEvidenceRaw = clamp(\n (adaptiveQuietBand - deliveryRatioEma) / adaptiveQuietBand,\n 0,\n 1\n);\n\nconst sessionElapsedSec = Math.max(\n 0,\n finiteOr(lifecycle.minutesSinceStart, 0) * 60\n);\n\nconst sessionArmFactor = 1 - Math.exp(-sessionElapsedSec / armTauSec);\n\nconst currentMinRefA = Math.max(1, finiteOr(params.currentMinA, 6));\nconst commandStrength = commandCurrentA / currentMinRefA;\n\nconst commandFactor = clamp(\n sigmoid((commandStrength - 1.0) * 2.2),\n 0,\n 1\n);\n\n// Source effectivement livrable.\n// IMPORTANT:\n// on évite de conclure \"voiture pleine\" si en réalité la source n'est\n// pas disponible (ex: attente grid takeover).\nconst manualLocalNoDemandEligible =\n manualLocalChargeAllowed &&\n (\n context.sourceMode === 'solar_only' ||\n context.sourceMode === 'solar_potential' ||\n context.sourceMode === 'blocked'\n );\n\nconst sourceDeliverableForNoDemand =\n manualLocalNoDemandEligible\n ? true\n : (context.sourceMode === 'grid_hc_allowed' || context.sourceMode === 'grid_forced')\n ? !!parsed.gridAvailable\n : (context.sourceMode === 'solar_only' || context.sourceMode === 'solar_potential')\n ? !!conditions.solarHoldOk || !!conditions.solarStartOk\n : context.sourceMode === 'hybrid_probe'\n ? !!conditions.probeHoldGuardOk || !!conditions.probeSuccessNow || !!conditions.probeStartOk\n : context.sourceMode === 'battery_assist_night'\n ? !!conditions.batteryAssistNightHoldOk || !!conditions.batteryAssistNightStartOk\n : false;\n\nconst evcsSessionArmedForNoDemand =\n !!cloud.sessionObservedActive &&\n !!parsed.evcsVehicleConnected &&\n !!parsed.evcsChargingSwitchOn &&\n !!parsed.chargePermissionNow &&\n !!parsed.controlLatch &&\n commandCurrentA > 0 &&\n sourceDeliverableForNoDemand;\n\nconst noDemandScoreRaw = evcsSessionArmedForNoDemand\n ? clamp(\n noDemandEvidenceRaw * sessionArmFactor * commandFactor,\n 0,\n 1\n )\n : 0;\n\nconst noDemandScoreEma = ema(prevNoDemandScoreEma, noDemandScoreRaw, alphaScore);\n\nconst adaptiveDecisionCenter = clamp(\n 0.72 - 0.20 * deliveryNoiseEma,\n 0.55,\n 0.82\n);\n\nconst adaptiveDecisionBand = clamp(\n 0.16 + 0.80 * deliveryNoiseEma,\n 0.12,\n 0.34\n);\n\nconst noDemandScoreOn = clamp(\n adaptiveDecisionCenter + adaptiveDecisionBand / 2,\n 0.60,\n 0.95\n);\n\nconst noDemandScoreOff = clamp(\n adaptiveDecisionCenter - adaptiveDecisionBand / 2,\n 0.15,\n noDemandScoreOn - 0.05\n);\n\nlet evcsVehicleNoDemandSemantic = prevNoDemandSemanticActive;\n\nif (!evcsSessionArmedForNoDemand) {\n evcsVehicleNoDemandSemantic = false;\n} else if (prevNoDemandSemanticActive) {\n evcsVehicleNoDemandSemantic = noDemandScoreEma >= noDemandScoreOff;\n} else {\n evcsVehicleNoDemandSemantic = noDemandScoreEma >= noDemandScoreOn;\n}\n\nconst evcsNoDemandSinceMs =\n evcsVehicleNoDemandSemantic\n ? (\n prevNoDemandSemanticActive && Number.isFinite(prevNoDemandSinceMs)\n ? prevNoDemandSinceMs\n : nowMs\n )\n : null;\n\nconst evcsNoDemandSinceMinutes =\n Number.isFinite(evcsNoDemandSinceMs) && Number.isFinite(nowMs)\n ? Math.max(0, (nowMs - evcsNoDemandSinceMs) / 60000)\n : null;\n\nconst vehicleDemandFilter = {\n dtSec: round3(noDemandDtSec),\n\n commandCurrentA: round3(commandCurrentA),\n realCurrentA: round3(realCurrentA),\n expectedPowerW: round3(expectedPowerW),\n realPowerW: round3(realPowerW),\n\n currentDeliveryRatioRaw: round3(currentDeliveryRatioRaw),\n powerDeliveryRatioRaw: round3(powerDeliveryRatioRaw),\n deliveryRatioRaw: round3(deliveryRatioRaw),\n deliveryRatioEma: round3(deliveryRatioEma),\n deliveryNoiseEma: round3(deliveryNoiseEma),\n\n adaptiveQuietBand: round3(adaptiveQuietBand),\n noDemandEvidenceRaw: round3(noDemandEvidenceRaw),\n\n sessionElapsedSec: round3(sessionElapsedSec),\n sessionArmFactor: round3(sessionArmFactor),\n commandFactor: round3(commandFactor),\n sourceDeliverableForNoDemand,\n evcsSessionArmedForNoDemand,\n\n noDemandScoreRaw: round3(noDemandScoreRaw),\n noDemandScoreEma: round3(noDemandScoreEma),\n\n noDemandScoreOn: round3(noDemandScoreOn),\n noDemandScoreOff: round3(noDemandScoreOff),\n\n evcsVehicleNoDemandSemantic,\n evcsNoDemandSinceMs: Number.isFinite(evcsNoDemandSinceMs) ? evcsNoDemandSinceMs : null,\n evcsNoDemandSinceMinutes: Number.isFinite(evcsNoDemandSinceMinutes)\n ? round3(evcsNoDemandSinceMinutes)\n : null,\n\n nextHistory: {\n evcsDeliveryRatioEma: round3(deliveryRatioEma),\n evcsDeliveryNoiseEma: round3(deliveryNoiseEma),\n evcsNoDemandScoreEma: round3(noDemandScoreEma),\n evcsNoDemandSemanticActive: evcsVehicleNoDemandSemantic,\n evcsNoDemandSinceMs: Number.isFinite(evcsNoDemandSinceMs) ? evcsNoDemandSinceMs : null,\n evcsNoDemandFilterEvalMs: nowMs\n }\n};\n\n// =====================================================\n// DECISION TREE\n// =====================================================\nif (!parsed.controllerEnabled) {\n desiredCharging = parsed.evcsChargingSwitchOn;\n desiredPermission = parsed.chargePermissionNow;\n desiredLatch = parsed.controlLatch;\n desiredCurrentA = clamp(\n derived.currentReferenceA || params.currentMinA,\n params.currentMinA,\n params.currentMaxAutoA\n );\n\n state = 'controller_disabled';\n currentDecision = 'hold';\n dynamicOk = false;\n mustStop = false;\n reason.push('Contrôleur désactivé');\n}\n\nelse if (strategy.isStopMode) {\n setOff({\n stateName: 'stopped_user',\n decisionName: 'stop_stop_mode',\n reasonText: 'Arrêt manuel via Stratégie STOP',\n dynamic: false,\n mustStopFlag: false,\n blockReason: 'manual_stop',\n blockedUntilStamp: now.stamp,\n });\n}\n\nelse if (parsed.evcsModeIsAuto) {\n setOff({\n stateName: 'mode_auto_conflict',\n decisionName: 'stop_auto_mode_conflict',\n reasonText: 'Borne en mode Auto interne',\n dynamic: false,\n mustStopFlag: true,\n blockReason: 'mode_auto_conflict',\n blockedUntilStamp: now.stamp,\n });\n}\n\nelse if (!parsed.evcsDeviceConnected || !parsed.evcsVehicleConnected) {\n setOff({\n stateName: 'vehicle_not_connected',\n decisionName: 'stop_not_connected',\n reasonText: 'Véhicule ou borne non connecté',\n dynamic: false,\n mustStopFlag: false,\n blockReason: 'vehicle_not_connected',\n blockedUntilStamp: now.stamp,\n });\n}\n\n// =====================================================\n// Fix F (09/08/2026) : MÉMOIRE \"CHARGE TERMINÉE\" PRIORITAIRE.\n// Incident 09/08 06:51→06:56 : la VW pleine relançait des micro-charges\n// (sur batterie, EDF OFF) → la garde mustStopHard émettait\n// 'battery_current_hard_stop' qui ÉCRASAIT l'état collant dans le helper\n// → completed perdu au cycle suivant → re-P7 → EDF rallumé (rebond).\n// Ici : tant que la session nuit est active et la charge terminée, l'état\n// collant est réécrit à CHAQUE cycle, AU-DESSUS des gardes. Aucune perte de\n// protection : ce setOff coupe la charge aussi fermement qu'un hard stop\n// (chg/perm/latch OFF + mustStop si la borne est encore armée).\n// =====================================================\nelse if (\n context.profile === 'night_hc' &&\n nightEvcs?.vehicleChargeCompleted &&\n !manualLocalChargeAllowed &&\n !strategy.forceGridRequested\n) {\n setOff({\n stateName: nightEvcs?.nightRecoveryDone\n ? 'night_recovery_done_idle'\n : 'night_session_done_idle',\n decisionName: cloud.sessionObservedActive ? 'stop_vehicle_full_or_paused' : 'hold',\n reasonText: nightEvcs?.nightRecoveryDone\n ? 'Charge véhicule terminée + batterie à la cible : veille nuit, réseau relâché'\n : 'Charge véhicule terminée : veille nuit, réseau relâché (recharge batterie si sous cible)',\n dynamic: false,\n mustStopFlag: !!cloud.sessionObservedActive,\n blockReason: 'none',\n blockedUntilStamp: now.stamp,\n });\n}\n\n// =====================================================\n// P9 (09/08/2026) : véhicule plein pendant la fenêtre \"départ imminent\"\n// (jour, hors HC) → veille COLLANTE 'departure_done_idle', réécrite chaque\n// cycle au-dessus des gardes (même principe que Fix F). Le réseau est\n// relâché, l'hystérésis coupe immédiatement (cut E2 étendu).\n// =====================================================\nelse if (\n nightEvcs?.departureChargeDone &&\n !manualLocalChargeAllowed &&\n !strategy.forceGridRequested\n) {\n setOff({\n stateName: 'departure_done_idle',\n decisionName: cloud.sessionObservedActive ? 'stop_vehicle_full_or_paused' : 'hold',\n reasonText: 'Charge terminée avant départ : veille, réseau relâché',\n dynamic: false,\n mustStopFlag: !!cloud.sessionObservedActive,\n blockReason: 'none',\n blockedUntilStamp: now.stamp,\n });\n}\n\n// =====================================================\n// P10 (09/08/2026) : véhicule plein en JOURNÉE → veille collante\n// 'day_vehicle_full_idle' jusqu'au débranchement. Évite les re-tentatives\n// solaires cycliques sur un véhicule plein (usure relais). Réécrite chaque\n// cycle au-dessus des gardes (principe Fix F). Échappatoires : force_*.\n// =====================================================\nelse if (\n nightEvcs?.dayVehicleFullIdle &&\n !manualLocalChargeAllowed &&\n !strategy.forceGridRequested\n) {\n setOff({\n stateName: 'day_vehicle_full_idle',\n decisionName: cloud.sessionObservedActive ? 'stop_vehicle_full_or_paused' : 'hold',\n reasonText: 'Véhicule plein : veille jour, aucune nouvelle tentative jusqu\\'au débranchement',\n dynamic: false,\n mustStopFlag: !!cloud.sessionObservedActive,\n blockReason: 'none',\n blockedUntilStamp: now.stamp,\n });\n}\n\nelse if (safeguards.mustStopHard) {\n setOff({\n stateName:\n derived.anyVebusLowBattery ? 'vebus_low_battery_hard_stop' :\n derived.vebusDcVoltageHardLow ? 'vebus_dc_voltage_hard_stop' :\n derived.vebusAcOutHardReached ? 'vebus_ac_hard_stop' :\n safeguards.batteryCurrentHardExceeded ? 'battery_current_hard_stop' :\n 'hard_stop',\n decisionName: 'stop_hard',\n reasonText: 'Hard stop de protection',\n dynamic: false,\n mustStopFlag: true,\n blockReason:\n safeguards.batteryCurrentHardExceeded ? 'battery_current_hard_stop' :\n derived.vebusDcVoltageHardLow ? 'vebus_dc_voltage_stop' :\n derived.anyVebusLowBattery ? 'vebus_low_battery_stop' :\n derived.vebusAcOutHardReached ? 'vebus_ac_hard_stop' :\n 'battery_protection_stop',\n blockedUntilStamp: formatParisStampFromMs(nowMs + 15 * 60000),\n });\n}\n\n// =====================================================\n// ACTIVE SESSION BRANCH\n// =====================================================\nelse if (cloud.sessionObservedActive) {\n if (cloud.enteringCloudDebt) {\n setCloudDebtStartedAt = true;\n }\n\n if (conditions.shouldStopOverloadAtMin) {\n setOff({\n stateName: 'vebus_overload_stop_min_current',\n decisionName: 'stop',\n reasonText: 'Arrêt: surcharge convertisseur persistante au courant mini',\n dynamic: false,\n mustStopFlag: true,\n blockReason: 'vebus_overload_persistent',\n blockedUntilStamp: formatParisStampFromMs(\n nowMs + batteryTune.global.overloadStopAfterSeconds * 1000\n ),\n });\n }\n\n else if (safeguards.gridBatteryProtectionStop) {\n setOff({\n stateName: 'stop_grid_battery_guard',\n decisionName: 'stop',\n reasonText: `Arrêt charge réseau: batterie trop sollicitée (${round1(parsed.batteryCurrentA)}A)`,\n dynamic: false,\n mustStopFlag: true,\n blockReason: 'grid_battery_guard',\n blockedUntilStamp: formatParisStampFromMs(nowMs + 20 * 60000),\n });\n }\n\n else if (conditions.probeFailedNow) {\n setOff({\n stateName: 'probe_failed_lock',\n decisionName: 'stop',\n reasonText: 'Mode sonde échoué',\n dynamic: false,\n mustStopFlag: true,\n blockReason: 'probe_failed',\n blockedUntilStamp: formatParisStampFromMs(nowMs + params.probeRetryMinutes * 60000),\n });\n }\n\n else if (derived.physicalCapA < params.currentMinA) {\n if (lifecycle.minRunElapsed) {\n setOff({\n stateName: 'stop_power_cap',\n decisionName: 'stop_power_cap',\n reasonText: 'Capacité système insuffisante',\n dynamic: false,\n mustStopFlag: true,\n blockReason: 'system_capacity_stop',\n blockedUntilStamp: formatParisStampFromMs(nowMs + 10 * 60000),\n });\n } else {\n setOn({\n stateName: 'hold_min_run_power_cap',\n decisionName: 'hold',\n currentA: params.currentMinA,\n reasonText: \"Maintien jusqu'au temps mini ON\",\n dynamic: false,\n mustStopFlag: false,\n });\n }\n }\n\n else if (conditions.probeWindowActive && !conditions.probeSuccessNow) {\n if (!conditions.probeHoldGuardOk) {\n setOff({\n stateName: 'probe_stop_guard',\n decisionName: 'stop',\n reasonText: 'Sonde interrompue: garde-fous non respectés',\n dynamic: false,\n mustStopFlag: true,\n blockReason: 'probe_failed',\n blockedUntilStamp: formatParisStampFromMs(nowMs + params.probeRetryMinutes * 60000),\n });\n } else {\n setOn({\n stateName: 'probe_hold',\n decisionName: 'hold',\n currentA: Math.min(params.probeCurrentA, derived.physicalCapA),\n reasonText: `Mode sonde actif à ${round1(Math.min(params.probeCurrentA, derived.physicalCapA))}A`,\n dynamic: true,\n mustStopFlag: false,\n });\n }\n }\n\n else {\n const activeSourceMode =\n conditions.probeSuccessNow\n ? 'solar_only'\n : context.sourceMode;\n\n const holdConditionsOk =\n (activeSourceMode === 'grid_hc_allowed' || activeSourceMode === 'grid_forced')\n ? conditions.gridHoldOk\n : (activeSourceMode === 'solar_only' || activeSourceMode === 'solar_potential')\n ? conditions.solarHoldOk\n : activeSourceMode === 'hybrid_probe'\n ? conditions.probeHoldGuardOk\n : activeSourceMode === 'battery_assist_night'\n ? conditions.batteryAssistNightHoldOk\n : false;\n\n const manualLocalHoldOverride =\n manualLocalChargeAllowed &&\n (\n activeSourceMode === 'solar_only' ||\n activeSourceMode === 'solar_potential' ||\n activeSourceMode === 'blocked'\n );\n\n const effectiveHoldConditionsOk =\n holdConditionsOk || manualLocalHoldOverride;\n\n // =================================================\n // VEHICLE FULL / PAUSED - NUMERIC FILTER DECISION\n // NB Fix F (09/08/2026) : le cas \"charge terminée en session nuit\" est\n // désormais intercepté PLUS HAUT dans la cascade (mémoire prioritaire),\n // avant les gardes. Ici ne restent que la détection VDF initiale et\n // les autres sources.\n // IMPORTANT:\n // - uniquement en session active\n // - uniquement si la source est réellement livrable\n // - sans utiliser le PV comme critère\n // =================================================\n if (\n vehicleDemandFilter.evcsVehicleNoDemandSemantic &&\n vehicleDemandFilter.sourceDeliverableForNoDemand\n ) {\n setOff({\n stateName: 'vehicle_full_or_paused',\n decisionName: 'stop_vehicle_full_or_paused',\n reasonText: 'Véhicule branché mais ne demande plus réellement de puissance',\n dynamic: false,\n mustStopFlag: false,\n blockReason: 'none',\n blockedUntilStamp: now.stamp,\n });\n\n reason.push(`DeliveryRatioEma=${vehicleDemandFilter.deliveryRatioEma}`);\n reason.push(`NoiseEma=${vehicleDemandFilter.deliveryNoiseEma}`);\n reason.push(`NoDemandScore=${vehicleDemandFilter.noDemandScoreEma}`);\n reason.push(`ScoreOn=${vehicleDemandFilter.noDemandScoreOn}`);\n reason.push(`ScoreOff=${vehicleDemandFilter.noDemandScoreOff}`);\n\n if (vehicleDemandFilter.evcsNoDemandSinceMinutes !== null) {\n reason.push(`NoDemand depuis ${round1(vehicleDemandFilter.evcsNoDemandSinceMinutes)} min`);\n }\n }\n\n else if (manualLocalHoldOverride && activeSourceMode === 'blocked') {\n setOn({\n stateName: 'charging_hold_manual_local',\n decisionName: 'hold',\n currentA: Math.min(derived.currentReferenceA, derived.physicalCapA),\n reasonText: 'Maintien manuel local hors fenêtre sans réseau',\n dynamic: true,\n mustStopFlag: false,\n });\n }\n\n else if (activeSourceMode === 'blocked') {\n if (lifecycle.minRunElapsed) {\n setOff({\n stateName: 'stop_source_lost',\n decisionName: 'stop',\n reasonText: 'Source autorisée perdue',\n dynamic: false,\n mustStopFlag: true,\n blockReason: context.profile === 'outside_window' ? 'outside_window' : 'source_unavailable',\n blockedUntilStamp:\n context.profile === 'outside_window' && Number.isFinite(locks.nextAllowedWindowStartMs)\n ? formatParisStampFromMs(locks.nextAllowedWindowStartMs)\n : formatParisStampFromMs(nowMs + params.minStopMinutes * 60000),\n });\n } else {\n setOn({\n stateName: 'hold_min_run_source_lost',\n decisionName: 'hold',\n currentA: Math.min(derived.currentReferenceA, derived.physicalCapA),\n reasonText: \"Source perdue mais maintien jusqu'au mini ON\",\n dynamic: false,\n mustStopFlag: false,\n });\n }\n }\n\n else if (\n (activeSourceMode === 'grid_hc_allowed' || activeSourceMode === 'grid_forced') &&\n !parsed.gridAvailable\n ) {\n if (lifecycle.minRunElapsed) {\n setOff({\n stateName:\n context.profile === 'night_hc'\n ? 'night_wait_grid_takeover_runtime'\n : 'wait_grid_source_runtime',\n decisionName: 'hold',\n reasonText:\n context.profile === 'night_hc'\n ? 'Relais réseau demandé pendant la session nuit EVCS'\n : 'Source réseau demandée mais pas encore disponible',\n dynamic: false,\n mustStopFlag: false,\n blockReason:\n context.profile === 'night_hc' ? 'grid_hc_unavailable' : 'grid_unavailable',\n blockedUntilStamp: formatParisStampFromMs(nowMs + 10 * 60000),\n });\n } else {\n setOn({\n stateName: 'hold_min_run_wait_grid_takeover',\n decisionName: 'hold',\n currentA: Math.min(derived.currentReferenceA, derived.physicalCapA),\n reasonText: \"Maintien jusqu'au mini ON en attente du réseau\",\n dynamic: false,\n mustStopFlag: false,\n });\n }\n }\n\n else if (!effectiveHoldConditionsOk) {\n if (lifecycle.minRunElapsed) {\n setOff({\n stateName: 'stop_conditions_lost',\n decisionName: 'stop',\n reasonText: 'Conditions de maintien perdues',\n dynamic: false,\n mustStopFlag: true,\n blockReason: 'hold_conditions_lost',\n blockedUntilStamp: formatParisStampFromMs(nowMs + 10 * 60000),\n });\n } else {\n setOn({\n stateName: 'hold_min_run',\n decisionName: 'hold',\n currentA: Math.min(derived.currentReferenceA, derived.physicalCapA),\n reasonText: \"Maintien jusqu'au temps mini ON\",\n dynamic: true,\n mustStopFlag: false,\n });\n }\n }\n\n else if ((derived.currentReferenceA > derived.physicalCapA) && lifecycle.currentCooldownElapsed) {\n setOn({\n stateName: 'charging_step_down_system_cap',\n decisionName: 'step_down',\n currentA: Math.max(\n params.currentMinA,\n Math.min(\n derived.currentReferenceA - Math.max(params.currentStepDownA, 2),\n derived.physicalCapA\n )\n ),\n reasonText: 'Réduction pour respecter la capacité système',\n dynamic: true,\n mustStopFlag: false,\n });\n }\n\n else if (conditions.shouldStepDownOverload) {\n setOn({\n stateName: 'charging_step_down_overload',\n decisionName: 'step_down_fast',\n currentA: Math.max(\n params.currentMinA,\n Math.min(\n derived.currentReferenceA - Math.max(batteryTune.global.overloadStepDownA, params.currentStepDownA),\n derived.physicalCapA\n )\n ),\n reasonText: 'Baisse rapide: surcharge convertisseur',\n dynamic: true,\n mustStopFlag: false,\n });\n }\n\n else if (conditions.shouldStepDownVebusPower) {\n setOn({\n stateName: 'charging_step_down_vebus_power',\n decisionName: 'step_down_fast',\n currentA: Math.max(\n params.currentMinA,\n Math.min(\n derived.currentReferenceA - Math.max(params.currentStepDownA + 1, 3),\n derived.physicalCapA\n )\n ),\n reasonText: 'Baisse rapide: puissance VE.Bus élevée',\n dynamic: true,\n mustStopFlag: false,\n });\n }\n\n else if (conditions.shouldStepDownVebusVoltage) {\n setOn({\n stateName: 'charging_step_down_dc_voltage',\n decisionName: 'step_down_fast',\n currentA: Math.max(\n params.currentMinA,\n Math.min(\n derived.currentReferenceA - Math.max(params.currentStepDownA + 1, 3),\n derived.physicalCapA\n )\n ),\n reasonText: 'Baisse rapide: tension DC basse',\n dynamic: true,\n mustStopFlag: false,\n });\n }\n\n else if (\n activeSourceMode === 'battery_assist_night' &&\n conditions.batteryAssistNightShouldStepDown\n ) {\n setOn({\n stateName: 'charging_step_down_battery_assist_night',\n decisionName: 'step_down',\n currentA: Math.max(\n params.currentMinA,\n Math.min(\n derived.currentReferenceA - params.currentStepDownA,\n derived.physicalCapA\n )\n ),\n reasonText:\n nightEvcs?.batteryRecoveryUrgent\n ? 'Réduction: approche départ, préparation takeover réseau'\n : 'Réduction: assistance batterie nuit devient trop coûteuse',\n dynamic: true,\n mustStopFlag: false,\n });\n }\n\n else if (\n (activeSourceMode === 'grid_hc_allowed' || activeSourceMode === 'grid_forced') &&\n conditions.gridShouldStepDownBattery\n ) {\n setOn({\n stateName:\n activeSourceMode === 'grid_forced'\n ? 'charging_step_down_grid_forced_guard'\n : 'charging_step_down_grid_guard',\n decisionName: 'step_down',\n currentA: Math.max(\n params.currentMinA,\n Math.min(\n derived.currentReferenceA - params.currentStepDownA,\n derived.physicalCapA\n )\n ),\n reasonText:\n activeSourceMode === 'grid_forced'\n ? 'Réduction réseau forcé: la batterie commence à participer'\n : 'Réduction réseau: la batterie commence à participer',\n dynamic: true,\n mustStopFlag: false,\n });\n }\n\n else if (\n (activeSourceMode === 'solar_only' || activeSourceMode === 'solar_potential') &&\n conditions.solarShouldStepDownCloudDebt\n ) {\n setOn({\n stateName: 'charging_step_down_cloud_debt',\n decisionName: 'step_down',\n currentA: Math.max(\n params.currentMinA,\n Math.min(\n derived.currentReferenceA - Math.max(params.currentStepDownA, 2),\n derived.physicalCapA\n )\n ),\n reasonText: 'Réduction: dette nuage trop élevée',\n dynamic: true,\n mustStopFlag: false,\n });\n }\n\n else if (\n (activeSourceMode === 'solar_only' || activeSourceMode === 'solar_potential') &&\n conditions.solarShouldStepDownBattery\n ) {\n setOn({\n stateName: 'charging_step_down_battery',\n decisionName: 'step_down',\n currentA: Math.max(\n params.currentMinA,\n Math.min(\n derived.currentReferenceA - params.currentStepDownA,\n derived.physicalCapA\n )\n ),\n reasonText: 'Réduction: batterie trop sollicitée',\n dynamic: true,\n mustStopFlag: false,\n });\n }\n\n else if (\n (activeSourceMode === 'grid_hc_allowed' || activeSourceMode === 'grid_forced') &&\n conditions.gridShouldStepUp\n ) {\n setOn({\n stateName:\n activeSourceMode === 'grid_forced'\n ? (\n nightEvcs?.batteryRecoveryUrgent\n ? 'night_departure_deadline_grid_forced'\n : 'charging_step_up_grid_forced'\n )\n : 'charging_step_up_grid_hc',\n decisionName: 'step_up',\n currentA: clamp(\n derived.currentReferenceA + params.currentStepUpA,\n params.currentMinA,\n derived.physicalCapA\n ),\n reasonText:\n activeSourceMode === 'grid_forced'\n ? (\n nightEvcs?.batteryRecoveryUrgent\n ? 'Hausse prudente: priorité départ, réseau forcé'\n : 'Hausse prudente du courant en charge réseau forcée'\n )\n : 'Hausse prudente du courant en HC réseau',\n dynamic: true,\n mustStopFlag: false,\n });\n }\n\n else if (\n activeSourceMode === 'battery_assist_night' &&\n conditions.batteryAssistNightShouldStepUp\n ) {\n setOn({\n stateName: 'charging_step_up_battery_assist_night',\n decisionName: 'step_up',\n currentA: clamp(\n derived.currentReferenceA + params.currentStepUpA,\n params.currentMinA,\n derived.physicalCapA\n ),\n reasonText: 'Hausse prudente du courant en assistance batterie nuit',\n dynamic: true,\n mustStopFlag: false,\n });\n }\n\n else if (\n (activeSourceMode === 'solar_only' || activeSourceMode === 'solar_potential') &&\n conditions.solarShouldStepUp\n ) {\n setOn({\n stateName:\n conditions.probeSuccessNow\n ? 'probe_success_step_up'\n : (modes.batteryPriorityMode\n ? 'charging_step_up_battery'\n : 'charging_step_up_forecast'),\n decisionName: 'step_up',\n currentA: clamp(\n derived.currentReferenceA + params.currentStepUpA,\n params.currentMinA,\n derived.physicalCapA\n ),\n reasonText: 'Hausse prudente du courant',\n dynamic: true,\n mustStopFlag: false,\n });\n }\n\n else {\n setOn({\n stateName:\n manualLocalHoldOverride ? 'charging_hold_manual_local' :\n conditions.probeSuccessNow ? 'probe_success_hold' :\n activeSourceMode === 'battery_assist_night' ? 'charging_hold_battery_assist_night' :\n (activeSourceMode === 'grid_hc_allowed' || activeSourceMode === 'grid_forced') ? 'charging_hold_grid' :\n cloud.cloudDebtActive ? 'charging_hold_cloud_tolerance' :\n 'charging_hold',\n decisionName: 'hold',\n currentA: Math.min(derived.currentReferenceA, derived.physicalCapA),\n reasonText:\n manualLocalHoldOverride\n ? 'Maintien manuel local du courant'\n : activeSourceMode === 'battery_assist_night'\n ? 'Maintien du courant en assistance batterie nuit'\n : 'Maintien du courant',\n dynamic: true,\n mustStopFlag: false,\n });\n }\n }\n}\n\n// =====================================================\n// START BRANCH\n// =====================================================\nelse {\n const startConditionsOk =\n (context.sourceMode === 'grid_hc_allowed' || context.sourceMode === 'grid_forced')\n ? conditions.gridStartOk\n : (context.sourceMode === 'solar_only' || context.sourceMode === 'solar_potential')\n ? conditions.solarStartOk\n : context.sourceMode === 'hybrid_probe'\n ? conditions.probeStartOk\n : context.sourceMode === 'battery_assist_night'\n ? conditions.batteryAssistNightStartOk\n : false;\n\n const manualLocalStartOverride =\n manualLocalChargeAllowed &&\n (\n context.sourceMode === 'solar_only' ||\n context.sourceMode === 'solar_potential' ||\n context.sourceMode === 'blocked'\n );\n\n if (!derived.canRunAtMinCurrent) {\n setOff({\n stateName: 'blocked_by_capacity_min_current',\n decisionName: 'hold',\n reasonText: 'Capacité insuffisante pour courant mini',\n dynamic: false,\n mustStopFlag: false,\n blockReason: 'system_capacity_stop',\n blockedUntilStamp: formatParisStampFromMs(nowMs + 10 * 60000),\n });\n }\n\n else if (manualLocalStartOverride) {\n setOn({\n stateName: 'start_manual_local_min_current',\n decisionName: 'start',\n currentA: params.currentMinA,\n reasonText:\n context.profile === 'outside_window'\n ? 'Démarrage manuel local hors fenêtre sans réseau'\n : 'Démarrage manuel local sans réseau',\n dynamic: true,\n mustStopFlag: false,\n });\n\n nextBlockReason = 'none';\n nextBlockedUntilStamp = now.stamp;\n setProbeStartedAt = false;\n }\n\n // P8 : véhicule plein en session nuit → veille auto-entretenue.\n // Le state est relu au cycle suivant (prev_surplus_state) : il maintient\n // vehicleChargeCompleted jusqu'au débranchement ou à la fin de fenêtre.\n // Fix A (08/08/2026) : une fois la recovery batterie terminée (SOC ≥ cible),\n // l'état devient 'night_recovery_done_idle' (collant) → takeover relâché\n // définitivement, plus de rebond EDF autour de la cible.\n else if (context.profile === 'night_hc' && nightEvcs?.vehicleChargeCompleted) {\n setOff({\n stateName: nightEvcs?.nightRecoveryDone\n ? 'night_recovery_done_idle'\n : 'night_session_done_idle',\n decisionName: 'hold',\n reasonText: nightEvcs?.nightRecoveryDone\n ? 'Charge véhicule terminée + batterie à la cible : veille nuit, réseau relâché'\n : 'Charge véhicule terminée : veille nuit, réseau relâché (recharge batterie si sous cible)',\n dynamic: false,\n mustStopFlag: false,\n blockReason: 'none',\n blockedUntilStamp: now.stamp,\n });\n }\n\n else if (context.sourceMode === 'blocked') {\n setOff({\n stateName:\n context.profile === 'outside_window'\n ? 'blocked_outside_window'\n : (context.profile === 'night_hc'\n ? 'night_no_grid_hc_lock'\n : 'idle_wait_source'),\n decisionName: 'hold',\n reasonText:\n context.profile === 'outside_window'\n ? 'Hors fenêtre préférée sans source suffisante'\n : context.profile === 'night_hc'\n ? 'Nuit sans autorisation HC réseau'\n : 'Source non autorisée ou insuffisante',\n dynamic: false,\n mustStopFlag: false,\n blockReason:\n context.profile === 'outside_window'\n ? 'outside_window'\n : context.profile === 'night_hc'\n ? 'grid_hc_unavailable'\n : 'source_unavailable',\n blockedUntilStamp:\n Number.isFinite(locks.nextAllowedWindowStartMs)\n ? formatParisStampFromMs(locks.nextAllowedWindowStartMs)\n : formatParisStampFromMs(nowMs + 15 * 60000),\n });\n }\n\n else if (\n (context.sourceMode === 'grid_hc_allowed' || context.sourceMode === 'grid_forced') &&\n !parsed.gridAvailable\n ) {\n setOff({\n stateName:\n context.profile === 'night_hc'\n ? 'night_wait_grid_takeover'\n : 'wait_grid_source',\n decisionName: 'hold',\n reasonText:\n context.profile === 'night_hc'\n ? (\n nightEvcs?.batteryRecoveryUrgent\n ? 'Réseau demandé: urgence recharge batterie avant départ'\n : 'Prise de relais réseau demandée pour session nuit EVCS'\n )\n : 'Source réseau demandée mais pas encore disponible',\n dynamic: false,\n mustStopFlag: false,\n blockReason:\n context.profile === 'night_hc'\n ? 'grid_hc_unavailable'\n : 'grid_unavailable',\n blockedUntilStamp: formatParisStampFromMs(nowMs + 10 * 60000),\n });\n }\n\n else if (locks.startBlockedByReason) {\n setOff({\n stateName: 'wait_reason_lock',\n decisionName: 'hold',\n reasonText: `Blocage anti-cliquetis: ${history.lastBlockReason}`,\n dynamic: false,\n mustStopFlag: false,\n blockReason: history.lastBlockReason,\n blockedUntilStamp: history.blockedUntilAt || now.stamp,\n });\n }\n\n else if (!startConditionsOk) {\n setOff({\n stateName: 'idle_wait_conditions',\n decisionName: 'hold',\n reasonText:\n context.sourceMode === 'battery_assist_night'\n ? 'Conditions de démarrage nuit batterie non réunies'\n : 'Conditions de démarrage non réunies',\n dynamic: false,\n mustStopFlag: false,\n blockReason: 'none',\n blockedUntilStamp: now.stamp,\n });\n }\n\n else if (!lifecycle.minStopElapsed) {\n setOff({\n stateName: 'wait_min_stop',\n decisionName: 'hold',\n reasonText: 'Temps mini OFF non écoulé',\n dynamic: false,\n mustStopFlag: false,\n blockReason: 'none',\n blockedUntilStamp: now.stamp,\n });\n }\n\n else {\n const startCurrentA =\n context.sourceMode === 'hybrid_probe'\n ? clamp(params.probeCurrentA, params.currentMinA, derived.physicalCapA)\n : params.currentMinA;\n\n setOn({\n stateName:\n context.sourceMode === 'grid_forced' ? (\n nightEvcs?.batteryRecoveryUrgent\n ? 'night_departure_deadline_grid_forced'\n : 'start_grid_forced_min_current'\n ) :\n context.sourceMode === 'grid_hc_allowed' ? 'start_grid_hc_min_current' :\n context.sourceMode === 'battery_assist_night' ? 'start_battery_assist_night_min_current' :\n context.sourceMode === 'hybrid_probe' ? 'probe_start_6a' :\n context.sourceMode === 'solar_potential' ? 'start_forecast_solar_potential' :\n modes.batteryFullHarvestMode ? 'start_min_current_battery_full_harvest' :\n modes.forecastCommitMode ? 'start_min_current_forecast_commit' :\n 'start_min_current',\n decisionName: 'start',\n currentA: startCurrentA,\n reasonText:\n context.sourceMode === 'grid_forced'\n ? (\n nightEvcs?.batteryRecoveryUrgent\n ? 'Démarrage réseau forcé: priorité départ / recharge batterie'\n : 'Démarrage en charge réseau forcée'\n )\n : context.sourceMode === 'grid_hc_allowed'\n ? 'Démarrage en fenêtre HC réseau'\n : context.sourceMode === 'battery_assist_night'\n ? 'Démarrage nuit en assistance batterie contrôlée'\n : context.sourceMode === 'hybrid_probe'\n ? 'Démarrage en mode sonde'\n : context.sourceMode === 'solar_potential'\n ? 'Démarrage par potentiel solaire confirmé'\n : modes.batteryFullHarvestMode\n ? 'Démarrage harvest solaire'\n : modes.forecastCommitMode\n ? 'Démarrage forecast commit'\n : 'Démarrage prudent à courant mini',\n dynamic: true,\n mustStopFlag: false,\n });\n\n nextBlockReason = 'none';\n nextBlockedUntilStamp = now.stamp;\n setProbeStartedAt = context.sourceMode === 'hybrid_probe';\n }\n}\n\n// =====================================================\n// CONTEXT REASONS\n// =====================================================\nif (nightEvcs?.nightSessionActive) {\n reason.push(\n `Nuit EVCS: target=${round1(nightEvcs.batteryMorningTargetPct)}%${nightEvcs.advisorNightTargetApplied ? ' (ADVISOR)' : ''} | trigger réseau=${round1(nightEvcs.gridTakeoverTriggerPct)}%`\n );\n\n if (Number.isFinite(nightEvcs.nightSessionStartSocPct)) {\n reason.push(\n `SOC départ session nuit=${round1(nightEvcs.nightSessionStartSocPct)}% | décharge autorisée=${round1(nightEvcs.maxBatteryDischargePoints)} pts`\n );\n }\n\n if (nightEvcs.batteryRecoveryUrgent) {\n reason.push(\"Urgence départ: il faut anticiper la reprise réseau\");\n }\n}\n\nif (manualLocalChargeAllowed) {\n reason.push(`ManualLocalCharge=on`);\n reason.push(`ManualLocalProfile=${context.profile}`);\n reason.push(`ManualLocalSource=${context.sourceMode}`);\n}\n\nreason.push(`VehicleDemandFilter delivery=${vehicleDemandFilter.deliveryRatioEma}`);\nreason.push(`VehicleDemandFilter noise=${vehicleDemandFilter.deliveryNoiseEma}`);\nreason.push(`VehicleDemandFilter score=${vehicleDemandFilter.noDemandScoreEma}`);\nreason.push(`VehicleDemandFilter noDemand=${vehicleDemandFilter.evcsVehicleNoDemandSemantic ? 'on' : 'off'}`);\n\n// =====================================================\n// OUTPUT\n// =====================================================\nreturn [{\n json: {\n ...prev,\n vehicleDemandFilter,\n decision: {\n desiredCharging,\n desiredPermission,\n desiredLatch,\n desiredCurrentA,\n\n state,\n currentDecision,\n dynamicOk,\n mustStop,\n reason,\n\n nextBlockReason,\n nextBlockedUntilStamp,\n\n setCloudDebtStartedAt,\n setProbeStartedAt,\n\n setNightSessionStart,\n clearNightSessionStart,\n\n notifyWanted,\n notifyTitle,\n notifyMessage,\n }\n }\n}];\n" + "jsCode": "function 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 round3(v) {\n return Math.round(v * 1000) / 1000;\n}\n\nfunction to2(n) {\n return String(n).padStart(2, '0');\n}\n\nfunction finiteOr(v, fallback) {\n return Number.isFinite(v) ? v : fallback;\n}\n\nfunction sigmoid(x) {\n return 1 / (1 + Math.exp(-x));\n}\n\nfunction alphaFromTau(dtSec, tauSec) {\n const dt = Math.max(1, finiteOr(dtSec, 60));\n const tau = Math.max(1, finiteOr(tauSec, 60));\n return Math.min(1, Math.max(0.001, 1 - Math.exp(-dt / tau)));\n}\n\nfunction ema(prev, sample, alpha) {\n if (!Number.isFinite(sample)) return Number.isFinite(prev) ? prev : 0;\n if (!Number.isFinite(prev)) return sample;\n return prev + alpha * (sample - prev);\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 now,\n nowMs,\n parsed,\n params,\n history,\n strategy,\n lifecycle,\n context,\n derived,\n locks,\n cloud,\n modes,\n safeguards,\n conditions,\n batteryTune,\n nightEvcs,\n} = prev;\n\n// =====================================================\n// DECISION INIT\n// =====================================================\nlet desiredCharging = false;\nlet desiredPermission = false;\nlet desiredLatch = false;\nlet desiredCurrentA = params.currentMinA;\n\nlet state = 'idle';\nlet currentDecision = 'hold';\nlet dynamicOk = false;\nlet mustStop = false;\n\nlet reason = [];\n\nlet setCloudDebtStartedAt = false;\nlet setProbeStartedAt = false;\n\n// EVCS night session memory\nlet setNightSessionStart = false;\nlet clearNightSessionStart = false;\n\nlet notifyWanted = false;\nlet notifyTitle = 'Victron EVCS Surplus';\nlet notifyMessage = '';\n\nlet nextBlockReason = 'none';\nlet nextBlockedUntilStamp = now.stamp;\n\n// =====================================================\n// HELPERS\n// =====================================================\nfunction setOff({\n stateName,\n decisionName = 'stop',\n reasonText,\n dynamic = false,\n mustStopFlag = false,\n blockReason = 'none',\n blockedUntilStamp = now.stamp,\n}) {\n desiredCharging = false;\n desiredPermission = false;\n desiredLatch = false;\n desiredCurrentA = params.currentMinA;\n\n state = stateName;\n currentDecision = decisionName;\n dynamicOk = dynamic;\n mustStop = mustStopFlag;\n nextBlockReason = blockReason;\n nextBlockedUntilStamp = blockedUntilStamp;\n\n if (reasonText) reason.push(reasonText);\n}\n\nfunction setOn({\n stateName,\n decisionName = 'hold',\n currentA = params.currentMinA,\n reasonText,\n dynamic = true,\n mustStopFlag = false,\n}) {\n desiredCharging = true;\n desiredPermission = true;\n desiredLatch = true;\n desiredCurrentA = clamp(\n currentA,\n params.currentMinA,\n Math.max(params.currentMinA, derived.physicalCapA || params.currentMinA)\n );\n\n state = stateName;\n currentDecision = decisionName;\n dynamicOk = dynamic;\n mustStop = mustStopFlag;\n\n if (reasonText) reason.push(reasonText);\n}\n\n// =====================================================\n// SESSION MEMORY SIDE EFFECTS\n// =====================================================\nif (nightEvcs?.nightSessionNeedsCapture) {\n setNightSessionStart = true;\n}\n\nif (nightEvcs?.nightSessionNeedsClear) {\n clearNightSessionStart = true;\n}\n\n// =====================================================\n// MANUAL LOCAL CHARGE SEMANTICS\n// IMPORTANT:\n// force_charge_now = bypass fenêtres pour la charge locale,\n// sans imposer le réseau.\n// =====================================================\nconst manualChargeBypassWindows = !!context.manualChargeBypassWindows;\nconst manualChargeLocalRequested = !!context.manualChargeLocalRequested;\n\nconst manualLocalChargeAllowed =\n !!strategy.forceChargeRequested &&\n manualChargeBypassWindows &&\n manualChargeLocalRequested &&\n !safeguards.mustStopHard &&\n !!derived.canRunAtMinCurrent;\n\n// =====================================================\n// EVCS NUMERIC NO-DEMAND FILTER\n// Objectif:\n// détecter \"voiture branchée + session armée + commande claire\n// mais plus de demande réelle\" sans seuils fixes bruts.\n// IMPORTANT:\n// on n'utilise PAS le PV comme critère.\n// =====================================================\nconst prevDeliveryRatioEma = finiteOr(history.evcsDeliveryRatioEma, NaN);\nconst prevDeliveryNoiseEma = finiteOr(history.evcsDeliveryNoiseEma, NaN);\nconst prevNoDemandScoreEma = finiteOr(history.evcsNoDemandScoreEma, NaN);\nconst prevNoDemandSemanticActive = !!history.evcsNoDemandSemanticActive;\nconst prevNoDemandSinceMs = finiteOr(history.evcsNoDemandSinceMs, NaN);\nconst prevNoDemandFilterEvalMs = finiteOr(history.evcsNoDemandFilterEvalMs, NaN);\n\nconst noDemandDtSec =\n Number.isFinite(prevNoDemandFilterEvalMs) && Number.isFinite(nowMs)\n ? clamp((nowMs - prevNoDemandFilterEvalMs) / 1000, 1, 600)\n : 60;\n\n// Constantes dynamiques du filtre = temps de réponse du filtre,\n// pas des seuils métiers bruts.\nconst deliveryTauSec = Math.max(15, finiteOr(params.evcsDeliveryTauSec, 45));\nconst noiseTauSec = Math.max(deliveryTauSec, finiteOr(params.evcsDeliveryNoiseTauSec, 180));\nconst scoreTauSec = Math.max(20, finiteOr(params.evcsNoDemandScoreTauSec, 150));\nconst armTauSec = Math.max(20, finiteOr(params.evcsNoDemandArmTauSec, 180));\n\nconst alphaDelivery = alphaFromTau(noDemandDtSec, deliveryTauSec);\nconst alphaNoise = alphaFromTau(noDemandDtSec, noiseTauSec);\nconst alphaScore = alphaFromTau(noDemandDtSec, scoreTauSec);\n\nconst acVoltageRef = finiteOr(derived.acVoltageRef, 230);\n\nconst commandCurrentA = clamp(\n finiteOr(\n derived.currentReferenceA,\n finiteOr(\n parsed.evcsCommandCurrentA,\n finiteOr(parsed.evcsSetCurrentA, finiteOr(params.currentMinA, 6))\n )\n ),\n 0,\n 32\n);\n\nconst expectedPowerW = Math.max(1, commandCurrentA * acVoltageRef);\n\nconst realCurrentA = Math.abs(\n finiteOr(\n parsed.evcsCurrentRealA,\n acVoltageRef > 180\n ? finiteOr(parsed.evcsAcPowerW, 0) / acVoltageRef\n : 0\n )\n);\n\nconst realPowerW = Math.max(0, finiteOr(parsed.evcsAcPowerW, 0));\n\nconst currentDeliveryRatioRaw = clamp(\n realCurrentA / Math.max(commandCurrentA, 0.1),\n 0,\n 1.25\n);\n\nconst powerDeliveryRatioRaw = clamp(\n realPowerW / expectedPowerW,\n 0,\n 1.25\n);\n\nconst deliveryRatioRaw = clamp(\n Math.max(currentDeliveryRatioRaw, powerDeliveryRatioRaw),\n 0,\n 1\n);\n\nconst deliveryRatioEma = ema(prevDeliveryRatioEma, deliveryRatioRaw, alphaDelivery);\n\nconst deliveryResidualAbs = Math.abs(deliveryRatioRaw - deliveryRatioEma);\nconst deliveryNoiseEma = ema(prevDeliveryNoiseEma, deliveryResidualAbs, alphaNoise);\n\nconst adaptiveQuietBand = clamp(\n 0.02 + 3.0 * deliveryNoiseEma,\n 0.02,\n 0.35\n);\n\nconst noDemandEvidenceRaw = clamp(\n (adaptiveQuietBand - deliveryRatioEma) / adaptiveQuietBand,\n 0,\n 1\n);\n\nconst sessionElapsedSec = Math.max(\n 0,\n finiteOr(lifecycle.minutesSinceStart, 0) * 60\n);\n\nconst sessionArmFactor = 1 - Math.exp(-sessionElapsedSec / armTauSec);\n\nconst currentMinRefA = Math.max(1, finiteOr(params.currentMinA, 6));\nconst commandStrength = commandCurrentA / currentMinRefA;\n\nconst commandFactor = clamp(\n sigmoid((commandStrength - 1.0) * 2.2),\n 0,\n 1\n);\n\n// Source effectivement livrable.\n// IMPORTANT:\n// on évite de conclure \"voiture pleine\" si en réalité la source n'est\n// pas disponible (ex: attente grid takeover).\nconst manualLocalNoDemandEligible =\n manualLocalChargeAllowed &&\n (\n context.sourceMode === 'solar_only' ||\n context.sourceMode === 'solar_potential' ||\n context.sourceMode === 'blocked'\n );\n\nconst sourceDeliverableForNoDemand =\n manualLocalNoDemandEligible\n ? true\n : (context.sourceMode === 'grid_hc_allowed' || context.sourceMode === 'grid_forced')\n ? !!parsed.gridAvailable\n : (context.sourceMode === 'solar_only' || context.sourceMode === 'solar_potential')\n ? !!conditions.solarHoldOk || !!conditions.solarStartOk\n : context.sourceMode === 'hybrid_probe'\n ? !!conditions.probeHoldGuardOk || !!conditions.probeSuccessNow || !!conditions.probeStartOk\n : context.sourceMode === 'battery_assist_night'\n ? !!conditions.batteryAssistNightHoldOk || !!conditions.batteryAssistNightStartOk\n : false;\n\nconst evcsSessionArmedForNoDemand =\n !!cloud.sessionObservedActive &&\n !!parsed.evcsVehicleConnected &&\n !!parsed.evcsChargingSwitchOn &&\n !!parsed.chargePermissionNow &&\n !!parsed.controlLatch &&\n commandCurrentA > 0 &&\n sourceDeliverableForNoDemand;\n\nconst noDemandScoreRaw = evcsSessionArmedForNoDemand\n ? clamp(\n noDemandEvidenceRaw * sessionArmFactor * commandFactor,\n 0,\n 1\n )\n : 0;\n\nconst noDemandScoreEma = ema(prevNoDemandScoreEma, noDemandScoreRaw, alphaScore);\n\nconst adaptiveDecisionCenter = clamp(\n 0.72 - 0.20 * deliveryNoiseEma,\n 0.55,\n 0.82\n);\n\nconst adaptiveDecisionBand = clamp(\n 0.16 + 0.80 * deliveryNoiseEma,\n 0.12,\n 0.34\n);\n\nconst noDemandScoreOn = clamp(\n adaptiveDecisionCenter + adaptiveDecisionBand / 2,\n 0.60,\n 0.95\n);\n\nconst noDemandScoreOff = clamp(\n adaptiveDecisionCenter - adaptiveDecisionBand / 2,\n 0.15,\n noDemandScoreOn - 0.05\n);\n\nlet evcsVehicleNoDemandSemantic = prevNoDemandSemanticActive;\n\nif (!evcsSessionArmedForNoDemand) {\n evcsVehicleNoDemandSemantic = false;\n} else if (prevNoDemandSemanticActive) {\n evcsVehicleNoDemandSemantic = noDemandScoreEma >= noDemandScoreOff;\n} else {\n evcsVehicleNoDemandSemantic = noDemandScoreEma >= noDemandScoreOn;\n}\n\nconst evcsNoDemandSinceMs =\n evcsVehicleNoDemandSemantic\n ? (\n prevNoDemandSemanticActive && Number.isFinite(prevNoDemandSinceMs)\n ? prevNoDemandSinceMs\n : nowMs\n )\n : null;\n\nconst evcsNoDemandSinceMinutes =\n Number.isFinite(evcsNoDemandSinceMs) && Number.isFinite(nowMs)\n ? Math.max(0, (nowMs - evcsNoDemandSinceMs) / 60000)\n : null;\n\nconst vehicleDemandFilter = {\n dtSec: round3(noDemandDtSec),\n\n commandCurrentA: round3(commandCurrentA),\n realCurrentA: round3(realCurrentA),\n expectedPowerW: round3(expectedPowerW),\n realPowerW: round3(realPowerW),\n\n currentDeliveryRatioRaw: round3(currentDeliveryRatioRaw),\n powerDeliveryRatioRaw: round3(powerDeliveryRatioRaw),\n deliveryRatioRaw: round3(deliveryRatioRaw),\n deliveryRatioEma: round3(deliveryRatioEma),\n deliveryNoiseEma: round3(deliveryNoiseEma),\n\n adaptiveQuietBand: round3(adaptiveQuietBand),\n noDemandEvidenceRaw: round3(noDemandEvidenceRaw),\n\n sessionElapsedSec: round3(sessionElapsedSec),\n sessionArmFactor: round3(sessionArmFactor),\n commandFactor: round3(commandFactor),\n sourceDeliverableForNoDemand,\n evcsSessionArmedForNoDemand,\n\n noDemandScoreRaw: round3(noDemandScoreRaw),\n noDemandScoreEma: round3(noDemandScoreEma),\n\n noDemandScoreOn: round3(noDemandScoreOn),\n noDemandScoreOff: round3(noDemandScoreOff),\n\n evcsVehicleNoDemandSemantic,\n evcsNoDemandSinceMs: Number.isFinite(evcsNoDemandSinceMs) ? evcsNoDemandSinceMs : null,\n evcsNoDemandSinceMinutes: Number.isFinite(evcsNoDemandSinceMinutes)\n ? round3(evcsNoDemandSinceMinutes)\n : null,\n\n nextHistory: {\n evcsDeliveryRatioEma: round3(deliveryRatioEma),\n evcsDeliveryNoiseEma: round3(deliveryNoiseEma),\n evcsNoDemandScoreEma: round3(noDemandScoreEma),\n evcsNoDemandSemanticActive: evcsVehicleNoDemandSemantic,\n evcsNoDemandSinceMs: Number.isFinite(evcsNoDemandSinceMs) ? evcsNoDemandSinceMs : null,\n evcsNoDemandFilterEvalMs: nowMs\n }\n};\n\n// =====================================================\n// DECISION TREE\n// =====================================================\nif (!parsed.controllerEnabled) {\n desiredCharging = parsed.evcsChargingSwitchOn;\n desiredPermission = parsed.chargePermissionNow;\n desiredLatch = parsed.controlLatch;\n desiredCurrentA = clamp(\n derived.currentReferenceA || params.currentMinA,\n params.currentMinA,\n params.currentMaxAutoA\n );\n\n state = 'controller_disabled';\n currentDecision = 'hold';\n dynamicOk = false;\n mustStop = false;\n reason.push('Contrôleur désactivé');\n}\n\nelse if (strategy.isStopMode) {\n setOff({\n stateName: 'stopped_user',\n decisionName: 'stop_stop_mode',\n reasonText: 'Arrêt manuel via Stratégie STOP',\n dynamic: false,\n mustStopFlag: false,\n blockReason: 'manual_stop',\n blockedUntilStamp: now.stamp,\n });\n}\n\nelse if (parsed.evcsModeIsAuto) {\n setOff({\n stateName: 'mode_auto_conflict',\n decisionName: 'stop_auto_mode_conflict',\n reasonText: 'Borne en mode Auto interne',\n dynamic: false,\n mustStopFlag: true,\n blockReason: 'mode_auto_conflict',\n blockedUntilStamp: now.stamp,\n });\n}\n\nelse if (!parsed.evcsDeviceConnected || !parsed.evcsVehicleConnected) {\n setOff({\n stateName: 'vehicle_not_connected',\n decisionName: 'stop_not_connected',\n reasonText: 'Véhicule ou borne non connecté',\n dynamic: false,\n mustStopFlag: false,\n blockReason: 'vehicle_not_connected',\n blockedUntilStamp: now.stamp,\n });\n}\n\n// =====================================================\n// Fix F (09/08/2026) : MÉMOIRE \"CHARGE TERMINÉE\" PRIORITAIRE.\n// Incident 09/08 06:51→06:56 : la VW pleine relançait des micro-charges\n// (sur batterie, EDF OFF) → la garde mustStopHard émettait\n// 'battery_current_hard_stop' qui ÉCRASAIT l'état collant dans le helper\n// → completed perdu au cycle suivant → re-P7 → EDF rallumé (rebond).\n// Ici : tant que la session nuit est active et la charge terminée, l'état\n// collant est réécrit à CHAQUE cycle, AU-DESSUS des gardes. Aucune perte de\n// protection : ce setOff coupe la charge aussi fermement qu'un hard stop\n// (chg/perm/latch OFF + mustStop si la borne est encore armée).\n// =====================================================\nelse if (\n context.profile === 'night_hc' &&\n nightEvcs?.vehicleChargeCompleted &&\n !manualLocalChargeAllowed &&\n !strategy.forceGridRequested\n) {\n setOff({\n stateName: nightEvcs?.nightRecoveryDone\n ? 'night_recovery_done_idle'\n : 'night_session_done_idle',\n decisionName: cloud.sessionObservedActive ? 'stop_vehicle_full_or_paused' : 'hold',\n reasonText: nightEvcs?.nightRecoveryDone\n ? 'Charge véhicule terminée + batterie à la cible : veille nuit, réseau relâché'\n : 'Charge véhicule terminée : veille nuit, réseau relâché (recharge batterie si sous cible)',\n dynamic: false,\n mustStopFlag: !!cloud.sessionObservedActive,\n blockReason: 'none',\n blockedUntilStamp: now.stamp,\n });\n}\n\n// =====================================================\n// P9 (09/08/2026) : véhicule plein pendant la fenêtre \"départ imminent\"\n// (jour, hors HC) → veille COLLANTE 'departure_done_idle', réécrite chaque\n// cycle au-dessus des gardes (même principe que Fix F). Le réseau est\n// relâché, l'hystérésis coupe immédiatement (cut E2 étendu).\n// =====================================================\nelse if (\n nightEvcs?.departureChargeDone &&\n !manualLocalChargeAllowed &&\n !strategy.forceGridRequested\n) {\n setOff({\n stateName: 'departure_done_idle',\n decisionName: cloud.sessionObservedActive ? 'stop_vehicle_full_or_paused' : 'hold',\n reasonText: 'Charge terminée avant départ : veille, réseau relâché',\n dynamic: false,\n mustStopFlag: !!cloud.sessionObservedActive,\n blockReason: 'none',\n blockedUntilStamp: now.stamp,\n });\n}\n\n// =====================================================\n// P10 (09/08/2026) : véhicule plein en JOURNÉE → veille collante\n// 'day_vehicle_full_idle' jusqu'au débranchement. Évite les re-tentatives\n// solaires cycliques sur un véhicule plein (usure relais). Réécrite chaque\n// cycle au-dessus des gardes (principe Fix F). Échappatoires : force_*.\n// =====================================================\nelse if (\n nightEvcs?.dayVehicleFullIdle &&\n !manualLocalChargeAllowed &&\n !strategy.forceGridRequested\n) {\n setOff({\n stateName: 'day_vehicle_full_idle',\n decisionName: cloud.sessionObservedActive ? 'stop_vehicle_full_or_paused' : 'hold',\n reasonText: 'Véhicule plein : veille jour, aucune nouvelle tentative jusqu\\'au débranchement',\n dynamic: false,\n mustStopFlag: !!cloud.sessionObservedActive,\n blockReason: 'none',\n blockedUntilStamp: now.stamp,\n });\n}\n\nelse if (safeguards.mustStopHard) {\n setOff({\n stateName:\n derived.anyVebusLowBattery ? 'vebus_low_battery_hard_stop' :\n derived.vebusDcVoltageHardLow ? 'vebus_dc_voltage_hard_stop' :\n derived.vebusAcOutHardReached ? 'vebus_ac_hard_stop' :\n safeguards.batteryCurrentHardExceeded ? 'battery_current_hard_stop' :\n 'hard_stop',\n decisionName: 'stop_hard',\n reasonText: 'Hard stop de protection',\n dynamic: false,\n mustStopFlag: true,\n blockReason:\n safeguards.batteryCurrentHardExceeded ? 'battery_current_hard_stop' :\n derived.vebusDcVoltageHardLow ? 'vebus_dc_voltage_stop' :\n derived.anyVebusLowBattery ? 'vebus_low_battery_stop' :\n derived.vebusAcOutHardReached ? 'vebus_ac_hard_stop' :\n 'battery_protection_stop',\n blockedUntilStamp: formatParisStampFromMs(nowMs + 15 * 60000),\n });\n}\n\n// =====================================================\n// ACTIVE SESSION BRANCH\n// =====================================================\nelse if (cloud.sessionObservedActive) {\n if (cloud.enteringCloudDebt) {\n setCloudDebtStartedAt = true;\n }\n\n if (conditions.shouldStopOverloadAtMin) {\n setOff({\n stateName: 'vebus_overload_stop_min_current',\n decisionName: 'stop',\n reasonText: 'Arrêt: surcharge convertisseur persistante au courant mini',\n dynamic: false,\n mustStopFlag: true,\n blockReason: 'vebus_overload_persistent',\n blockedUntilStamp: formatParisStampFromMs(\n nowMs + batteryTune.global.overloadStopAfterSeconds * 1000\n ),\n });\n }\n\n else if (safeguards.gridBatteryProtectionStop) {\n setOff({\n stateName: 'stop_grid_battery_guard',\n decisionName: 'stop',\n reasonText: `Arrêt charge réseau: batterie trop sollicitée (${round1(parsed.batteryCurrentA)}A)`,\n dynamic: false,\n mustStopFlag: true,\n blockReason: 'grid_battery_guard',\n blockedUntilStamp: formatParisStampFromMs(nowMs + 20 * 60000),\n });\n }\n\n else if (conditions.probeFailedNow) {\n setOff({\n stateName: 'probe_failed_lock',\n decisionName: 'stop',\n reasonText: 'Mode sonde échoué',\n dynamic: false,\n mustStopFlag: true,\n blockReason: 'probe_failed',\n blockedUntilStamp: formatParisStampFromMs(nowMs + params.probeRetryMinutes * 60000),\n });\n }\n\n else if (derived.physicalCapA < params.currentMinA) {\n if (lifecycle.minRunElapsed) {\n setOff({\n stateName: 'stop_power_cap',\n decisionName: 'stop_power_cap',\n reasonText: 'Capacité système insuffisante',\n dynamic: false,\n mustStopFlag: true,\n blockReason: 'system_capacity_stop',\n blockedUntilStamp: formatParisStampFromMs(nowMs + 10 * 60000),\n });\n } else {\n setOn({\n stateName: 'hold_min_run_power_cap',\n decisionName: 'hold',\n currentA: params.currentMinA,\n reasonText: \"Maintien jusqu'au temps mini ON\",\n dynamic: false,\n mustStopFlag: false,\n });\n }\n }\n\n else if (conditions.probeWindowActive && !conditions.probeSuccessNow) {\n if (!conditions.probeHoldGuardOk) {\n setOff({\n stateName: 'probe_stop_guard',\n decisionName: 'stop',\n reasonText: 'Sonde interrompue: garde-fous non respectés',\n dynamic: false,\n mustStopFlag: true,\n blockReason: 'probe_failed',\n blockedUntilStamp: formatParisStampFromMs(nowMs + params.probeRetryMinutes * 60000),\n });\n } else {\n setOn({\n stateName: 'probe_hold',\n decisionName: 'hold',\n currentA: Math.min(params.probeCurrentA, derived.physicalCapA),\n reasonText: `Mode sonde actif à ${round1(Math.min(params.probeCurrentA, derived.physicalCapA))}A`,\n dynamic: true,\n mustStopFlag: false,\n });\n }\n }\n\n else {\n const activeSourceMode =\n conditions.probeSuccessNow\n ? 'solar_only'\n : context.sourceMode;\n\n const holdConditionsOk =\n (activeSourceMode === 'grid_hc_allowed' || activeSourceMode === 'grid_forced')\n ? conditions.gridHoldOk\n : (activeSourceMode === 'solar_only' || activeSourceMode === 'solar_potential')\n ? conditions.solarHoldOk\n : activeSourceMode === 'hybrid_probe'\n ? conditions.probeHoldGuardOk\n : activeSourceMode === 'battery_assist_night'\n ? conditions.batteryAssistNightHoldOk\n : false;\n\n const manualLocalHoldOverride =\n manualLocalChargeAllowed &&\n (\n activeSourceMode === 'solar_only' ||\n activeSourceMode === 'solar_potential' ||\n activeSourceMode === 'blocked'\n );\n\n const effectiveHoldConditionsOk =\n holdConditionsOk || manualLocalHoldOverride;\n\n // =================================================\n // VEHICLE FULL / PAUSED - NUMERIC FILTER DECISION\n // NB Fix F (09/08/2026) : le cas \"charge terminée en session nuit\" est\n // désormais intercepté PLUS HAUT dans la cascade (mémoire prioritaire),\n // avant les gardes. Ici ne restent que la détection VDF initiale et\n // les autres sources.\n // IMPORTANT:\n // - uniquement en session active\n // - uniquement si la source est réellement livrable\n // - sans utiliser le PV comme critère\n // =================================================\n if (\n vehicleDemandFilter.evcsVehicleNoDemandSemantic &&\n vehicleDemandFilter.sourceDeliverableForNoDemand\n ) {\n setOff({\n stateName: 'vehicle_full_or_paused',\n decisionName: 'stop_vehicle_full_or_paused',\n reasonText: 'Véhicule branché mais ne demande plus réellement de puissance',\n dynamic: false,\n mustStopFlag: false,\n blockReason: 'none',\n blockedUntilStamp: now.stamp,\n });\n\n reason.push(`DeliveryRatioEma=${vehicleDemandFilter.deliveryRatioEma}`);\n reason.push(`NoiseEma=${vehicleDemandFilter.deliveryNoiseEma}`);\n reason.push(`NoDemandScore=${vehicleDemandFilter.noDemandScoreEma}`);\n reason.push(`ScoreOn=${vehicleDemandFilter.noDemandScoreOn}`);\n reason.push(`ScoreOff=${vehicleDemandFilter.noDemandScoreOff}`);\n\n if (vehicleDemandFilter.evcsNoDemandSinceMinutes !== null) {\n reason.push(`NoDemand depuis ${round1(vehicleDemandFilter.evcsNoDemandSinceMinutes)} min`);\n }\n }\n\n else if (manualLocalHoldOverride && activeSourceMode === 'blocked') {\n setOn({\n stateName: 'charging_hold_manual_local',\n decisionName: 'hold',\n currentA: Math.min(derived.currentReferenceA, derived.physicalCapA),\n reasonText: 'Maintien manuel local hors fenêtre sans réseau',\n dynamic: true,\n mustStopFlag: false,\n });\n }\n\n else if (activeSourceMode === 'blocked') {\n if (lifecycle.minRunElapsed) {\n setOff({\n stateName: 'stop_source_lost',\n decisionName: 'stop',\n reasonText: 'Source autorisée perdue',\n dynamic: false,\n mustStopFlag: true,\n blockReason: context.profile === 'outside_window' ? 'outside_window' : 'source_unavailable',\n blockedUntilStamp:\n context.profile === 'outside_window' && Number.isFinite(locks.nextAllowedWindowStartMs)\n ? formatParisStampFromMs(locks.nextAllowedWindowStartMs)\n : formatParisStampFromMs(nowMs + params.minStopMinutes * 60000),\n });\n } else {\n setOn({\n stateName: 'hold_min_run_source_lost',\n decisionName: 'hold',\n currentA: Math.min(derived.currentReferenceA, derived.physicalCapA),\n reasonText: \"Source perdue mais maintien jusqu'au mini ON\",\n dynamic: false,\n mustStopFlag: false,\n });\n }\n }\n\n else if (\n (activeSourceMode === 'grid_hc_allowed' || activeSourceMode === 'grid_forced') &&\n !parsed.gridAvailable\n ) {\n if (lifecycle.minRunElapsed) {\n setOff({\n stateName:\n context.profile === 'night_hc'\n ? 'night_wait_grid_takeover_runtime'\n : 'wait_grid_source_runtime',\n decisionName: 'hold',\n reasonText:\n context.profile === 'night_hc'\n ? 'Relais réseau demandé pendant la session nuit EVCS'\n : 'Source réseau demandée mais pas encore disponible',\n dynamic: false,\n mustStopFlag: false,\n blockReason:\n context.profile === 'night_hc' ? 'grid_hc_unavailable' : 'grid_unavailable',\n blockedUntilStamp: formatParisStampFromMs(nowMs + 10 * 60000),\n });\n } else {\n setOn({\n stateName: 'hold_min_run_wait_grid_takeover',\n decisionName: 'hold',\n currentA: Math.min(derived.currentReferenceA, derived.physicalCapA),\n reasonText: \"Maintien jusqu'au mini ON en attente du réseau\",\n dynamic: false,\n mustStopFlag: false,\n });\n }\n }\n\n else if (!effectiveHoldConditionsOk) {\n if (lifecycle.minRunElapsed) {\n setOff({\n stateName: 'stop_conditions_lost',\n decisionName: 'stop',\n reasonText: 'Conditions de maintien perdues',\n dynamic: false,\n mustStopFlag: true,\n blockReason: 'hold_conditions_lost',\n blockedUntilStamp: formatParisStampFromMs(nowMs + 10 * 60000),\n });\n } else {\n setOn({\n stateName: 'hold_min_run',\n decisionName: 'hold',\n currentA: Math.min(derived.currentReferenceA, derived.physicalCapA),\n reasonText: \"Maintien jusqu'au temps mini ON\",\n dynamic: true,\n mustStopFlag: false,\n });\n }\n }\n\n // SÉQUENCE D'OUVERTURE (31/08/2026) : un plafond n'est pas une cible qu'on\n // approche, c'est une limite qu'on respecte. Cette réduction attendait le\n // cooldown de courant — un compteur de confort — et ne descendait que d'un\n // pas à la fois : de 28 A à 20 A elle prenait plusieurs cooldowns, pendant\n // lesquels les onduleurs portaient un courant qu'ils n'ont plus le droit de\n // porter (le plafond retombe à la disparition du réseau). Désormais :\n // descente IMMÉDIATE et DIRECTE au plafond. Réduire est toujours sûr ; c'est\n // la montée qui doit être temporisée, et elle l'est.\n else if (derived.currentReferenceA > derived.physicalCapA) {\n setOn({\n stateName: 'charging_step_down_system_cap',\n decisionName: 'step_down_fast',\n currentA: Math.max(params.currentMinA, derived.physicalCapA),\n reasonText: `Réduction immédiate au plafond système ${derived.physicalCapA}A`,\n dynamic: true,\n mustStopFlag: false,\n });\n }\n\n else if (conditions.shouldStepDownOverload) {\n setOn({\n stateName: 'charging_step_down_overload',\n decisionName: 'step_down_fast',\n currentA: Math.max(\n params.currentMinA,\n Math.min(\n derived.currentReferenceA - Math.max(batteryTune.global.overloadStepDownA, params.currentStepDownA),\n derived.physicalCapA\n )\n ),\n reasonText: 'Baisse rapide: surcharge convertisseur',\n dynamic: true,\n mustStopFlag: false,\n });\n }\n\n else if (conditions.shouldStepDownVebusPower) {\n setOn({\n stateName: 'charging_step_down_vebus_power',\n decisionName: 'step_down_fast',\n currentA: Math.max(\n params.currentMinA,\n Math.min(\n derived.currentReferenceA - Math.max(params.currentStepDownA + 1, 3),\n derived.physicalCapA\n )\n ),\n reasonText: 'Baisse rapide: puissance VE.Bus élevée',\n dynamic: true,\n mustStopFlag: false,\n });\n }\n\n else if (conditions.shouldStepDownVebusVoltage) {\n setOn({\n stateName: 'charging_step_down_dc_voltage',\n decisionName: 'step_down_fast',\n currentA: Math.max(\n params.currentMinA,\n Math.min(\n derived.currentReferenceA - Math.max(params.currentStepDownA + 1, 3),\n derived.physicalCapA\n )\n ),\n reasonText: 'Baisse rapide: tension DC basse',\n dynamic: true,\n mustStopFlag: false,\n });\n }\n\n else if (\n activeSourceMode === 'battery_assist_night' &&\n conditions.batteryAssistNightShouldStepDown\n ) {\n setOn({\n stateName: 'charging_step_down_battery_assist_night',\n decisionName: 'step_down',\n currentA: Math.max(\n params.currentMinA,\n Math.min(\n derived.currentReferenceA - params.currentStepDownA,\n derived.physicalCapA\n )\n ),\n reasonText:\n nightEvcs?.batteryRecoveryUrgent\n ? 'Réduction: approche départ, préparation takeover réseau'\n : 'Réduction: assistance batterie nuit devient trop coûteuse',\n dynamic: true,\n mustStopFlag: false,\n });\n }\n\n else if (\n (activeSourceMode === 'grid_hc_allowed' || activeSourceMode === 'grid_forced') &&\n conditions.gridShouldStepDownBattery\n ) {\n setOn({\n stateName:\n activeSourceMode === 'grid_forced'\n ? 'charging_step_down_grid_forced_guard'\n : 'charging_step_down_grid_guard',\n decisionName: 'step_down',\n currentA: Math.max(\n params.currentMinA,\n Math.min(\n derived.currentReferenceA - params.currentStepDownA,\n derived.physicalCapA\n )\n ),\n reasonText:\n activeSourceMode === 'grid_forced'\n ? 'Réduction réseau forcé: la batterie commence à participer'\n : 'Réduction réseau: la batterie commence à participer',\n dynamic: true,\n mustStopFlag: false,\n });\n }\n\n else if (\n (activeSourceMode === 'solar_only' || activeSourceMode === 'solar_potential') &&\n conditions.solarShouldStepDownCloudDebt\n ) {\n setOn({\n stateName: 'charging_step_down_cloud_debt',\n decisionName: 'step_down',\n currentA: Math.max(\n params.currentMinA,\n Math.min(\n derived.currentReferenceA - Math.max(params.currentStepDownA, 2),\n derived.physicalCapA\n )\n ),\n reasonText: 'Réduction: dette nuage trop élevée',\n dynamic: true,\n mustStopFlag: false,\n });\n }\n\n else if (\n (activeSourceMode === 'solar_only' || activeSourceMode === 'solar_potential') &&\n conditions.solarShouldStepDownBattery\n ) {\n setOn({\n stateName: 'charging_step_down_battery',\n decisionName: 'step_down',\n currentA: Math.max(\n params.currentMinA,\n Math.min(\n derived.currentReferenceA - params.currentStepDownA,\n derived.physicalCapA\n )\n ),\n reasonText: 'Réduction: batterie trop sollicitée',\n dynamic: true,\n mustStopFlag: false,\n });\n }\n\n else if (\n (activeSourceMode === 'grid_hc_allowed' || activeSourceMode === 'grid_forced') &&\n conditions.gridShouldStepUp\n ) {\n setOn({\n stateName:\n activeSourceMode === 'grid_forced'\n ? (\n nightEvcs?.batteryRecoveryUrgent\n ? 'night_departure_deadline_grid_forced'\n : 'charging_step_up_grid_forced'\n )\n : 'charging_step_up_grid_hc',\n decisionName: 'step_up',\n currentA: clamp(\n derived.currentReferenceA + params.currentStepUpA,\n params.currentMinA,\n derived.physicalCapA\n ),\n reasonText:\n activeSourceMode === 'grid_forced'\n ? (\n nightEvcs?.batteryRecoveryUrgent\n ? 'Hausse prudente: priorité départ, réseau forcé'\n : 'Hausse prudente du courant en charge réseau forcée'\n )\n : 'Hausse prudente du courant en HC réseau',\n dynamic: true,\n mustStopFlag: false,\n });\n }\n\n else if (\n activeSourceMode === 'battery_assist_night' &&\n conditions.batteryAssistNightShouldStepUp\n ) {\n setOn({\n stateName: 'charging_step_up_battery_assist_night',\n decisionName: 'step_up',\n currentA: clamp(\n derived.currentReferenceA + params.currentStepUpA,\n params.currentMinA,\n derived.physicalCapA\n ),\n reasonText: 'Hausse prudente du courant en assistance batterie nuit',\n dynamic: true,\n mustStopFlag: false,\n });\n }\n\n else if (\n (activeSourceMode === 'solar_only' || activeSourceMode === 'solar_potential') &&\n conditions.solarShouldStepUp\n ) {\n setOn({\n stateName:\n conditions.probeSuccessNow\n ? 'probe_success_step_up'\n : (modes.batteryPriorityMode\n ? 'charging_step_up_battery'\n : 'charging_step_up_forecast'),\n decisionName: 'step_up',\n currentA: clamp(\n derived.currentReferenceA + params.currentStepUpA,\n params.currentMinA,\n derived.physicalCapA\n ),\n reasonText: 'Hausse prudente du courant',\n dynamic: true,\n mustStopFlag: false,\n });\n }\n\n else {\n setOn({\n stateName:\n manualLocalHoldOverride ? 'charging_hold_manual_local' :\n conditions.probeSuccessNow ? 'probe_success_hold' :\n activeSourceMode === 'battery_assist_night' ? 'charging_hold_battery_assist_night' :\n (activeSourceMode === 'grid_hc_allowed' || activeSourceMode === 'grid_forced') ? 'charging_hold_grid' :\n cloud.cloudDebtActive ? 'charging_hold_cloud_tolerance' :\n 'charging_hold',\n decisionName: 'hold',\n currentA: Math.min(derived.currentReferenceA, derived.physicalCapA),\n reasonText:\n manualLocalHoldOverride\n ? 'Maintien manuel local du courant'\n : activeSourceMode === 'battery_assist_night'\n ? 'Maintien du courant en assistance batterie nuit'\n : 'Maintien du courant',\n dynamic: true,\n mustStopFlag: false,\n });\n }\n }\n}\n\n// =====================================================\n// START BRANCH\n// =====================================================\nelse {\n const startConditionsOk =\n (context.sourceMode === 'grid_hc_allowed' || context.sourceMode === 'grid_forced')\n ? conditions.gridStartOk\n : (context.sourceMode === 'solar_only' || context.sourceMode === 'solar_potential')\n ? conditions.solarStartOk\n : context.sourceMode === 'hybrid_probe'\n ? conditions.probeStartOk\n : context.sourceMode === 'battery_assist_night'\n ? conditions.batteryAssistNightStartOk\n : false;\n\n const manualLocalStartOverride =\n manualLocalChargeAllowed &&\n (\n context.sourceMode === 'solar_only' ||\n context.sourceMode === 'solar_potential' ||\n context.sourceMode === 'blocked'\n );\n\n if (!derived.canRunAtMinCurrent) {\n setOff({\n stateName: 'blocked_by_capacity_min_current',\n decisionName: 'hold',\n reasonText: 'Capacité insuffisante pour courant mini',\n dynamic: false,\n mustStopFlag: false,\n blockReason: 'system_capacity_stop',\n blockedUntilStamp: formatParisStampFromMs(nowMs + 10 * 60000),\n });\n }\n\n else if (manualLocalStartOverride) {\n setOn({\n stateName: 'start_manual_local_min_current',\n decisionName: 'start',\n currentA: params.currentMinA,\n reasonText:\n context.profile === 'outside_window'\n ? 'Démarrage manuel local hors fenêtre sans réseau'\n : 'Démarrage manuel local sans réseau',\n dynamic: true,\n mustStopFlag: false,\n });\n\n nextBlockReason = 'none';\n nextBlockedUntilStamp = now.stamp;\n setProbeStartedAt = false;\n }\n\n // P8 : véhicule plein en session nuit → veille auto-entretenue.\n // Le state est relu au cycle suivant (prev_surplus_state) : il maintient\n // vehicleChargeCompleted jusqu'au débranchement ou à la fin de fenêtre.\n // Fix A (08/08/2026) : une fois la recovery batterie terminée (SOC ≥ cible),\n // l'état devient 'night_recovery_done_idle' (collant) → takeover relâché\n // définitivement, plus de rebond EDF autour de la cible.\n else if (context.profile === 'night_hc' && nightEvcs?.vehicleChargeCompleted) {\n setOff({\n stateName: nightEvcs?.nightRecoveryDone\n ? 'night_recovery_done_idle'\n : 'night_session_done_idle',\n decisionName: 'hold',\n reasonText: nightEvcs?.nightRecoveryDone\n ? 'Charge véhicule terminée + batterie à la cible : veille nuit, réseau relâché'\n : 'Charge véhicule terminée : veille nuit, réseau relâché (recharge batterie si sous cible)',\n dynamic: false,\n mustStopFlag: false,\n blockReason: 'none',\n blockedUntilStamp: now.stamp,\n });\n }\n\n else if (context.sourceMode === 'blocked') {\n setOff({\n stateName:\n context.profile === 'outside_window'\n ? 'blocked_outside_window'\n : (context.profile === 'night_hc'\n ? 'night_no_grid_hc_lock'\n : 'idle_wait_source'),\n decisionName: 'hold',\n reasonText:\n context.profile === 'outside_window'\n ? 'Hors fenêtre préférée sans source suffisante'\n : context.profile === 'night_hc'\n ? 'Nuit sans autorisation HC réseau'\n : 'Source non autorisée ou insuffisante',\n dynamic: false,\n mustStopFlag: false,\n blockReason:\n context.profile === 'outside_window'\n ? 'outside_window'\n : context.profile === 'night_hc'\n ? 'grid_hc_unavailable'\n : 'source_unavailable',\n blockedUntilStamp:\n Number.isFinite(locks.nextAllowedWindowStartMs)\n ? formatParisStampFromMs(locks.nextAllowedWindowStartMs)\n : formatParisStampFromMs(nowMs + 15 * 60000),\n });\n }\n\n else if (\n (context.sourceMode === 'grid_hc_allowed' || context.sourceMode === 'grid_forced') &&\n !parsed.gridAvailable\n ) {\n setOff({\n stateName:\n context.profile === 'night_hc'\n ? 'night_wait_grid_takeover'\n : 'wait_grid_source',\n decisionName: 'hold',\n reasonText:\n context.profile === 'night_hc'\n ? (\n nightEvcs?.batteryRecoveryUrgent\n ? 'Réseau demandé: urgence recharge batterie avant départ'\n : 'Prise de relais réseau demandée pour session nuit EVCS'\n )\n : 'Source réseau demandée mais pas encore disponible',\n dynamic: false,\n mustStopFlag: false,\n blockReason:\n context.profile === 'night_hc'\n ? 'grid_hc_unavailable'\n : 'grid_unavailable',\n blockedUntilStamp: formatParisStampFromMs(nowMs + 10 * 60000),\n });\n }\n\n else if (locks.startBlockedByReason) {\n setOff({\n stateName: 'wait_reason_lock',\n decisionName: 'hold',\n reasonText: `Blocage anti-cliquetis: ${history.lastBlockReason}`,\n dynamic: false,\n mustStopFlag: false,\n blockReason: history.lastBlockReason,\n blockedUntilStamp: history.blockedUntilAt || now.stamp,\n });\n }\n\n else if (!startConditionsOk) {\n setOff({\n stateName: 'idle_wait_conditions',\n decisionName: 'hold',\n reasonText:\n context.sourceMode === 'battery_assist_night'\n ? 'Conditions de démarrage nuit batterie non réunies'\n : 'Conditions de démarrage non réunies',\n dynamic: false,\n mustStopFlag: false,\n blockReason: 'none',\n blockedUntilStamp: now.stamp,\n });\n }\n\n else if (!lifecycle.minStopElapsed) {\n setOff({\n stateName: 'wait_min_stop',\n decisionName: 'hold',\n reasonText: 'Temps mini OFF non écoulé',\n dynamic: false,\n mustStopFlag: false,\n blockReason: 'none',\n blockedUntilStamp: now.stamp,\n });\n }\n\n else {\n const startCurrentA =\n context.sourceMode === 'hybrid_probe'\n ? clamp(params.probeCurrentA, params.currentMinA, derived.physicalCapA)\n : params.currentMinA;\n\n setOn({\n stateName:\n context.sourceMode === 'grid_forced' ? (\n nightEvcs?.batteryRecoveryUrgent\n ? 'night_departure_deadline_grid_forced'\n : 'start_grid_forced_min_current'\n ) :\n context.sourceMode === 'grid_hc_allowed' ? 'start_grid_hc_min_current' :\n context.sourceMode === 'battery_assist_night' ? 'start_battery_assist_night_min_current' :\n context.sourceMode === 'hybrid_probe' ? 'probe_start_6a' :\n context.sourceMode === 'solar_potential' ? 'start_forecast_solar_potential' :\n modes.batteryFullHarvestMode ? 'start_min_current_battery_full_harvest' :\n modes.forecastCommitMode ? 'start_min_current_forecast_commit' :\n 'start_min_current',\n decisionName: 'start',\n currentA: startCurrentA,\n reasonText:\n context.sourceMode === 'grid_forced'\n ? (\n nightEvcs?.batteryRecoveryUrgent\n ? 'Démarrage réseau forcé: priorité départ / recharge batterie'\n : 'Démarrage en charge réseau forcée'\n )\n : context.sourceMode === 'grid_hc_allowed'\n ? 'Démarrage en fenêtre HC réseau'\n : context.sourceMode === 'battery_assist_night'\n ? 'Démarrage nuit en assistance batterie contrôlée'\n : context.sourceMode === 'hybrid_probe'\n ? 'Démarrage en mode sonde'\n : context.sourceMode === 'solar_potential'\n ? 'Démarrage par potentiel solaire confirmé'\n : modes.batteryFullHarvestMode\n ? 'Démarrage harvest solaire'\n : modes.forecastCommitMode\n ? 'Démarrage forecast commit'\n : 'Démarrage prudent à courant mini',\n dynamic: true,\n mustStopFlag: false,\n });\n\n nextBlockReason = 'none';\n nextBlockedUntilStamp = now.stamp;\n setProbeStartedAt = context.sourceMode === 'hybrid_probe';\n }\n}\n\n// =====================================================\n// CONTEXT REASONS\n// =====================================================\nif (nightEvcs?.nightSessionActive) {\n reason.push(\n `Nuit EVCS: target=${round1(nightEvcs.batteryMorningTargetPct)}%${nightEvcs.advisorNightTargetApplied ? ' (ADVISOR)' : ''} | trigger réseau=${round1(nightEvcs.gridTakeoverTriggerPct)}%`\n );\n\n if (Number.isFinite(nightEvcs.nightSessionStartSocPct)) {\n reason.push(\n `SOC départ session nuit=${round1(nightEvcs.nightSessionStartSocPct)}% | décharge autorisée=${round1(nightEvcs.maxBatteryDischargePoints)} pts`\n );\n }\n\n if (nightEvcs.batteryRecoveryUrgent) {\n reason.push(\"Urgence départ: il faut anticiper la reprise réseau\");\n }\n}\n\nif (manualLocalChargeAllowed) {\n reason.push(`ManualLocalCharge=on`);\n reason.push(`ManualLocalProfile=${context.profile}`);\n reason.push(`ManualLocalSource=${context.sourceMode}`);\n}\n\nreason.push(`VehicleDemandFilter delivery=${vehicleDemandFilter.deliveryRatioEma}`);\nreason.push(`VehicleDemandFilter noise=${vehicleDemandFilter.deliveryNoiseEma}`);\nreason.push(`VehicleDemandFilter score=${vehicleDemandFilter.noDemandScoreEma}`);\nreason.push(`VehicleDemandFilter noDemand=${vehicleDemandFilter.evcsVehicleNoDemandSemantic ? 'on' : 'off'}`);\n\n// =====================================================\n// OUTPUT\n// =====================================================\nreturn [{\n json: {\n ...prev,\n vehicleDemandFilter,\n decision: {\n desiredCharging,\n desiredPermission,\n desiredLatch,\n desiredCurrentA,\n\n state,\n currentDecision,\n dynamicOk,\n mustStop,\n reason,\n\n nextBlockReason,\n nextBlockedUntilStamp,\n\n setCloudDebtStartedAt,\n setProbeStartedAt,\n\n setNightSessionStart,\n clearNightSessionStart,\n\n notifyWanted,\n notifyTitle,\n notifyMessage,\n }\n }\n}];\n" }, "type": "n8n-nodes-base.code", "typeVersion": 2,