diff --git a/HANDOFF.md b/HANDOFF.md index 93ffe9d..8626f54 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -1317,5 +1317,32 @@ autoriser une charge complete avec EDF.** Decisions (30/09) : profil **prudent + integration de la puissance, le compteur fait foi). Charge dans Grafana, requetes OK (la courbe « donne » se remplit a partir de 01:24). +### 01/10 matin — Premiere nuit du budget : il n'a pas joue, et deux defauts anciens +**Constat** : JEEP chargee de 01:25 a 06:37 en « Assistance batterie nuit », **7,5 kWh** +(pleine, 8,9 kWh au compteur depuis 17:29) ; batterie 64 % a 01:00 -> 37 % a 06:00 ; EDF +ferme **06:00 -> 06:38** (2,15 kWh importes, HC). Mode sans_edf, budget publie 5,93 kWh a +01:25. +1. **Le budget n'a jamais ete fige** : `input_text.victron_evcs_night_budget` est reste + « unknown ». Cause : « Build EVCS Surplus Controller V2.0 Actions » ne relaie QUE les + appels qu'il nomme. **Trois ecritures produites n'etaient jamais executees** : le budget + (30/09), le **verrou P12 `night_battery_done` (depuis le 15/09)** et l'acquittement du + bouton reset borne (depuis le 19/09). Les golden s'arretent a HA_SERVICE_CALLS_OUTPUT et ne + le voyaient pas. Corrige (`src/nodes/BUILD_ACTIONS.js`) + **garde-fou golden + `build-actions-transmet-tout`** (tout appel produit doit etre nomme dans le Build : 51/51, + echouait sur les trois avant). +2. **Le verrou P12 ne se relachait jamais** (`prev || …`) : invisible tant qu'il n'etait pas + ecrit ; avec l'ecriture retablie, P12 serait mort pour toutes les nuits suivantes. Il ne + vaut plus que pendant la session de nuit. Golden **132/132** (+2 : verrou pose au budget, + leve en fin de session), mutation qui mord. Deploye 07:22 ; **verifie en reel** : budget + fige « 2,76|4016,2 » a 07:23 (fin de fenetre, JEEP pleine, sans effet), efface a 07:24. +3. **EDF a 06:00 = faux positif de `batteryRecoveryUrgent`** (WINDOWS_SOURCEV2 l.390) : depart + JEEP du matin `input_datetime.victron_evcs_departure_time_jeep` = 06:30, marge + `evcs_night_departure_safety_min` = 30 min -> a 06:00, 0 min de recharge necessaire + 30 + >= 30 = « urgent », alors qu'il manquait **0 kWh** (SOC 37 > cible 35). `grid_forced` : + maison ET voiture sur EDF. **Recurrent** : fermeture vers 06:00 les 21, 22, 25, 27/09 et + 01/10. Mon exception « EDF deja engage pour la batterie » l'a laisse passer en sans_edf. + Correctif propose (exiger un manque > 0), **decision du proprietaire en attente**, ainsi + que la realite du depart de la JEEP a 06:30. + --- -*Document mis a jour le : 30/09/2026 (~18:30) — commande vocale du mode de nuit validee, premiere nuit budget a suivre ; budget voiture de la nuit sans EDF (bilan + controleur) ; fiche de l'agent allegee (25 831 car.), concierge sans silence, calendrier JEEP mar/sam ; nuit du 28-29 verifiee, achat EDF annonce = maison en HC + manque batterie ; regle HC active, vehicule branche a midi (JEEP/VW/ZERO), prevision de nuit a trois verdicts, nuits superposees et bilan recale sur la soiree* +*Document mis a jour le : 01/10/2026 (~07:35) — premiere nuit du budget : ecritures perdues et verrou P12 corriges, faux positif de depart a 06:00 ; commande vocale du mode de nuit validee, premiere nuit budget a suivre ; budget voiture de la nuit sans EDF (bilan + controleur) ; fiche de l'agent allegee (25 831 car.), concierge sans silence, calendrier JEEP mar/sam ; nuit du 28-29 verifiee, achat EDF annonce = maison en HC + manque batterie ; regle HC active, vehicule branche a midi (JEEP/VW/ZERO), prevision de nuit a trois verdicts, nuits superposees et bilan recale sur la soiree* diff --git a/src/nodes/BUILD_ACTIONS.js b/src/nodes/BUILD_ACTIONS.js index cc23151..b379bb0 100644 --- a/src/nodes/BUILD_ACTIONS.js +++ b/src/nodes/BUILD_ACTIONS.js @@ -127,6 +127,34 @@ pushAction( !!calls.helper_evcs_no_demand_filter_eval_at?.execute ); +// ===================================================== +// ECRITURES CONDITIONNELLES OUBLIEES (01/10/2026) +// Produites par HA_SERVICE_CALLS_OUTPUT mais jamais relayees jusqu'ici : +// - verrou P12 (`night_battery_done`, une seule passe sur batterie) depuis +// le 15/09 ; +// - acquittement du bouton reset borne (momentane) depuis le 19/09 ; +// - budget voiture de la nuit fige (30/09) : la nuit du 30/09 la JEEP a pris +// 7,5 kWh sur la batterie au lieu du budget. +// Garde-fou : tests/golden/run.mjs verifie que tout appel produit est nomme ici. +// ===================================================== +pushAction( + 'helper_night_battery_done', + calls.helper_night_battery_done, + !!calls.helper_night_battery_done?.execute +); + +pushAction( + 'helper_reset_borne_ack', + calls.helper_reset_borne_ack, + !!calls.helper_reset_borne_ack?.execute +); + +pushAction( + 'helper_night_budget', + calls.helper_night_budget, + !!calls.helper_night_budget?.execute +); + // ===================================================== // DEMANDE RÉSEAU EDF SI SOURCE GRID // ===================================================== diff --git a/src/nodes/WINDOWS_SOURCEV2.js b/src/nodes/WINDOWS_SOURCEV2.js index 73210f5..b646a38 100644 --- a/src/nodes/WINDOWS_SOURCEV2.js +++ b/src/nodes/WINDOWS_SOURCEV2.js @@ -659,10 +659,16 @@ const nightBudgetReached = nightBudgetDeliveredKwh >= nightBudget.budgetKwh - 0.05; const nightChargeComplete = params.nightChargeMode === 'complete'; +// Le verrou ne vaut QUE pendant la session de nuit (01/10/2026). Ecrit sous la +// forme `prev || …`, il restait vrai pour toujours une fois pose ; invisible +// tant que son ecriture n'atteignait pas HA (jamais relayee du 15/09 au 01/10). +// Fin de session (debranchement ou fin de fenetre HC) = verrou leve. const nightBatterySurplusExhausted = - params.nightBatteryDonePrev || - (nightSessionActive && parsed.soc <= nightBatterySurplusFloorPct) || - (nightSessionActive && nightBudgetReached); + nightSessionActive && ( + params.nightBatteryDonePrev || + parsed.soc <= nightBatterySurplusFloorPct || + nightBudgetReached + ); const nightBatterySurplusAvailable = nightSessionActive && diff --git a/tests/golden/run.mjs b/tests/golden/run.mjs index ab5c629..f68693e 100644 --- a/tests/golden/run.mjs +++ b/tests/golden/run.mjs @@ -40,6 +40,22 @@ const nodeCode = Object.fromEntries( CHAIN.map(n => [n, wf.nodes.find(x => x.name === n).parameters.jsCode]) ); +// GARDE-FOU (01/10/2026) : tout appel produit par HA_SERVICE_CALLS_OUTPUT doit +// etre transmis par « Build … Actions », qui ne relaie QUE les appels qu'il +// nomme. Trois ecritures ne partaient jamais (le verrou P12 depuis le 15/09, +// l'acquittement du bouton reset borne, le budget voiture du 30/09) : les +// scenarios s'arretent a HA_SERVICE_CALLS_OUTPUT et ne pouvaient pas le voir. +const BUILD_CODE = wf.nodes.find(x => x.name === 'Build EVCS Surplus Controller V2.0 Actions').parameters.jsCode; +const appelsProduits = [...nodeCode.HA_SERVICE_CALLS_OUTPUT.matchAll(/^ {6}([a-z_0-9]+): \{/gm)].map(m => m[1]); +const appelsPerdus = [...new Set(appelsProduits)].filter(n => !BUILD_CODE.includes(`'${n}'`)); +let gardeFou = 0; +if (appelsPerdus.length) { + gardeFou = 1; + console.log(`❌ build-actions-transmet-tout | appels produits mais jamais executes : ${appelsPerdus.join(', ')}`); +} else { + console.log(`✅ build-actions-transmet-tout (${new Set(appelsProduits).size} appels)`); +} + // Horloge simulée : Date épinglée à l'instant du scénario dans la sandbox. function makeFakeDate(fixedMs) { return class FakeDate extends Date { @@ -122,6 +138,8 @@ function summarize(out) { sessionCommittedService: calls.helper_night_grid_session_committed?.execute ? calls.helper_night_grid_session_committed.service : null, + // Verrou P12 : service ecrit ce cycle (turn_on/turn_off), null sinon. + nightBatteryDoneService: calls.helper_night_battery_done?.execute ? calls.helper_night_battery_done.service : null, // Budget voiture de la nuit (30/09) : valeur ecrite ce cycle, null sinon. nightBudgetWrite: calls.helper_night_budget?.execute ? calls.helper_night_budget.data.value : null, // Consigne de puissance reseau ecrite ce cycle (null = aucune ecriture). @@ -132,7 +150,7 @@ function summarize(out) { } const jsonResults = {}; -let failures = 0, ran = 0; +let failures = gardeFou, ran = 0; for (const sc of scenarios) { if (only && !sc.id.includes(only)) continue; ran++; diff --git a/tests/golden/scenarios.mjs b/tests/golden/scenarios.mjs index 4c122f2..f19dbee 100644 --- a/tests/golden/scenarios.mjs +++ b/tests/golden/scenarios.mjs @@ -3132,6 +3132,21 @@ export const scenarios = [ expect: { sourceMode: 'blocked', desiredCharging: false, nightBudgetWrite: null, takeoverNeeded: false, edfOnAction: false }, }, + { + id: 'p12-verrou-pose-quand-le-budget-est-atteint', + label: 'Budget atteint en session : le verrou P12 est ecrit (une seule passe sur batterie)', + time: '2026-09-30T02:30:00+02:00', + overrides: { ...BUDGET_NUIT, ...EN_CHARGE_BATTERIE, balance_last_run: '30/09 02:25', soc_percent: '50', + night_charge_mode: 'sans_edf', night_budget_prev: '3.7|4000', evcs_total_energy_kwh: '4003.75' }, + expect: { nightBatteryDoneService: 'turn_on' }, + }, + { + id: 'p12-verrou-leve-en-fin-de-session', + label: '08:00, verrou encore pose : il est leve (sinon la charge sur batterie mourait pour toutes les nuits suivantes)', + time: '2026-09-30T08:00:00+02:00', + overrides: { ...BUDGET_NUIT, balance_last_run: '30/09 07:55', night_battery_done_prev: 'on' }, + expect: { nightBatteryDoneService: 'turn_off' }, + }, { id: 'budget-efface-en-fin-de-session', label: 'Fin de fenetre HC (08:00) : le budget fige est efface', diff --git a/workflows/evcs-surplus-controller-v2.6.json b/workflows/evcs-surplus-controller-v2.6.json index 36972b4..7423e94 100644 --- a/workflows/evcs-surplus-controller-v2.6.json +++ b/workflows/evcs-surplus-controller-v2.6.json @@ -68,7 +68,7 @@ }, { "parameters": { - "jsCode": "const root = $input.first().json || {};\nconst decision = root.decision || {};\nconst calls = root.ha_service_calls || {};\nconst context = root.context || {}; \nconst actions = [];\n\nfunction pushAction(action, call, enabled = true) {\n if (!enabled) return;\n if (!call) return;\n if (call.execute === false) return;\n\n const { execute, ...rest } = call;\n\n actions.push({\n json: {\n action,\n ...rest\n }\n });\n}\n\n// =====================================================\n// HELPERS TOUJOURS MIS À JOUR\n// =====================================================\n[\n 'helper_state',\n 'helper_current_decision',\n 'helper_reason',\n 'helper_operating_mode',\n 'helper_source_mode',\n 'helper_target_current',\n 'helper_budget_remaining',\n 'helper_projected_soc',\n 'helper_cloud_debt',\n 'helper_dynamic_ok',\n 'helper_must_stop',\n 'helper_evcs_priority_active',\n 'helper_energy_priority_mode',\n 'helper_energy_priority_reason',\n 'helper_evcs_priority_until',\n\n // ===================================================\n // NIGHT EVCS HELPERS\n // ===================================================\n 'helper_night_target_soc',\n 'helper_night_grid_trigger_soc',\n 'helper_night_preferred_source_mode',\n 'helper_night_grid_takeover_needed',\n 'helper_night_battery_recovery_urgent',\n 'helper_night_session_active',\n\n // ===================================================\n // VEHICLE DEMAND FILTER HISTORY HELPERS\n // ===================================================\n 'helper_evcs_delivery_ratio_ema',\n 'helper_evcs_delivery_noise_ema',\n 'helper_evcs_no_demand_score_ema',\n 'helper_evcs_no_demand_semantic_active',\n].forEach(name => pushAction(name, calls[name], true));\n\n// =====================================================\n// HELPERS / TIMESTAMPS CONDITIONNELS\n// =====================================================\npushAction(\n 'helper_cloud_debt_started_at',\n calls.helper_cloud_debt_started_at,\n !!decision.set_cloud_debt_started_at\n);\n\npushAction(\n 'helper_night_grid_session_committed',\n calls.helper_night_grid_session_committed,\n !!calls.helper_night_grid_session_committed?.execute\n);\n\npushAction(\n 'helper_battery_soft_breach_at',\n calls.helper_battery_soft_breach_at,\n !!calls.helper_battery_soft_breach_at?.execute\n);\n\npushAction(\n 'helper_block_reason',\n calls.helper_block_reason,\n !!decision.set_block_reason\n);\n\npushAction(\n 'helper_blocked_until',\n calls.helper_blocked_until,\n !!decision.set_blocked_until\n);\n\npushAction(\n 'helper_probe_started_at',\n calls.helper_probe_started_at,\n !!decision.set_probe_started_at\n);\n\n// =====================================================\n// NIGHT SESSION MEMORY\n// =====================================================\npushAction(\n 'helper_night_session_start_soc',\n calls.helper_night_session_start_soc,\n !!decision.set_night_session_start || !!decision.clear_night_session_start\n);\n\npushAction(\n 'helper_night_session_start_at',\n calls.helper_night_session_start_at,\n !!decision.set_night_session_start || !!decision.clear_night_session_start\n);\n\n// =====================================================\n// VEHICLE DEMAND FILTER TIMESTAMPS\n// =====================================================\npushAction(\n 'helper_evcs_no_demand_since_at',\n calls.helper_evcs_no_demand_since_at,\n !!calls.helper_evcs_no_demand_since_at?.execute\n);\n\npushAction(\n 'helper_evcs_no_demand_filter_eval_at',\n calls.helper_evcs_no_demand_filter_eval_at,\n !!calls.helper_evcs_no_demand_filter_eval_at?.execute\n);\n\n// =====================================================\n// DEMANDE RÉSEAU EDF SI SOURCE GRID\n// =====================================================\npushAction(\n 'edf_general_ok_on',\n calls.edf_general_ok_on,\n context.sourceMode === 'grid_hc_allowed' || context.sourceMode === 'grid_forced'\n);\n\n// =====================================================\n// CONSIGNE DE PUISSANCE RESEAU\n// Fermer le contacteur ne suffit pas : sans consigne, l'ESS laisse la\n// batterie porter la maison et la voiture (nuit du 01/09/2026).\n// =====================================================\npushAction(\n 'grid_power_setpoint',\n calls.grid_power_setpoint,\n !!calls.grid_power_setpoint?.execute\n);\n\n// =====================================================\n// AUTO START OFF SI BESOIN\n// =====================================================\npushAction(\n 'evcs_auto_start_off',\n calls.evcs_auto_start_off,\n decision.auto_start_action === 'turn_off'\n);\n\n// =====================================================\n// SI ON ARRÊTE : CHARGING OFF D'ABORD\n// =====================================================\npushAction(\n 'evcs_charging',\n calls.evcs_charging,\n decision.charging_action === 'turn_off'\n);\n\n// =====================================================\n// PERMISSION / LATCH\n// =====================================================\npushAction(\n 'helper_permission',\n calls.helper_permission,\n decision.permission_action !== 'none'\n);\n\npushAction(\n 'helper_latch',\n calls.helper_latch,\n decision.latch_action !== 'none'\n);\n\n// =====================================================\n// COURANT SI NÉCESSAIRE\n// =====================================================\npushAction(\n 'evcs_set_current',\n calls.evcs_set_current,\n decision.current_action === 'set'\n);\n\npushAction(\n 'helper_last_current_change_at',\n calls.helper_last_current_change_at,\n !!decision.set_last_current_change_at\n);\n\n// =====================================================\n// SI ON DÉMARRE : CHARGING ON À LA FIN\n// =====================================================\npushAction(\n 'evcs_charging',\n calls.evcs_charging,\n decision.charging_action === 'turn_on'\n);\n\n// =====================================================\n// TIMESTAMPS START / STOP\n// =====================================================\npushAction(\n 'helper_last_start_at',\n calls.helper_last_start_at,\n !!decision.set_last_start_at\n);\n\npushAction(\n 'helper_last_stop_at',\n calls.helper_last_stop_at,\n !!decision.set_last_stop_at\n);\n\n// =====================================================\n// SIGNATURE + LOGBOOK SI CHANGEMENT\n// =====================================================\npushAction(\n 'helper_signature',\n calls.helper_signature,\n !!decision.signature_changed\n);\n\npushAction(\n 'logbook',\n calls.logbook,\n !!decision.signature_changed\n);\n\n// =====================================================\n// NOTIFICATIONS\n// =====================================================\npushAction(\n 'mobile_notification',\n calls.mobile_notification,\n !!decision.should_notify\n);\n\nreturn actions;\n" + "jsCode": "const root = $input.first().json || {};\nconst decision = root.decision || {};\nconst calls = root.ha_service_calls || {};\nconst context = root.context || {}; \nconst actions = [];\n\nfunction pushAction(action, call, enabled = true) {\n if (!enabled) return;\n if (!call) return;\n if (call.execute === false) return;\n\n const { execute, ...rest } = call;\n\n actions.push({\n json: {\n action,\n ...rest\n }\n });\n}\n\n// =====================================================\n// HELPERS TOUJOURS MIS À JOUR\n// =====================================================\n[\n 'helper_state',\n 'helper_current_decision',\n 'helper_reason',\n 'helper_operating_mode',\n 'helper_source_mode',\n 'helper_target_current',\n 'helper_budget_remaining',\n 'helper_projected_soc',\n 'helper_cloud_debt',\n 'helper_dynamic_ok',\n 'helper_must_stop',\n 'helper_evcs_priority_active',\n 'helper_energy_priority_mode',\n 'helper_energy_priority_reason',\n 'helper_evcs_priority_until',\n\n // ===================================================\n // NIGHT EVCS HELPERS\n // ===================================================\n 'helper_night_target_soc',\n 'helper_night_grid_trigger_soc',\n 'helper_night_preferred_source_mode',\n 'helper_night_grid_takeover_needed',\n 'helper_night_battery_recovery_urgent',\n 'helper_night_session_active',\n\n // ===================================================\n // VEHICLE DEMAND FILTER HISTORY HELPERS\n // ===================================================\n 'helper_evcs_delivery_ratio_ema',\n 'helper_evcs_delivery_noise_ema',\n 'helper_evcs_no_demand_score_ema',\n 'helper_evcs_no_demand_semantic_active',\n].forEach(name => pushAction(name, calls[name], true));\n\n// =====================================================\n// HELPERS / TIMESTAMPS CONDITIONNELS\n// =====================================================\npushAction(\n 'helper_cloud_debt_started_at',\n calls.helper_cloud_debt_started_at,\n !!decision.set_cloud_debt_started_at\n);\n\npushAction(\n 'helper_night_grid_session_committed',\n calls.helper_night_grid_session_committed,\n !!calls.helper_night_grid_session_committed?.execute\n);\n\npushAction(\n 'helper_battery_soft_breach_at',\n calls.helper_battery_soft_breach_at,\n !!calls.helper_battery_soft_breach_at?.execute\n);\n\npushAction(\n 'helper_block_reason',\n calls.helper_block_reason,\n !!decision.set_block_reason\n);\n\npushAction(\n 'helper_blocked_until',\n calls.helper_blocked_until,\n !!decision.set_blocked_until\n);\n\npushAction(\n 'helper_probe_started_at',\n calls.helper_probe_started_at,\n !!decision.set_probe_started_at\n);\n\n// =====================================================\n// NIGHT SESSION MEMORY\n// =====================================================\npushAction(\n 'helper_night_session_start_soc',\n calls.helper_night_session_start_soc,\n !!decision.set_night_session_start || !!decision.clear_night_session_start\n);\n\npushAction(\n 'helper_night_session_start_at',\n calls.helper_night_session_start_at,\n !!decision.set_night_session_start || !!decision.clear_night_session_start\n);\n\n// =====================================================\n// VEHICLE DEMAND FILTER TIMESTAMPS\n// =====================================================\npushAction(\n 'helper_evcs_no_demand_since_at',\n calls.helper_evcs_no_demand_since_at,\n !!calls.helper_evcs_no_demand_since_at?.execute\n);\n\npushAction(\n 'helper_evcs_no_demand_filter_eval_at',\n calls.helper_evcs_no_demand_filter_eval_at,\n !!calls.helper_evcs_no_demand_filter_eval_at?.execute\n);\n\n// =====================================================\n// ECRITURES CONDITIONNELLES OUBLIEES (01/10/2026)\n// Produites par HA_SERVICE_CALLS_OUTPUT mais jamais relayees jusqu'ici :\n// - verrou P12 (`night_battery_done`, une seule passe sur batterie) depuis\n// le 15/09 ;\n// - acquittement du bouton reset borne (momentane) depuis le 19/09 ;\n// - budget voiture de la nuit fige (30/09) : la nuit du 30/09 la JEEP a pris\n// 7,5 kWh sur la batterie au lieu du budget.\n// Garde-fou : tests/golden/run.mjs verifie que tout appel produit est nomme ici.\n// =====================================================\npushAction(\n 'helper_night_battery_done',\n calls.helper_night_battery_done,\n !!calls.helper_night_battery_done?.execute\n);\n\npushAction(\n 'helper_reset_borne_ack',\n calls.helper_reset_borne_ack,\n !!calls.helper_reset_borne_ack?.execute\n);\n\npushAction(\n 'helper_night_budget',\n calls.helper_night_budget,\n !!calls.helper_night_budget?.execute\n);\n\n// =====================================================\n// DEMANDE RÉSEAU EDF SI SOURCE GRID\n// =====================================================\npushAction(\n 'edf_general_ok_on',\n calls.edf_general_ok_on,\n context.sourceMode === 'grid_hc_allowed' || context.sourceMode === 'grid_forced'\n);\n\n// =====================================================\n// CONSIGNE DE PUISSANCE RESEAU\n// Fermer le contacteur ne suffit pas : sans consigne, l'ESS laisse la\n// batterie porter la maison et la voiture (nuit du 01/09/2026).\n// =====================================================\npushAction(\n 'grid_power_setpoint',\n calls.grid_power_setpoint,\n !!calls.grid_power_setpoint?.execute\n);\n\n// =====================================================\n// AUTO START OFF SI BESOIN\n// =====================================================\npushAction(\n 'evcs_auto_start_off',\n calls.evcs_auto_start_off,\n decision.auto_start_action === 'turn_off'\n);\n\n// =====================================================\n// SI ON ARRÊTE : CHARGING OFF D'ABORD\n// =====================================================\npushAction(\n 'evcs_charging',\n calls.evcs_charging,\n decision.charging_action === 'turn_off'\n);\n\n// =====================================================\n// PERMISSION / LATCH\n// =====================================================\npushAction(\n 'helper_permission',\n calls.helper_permission,\n decision.permission_action !== 'none'\n);\n\npushAction(\n 'helper_latch',\n calls.helper_latch,\n decision.latch_action !== 'none'\n);\n\n// =====================================================\n// COURANT SI NÉCESSAIRE\n// =====================================================\npushAction(\n 'evcs_set_current',\n calls.evcs_set_current,\n decision.current_action === 'set'\n);\n\npushAction(\n 'helper_last_current_change_at',\n calls.helper_last_current_change_at,\n !!decision.set_last_current_change_at\n);\n\n// =====================================================\n// SI ON DÉMARRE : CHARGING ON À LA FIN\n// =====================================================\npushAction(\n 'evcs_charging',\n calls.evcs_charging,\n decision.charging_action === 'turn_on'\n);\n\n// =====================================================\n// TIMESTAMPS START / STOP\n// =====================================================\npushAction(\n 'helper_last_start_at',\n calls.helper_last_start_at,\n !!decision.set_last_start_at\n);\n\npushAction(\n 'helper_last_stop_at',\n calls.helper_last_stop_at,\n !!decision.set_last_stop_at\n);\n\n// =====================================================\n// SIGNATURE + LOGBOOK SI CHANGEMENT\n// =====================================================\npushAction(\n 'helper_signature',\n calls.helper_signature,\n !!decision.signature_changed\n);\n\npushAction(\n 'logbook',\n calls.logbook,\n !!decision.signature_changed\n);\n\n// =====================================================\n// NOTIFICATIONS\n// =====================================================\npushAction(\n 'mobile_notification',\n calls.mobile_notification,\n !!decision.should_notify\n);\n\nreturn actions;\n" }, "type": "n8n-nodes-base.code", "typeVersion": 2, @@ -165,7 +165,7 @@ }, { "parameters": { - "jsCode": "function minutesSince(ts, nowMs) {\n if (ts === null || nowMs === null) return null;\n return Math.max(0, (nowMs - ts) / 60000);\n}\n\nfunction secondsSince(ts, nowMs) {\n if (ts === null || nowMs === null) return null;\n return Math.max(0, (nowMs - ts) / 1000);\n}\n\nfunction isWithinWindow(nowMin, startMin, stopMin) {\n if (!Number.isFinite(startMin) || !Number.isFinite(stopMin)) return false;\n if (startMin === stopMin) return false;\n if (startMin < stopMin) return nowMin >= startMin && nowMin < stopMin;\n return nowMin >= startMin || nowMin < stopMin;\n}\n\n// Temps restant dans une fenetre, en minutes, ou null si on n'y est pas.\n//\n// ⚠️ IL VIT ICI, A COTE DE `isWithinWindow`, ET PAS DANS LE NOEUD QUI L'UTILISE.\n// Ce qui serait duplique ailleurs n'est pas l'analyse des heures — INPUT_PARSE\n// les a deja converties en minutes — c'est la LOGIQUE D'APPARTENANCE, et en\n// particulier le traitement du passage de minuit. Une seconde interpretation\n// divergerait le jour ou une borne bouge, et elles ont deja bouge une fois\n// (01:30 -> 01:24 le 09/07/2026).\nfunction minutesRemainingInWindow(nowMin, startMin, stopMin) {\n if (!isWithinWindow(nowMin, startMin, stopMin)) return null;\n if (startMin < stopMin) return stopMin - nowMin;\n // Fenetre a cheval sur minuit : avant minuit il reste la fin de la nuit EN\n // PLUS du reste de la journee. Atteignable par les valeurs de repli\n // d'INPUT_PARSE (23:00 -> 06:00) si les helpers d'horaire sont illisibles.\n return nowMin >= startMin ? stopMin + 1440 - nowMin : stopMin - nowMin;\n}\n\nfunction clamp(v, min, max) {\n return Math.min(Math.max(v, min), max);\n}\n\nconst prev = $input.first().json;\nconst {\n input,\n nowMs,\n now,\n cfg,\n parsed,\n params,\n history,\n batteryTune\n} = prev;\n\n// =====================================================\n// STRATEGY\n// =====================================================\nconst userStrategy = (params.userStrategyRaw ?? 'auto_solar').toString().trim().toLowerCase();\n\nconst isStopMode = userStrategy === 'stop';\nconst forceGridRequested = userStrategy === 'force_grid_now';\nconst forceChargeRequested = userStrategy === 'force_charge_now';\nconst isForceMode = forceGridRequested || forceChargeRequested;\n\n// =====================================================\n// LIFECYCLE / TIMING\n// =====================================================\nconst minutesSinceStart = minutesSince(history.lastStartMs, nowMs);\nconst minutesSinceStop = minutesSince(history.lastStopMs, nowMs);\nconst secondsSinceCurrentChange = secondsSince(history.lastCurrentChangeMs, nowMs);\nconst minutesSinceProbeStart = minutesSince(history.probeStartedMs, nowMs);\n\nconst minRunElapsed =\n minutesSinceStart === null ? true : minutesSinceStart >= params.minRunMinutes;\n\nconst minStopElapsed =\n minutesSinceStop === null ? true : minutesSinceStop >= params.minStopMinutes;\n\nconst currentCooldownBaseSec = parsed.evcsChargingSwitchOn\n ? batteryTune.global.stepDownCooldownSeconds\n : batteryTune.global.stepUpCooldownSeconds;\n\nconst currentCooldownElapsed =\n secondsSinceCurrentChange === null\n ? true\n : secondsSinceCurrentChange >= Math.max(\n params.currentChangeCooldownSeconds,\n currentCooldownBaseSec\n );\n\n// =====================================================\n// DEPARTURE PRIORITY\n// =====================================================\nconst minutesUntilDeparture =\n params.departureTimeMin === null\n ? null\n : (() => {\n const rawDiff = params.departureTimeMin - now.minNow;\n return rawDiff >= 0 ? rawDiff : (1440 - now.minNow + params.departureTimeMin);\n })();\n\nconst departurePriorityActive =\n parsed.departurePriorityEnabled &&\n Number.isFinite(minutesUntilDeparture) &&\n minutesUntilDeparture >= 0 &&\n minutesUntilDeparture <= cfg.departurePriorityWindowMinutes &&\n parsed.evcsVehicleConnected;\n\n// =====================================================\n// MANUAL CHARGE SEMANTICS\n// IMPORTANT:\n// force_charge_now = bypass fenêtres pour la charge,\n// MAIS PAS demande implicite de réseau.\n// =====================================================\nconst manualChargeBypassWindows = forceChargeRequested;\nconst manualChargeLocalRequested = forceChargeRequested && !forceGridRequested;\n\n// Compatibilité ascendante : on garde le champ dans context,\n// mais on le neutralise volontairement.\nconst forceChargeDepartureGridOverride = false;\n\n// =====================================================\n// SYSTEM DERIVED VALUES\n// =====================================================\nconst acVoltageRef =\n Number.isFinite(parsed.vebusAcOutVoltageV) && parsed.vebusAcOutVoltageV > 180\n ? parsed.vebusAcOutVoltageV\n : cfg.nominalAcVoltageV;\n\nconst batteryVoltageRef =\n Number.isFinite(parsed.vebusDcVoltageV) && parsed.vebusDcVoltageV > 0\n ? parsed.vebusDcVoltageV\n : 51.2;\n\nconst houseNonEvcsW = Math.max(0, parsed.houseAcPowerW - parsed.evcsAcPowerW);\nconst vebusNonEvcsW = Math.max(0, parsed.vebusAcOutPowerW - parsed.evcsAcPowerW);\n\nconst totalVebusSoftCeilingW = Math.max(6500, params.acLoadsMaxSafeW || 8500);\nconst totalVebusHardCeilingW = totalVebusSoftCeilingW + 400;\n\n// =====================================================\n// FENETRE D'ETABLISSEMENT DU RESEAU (31/08/2026)\n// =====================================================\n// Mesure du 31/08 : a la fermeture du contacteur, le reseau reprend d'un coup\n// TOUTE la charge de la maison et le solaire bascule vers la batterie. Batterie\n// preneuse, la production ne bouge pas ; batterie pleine, elle s'effondre (2663\n// -> 559 W) faute de destination. Dans les deux cas l'import decroit ensuite\n// jusqu'a la consigne en ~150 s (1985 -> 25 W, puis 2600 -> 52 W).\n//\n// Pendant cette phase, PV, import et courant batterie ne decrivent aucun regime\n// permanent : la boucle de 30 s y prend cinq decisions, et la moyenne PV 10 min\n// en garde la trace une dizaine de minutes de plus. Le systeme lit \"plus de\n// soleil\", en conclut qu'il a besoin du reseau, et confirme ainsi sa propre\n// premisse -- une boucle de rattrapage, comme les trois du 24/08.\n//\n// ASYMETRIE VOULUE : la fenetre ne s'ouvre qu'a l'ARRIVEE du reseau. A sa\n// disparition, les onduleurs reprennent instantanement toute la charge : les\n// gardes doivent agir plus vite, jamais moins vite.\nconst gridSettling =\n parsed.gridAvailable &&\n parsed.gridTransitionAgeSec < params.gridSettleSeconds;\n\nconst endDayGuardSoc = Math.max(params.endOfDaySocGuard, params.endOfDaySocTarget);\n\nconst energyNeededForEndDayGuardKwh = Math.max(\n 0,\n ((endDayGuardSoc - parsed.soc) / 100) * cfg.batteryUsableCapacityKwh\n);\n\nconst forecastBufferKwh =\n parsed.todayRemainingKwh -\n params.baseLoadRemainingKwh -\n params.flexibleSafetyMarginKwh -\n params.requiredEnergyKwh -\n energyNeededForEndDayGuardKwh;\n\nconst projectedEndSocAfterCharge = clamp(\n parsed.soc + (\n (\n parsed.todayRemainingKwh -\n params.baseLoadRemainingKwh -\n params.flexibleSafetyMarginKwh -\n params.requiredEnergyKwh\n ) / cfg.batteryUsableCapacityKwh\n ) * 100,\n 0,\n 100\n);\n\nconst currentReferenceA = clamp(\n Number.isFinite(parsed.evcsCommandCurrentA)\n ? parsed.evcsCommandCurrentA\n : Number.isFinite(parsed.evcsSetCurrentA)\n ? parsed.evcsSetCurrentA\n : params.targetCurrentAutoA,\n 0,\n 32\n);\n\nconst anyVebusOverload = parsed.vebusOverload || parsed.vebusL1Overload;\nconst anyVebusLowBattery = parsed.vebusLowBattery || parsed.vebusL1LowBattery;\n\nconst vebusAcOutSoftReached = parsed.vebusAcOutPowerW >= totalVebusSoftCeilingW;\nconst vebusAcOutHardReached = parsed.vebusAcOutPowerW >= totalVebusHardCeilingW;\n\nconst vebusDcVoltageSoftLow =\n Number.isFinite(parsed.vebusDcVoltageV) &&\n parsed.vebusDcVoltageV > 0 &&\n parsed.vebusDcVoltageV <= cfg.vebusDcVoltageSoftFloorV;\n\nconst vebusDcVoltageHardLow =\n Number.isFinite(parsed.vebusDcVoltageV) &&\n parsed.vebusDcVoltageV > 0 &&\n parsed.vebusDcVoltageV <= cfg.vebusDcVoltageHardFloorV;\n\nconst houseLoadHighVsHeadroom = houseNonEvcsW > params.houseAcHeadroomW;\n\n// =====================================================\n// WINDOWS / PROFILE\n// =====================================================\nconst inNightHcWindow = isWithinWindow(now.minNow, params.hcNightStartMin, params.hcNightStopMin);\nconst inMorningWindow = isWithinWindow(now.minNow, params.morningStartMin, params.morningStopMin);\nconst inMiddayWindow = isWithinWindow(now.minNow, params.hcMiddayStartMin, params.hcMiddayStopMin);\nconst inEveningWindow = isWithinWindow(now.minNow, params.eveningStartMin, params.eveningStopMin);\n\nconst profile =\n inNightHcWindow ? 'night_hc' :\n inMorningWindow ? 'morning_solar' :\n inMiddayWindow ? 'midday_hc_or_solar' :\n inEveningWindow ? 'evening_high_soc' :\n 'outside_window';\n\n// =====================================================\n// EVCS NIGHT PREVIEW ENGINE\n// =====================================================\nconst nightSessionActive =\n profile === 'night_hc' &&\n parsed.evcsVehicleConnected;\n\nconst nightSessionStartSocPct =\n nightSessionActive\n ? (\n Number.isFinite(history.nightSessionStartSocPct)\n ? history.nightSessionStartSocPct\n : parsed.soc\n )\n : null;\n\nconst nightSessionStartAt =\n nightSessionActive\n ? (history.nightSessionStartAt || '')\n : '';\n\nconst nightSessionStartMs =\n nightSessionActive\n ? (history.nightSessionStartMs ?? null)\n : null;\n\nconst nightSessionNeedsCapture =\n nightSessionActive &&\n (\n !Number.isFinite(history.nightSessionStartSocPct) ||\n !nightSessionStartAt ||\n nightSessionStartMs === null\n );\n\nconst nightSessionNeedsClear =\n !nightSessionActive &&\n (\n Number.isFinite(history.nightSessionStartSocPct) ||\n !!history.nightSessionStartAt\n );\n\nconst batteryHardFloorPct =\n params.evcsNightBatteryHardFloorPct;\n\n// P4 : la nuit avant minuit, today_remaining ~ 0 => utiliser la prévision du lendemain.\nconst nightForecastHour = now.hour;\nconst nightForecastKwh =\n (nightForecastHour >= 20 || nightForecastHour < 8)\n ? Math.max(parsed.todayRemainingKwh, parsed.tomorrowKwh || 0)\n : parsed.todayRemainingKwh;\n\nconst batteryMorningTargetRulePct =\n nightForecastKwh >= params.evcsNightStrongForecastKwh\n ? params.evcsNightTargetStrongForecastPct\n : nightForecastKwh >= params.evcsNightMediumForecastKwh\n ? params.evcsNightTargetMediumForecastPct\n : params.evcsNightTargetWeakForecastPct;\n\n// =====================================================\n// LE CONSEILLER NE FIXE PLUS LA CIBLE (retire le 13/09/2026)\n// =====================================================\n// Le premier cablage reel (09/08/2026, HANDOFF §20) faisait suivre a la cible\n// du matin `input_number.evcs_advisor_night_target_soc`, derriere un toggle,\n// un statut `ok`, une fraicheur de 120 min et un bornage.\n// Le 12/09 le conseiller est devenu OBSERVATEUR : il n'emet plus de cible, et\n// ce helper est fige depuis. Les quatre gardes continuaient pourtant de passer\n// toutes les quatre — la garde de fraicheur date le RUN, pas la VALEUR, et le\n// conseiller tourne toujours. Activer le toggle aurait donc applique un chiffre\n// perime sans qu'aucune garde ne s'y oppose. Un cablage qui ne peut plus que se\n// tromper se retire ; le scenario `advisor-cible-ignoree` le verifie.\nconst batteryMorningTargetBasePct = batteryMorningTargetRulePct;\n\n// =====================================================\n// CIBLE DU MATIN : UNE ENERGIE A COUVRIR, PAS UN PALIER (03/09/2026)\n// =====================================================\n// La regle historique classe la journee du lendemain en trois paliers\n// (>= 18 kWh -> 35 %, >= 10 -> 55 %, sinon 85 %) a partir de son TOTAL. Elle\n// est donc aveugle a l'heure d'arrivee de l'energie : le 03/09, demain\n// annoncait 33,9 kWh — « fort », donc 35 % — mais avec un matin creux\n// (0,03 kWh a 7 h, 0,77 a 8 h, 1,81 a 9 h) et un croisement a 9 h.\n//\n// C'est le meme defaut que le plancher du soir (cf. le bloc BILAN de\n// BATTERY_CLOUD_LOCKS), a l'autre bout de la nuit : un palier fige contre une\n// energie mesurable. Le bilan publie la seconde, instant par instant.\n//\n// RELEVE DE LA NUIT 02-03/09, qui a motive ce cablage :\n//\n// heure requis SOC reel cible fixe\n// 00:30 62,4 53 35\n// 03:00 50,4 45 35 <- EDF relache ici, 5 points sous la ligne\n// 04:30 41,7 37 35\n// 06:00 37,1 22 35 <- hysteresis, puis reseau PLEIN TARIF\n// de 07:24 a 10:33 (1,49 kWh)\n//\n// La batterie est restee sous la ligne toute la nuit et le bilan le savait des\n// 00:30. Le `required_soc` decroit proprement avec la nuit qui se consomme\n// (6,1 -> 0 kWh a couvrir) : c'est bien une cible instantanee, pas une\n// constante deguisee.\n//\n// SENS UNIQUE, comme pour le plancher du soir : le bilan ne peut que RELEVER\n// la cible, jamais l'abaisser. La direction corrigee est celle de l'echec\n// observe (cible trop basse -> batterie a plat avant le croisement -> reseau\n// au plein tarif le matin). L'abaisser — la regle dit 85 % l'hiver quand le\n// bilan dira parfois moins — ouvrirait un risque neuf que rien n'a encore\n// valide. Le principe unificateur : le bilan ne pousse JAMAIS dans le sens\n// « couvre moins bien la nuit ».\n//\n// PLAFOND 90 % : au-dela, la batterie cesse d'etre un puits et la production\n// du matin se fait evincer (cf. mesures du 31/08). Charger au reseau au-dela\n// de 90 echangerait un kWh bon marche cette nuit contre plusieurs kWh censures\n// demain. C'est deja la borne du chemin advisor : aucune constante nouvelle.\n//\n// PAS D'OSCILLATEUR : `required_soc` decroit de facon monotone pendant la nuit\n// (l'energie restant a couvrir ne fait que diminuer), et la passe unique de\n// recovery (`nightRecoveryDonePrev`, 27/08) reste en place.\n//\n// REPLI identique au plancher du soir : fraicheur, statut, valeur finie —\n// sinon regle historique. Kill-switch existant : `evcs_balance_enabled` OFF\n// fige `balance_last_run` et la cible retombe seule sur les paliers.\nconst BALANCE_TARGET_MAX_AGE_MIN = 30;\nconst balanceTargetAgeMin = minutesSince(params.balanceLastRunMs, nowMs);\nconst balanceMorningTargetApplied =\n params.balanceStatusOk &&\n Number.isFinite(params.balanceRequiredSocPct) &&\n balanceTargetAgeMin !== null &&\n balanceTargetAgeMin <= BALANCE_TARGET_MAX_AGE_MIN;\n\nconst batteryMorningTargetPct = balanceMorningTargetApplied\n ? Math.min(90, Math.max(batteryMorningTargetBasePct, params.balanceRequiredSocPct))\n : batteryMorningTargetBasePct;\n\nconst maxBatteryDischargePoints =\n nightSessionActive && Number.isFinite(nightSessionStartSocPct)\n ? Math.max(0, nightSessionStartSocPct - batteryMorningTargetPct)\n : 0;\n\nconst gridTakeoverTriggerPct =\n Math.max(batteryHardFloorPct, batteryMorningTargetPct);\n\nconst batteryBelowHardFloor =\n parsed.soc <= batteryHardFloorPct;\n\nconst batteryBelowMorningTarget =\n parsed.soc < batteryMorningTargetPct;\n\nconst batteryAtOrBelowGridTakeoverTrigger =\n parsed.soc <= gridTakeoverTriggerPct;\n\nconst batteryKwhMissingToTarget =\n nightSessionActive\n ? Math.max(\n 0,\n ((batteryMorningTargetPct - parsed.soc) / 100) * cfg.batteryUsableCapacityKwh\n )\n : 0;\n\nconst batteryRecoveryMinutesNeeded =\n nightSessionActive && params.evcsNightBatteryRecoveryPowerKw > 0\n ? (batteryKwhMissingToTarget / params.evcsNightBatteryRecoveryPowerKw) * 60\n : 0;\n\nconst batteryRecoveryUrgent =\n nightSessionActive &&\n Number.isFinite(minutesUntilDeparture) &&\n minutesUntilDeparture >= 0 &&\n (\n batteryRecoveryMinutesNeeded + params.evcsNightDepartureSafetyMin\n >= minutesUntilDeparture\n );\n\n// P2 ANTI-CRENEAUX : continuité du takeover réseau nocturne.\n// Une fois le réseau HC engagé pendant une session nuit, on y reste\n// jusqu'à la fin de la session (fenêtre/débranchement) ou batterie quasi pleine.\n// Évite le cycle batterie->jeep->réseau->batterie (pertes + charge hachée).\nconst NIGHT_TAKEOVER_RELEASE_SOC_PCT = 90;\nconst nightTakeoverPrev =\n (input.night_takeover_prev ?? 'off').toString().trim().toLowerCase() === 'on';\n\n// P8 (07/08/2026) : \"charge terminée\" COLLANT via l'état surplus du cycle\n// précédent (helper input_text.victron_evcs_surplus_state relu par le snapshot).\n// Chaîne : VDF stoppe la charge (state vehicle_full_or_paused) → ce flag s'allume\n// → DECISION émet night_session_done_idle → le flag s'auto-entretient jusqu'au\n// débranchement (state vehicle_not_connected) ou à la fin de la fenêtre nuit.\nconst prevSurplusState = (input.prev_surplus_state ?? '').toString().trim().toLowerCase();\n\n// P11 (12/08/2026) : RESET du collant \"véhicule plein\" au CHANGEMENT DE\n// VÉHICULE. Incident 12/08 : JEEP chargée → night_recovery_done_idle collant ;\n// débranchement JEEP + branchement VW → l'état collant a survécu et bloquait\n// la VW (« charge terminée » = celle de la JEEP, pas de la VW).\n// Signal fiable : sensor.victron_evcs_charging_time repart de 0 à chaque\n// nouvelle connexion physique. Sous ~90 s → session neuve → on neutralise le\n// collant pour laisser le système RETESTER le véhicule fraîchement branché.\nconst P11_FRESH_SESSION_MAX_SEC = 90;\n// P11-bis (19/09/2026) : le charging_time de la borne repart de 0 sur un\n// debranchement FRANC, mais reste FIGE sur un swap trop rapide (14 s le 19/09 :\n// VW debranchee, JEEP branchee, borne gelee a l'etat VW). P11 restait alors\n// aveugle et le collant \"vehicule plein\" de la VW bloquait la JEEP. Le\n// last_changed du capteur de connexion, lui, a bien bouge sur ce swap : c'est\n// un SECOND signal de fraicheur, independant de la borne. Meme fenetre (90 s =\n// 3 cycles de 30 s, assez pour capter un (re)branchement entre deux cycles).\n// Cout d'un faux positif (glitch MQTT sub-seconde) : au pire un retest, que le\n// statut 3 / VDF re-latche aussitot si le vehicule est vraiment plein.\nconst sessionFresh =\n parsed.evcsVehicleConnected &&\n (\n params.resetBorneRequested || // echappatoire manuelle (bouton reset borne)\n (Number.isFinite(parsed.evcsChargingTimeSec) &&\n parsed.evcsChargingTimeSec <= P11_FRESH_SESSION_MAX_SEC) ||\n (Number.isFinite(parsed.evcsVehicleConnectedAgeSec) &&\n parsed.evcsVehicleConnectedAgeSec <= P11_FRESH_SESSION_MAX_SEC)\n );\n\n// Fix D (08/08/2026) : le statut natif EVCS \"charged\" (3) vaut aussi fin de\n// charge, sans attendre le VehicleDemandFilter (désarmé si la session a été\n// stoppée par une garde, ex battery_current_hard_stop → détection tardive\n// observée la nuit du 07-08/08).\n// Fix E (09/08/2026) : garde puissance ≤ 300 W pour ne jamais latcher \"fini\"\n// sur un glitch de statut pendant une vraie charge. NB : le statut retombe\n// (3→1/6) dès l'ouverture du relais → la persistance est assurée par l'état\n// collant écrit par DECISION pendant la session (voir Fix E DECISION).\n// P11 : sur une session neuve, on ignore aussi un éventuel statut 3 résiduel\n// (le temps que la borne renégocie avec le nouveau véhicule).\n// Fix H (07/09/2026) : un statut \"charged\" n'est pas cru sur parole.\n// La borne l'a annonce le 07/09 a 14:28:18 apres 2 min 42 s de charge et\n// 0,10 kWh livre, ce qui a latche `day_vehicle_full_idle` pour deux heures\n// alors que le vehicule pouvait charger (il a repris a 16:30 et tenu).\n//\n// Aucun signal ELECTRIQUE instantane ne separe une vraie fin de charge d'une\n// interruption : mesure a la seconde des deux coupures, elles sont\n// superposables (VW 06/09, vraie fin : 1534 -> 550 -> 82 -> 11 -> 0 W ;\n// JEEP 07/09, faux positif : 1626 -> 741 -> 100 -> 13 -> 0 W). Seule la\n// DUREE de la session les distingue, et le systeme porte deja la grandeur qui\n// dit ce qu'est une charge etablie : min_run_minutes (29 min en production).\n//\n// Deux formes de \"charged\" sont credibles, et une seule ne l'est pas :\n// energie de session nulle -> le vehicule etait plein en arrivant (P10)\n// session >= min_run_minutes -> charge menee a son terme\n// entre les deux -> charge INTERROMPUE : on ne latche pas\n// Convergence : une session interrompue redemarre a energie nulle, donc un\n// second \"charged\" immediat retombe dans le premier cas et latche.\nconst chargedSessionEnergyKwh = Math.max(0, parsed.evcsSessionEnergyKwh || 0);\nconst chargedSessionEstablished =\n Number.isFinite(parsed.evcsChargingTimeSec) &&\n parsed.evcsChargingTimeSec >= params.minRunMinutes * 60;\nconst chargedCredible =\n chargedSessionEnergyKwh <= 0 || chargedSessionEstablished;\n\nconst evcsStatusCharged =\n !sessionFresh &&\n Number.parseFloat(parsed.evcsStatusRaw) === 3 &&\n Math.max(0, parsed.evcsAcPowerW || 0) <= 300 &&\n chargedCredible;\n\nconst vehicleChargeCompleted =\n nightSessionActive &&\n !sessionFresh &&\n (\n prevSurplusState === 'vehicle_full_or_paused' ||\n prevSurplusState === 'night_session_done_idle' ||\n prevSurplusState === 'night_recovery_done_idle' ||\n prevSurplusState === 'day_vehicle_full_idle' ||\n evcsStatusCharged\n );\n\n// Fix A (08/08/2026) : recovery batterie post-charge = UNE SEULE passe.\n// Sans flag collant, SOC==cible est un point dégénéré (engage soc<=cible,\n// release soc>cible, l'ACM recharge pile à la cible) → rebond EDF ON/OFF\n// période ~10 min observé la nuit du 07-08/08. Une fois la cible atteinte,\n// l'état 'night_recovery_done_idle' (auto-entretenu via prev_surplus_state)\n// coupe le takeover. Ré-engagement uniquement sous cible − 2 pts (bande),\n// hard floor et fenêtre/débranchement restent prioritaires.\n// ⚠ 'night_recovery_done_idle' ne doit matcher AUCUN pattern no-demand de\n// l'hystérésis (full_or_paused / not_requesting_power / charge_complete /\n// no_demand) — même contrainte que 'night_session_done_idle'.\n// 27/08/2026 : la bande de 2 points fabriquait un oscillateur. Relevé de la\n// nuit du 26-27/08 (cible 35) : engagement à SOC 33, relâchement à 35, trois\n// cycles EDF de 52, 79 et 25 min, 2,5 kWh importés pour rien un matin de grand\n// soleil ; la nuit du 20-21/08 en comptait six. Deux points de SOC valent\n// ~0,34 kWh, soit une demi-heure de conso nocturne : ce n'est pas une\n// hystérésis, c'est la période d'une horloge — d'autant plus régulière que la\n// charge de nuit est parfaitement plate.\n// La recovery batterie post-charge est donc UNE SEULE passe par session de\n// nuit, ce qui était déjà l'intention affichée du Fix A (\"relâché\n// DÉFINITIVEMENT\"). Aucun seuil de ré-engagement ici : le plancher dur reste\n// un terme indépendant de nightGridTakeoverNeeded (voir plus bas), et\n// l'hystérésis réseau garde sa propre bande 20/30. Le vrai critère de\n// ré-engagement est un budget d'énergie restant à couvrir, pas un delta de\n// SOC : il appartient au bilan énergétique, pas à une constante de plus ici.\nconst nightRecoveryDonePrev = prevSurplusState === 'night_recovery_done_idle';\nconst nightRecoveryDone =\n vehicleChargeCompleted &&\n (parsed.soc >= batteryMorningTargetPct || nightRecoveryDonePrev);\n\n// =====================================================\n// P9 (09/08/2026) : CHARGE RÉSEAU AVANT DÉPART, HORS FENÊTRE HC.\n// Cas VW : départ 08:50 > fin HC 07:24 → fenêtre morte où le véhicule\n// sous-chargé attendait un solaire insuffisant (observé le 09/08 : advisor\n// force_grid_now, divergence n°10). Si le toggle est ON et que le départ du\n// véhicule SÉLECTIONNÉ est dans ≤ 120 min : réseau autorisé (grid_hc_allowed)\n// jusqu'à fin de charge. L'arrêt réutilise TOUTE la mécanique anti-rebond :\n// VDF → vehicle_full_or_paused → état collant 'departure_done_idle'\n// (réécrit chaque cycle, patterns hystérésis sûrs, coupure immédiate E2).\n// =====================================================\nconst P9_DEPARTURE_LEAD_MIN = 120;\n\nconst departureWindowActive =\n params.allowGridDepartureUrgent &&\n parsed.evcsVehicleConnected &&\n !inNightHcWindow &&\n Number.isFinite(minutesUntilDeparture) &&\n minutesUntilDeparture >= 0 &&\n minutesUntilDeparture <= P9_DEPARTURE_LEAD_MIN;\n\nconst departureDonePrev =\n prevSurplusState === 'departure_done_idle' ||\n (!inNightHcWindow && prevSurplusState === 'vehicle_full_or_paused');\n\nconst departureChargeDone =\n departureWindowActive &&\n !sessionFresh &&\n (departureDonePrev || evcsStatusCharged);\n\nconst departureUrgentActive =\n departureWindowActive &&\n !departureChargeDone;\n\n// =====================================================\n// P10 (09/08/2026) : MÉMOIRE \"VÉHICULE PLEIN\" EN JOURNÉE.\n// Observé le 09/08 09:49 : VW pleine branchée depuis 07:00, le surplus\n// solaire retentait une charge (statut \"Charged\", 0 W) toutes les ~15 min\n// toute la journée (usure relais, bruit). Les états collants P8/P9 étaient\n// scopés nuit/fenêtre départ. Ici : une fois le véhicule vu plein (statut 3\n// ou VDF), état collant 'day_vehicle_full_idle' jusqu'au DÉBRANCHEMENT.\n// - hérite des états \"done\" de la nuit (07:24 → pas de re-tentative) ;\n// - la nuit hérite en retour (vehicleChargeCompleted inclut day_*) ;\n// - échappatoires : force_charge_now / force_grid_now / débrancher-rebrancher.\n// =====================================================\nconst dayVehicleFullPrev =\n prevSurplusState === 'day_vehicle_full_idle' ||\n (\n !inNightHcWindow &&\n (\n prevSurplusState === 'vehicle_full_or_paused' ||\n prevSurplusState === 'night_recovery_done_idle' ||\n prevSurplusState === 'night_session_done_idle' ||\n prevSurplusState === 'departure_done_idle'\n )\n );\n\nconst dayVehicleFullIdle =\n parsed.evcsVehicleConnected &&\n !sessionFresh &&\n !inNightHcWindow &&\n !isStopMode &&\n !forceGridRequested &&\n !forceChargeRequested &&\n (dayVehicleFullPrev || evcsStatusCharged);\n\n// P8 : correction du fix 04/08 (logique inversée : \"|| vehicleChargeCompleted\"\n// GARDAIT le latch après la charge au lieu de le libérer).\nconst nightTakeoverLatched =\n nightSessionActive &&\n nightTakeoverPrev &&\n parsed.soc < NIGHT_TAKEOVER_RELEASE_SOC_PCT &&\n !vehicleChargeCompleted;\n\n// =====================================================\n// P12 — CHARGE NUIT SUR LA BATTERIE (15/09/2026)\n// =====================================================\n// Idee du proprietaire : plutot qu'acheter du reseau la nuit pour charger la\n// voiture, lui donner le solaire DEJA STOCKE, doucement, et laisser ainsi de la\n// place dans la batterie pour la production du lendemain. Payer du reseau la\n// nuit pour ecreter le solaire le lendemain, c'est perdre deux fois.\n//\n// Le mode `battery_assist_night` existe depuis toujours pour exactement ca, et\n// il est MORT depuis toujours : il exige `batteryCurrentA >= plancher de\n// decharge` (0 A), impossible la nuit puisque la maison tire sur la batterie\n// (§11 du HANDOFF, ~ -14 A). On ne cree donc pas un chemin, on rend viable\n// celui qui existe — en remplacant un test de COURANT impossible par un test\n// d'ENERGIE : rester au-dessus de ce qu'il faut pour tenir jusqu'au croisement.\n//\n// Le plancher n'est PAS une constante nouvelle : c'est `required_soc_prudent`,\n// que le bilan publie deja, en profil p80. La marge est mesuree, pas inventee.\nconst nightBatterySurplusFloorPct = Math.max(\n batteryHardFloorPct,\n Number.isFinite(params.balanceRequiredSocPrudentPct)\n ? params.balanceRequiredSocPrudentPct\n : Infinity,\n);\n\n// ⚠️ LE VERROU, ET IL N'EST PAS DECORATIF. Le plancher DESCEND pendant la nuit\n// (il reste moins de nuit a couvrir) et le SOC aussi. Mesure du 15/09 :\n//\n// voiture en charge : SOC -7,1 pt/h, plancher -3,7 pt/h -> le surplus s'epuise\n// voiture arretee : SOC -2,6 pt/h, plancher -3,7 pt/h -> le surplus REVIENT\n//\n// Sans verrou, la charge repartirait des qu'elle s'arrete : cinquieme\n// oscillateur du projet, par construction. UNE SEULE PASSE de surplus par\n// session de nuit, comme la recovery batterie depuis le 27/08.\n// BUDGET VOITURE DE LA NUIT (30/09/2026, decisions du proprietaire).\n// « Definir combien la voiture peut prendre sans utiliser EDF, et brider la\n// charge a cette valeur ou autoriser une charge complete avec EDF. »\n// Le bilan publie le budget ; il est FIGE au premier cycle de la session\n// (avec l'index du compteur de la borne) dans input_text\n// victron_evcs_night_budget = « budget|index », efface en fin de session.\n// L'arret se juge sur le COMPTEUR, qui ne fait que monter : condition\n// monotone, aucun oscillateur possible (meme logique que §40). Le plancher de\n// SOC prudent reste un second arret, jamais retire.\nconst nightBudgetPrev = (() => {\n const m = String(params.nightBudgetPrevRaw || '').match(/^\\s*([\\d.]+)\\|([\\d.]+)\\s*$/);\n return m ? { budgetKwh: Number(m[1]), refKwh: Number(m[2]) } : null;\n})();\nconst nightBudgetCaptureOk =\n nightSessionActive && !nightBudgetPrev &&\n balanceMorningTargetApplied &&\n Number.isFinite(params.balanceNightVehicleBudgetKwh) &&\n Number.isFinite(params.evcsTotalEnergyKwh);\nconst nightBudget = !nightSessionActive\n ? null\n : nightBudgetPrev || (nightBudgetCaptureOk\n ? { budgetKwh: Math.max(0, params.balanceNightVehicleBudgetKwh), refKwh: params.evcsTotalEnergyKwh }\n : null);\nconst nightBudgetToWrite = nightSessionActive\n ? (nightBudgetCaptureOk ? `${Math.round(nightBudget.budgetKwh * 100) / 100}|${nightBudget.refKwh}` : null)\n : (nightBudgetPrev ? '' : null);\nconst nightBudgetDeliveredKwh = nightBudget && Number.isFinite(params.evcsTotalEnergyKwh)\n ? Math.max(0, params.evcsTotalEnergyKwh - nightBudget.refKwh)\n : null;\nconst nightBudgetReached =\n !!nightBudget && nightBudgetDeliveredKwh !== null &&\n nightBudgetDeliveredKwh >= nightBudget.budgetKwh - 0.05;\nconst nightChargeComplete = params.nightChargeMode === 'complete';\n\nconst nightBatterySurplusExhausted =\n params.nightBatteryDonePrev ||\n (nightSessionActive && parsed.soc <= nightBatterySurplusFloorPct) ||\n (nightSessionActive && nightBudgetReached);\n\nconst nightBatterySurplusAvailable =\n nightSessionActive &&\n params.nightBatteryChargeEnabled &&\n balanceMorningTargetApplied &&\n Number.isFinite(params.balanceRequiredSocPrudentPct) &&\n !vehicleChargeCompleted &&\n // Le plancher est teste UNE SEULE FOIS, dans `nightBatterySurplusExhausted`.\n // Le retester ici serait redondant — et une redondance ne se teste pas : la\n // mutation du 15/09 l'a montre en ne cassant aucun scenario.\n !nightBatterySurplusExhausted;\n\n// La batterie d'abord, le reseau quand le surplus est epuise. C'est la soupape\n// decrite par le proprietaire — « quitte a fermer EDF entre 6h et 7h24 pour\n// completer » — et elle devient automatique : l'epuisement du surplus rend\n// `nightGridHcPreferred` vrai, donc P7 prend le relais.\n// « Sans EDF » (defaut) : la voiture ne prend JAMAIS le reseau la nuit ; une\n// fois le budget batterie atteint (ou sans budget exploitable), elle attend le\n// soleil. « Charge complete » : EDF en HC finit la charge, comme avant.\nconst nightGridHcPreferred =\n nightSessionActive &&\n params.allowGridHcNight &&\n nightChargeComplete &&\n !vehicleChargeCompleted &&\n !nightBatterySurplusAvailable;\n\nconst nightCarWaitsNoGrid =\n nightSessionActive &&\n !nightChargeComplete &&\n !vehicleChargeCompleted &&\n !nightBatterySurplusAvailable;\n\nconst batteryAssistAllowed =\n nightSessionActive &&\n !nightGridHcPreferred &&\n !nightCarWaitsNoGrid &&\n parsed.soc > batteryHardFloorPct &&\n parsed.soc > gridTakeoverTriggerPct &&\n !nightTakeoverLatched &&\n !batteryRecoveryUrgent;\n\n// =====================================================\n// SESSION RESEAU UNIQUE DE LA NUIT (03/09/2026)\n// =====================================================\n// §26 avait pose l'objectif — « une session reseau unique et dimensionnee » —\n// et designe le bon critere : « un budget d'energie restant a couvrir, il\n// appartient au bilan energetique ». Le bilan est cable depuis ce soir.\n//\n// CE QUI A ETE MESURE AVANT D'ECRIRE CECI, et qui a dicte la conception :\n// la conversion kWh -> points de SOC du bilan est une constante unique\n// (15 kWh). La jauge reelle vaut ~17 kWh au-dessus de 42 % (0,164 a\n// 0,171 kWh/point sur trois fenetres independantes) mais s'effondre en\n// dessous : 12,1 kWh entre 45 et 22 %, 8,2 kWh entre 33 et 20 %. Un point de\n// SOC vaut donc MOITIE MOINS dans la bande ou la nuit se termine, et\n// `required_soc` y est systematiquement trop BAS — c'est pourquoi la batterie\n// est restee sous la ligne toute la nuit du 02-03/09, en descendant 1,75 fois\n// plus vite qu'elle.\n//\n// CONSEQUENCE DE CONCEPTION : le relachement ne doit PAS etre un seuil de SOC.\n// Il porterait sur une grandeur fausse, et tout seuil que le SOC repasse\n// ensuite rouvre l'oscillateur eteint le 27/08.\n//\n// Donc : ENGAGEMENT sur le bilan (batterie sous la cible), RELACHEMENT a la\n// FIN DE LA FENETRE. L'oscillation devient structurellement impossible : la\n// condition de sortie est monotone, une fenetre ne se ferme qu'une fois. Le\n// bit d'engagement est porte par\n// input_boolean.victron_evcs_night_grid_session_committed — sans lui le\n// systeme reevaluerait a chaque cycle et retomberait dans une bande.\n//\n// CE QUE CA FAIT CONCRETEMENT : l'ACM recharge jusqu'a la cible puis tient la\n// batterie a 2 A (evcs_takeover_hold) ; la maison finit la nuit sur le reseau\n// en HEURES CREUSES au lieu de vider la reserve, et la batterie arrive a\n// l'aube pres de sa cible, donc avec de la place pour le solaire du matin —\n// au lieu d'etre a 20 % avec le reseau encore ouvert a 10:33 (03/09).\n//\n// PORTEE : uniquement quand le bilan est exploitable, et uniquement pendant\n// une session nuit (fenetre HC + vehicule branche). Sinon, comportement\n// historique inchange. Le bit se nettoie tout seul a la fin de la session.\nconst nightGridSessionCommittedPrev =\n (input.night_grid_session_committed_prev ?? 'off').toString().trim().toLowerCase() === 'on';\n\nconst nightGridSessionUncovered =\n balanceMorningTargetApplied && batteryBelowMorningTarget;\n\n// ⚠️ SANS VEHICULE AUSSI (12/09/2026).\n// Nuit du 11-12/09, voiture rendue la veille : le bilan a reclame entre 92 %\n// et 59 % de SOC a CHAQUE calcul de la nuit, `reseau_necessaire` vrai partout,\n// et rien ne s'est passe. SOC bloque a 20 % de 23:43 a l'aube, consigne reseau\n// figee a 700 W depuis 18:36 — soit exactement la maison. L'hysteresis avait\n// bien ferme le contacteur (SOC <= 20), mais fermer le contacteur ne suffit\n// pas : il faut DEMANDER la puissance (lecon du 01/09). Personne ne la\n// demandait, parce que la session exigeait un vehicule branche.\n//\n// Or le besoin n'a rien a voir avec la voiture : c'est la maison qui doit\n// traverser la nuit, et le bilan sait exactement ce qu'il lui faut. La\n// fenetre HC suffit. Le relachement reste la FIN DE FENETRE, donc monotone,\n// donc toujours immune a l'oscillation (§40).\n//\n// Sans cette correction, le cout se paye le matin : a 07:24 le SOC est encore\n// sous le seuil OFF de l'hysteresis (30), donc le contacteur reste ferme et\n// la maison passe au PLEIN TARIF jusqu'au croisement PV — le 1,49 kWh deja\n// consigne le 03/09.\n// ⚠️ LA FENETRE DE MIDI EST AUSSI UNE FENETRE D'HEURES CREUSES (18/09/2026).\n// L'engagement de session etait gate sur `night_hc` seul, parce que §40 traitait\n// la nuit. Consequence mesuree : la charge HC manuelle demandee par le\n// proprietaire ne pouvait RIEN faire a midi — le plancher relevait bien la cible\n// du bilan, mais aucune session ne s'engageait, donc le contacteur ne se fermait\n// pas et la consigne restait au repos. Le reglage aurait releve le plancher du\n// soir (donc bloque la voiture) sans jamais charger la batterie.\n//\n// ⚠️ ET LE CRITERE N'EST PAS LE MEME AUX DEUX FENETRES. La nuit, on compare le\n// SOC a `batteryMorningTargetPct`, qui contient le palier de prevision\n// (35/55/85). A midi ce palier n'a aucun sens : il vaut 35 par beau temps, donc\n// un SOC de 30 % en plein soleil engagerait une session reseau — c'est-a-dire la\n// substitution du solaire par le reseau qu'on a mis des semaines a eteindre.\n//\n// A midi le critere honnete est le SOC requis du bilan lui-meme, qui\n// s'auto-regule : le croisement PV etant passe, `energyToCoverKwh` vaut zero et\n// `required_soc` retombe au plancher dur. Releve du 17/09 a 13:51, plein soleil :\n// `required_soc` = 22,0 pour `energy_to_cover` = 0,0. Il ne remonte qu'un jour\n// sombre — ou quand le proprietaire pose un plancher manuel, ce qui est\n// exactement le cas d'usage.\n//\n// ET LA VOITURE (28/09/2026, strategie du proprietaire) : « JEEP branchee\n// engage le reseau en HC midi si l'analyse de production le justifie ». Le\n// bilan fait l'analyse (besoin saisi contre solaire disponible avant le depart,\n// batterie gardee pour tenir jusqu'a 01:24) et publie un verdict ; on le relaie,\n// sous les memes gardes de fraicheur et de statut que la cible.\nconst middayVehicleGridNeeded =\n balanceMorningTargetApplied &&\n params.balanceVehicleGridNeeded &&\n parsed.evcsVehicleConnected;\n\nconst middayGridSessionUncovered =\n balanceMorningTargetApplied &&\n (\n (Number.isFinite(params.balanceRequiredSocPct) &&\n parsed.soc < params.balanceRequiredSocPct) ||\n middayVehicleGridNeeded\n );\n\nconst gridSessionInHcWindow =\n profile === 'night_hc' || profile === 'midday_hc_or_solar';\n\nconst gridSessionUncovered =\n profile === 'night_hc' ? nightGridSessionUncovered : middayGridSessionUncovered;\n\nconst nightGridSessionCommitted =\n gridSessionInHcWindow &&\n (gridSessionUncovered || nightGridSessionCommittedPrev);\n\nconst nightGridTakeoverNeeded =\n nightGridSessionCommitted ||\n (\n nightSessionActive &&\n (\n nightGridSessionCommitted ||\n nightGridHcPreferred ||\n batteryBelowHardFloor ||\n (\n // Fix A : après fin de charge, le seul motif réseau restant est la\n // recovery batterie, et elle ne se ré-engage pas une fois done.\n vehicleChargeCompleted\n ? !nightRecoveryDone\n : (\n batteryAtOrBelowGridTakeoverTrigger ||\n batteryRecoveryUrgent ||\n nightTakeoverLatched\n )\n )\n )\n );\n\nconst nightPreferredSourceMode =\n !nightSessionActive\n ? 'blocked'\n : vehicleChargeCompleted\n ? 'blocked'\n // « Sans EDF » ne bloque la voiture que si EDF n'est PAS deja engage pour\n // la batterie (plancher dur, cible du matin, session du bilan, verrou) :\n // proteger le materiel prime (regle du proprietaire, 05/09), et EDF deja\n // ferme en HC, la voiture en profite au meme tarif.\n : nightCarWaitsNoGrid && !nightGridTakeoverNeeded\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 // Session de midi engagee et contacteur ferme : la voiture charge sur le\n // reseau avec la batterie (« batterie et voiture ensemble », 28/09), meme au\n // soleil. La consigne garde son credit solaire (HA_SERVICE_CALLS_OUTPUT) :\n // elle ne preleve au reseau que ce que les chargeurs ne donnent pas.\n if (profile === 'midday_hc_or_solar' && nightGridSessionCommitted && parsed.gridAvailable) {\n sourceMode = 'grid_hc_allowed';\n }\n\n else if (strongSolarSignal) {\n sourceMode = 'solar_only';\n }\n\n else if (isHighBatteryForPotential && potentialAllowed) {\n sourceMode = 'solar_potential';\n }\n\n else if (profile === 'midday_hc_or_solar' && middayProbeEligible) {\n sourceMode = 'hybrid_probe';\n }\n\n else if (profile === 'morning_solar' && morningProbeEligible) {\n sourceMode = 'hybrid_probe';\n }\n\n else if (profile === 'midday_hc_or_solar' && params.allowGridHcMidday && parsed.gridAvailable) {\n sourceMode = 'grid_hc_allowed';\n }\n\n else {\n sourceMode = 'blocked';\n }\n}\n\nelse if (profile === 'evening_high_soc') {\n if (strongSolarSignal) {\n sourceMode = 'solar_only';\n }\n\n else if (isHighBatteryForPotential && potentialAllowed) {\n sourceMode = 'solar_potential';\n }\n\n else {\n sourceMode = 'blocked';\n }\n}\n\nelse if (profile === 'outside_window') {\n if (strongSolarSignal) {\n sourceMode = 'solar_only';\n }\n\n else if (isHighBatteryForPotential && potentialAllowed) {\n sourceMode = 'solar_potential';\n }\n\n else if (afternoonProbeEligible) {\n sourceMode = 'hybrid_probe';\n }\n\n else {\n // IMPORTANT:\n // hors fenêtre = pas de réseau programmé.\n // Donc on ne bascule plus vers grid_forced juste parce que le réseau existe.\n sourceMode = 'blocked';\n }\n}\n\n// P9 : le réseau avant départ prend le pas sur les modes spéculatifs du jour\n// (blocked / solar_potential / hybrid_probe), mais JAMAIS sur le solaire réel\n// (solar_only), ni sur stop / force_* (déjà traités en tête de cascade).\nif (\n departureUrgentActive &&\n !isStopMode &&\n !forceGridRequested &&\n !forceChargeRequested &&\n profile !== 'night_hc' &&\n sourceMode !== 'solar_only'\n) {\n sourceMode = 'grid_hc_allowed';\n}\n\n// P9 : véhicule plein pendant la fenêtre départ → aucune source.\nif (departureChargeDone && profile !== 'night_hc' && !isStopMode && !forceGridRequested && !forceChargeRequested) {\n sourceMode = 'blocked';\n}\n\n// P10 : véhicule plein en journée (mémoire collante) → aucune source,\n// y compris solaire (charger un véhicule plein ne sert à rien).\nif (dayVehicleFullIdle && profile !== 'night_hc') {\n sourceMode = 'blocked';\n}\n\n// =====================================================\n// PLAFOND DE COURANT EVCS — calculé APRÈS sourceMode (31/08/2026)\n// =====================================================\n// P5 : plafond majoré (input_number.evcs_plafond_courant_edf_on, jusqu'à 28 A\n// en BOOST) quand le réseau alimente réellement AC-IN ; sinon plafond câblé\n// prudent, celui que les deux MultiPlus peuvent porter SEULS.\n//\n// SÉQUENCE D'OUVERTURE (31/08/2026) — pourquoi ce bloc a été déplacé ici.\n// Le plafond majoré n'est légitime que tant que le réseau est là. Or c'est\n// l'hystérésis (cycle 1 min) qui ouvre le contacteur, et le contrôleur EVCS ne\n// l'apprenait qu'après coup : pendant ce temps les deux onduleurs portaient\n// seuls la voiture au courant majoré plus la maison — le mécanisme du W-105.\n//\n// Correctif sans poignée de main entre les deux automates : le plafond majoré\n// exige désormais que l'EVCS DEMANDE lui-même le réseau. Sa demande tombe au\n// cycle où la fenêtre se ferme (30 s), alors que l'hystérésis ne peut ouvrir\n// qu'ensuite (override disparu ET min_on écoulé, cycle 1 min). L'EVCS baisse\n// donc AVANT, mécaniquement, sans rien coordonner.\n// Le calcul doit venir après sourceMode ; rien entre les deux ne l'utilisait.\nconst evcsGridCeilingAllowed =\n parsed.acInAvailable &&\n (\n sourceMode === 'grid_forced' ||\n sourceMode === 'grid_hc_allowed' ||\n forceGridRequested\n );\n\nconst evcsDedicatedCapBaseW = Math.max(0, cfg.evcsDedicatedMaxW - cfg.systemReserveW);\nconst safeEvcsByDedicatedCapW = evcsGridCeilingAllowed\n ? Math.max(0, params.gridCurrentMaxA * acVoltageRef)\n : evcsDedicatedCapBaseW;\nconst safeEvcsByVebusW = Math.max(0, totalVebusSoftCeilingW - vebusNonEvcsW - cfg.systemReserveW);\nconst safeEvcsCombinedW = Math.max(0, Math.min(safeEvcsByDedicatedCapW, safeEvcsByVebusW));\n\nconst systemCapCurrentA = Math.max(0, Math.floor(safeEvcsCombinedW / acVoltageRef));\nconst physicalCapA = clamp(\n systemCapCurrentA,\n 0,\n evcsGridCeilingAllowed\n ? Math.max(params.currentMaxAutoA, params.gridCurrentMaxA)\n : params.currentMaxAutoA\n);\nconst canRunAtMinCurrent = physicalCapA >= params.currentMinA;\n\nconst operatingMode = profile;\n\n// =====================================================\n// OUTPUT\n// =====================================================\nreturn [{\n json: {\n ...prev,\n\n strategy: {\n userStrategy,\n isStopMode,\n isForceMode,\n forceGridRequested,\n forceChargeRequested,\n },\n\n lifecycle: {\n minutesSinceStart,\n minutesSinceStop,\n secondsSinceCurrentChange,\n minutesSinceProbeStart,\n minRunElapsed,\n minStopElapsed,\n currentCooldownElapsed,\n minutesUntilDeparture,\n },\n\n windows: {\n inNightHcWindow,\n inMorningWindow,\n inMiddayWindow,\n inEveningWindow,\n // Temps restant dans la fenetre d'HEURES CREUSES courante (nuit ou midi),\n // null hors de toute fenetre creuse. Consomme par le dimensionnement de\n // la consigne reseau : recharger la batterie sur le reseau n'a de sens\n // economique que la-dedans, donc `null` y vaut « pas de part batterie »\n // sans test supplementaire.\n minutesRemainingInHcWindow:\n minutesRemainingInWindow(now.minNow, params.hcNightStartMin, params.hcNightStopMin)\n ?? minutesRemainingInWindow(now.minNow, params.hcMiddayStartMin, params.hcMiddayStopMin),\n },\n\n derived: {\n acVoltageRef,\n batteryVoltageRef,\n houseNonEvcsW,\n vebusNonEvcsW,\n totalVebusSoftCeilingW,\n totalVebusHardCeilingW,\n safeEvcsByDedicatedCapW,\n safeEvcsByVebusW,\n safeEvcsCombinedW,\n systemCapCurrentA,\n physicalCapA,\n canRunAtMinCurrent,\n evcsGridCeilingAllowed,\n gridSettling,\n gridTransitionAgeSec: parsed.gridTransitionAgeSec,\n endDayGuardSoc,\n energyNeededForEndDayGuardKwh,\n forecastBufferKwh,\n projectedEndSocAfterCharge,\n currentReferenceA,\n anyVebusOverload,\n anyVebusLowBattery,\n vebusAcOutSoftReached,\n vebusAcOutHardReached,\n vebusDcVoltageSoftLow,\n vebusDcVoltageHardLow,\n houseLoadHighVsHeadroom,\n },\n\n context: {\n departurePriorityActive,\n forceChargeDepartureGridOverride,\n manualChargeBypassWindows,\n manualChargeLocalRequested,\n morningProbeEligible,\n middayProbeEligible,\n afternoonProbeEligible,\n pvProvocationEligible,\n isHighBatteryForPotential,\n strongSolarSignal,\n effectivePvStartMinW,\n profile,\n operatingMode,\n sourceMode,\n },\n\n // R1 : source de vérité UNIQUE du moteur nuit.\n // BATTERY_CLOUD_LOCKS consomme cet objet, il ne recalcule plus rien.\n nightEvcsPreview: {\n nightSessionActive,\n nightSessionNeedsCapture,\n nightSessionNeedsClear,\n nightSessionStartSocPct,\n nightSessionStartAt,\n nightSessionStartMs,\n\n batteryHardFloorPct,\n batteryMorningTargetPct,\n batteryMorningTargetBasePct,\n balanceMorningTargetApplied,\n nightBatterySurplusFloorPct,\n nightBatterySurplusAvailable,\n nightBatterySurplusExhausted,\n nightBudgetKwh: nightBudget ? nightBudget.budgetKwh : null,\n nightBudgetDeliveredKwh,\n nightBudgetReached,\n nightBudgetToWrite,\n nightChargeMode: params.nightChargeMode,\n nightCarWaitsNoGrid,\n nightGridSessionCommitted,\n middayVehicleGridNeeded,\n nightGridSessionUncovered,\n balanceTargetAgeMin:\n balanceTargetAgeMin === null ? null : Math.round(balanceTargetAgeMin * 10) / 10,\n batteryMorningTargetRulePct,\n maxBatteryDischargePoints,\n gridTakeoverTriggerPct,\n\n batteryBelowHardFloor,\n batteryBelowMorningTarget,\n batteryAtOrBelowGridTakeoverTrigger,\n\n batteryKwhMissingToTarget,\n batteryRecoveryMinutesNeeded,\n batteryRecoveryUrgent,\n batteryRecoveryAllowedNow,\n batteryTargetReached,\n\n batteryAssistAllowed,\n nightGridTakeoverNeeded,\n nightTakeoverLatched,\n nightGridHcPreferred,\n vehicleChargeCompleted,\n nightRecoveryDone,\n nightPreferredSourceMode,\n\n // P9\n departureUrgentActive,\n departureChargeDone,\n\n // P10\n dayVehicleFullIdle,\n\n // P11\n sessionFresh,\n chargedSessionEnergyKwh,\n chargedSessionEstablished,\n chargedCredible,\n }\n }\n}];\n" + "jsCode": "function minutesSince(ts, nowMs) {\n if (ts === null || nowMs === null) return null;\n return Math.max(0, (nowMs - ts) / 60000);\n}\n\nfunction secondsSince(ts, nowMs) {\n if (ts === null || nowMs === null) return null;\n return Math.max(0, (nowMs - ts) / 1000);\n}\n\nfunction isWithinWindow(nowMin, startMin, stopMin) {\n if (!Number.isFinite(startMin) || !Number.isFinite(stopMin)) return false;\n if (startMin === stopMin) return false;\n if (startMin < stopMin) return nowMin >= startMin && nowMin < stopMin;\n return nowMin >= startMin || nowMin < stopMin;\n}\n\n// Temps restant dans une fenetre, en minutes, ou null si on n'y est pas.\n//\n// ⚠️ IL VIT ICI, A COTE DE `isWithinWindow`, ET PAS DANS LE NOEUD QUI L'UTILISE.\n// Ce qui serait duplique ailleurs n'est pas l'analyse des heures — INPUT_PARSE\n// les a deja converties en minutes — c'est la LOGIQUE D'APPARTENANCE, et en\n// particulier le traitement du passage de minuit. Une seconde interpretation\n// divergerait le jour ou une borne bouge, et elles ont deja bouge une fois\n// (01:30 -> 01:24 le 09/07/2026).\nfunction minutesRemainingInWindow(nowMin, startMin, stopMin) {\n if (!isWithinWindow(nowMin, startMin, stopMin)) return null;\n if (startMin < stopMin) return stopMin - nowMin;\n // Fenetre a cheval sur minuit : avant minuit il reste la fin de la nuit EN\n // PLUS du reste de la journee. Atteignable par les valeurs de repli\n // d'INPUT_PARSE (23:00 -> 06:00) si les helpers d'horaire sont illisibles.\n return nowMin >= startMin ? stopMin + 1440 - nowMin : stopMin - nowMin;\n}\n\nfunction clamp(v, min, max) {\n return Math.min(Math.max(v, min), max);\n}\n\nconst prev = $input.first().json;\nconst {\n input,\n nowMs,\n now,\n cfg,\n parsed,\n params,\n history,\n batteryTune\n} = prev;\n\n// =====================================================\n// STRATEGY\n// =====================================================\nconst userStrategy = (params.userStrategyRaw ?? 'auto_solar').toString().trim().toLowerCase();\n\nconst isStopMode = userStrategy === 'stop';\nconst forceGridRequested = userStrategy === 'force_grid_now';\nconst forceChargeRequested = userStrategy === 'force_charge_now';\nconst isForceMode = forceGridRequested || forceChargeRequested;\n\n// =====================================================\n// LIFECYCLE / TIMING\n// =====================================================\nconst minutesSinceStart = minutesSince(history.lastStartMs, nowMs);\nconst minutesSinceStop = minutesSince(history.lastStopMs, nowMs);\nconst secondsSinceCurrentChange = secondsSince(history.lastCurrentChangeMs, nowMs);\nconst minutesSinceProbeStart = minutesSince(history.probeStartedMs, nowMs);\n\nconst minRunElapsed =\n minutesSinceStart === null ? true : minutesSinceStart >= params.minRunMinutes;\n\nconst minStopElapsed =\n minutesSinceStop === null ? true : minutesSinceStop >= params.minStopMinutes;\n\nconst currentCooldownBaseSec = parsed.evcsChargingSwitchOn\n ? batteryTune.global.stepDownCooldownSeconds\n : batteryTune.global.stepUpCooldownSeconds;\n\nconst currentCooldownElapsed =\n secondsSinceCurrentChange === null\n ? true\n : secondsSinceCurrentChange >= Math.max(\n params.currentChangeCooldownSeconds,\n currentCooldownBaseSec\n );\n\n// =====================================================\n// DEPARTURE PRIORITY\n// =====================================================\nconst minutesUntilDeparture =\n params.departureTimeMin === null\n ? null\n : (() => {\n const rawDiff = params.departureTimeMin - now.minNow;\n return rawDiff >= 0 ? rawDiff : (1440 - now.minNow + params.departureTimeMin);\n })();\n\nconst departurePriorityActive =\n parsed.departurePriorityEnabled &&\n Number.isFinite(minutesUntilDeparture) &&\n minutesUntilDeparture >= 0 &&\n minutesUntilDeparture <= cfg.departurePriorityWindowMinutes &&\n parsed.evcsVehicleConnected;\n\n// =====================================================\n// MANUAL CHARGE SEMANTICS\n// IMPORTANT:\n// force_charge_now = bypass fenêtres pour la charge,\n// MAIS PAS demande implicite de réseau.\n// =====================================================\nconst manualChargeBypassWindows = forceChargeRequested;\nconst manualChargeLocalRequested = forceChargeRequested && !forceGridRequested;\n\n// Compatibilité ascendante : on garde le champ dans context,\n// mais on le neutralise volontairement.\nconst forceChargeDepartureGridOverride = false;\n\n// =====================================================\n// SYSTEM DERIVED VALUES\n// =====================================================\nconst acVoltageRef =\n Number.isFinite(parsed.vebusAcOutVoltageV) && parsed.vebusAcOutVoltageV > 180\n ? parsed.vebusAcOutVoltageV\n : cfg.nominalAcVoltageV;\n\nconst batteryVoltageRef =\n Number.isFinite(parsed.vebusDcVoltageV) && parsed.vebusDcVoltageV > 0\n ? parsed.vebusDcVoltageV\n : 51.2;\n\nconst houseNonEvcsW = Math.max(0, parsed.houseAcPowerW - parsed.evcsAcPowerW);\nconst vebusNonEvcsW = Math.max(0, parsed.vebusAcOutPowerW - parsed.evcsAcPowerW);\n\nconst totalVebusSoftCeilingW = Math.max(6500, params.acLoadsMaxSafeW || 8500);\nconst totalVebusHardCeilingW = totalVebusSoftCeilingW + 400;\n\n// =====================================================\n// FENETRE D'ETABLISSEMENT DU RESEAU (31/08/2026)\n// =====================================================\n// Mesure du 31/08 : a la fermeture du contacteur, le reseau reprend d'un coup\n// TOUTE la charge de la maison et le solaire bascule vers la batterie. Batterie\n// preneuse, la production ne bouge pas ; batterie pleine, elle s'effondre (2663\n// -> 559 W) faute de destination. Dans les deux cas l'import decroit ensuite\n// jusqu'a la consigne en ~150 s (1985 -> 25 W, puis 2600 -> 52 W).\n//\n// Pendant cette phase, PV, import et courant batterie ne decrivent aucun regime\n// permanent : la boucle de 30 s y prend cinq decisions, et la moyenne PV 10 min\n// en garde la trace une dizaine de minutes de plus. Le systeme lit \"plus de\n// soleil\", en conclut qu'il a besoin du reseau, et confirme ainsi sa propre\n// premisse -- une boucle de rattrapage, comme les trois du 24/08.\n//\n// ASYMETRIE VOULUE : la fenetre ne s'ouvre qu'a l'ARRIVEE du reseau. A sa\n// disparition, les onduleurs reprennent instantanement toute la charge : les\n// gardes doivent agir plus vite, jamais moins vite.\nconst gridSettling =\n parsed.gridAvailable &&\n parsed.gridTransitionAgeSec < params.gridSettleSeconds;\n\nconst endDayGuardSoc = Math.max(params.endOfDaySocGuard, params.endOfDaySocTarget);\n\nconst energyNeededForEndDayGuardKwh = Math.max(\n 0,\n ((endDayGuardSoc - parsed.soc) / 100) * cfg.batteryUsableCapacityKwh\n);\n\nconst forecastBufferKwh =\n parsed.todayRemainingKwh -\n params.baseLoadRemainingKwh -\n params.flexibleSafetyMarginKwh -\n params.requiredEnergyKwh -\n energyNeededForEndDayGuardKwh;\n\nconst projectedEndSocAfterCharge = clamp(\n parsed.soc + (\n (\n parsed.todayRemainingKwh -\n params.baseLoadRemainingKwh -\n params.flexibleSafetyMarginKwh -\n params.requiredEnergyKwh\n ) / cfg.batteryUsableCapacityKwh\n ) * 100,\n 0,\n 100\n);\n\nconst currentReferenceA = clamp(\n Number.isFinite(parsed.evcsCommandCurrentA)\n ? parsed.evcsCommandCurrentA\n : Number.isFinite(parsed.evcsSetCurrentA)\n ? parsed.evcsSetCurrentA\n : params.targetCurrentAutoA,\n 0,\n 32\n);\n\nconst anyVebusOverload = parsed.vebusOverload || parsed.vebusL1Overload;\nconst anyVebusLowBattery = parsed.vebusLowBattery || parsed.vebusL1LowBattery;\n\nconst vebusAcOutSoftReached = parsed.vebusAcOutPowerW >= totalVebusSoftCeilingW;\nconst vebusAcOutHardReached = parsed.vebusAcOutPowerW >= totalVebusHardCeilingW;\n\nconst vebusDcVoltageSoftLow =\n Number.isFinite(parsed.vebusDcVoltageV) &&\n parsed.vebusDcVoltageV > 0 &&\n parsed.vebusDcVoltageV <= cfg.vebusDcVoltageSoftFloorV;\n\nconst vebusDcVoltageHardLow =\n Number.isFinite(parsed.vebusDcVoltageV) &&\n parsed.vebusDcVoltageV > 0 &&\n parsed.vebusDcVoltageV <= cfg.vebusDcVoltageHardFloorV;\n\nconst houseLoadHighVsHeadroom = houseNonEvcsW > params.houseAcHeadroomW;\n\n// =====================================================\n// WINDOWS / PROFILE\n// =====================================================\nconst inNightHcWindow = isWithinWindow(now.minNow, params.hcNightStartMin, params.hcNightStopMin);\nconst inMorningWindow = isWithinWindow(now.minNow, params.morningStartMin, params.morningStopMin);\nconst inMiddayWindow = isWithinWindow(now.minNow, params.hcMiddayStartMin, params.hcMiddayStopMin);\nconst inEveningWindow = isWithinWindow(now.minNow, params.eveningStartMin, params.eveningStopMin);\n\nconst profile =\n inNightHcWindow ? 'night_hc' :\n inMorningWindow ? 'morning_solar' :\n inMiddayWindow ? 'midday_hc_or_solar' :\n inEveningWindow ? 'evening_high_soc' :\n 'outside_window';\n\n// =====================================================\n// EVCS NIGHT PREVIEW ENGINE\n// =====================================================\nconst nightSessionActive =\n profile === 'night_hc' &&\n parsed.evcsVehicleConnected;\n\nconst nightSessionStartSocPct =\n nightSessionActive\n ? (\n Number.isFinite(history.nightSessionStartSocPct)\n ? history.nightSessionStartSocPct\n : parsed.soc\n )\n : null;\n\nconst nightSessionStartAt =\n nightSessionActive\n ? (history.nightSessionStartAt || '')\n : '';\n\nconst nightSessionStartMs =\n nightSessionActive\n ? (history.nightSessionStartMs ?? null)\n : null;\n\nconst nightSessionNeedsCapture =\n nightSessionActive &&\n (\n !Number.isFinite(history.nightSessionStartSocPct) ||\n !nightSessionStartAt ||\n nightSessionStartMs === null\n );\n\nconst nightSessionNeedsClear =\n !nightSessionActive &&\n (\n Number.isFinite(history.nightSessionStartSocPct) ||\n !!history.nightSessionStartAt\n );\n\nconst batteryHardFloorPct =\n params.evcsNightBatteryHardFloorPct;\n\n// P4 : la nuit avant minuit, today_remaining ~ 0 => utiliser la prévision du lendemain.\nconst nightForecastHour = now.hour;\nconst nightForecastKwh =\n (nightForecastHour >= 20 || nightForecastHour < 8)\n ? Math.max(parsed.todayRemainingKwh, parsed.tomorrowKwh || 0)\n : parsed.todayRemainingKwh;\n\nconst batteryMorningTargetRulePct =\n nightForecastKwh >= params.evcsNightStrongForecastKwh\n ? params.evcsNightTargetStrongForecastPct\n : nightForecastKwh >= params.evcsNightMediumForecastKwh\n ? params.evcsNightTargetMediumForecastPct\n : params.evcsNightTargetWeakForecastPct;\n\n// =====================================================\n// LE CONSEILLER NE FIXE PLUS LA CIBLE (retire le 13/09/2026)\n// =====================================================\n// Le premier cablage reel (09/08/2026, HANDOFF §20) faisait suivre a la cible\n// du matin `input_number.evcs_advisor_night_target_soc`, derriere un toggle,\n// un statut `ok`, une fraicheur de 120 min et un bornage.\n// Le 12/09 le conseiller est devenu OBSERVATEUR : il n'emet plus de cible, et\n// ce helper est fige depuis. Les quatre gardes continuaient pourtant de passer\n// toutes les quatre — la garde de fraicheur date le RUN, pas la VALEUR, et le\n// conseiller tourne toujours. Activer le toggle aurait donc applique un chiffre\n// perime sans qu'aucune garde ne s'y oppose. Un cablage qui ne peut plus que se\n// tromper se retire ; le scenario `advisor-cible-ignoree` le verifie.\nconst batteryMorningTargetBasePct = batteryMorningTargetRulePct;\n\n// =====================================================\n// CIBLE DU MATIN : UNE ENERGIE A COUVRIR, PAS UN PALIER (03/09/2026)\n// =====================================================\n// La regle historique classe la journee du lendemain en trois paliers\n// (>= 18 kWh -> 35 %, >= 10 -> 55 %, sinon 85 %) a partir de son TOTAL. Elle\n// est donc aveugle a l'heure d'arrivee de l'energie : le 03/09, demain\n// annoncait 33,9 kWh — « fort », donc 35 % — mais avec un matin creux\n// (0,03 kWh a 7 h, 0,77 a 8 h, 1,81 a 9 h) et un croisement a 9 h.\n//\n// C'est le meme defaut que le plancher du soir (cf. le bloc BILAN de\n// BATTERY_CLOUD_LOCKS), a l'autre bout de la nuit : un palier fige contre une\n// energie mesurable. Le bilan publie la seconde, instant par instant.\n//\n// RELEVE DE LA NUIT 02-03/09, qui a motive ce cablage :\n//\n// heure requis SOC reel cible fixe\n// 00:30 62,4 53 35\n// 03:00 50,4 45 35 <- EDF relache ici, 5 points sous la ligne\n// 04:30 41,7 37 35\n// 06:00 37,1 22 35 <- hysteresis, puis reseau PLEIN TARIF\n// de 07:24 a 10:33 (1,49 kWh)\n//\n// La batterie est restee sous la ligne toute la nuit et le bilan le savait des\n// 00:30. Le `required_soc` decroit proprement avec la nuit qui se consomme\n// (6,1 -> 0 kWh a couvrir) : c'est bien une cible instantanee, pas une\n// constante deguisee.\n//\n// SENS UNIQUE, comme pour le plancher du soir : le bilan ne peut que RELEVER\n// la cible, jamais l'abaisser. La direction corrigee est celle de l'echec\n// observe (cible trop basse -> batterie a plat avant le croisement -> reseau\n// au plein tarif le matin). L'abaisser — la regle dit 85 % l'hiver quand le\n// bilan dira parfois moins — ouvrirait un risque neuf que rien n'a encore\n// valide. Le principe unificateur : le bilan ne pousse JAMAIS dans le sens\n// « couvre moins bien la nuit ».\n//\n// PLAFOND 90 % : au-dela, la batterie cesse d'etre un puits et la production\n// du matin se fait evincer (cf. mesures du 31/08). Charger au reseau au-dela\n// de 90 echangerait un kWh bon marche cette nuit contre plusieurs kWh censures\n// demain. C'est deja la borne du chemin advisor : aucune constante nouvelle.\n//\n// PAS D'OSCILLATEUR : `required_soc` decroit de facon monotone pendant la nuit\n// (l'energie restant a couvrir ne fait que diminuer), et la passe unique de\n// recovery (`nightRecoveryDonePrev`, 27/08) reste en place.\n//\n// REPLI identique au plancher du soir : fraicheur, statut, valeur finie —\n// sinon regle historique. Kill-switch existant : `evcs_balance_enabled` OFF\n// fige `balance_last_run` et la cible retombe seule sur les paliers.\nconst BALANCE_TARGET_MAX_AGE_MIN = 30;\nconst balanceTargetAgeMin = minutesSince(params.balanceLastRunMs, nowMs);\nconst balanceMorningTargetApplied =\n params.balanceStatusOk &&\n Number.isFinite(params.balanceRequiredSocPct) &&\n balanceTargetAgeMin !== null &&\n balanceTargetAgeMin <= BALANCE_TARGET_MAX_AGE_MIN;\n\nconst batteryMorningTargetPct = balanceMorningTargetApplied\n ? Math.min(90, Math.max(batteryMorningTargetBasePct, params.balanceRequiredSocPct))\n : batteryMorningTargetBasePct;\n\nconst maxBatteryDischargePoints =\n nightSessionActive && Number.isFinite(nightSessionStartSocPct)\n ? Math.max(0, nightSessionStartSocPct - batteryMorningTargetPct)\n : 0;\n\nconst gridTakeoverTriggerPct =\n Math.max(batteryHardFloorPct, batteryMorningTargetPct);\n\nconst batteryBelowHardFloor =\n parsed.soc <= batteryHardFloorPct;\n\nconst batteryBelowMorningTarget =\n parsed.soc < batteryMorningTargetPct;\n\nconst batteryAtOrBelowGridTakeoverTrigger =\n parsed.soc <= gridTakeoverTriggerPct;\n\nconst batteryKwhMissingToTarget =\n nightSessionActive\n ? Math.max(\n 0,\n ((batteryMorningTargetPct - parsed.soc) / 100) * cfg.batteryUsableCapacityKwh\n )\n : 0;\n\nconst batteryRecoveryMinutesNeeded =\n nightSessionActive && params.evcsNightBatteryRecoveryPowerKw > 0\n ? (batteryKwhMissingToTarget / params.evcsNightBatteryRecoveryPowerKw) * 60\n : 0;\n\nconst batteryRecoveryUrgent =\n nightSessionActive &&\n Number.isFinite(minutesUntilDeparture) &&\n minutesUntilDeparture >= 0 &&\n (\n batteryRecoveryMinutesNeeded + params.evcsNightDepartureSafetyMin\n >= minutesUntilDeparture\n );\n\n// P2 ANTI-CRENEAUX : continuité du takeover réseau nocturne.\n// Une fois le réseau HC engagé pendant une session nuit, on y reste\n// jusqu'à la fin de la session (fenêtre/débranchement) ou batterie quasi pleine.\n// Évite le cycle batterie->jeep->réseau->batterie (pertes + charge hachée).\nconst NIGHT_TAKEOVER_RELEASE_SOC_PCT = 90;\nconst nightTakeoverPrev =\n (input.night_takeover_prev ?? 'off').toString().trim().toLowerCase() === 'on';\n\n// P8 (07/08/2026) : \"charge terminée\" COLLANT via l'état surplus du cycle\n// précédent (helper input_text.victron_evcs_surplus_state relu par le snapshot).\n// Chaîne : VDF stoppe la charge (state vehicle_full_or_paused) → ce flag s'allume\n// → DECISION émet night_session_done_idle → le flag s'auto-entretient jusqu'au\n// débranchement (state vehicle_not_connected) ou à la fin de la fenêtre nuit.\nconst prevSurplusState = (input.prev_surplus_state ?? '').toString().trim().toLowerCase();\n\n// P11 (12/08/2026) : RESET du collant \"véhicule plein\" au CHANGEMENT DE\n// VÉHICULE. Incident 12/08 : JEEP chargée → night_recovery_done_idle collant ;\n// débranchement JEEP + branchement VW → l'état collant a survécu et bloquait\n// la VW (« charge terminée » = celle de la JEEP, pas de la VW).\n// Signal fiable : sensor.victron_evcs_charging_time repart de 0 à chaque\n// nouvelle connexion physique. Sous ~90 s → session neuve → on neutralise le\n// collant pour laisser le système RETESTER le véhicule fraîchement branché.\nconst P11_FRESH_SESSION_MAX_SEC = 90;\n// P11-bis (19/09/2026) : le charging_time de la borne repart de 0 sur un\n// debranchement FRANC, mais reste FIGE sur un swap trop rapide (14 s le 19/09 :\n// VW debranchee, JEEP branchee, borne gelee a l'etat VW). P11 restait alors\n// aveugle et le collant \"vehicule plein\" de la VW bloquait la JEEP. Le\n// last_changed du capteur de connexion, lui, a bien bouge sur ce swap : c'est\n// un SECOND signal de fraicheur, independant de la borne. Meme fenetre (90 s =\n// 3 cycles de 30 s, assez pour capter un (re)branchement entre deux cycles).\n// Cout d'un faux positif (glitch MQTT sub-seconde) : au pire un retest, que le\n// statut 3 / VDF re-latche aussitot si le vehicule est vraiment plein.\nconst sessionFresh =\n parsed.evcsVehicleConnected &&\n (\n params.resetBorneRequested || // echappatoire manuelle (bouton reset borne)\n (Number.isFinite(parsed.evcsChargingTimeSec) &&\n parsed.evcsChargingTimeSec <= P11_FRESH_SESSION_MAX_SEC) ||\n (Number.isFinite(parsed.evcsVehicleConnectedAgeSec) &&\n parsed.evcsVehicleConnectedAgeSec <= P11_FRESH_SESSION_MAX_SEC)\n );\n\n// Fix D (08/08/2026) : le statut natif EVCS \"charged\" (3) vaut aussi fin de\n// charge, sans attendre le VehicleDemandFilter (désarmé si la session a été\n// stoppée par une garde, ex battery_current_hard_stop → détection tardive\n// observée la nuit du 07-08/08).\n// Fix E (09/08/2026) : garde puissance ≤ 300 W pour ne jamais latcher \"fini\"\n// sur un glitch de statut pendant une vraie charge. NB : le statut retombe\n// (3→1/6) dès l'ouverture du relais → la persistance est assurée par l'état\n// collant écrit par DECISION pendant la session (voir Fix E DECISION).\n// P11 : sur une session neuve, on ignore aussi un éventuel statut 3 résiduel\n// (le temps que la borne renégocie avec le nouveau véhicule).\n// Fix H (07/09/2026) : un statut \"charged\" n'est pas cru sur parole.\n// La borne l'a annonce le 07/09 a 14:28:18 apres 2 min 42 s de charge et\n// 0,10 kWh livre, ce qui a latche `day_vehicle_full_idle` pour deux heures\n// alors que le vehicule pouvait charger (il a repris a 16:30 et tenu).\n//\n// Aucun signal ELECTRIQUE instantane ne separe une vraie fin de charge d'une\n// interruption : mesure a la seconde des deux coupures, elles sont\n// superposables (VW 06/09, vraie fin : 1534 -> 550 -> 82 -> 11 -> 0 W ;\n// JEEP 07/09, faux positif : 1626 -> 741 -> 100 -> 13 -> 0 W). Seule la\n// DUREE de la session les distingue, et le systeme porte deja la grandeur qui\n// dit ce qu'est une charge etablie : min_run_minutes (29 min en production).\n//\n// Deux formes de \"charged\" sont credibles, et une seule ne l'est pas :\n// energie de session nulle -> le vehicule etait plein en arrivant (P10)\n// session >= min_run_minutes -> charge menee a son terme\n// entre les deux -> charge INTERROMPUE : on ne latche pas\n// Convergence : une session interrompue redemarre a energie nulle, donc un\n// second \"charged\" immediat retombe dans le premier cas et latche.\nconst chargedSessionEnergyKwh = Math.max(0, parsed.evcsSessionEnergyKwh || 0);\nconst chargedSessionEstablished =\n Number.isFinite(parsed.evcsChargingTimeSec) &&\n parsed.evcsChargingTimeSec >= params.minRunMinutes * 60;\nconst chargedCredible =\n chargedSessionEnergyKwh <= 0 || chargedSessionEstablished;\n\nconst evcsStatusCharged =\n !sessionFresh &&\n Number.parseFloat(parsed.evcsStatusRaw) === 3 &&\n Math.max(0, parsed.evcsAcPowerW || 0) <= 300 &&\n chargedCredible;\n\nconst vehicleChargeCompleted =\n nightSessionActive &&\n !sessionFresh &&\n (\n prevSurplusState === 'vehicle_full_or_paused' ||\n prevSurplusState === 'night_session_done_idle' ||\n prevSurplusState === 'night_recovery_done_idle' ||\n prevSurplusState === 'day_vehicle_full_idle' ||\n evcsStatusCharged\n );\n\n// Fix A (08/08/2026) : recovery batterie post-charge = UNE SEULE passe.\n// Sans flag collant, SOC==cible est un point dégénéré (engage soc<=cible,\n// release soc>cible, l'ACM recharge pile à la cible) → rebond EDF ON/OFF\n// période ~10 min observé la nuit du 07-08/08. Une fois la cible atteinte,\n// l'état 'night_recovery_done_idle' (auto-entretenu via prev_surplus_state)\n// coupe le takeover. Ré-engagement uniquement sous cible − 2 pts (bande),\n// hard floor et fenêtre/débranchement restent prioritaires.\n// ⚠ 'night_recovery_done_idle' ne doit matcher AUCUN pattern no-demand de\n// l'hystérésis (full_or_paused / not_requesting_power / charge_complete /\n// no_demand) — même contrainte que 'night_session_done_idle'.\n// 27/08/2026 : la bande de 2 points fabriquait un oscillateur. Relevé de la\n// nuit du 26-27/08 (cible 35) : engagement à SOC 33, relâchement à 35, trois\n// cycles EDF de 52, 79 et 25 min, 2,5 kWh importés pour rien un matin de grand\n// soleil ; la nuit du 20-21/08 en comptait six. Deux points de SOC valent\n// ~0,34 kWh, soit une demi-heure de conso nocturne : ce n'est pas une\n// hystérésis, c'est la période d'une horloge — d'autant plus régulière que la\n// charge de nuit est parfaitement plate.\n// La recovery batterie post-charge est donc UNE SEULE passe par session de\n// nuit, ce qui était déjà l'intention affichée du Fix A (\"relâché\n// DÉFINITIVEMENT\"). Aucun seuil de ré-engagement ici : le plancher dur reste\n// un terme indépendant de nightGridTakeoverNeeded (voir plus bas), et\n// l'hystérésis réseau garde sa propre bande 20/30. Le vrai critère de\n// ré-engagement est un budget d'énergie restant à couvrir, pas un delta de\n// SOC : il appartient au bilan énergétique, pas à une constante de plus ici.\nconst nightRecoveryDonePrev = prevSurplusState === 'night_recovery_done_idle';\nconst nightRecoveryDone =\n vehicleChargeCompleted &&\n (parsed.soc >= batteryMorningTargetPct || nightRecoveryDonePrev);\n\n// =====================================================\n// P9 (09/08/2026) : CHARGE RÉSEAU AVANT DÉPART, HORS FENÊTRE HC.\n// Cas VW : départ 08:50 > fin HC 07:24 → fenêtre morte où le véhicule\n// sous-chargé attendait un solaire insuffisant (observé le 09/08 : advisor\n// force_grid_now, divergence n°10). Si le toggle est ON et que le départ du\n// véhicule SÉLECTIONNÉ est dans ≤ 120 min : réseau autorisé (grid_hc_allowed)\n// jusqu'à fin de charge. L'arrêt réutilise TOUTE la mécanique anti-rebond :\n// VDF → vehicle_full_or_paused → état collant 'departure_done_idle'\n// (réécrit chaque cycle, patterns hystérésis sûrs, coupure immédiate E2).\n// =====================================================\nconst P9_DEPARTURE_LEAD_MIN = 120;\n\nconst departureWindowActive =\n params.allowGridDepartureUrgent &&\n parsed.evcsVehicleConnected &&\n !inNightHcWindow &&\n Number.isFinite(minutesUntilDeparture) &&\n minutesUntilDeparture >= 0 &&\n minutesUntilDeparture <= P9_DEPARTURE_LEAD_MIN;\n\nconst departureDonePrev =\n prevSurplusState === 'departure_done_idle' ||\n (!inNightHcWindow && prevSurplusState === 'vehicle_full_or_paused');\n\nconst departureChargeDone =\n departureWindowActive &&\n !sessionFresh &&\n (departureDonePrev || evcsStatusCharged);\n\nconst departureUrgentActive =\n departureWindowActive &&\n !departureChargeDone;\n\n// =====================================================\n// P10 (09/08/2026) : MÉMOIRE \"VÉHICULE PLEIN\" EN JOURNÉE.\n// Observé le 09/08 09:49 : VW pleine branchée depuis 07:00, le surplus\n// solaire retentait une charge (statut \"Charged\", 0 W) toutes les ~15 min\n// toute la journée (usure relais, bruit). Les états collants P8/P9 étaient\n// scopés nuit/fenêtre départ. Ici : une fois le véhicule vu plein (statut 3\n// ou VDF), état collant 'day_vehicle_full_idle' jusqu'au DÉBRANCHEMENT.\n// - hérite des états \"done\" de la nuit (07:24 → pas de re-tentative) ;\n// - la nuit hérite en retour (vehicleChargeCompleted inclut day_*) ;\n// - échappatoires : force_charge_now / force_grid_now / débrancher-rebrancher.\n// =====================================================\nconst dayVehicleFullPrev =\n prevSurplusState === 'day_vehicle_full_idle' ||\n (\n !inNightHcWindow &&\n (\n prevSurplusState === 'vehicle_full_or_paused' ||\n prevSurplusState === 'night_recovery_done_idle' ||\n prevSurplusState === 'night_session_done_idle' ||\n prevSurplusState === 'departure_done_idle'\n )\n );\n\nconst dayVehicleFullIdle =\n parsed.evcsVehicleConnected &&\n !sessionFresh &&\n !inNightHcWindow &&\n !isStopMode &&\n !forceGridRequested &&\n !forceChargeRequested &&\n (dayVehicleFullPrev || evcsStatusCharged);\n\n// P8 : correction du fix 04/08 (logique inversée : \"|| vehicleChargeCompleted\"\n// GARDAIT le latch après la charge au lieu de le libérer).\nconst nightTakeoverLatched =\n nightSessionActive &&\n nightTakeoverPrev &&\n parsed.soc < NIGHT_TAKEOVER_RELEASE_SOC_PCT &&\n !vehicleChargeCompleted;\n\n// =====================================================\n// P12 — CHARGE NUIT SUR LA BATTERIE (15/09/2026)\n// =====================================================\n// Idee du proprietaire : plutot qu'acheter du reseau la nuit pour charger la\n// voiture, lui donner le solaire DEJA STOCKE, doucement, et laisser ainsi de la\n// place dans la batterie pour la production du lendemain. Payer du reseau la\n// nuit pour ecreter le solaire le lendemain, c'est perdre deux fois.\n//\n// Le mode `battery_assist_night` existe depuis toujours pour exactement ca, et\n// il est MORT depuis toujours : il exige `batteryCurrentA >= plancher de\n// decharge` (0 A), impossible la nuit puisque la maison tire sur la batterie\n// (§11 du HANDOFF, ~ -14 A). On ne cree donc pas un chemin, on rend viable\n// celui qui existe — en remplacant un test de COURANT impossible par un test\n// d'ENERGIE : rester au-dessus de ce qu'il faut pour tenir jusqu'au croisement.\n//\n// Le plancher n'est PAS une constante nouvelle : c'est `required_soc_prudent`,\n// que le bilan publie deja, en profil p80. La marge est mesuree, pas inventee.\nconst nightBatterySurplusFloorPct = Math.max(\n batteryHardFloorPct,\n Number.isFinite(params.balanceRequiredSocPrudentPct)\n ? params.balanceRequiredSocPrudentPct\n : Infinity,\n);\n\n// ⚠️ LE VERROU, ET IL N'EST PAS DECORATIF. Le plancher DESCEND pendant la nuit\n// (il reste moins de nuit a couvrir) et le SOC aussi. Mesure du 15/09 :\n//\n// voiture en charge : SOC -7,1 pt/h, plancher -3,7 pt/h -> le surplus s'epuise\n// voiture arretee : SOC -2,6 pt/h, plancher -3,7 pt/h -> le surplus REVIENT\n//\n// Sans verrou, la charge repartirait des qu'elle s'arrete : cinquieme\n// oscillateur du projet, par construction. UNE SEULE PASSE de surplus par\n// session de nuit, comme la recovery batterie depuis le 27/08.\n// BUDGET VOITURE DE LA NUIT (30/09/2026, decisions du proprietaire).\n// « Definir combien la voiture peut prendre sans utiliser EDF, et brider la\n// charge a cette valeur ou autoriser une charge complete avec EDF. »\n// Le bilan publie le budget ; il est FIGE au premier cycle de la session\n// (avec l'index du compteur de la borne) dans input_text\n// victron_evcs_night_budget = « budget|index », efface en fin de session.\n// L'arret se juge sur le COMPTEUR, qui ne fait que monter : condition\n// monotone, aucun oscillateur possible (meme logique que §40). Le plancher de\n// SOC prudent reste un second arret, jamais retire.\nconst nightBudgetPrev = (() => {\n const m = String(params.nightBudgetPrevRaw || '').match(/^\\s*([\\d.]+)\\|([\\d.]+)\\s*$/);\n return m ? { budgetKwh: Number(m[1]), refKwh: Number(m[2]) } : null;\n})();\nconst nightBudgetCaptureOk =\n nightSessionActive && !nightBudgetPrev &&\n balanceMorningTargetApplied &&\n Number.isFinite(params.balanceNightVehicleBudgetKwh) &&\n Number.isFinite(params.evcsTotalEnergyKwh);\nconst nightBudget = !nightSessionActive\n ? null\n : nightBudgetPrev || (nightBudgetCaptureOk\n ? { budgetKwh: Math.max(0, params.balanceNightVehicleBudgetKwh), refKwh: params.evcsTotalEnergyKwh }\n : null);\nconst nightBudgetToWrite = nightSessionActive\n ? (nightBudgetCaptureOk ? `${Math.round(nightBudget.budgetKwh * 100) / 100}|${nightBudget.refKwh}` : null)\n : (nightBudgetPrev ? '' : null);\nconst nightBudgetDeliveredKwh = nightBudget && Number.isFinite(params.evcsTotalEnergyKwh)\n ? Math.max(0, params.evcsTotalEnergyKwh - nightBudget.refKwh)\n : null;\nconst nightBudgetReached =\n !!nightBudget && nightBudgetDeliveredKwh !== null &&\n nightBudgetDeliveredKwh >= nightBudget.budgetKwh - 0.05;\nconst nightChargeComplete = params.nightChargeMode === 'complete';\n\n// Le verrou ne vaut QUE pendant la session de nuit (01/10/2026). Ecrit sous la\n// forme `prev || …`, il restait vrai pour toujours une fois pose ; invisible\n// tant que son ecriture n'atteignait pas HA (jamais relayee du 15/09 au 01/10).\n// Fin de session (debranchement ou fin de fenetre HC) = verrou leve.\nconst nightBatterySurplusExhausted =\n nightSessionActive && (\n params.nightBatteryDonePrev ||\n parsed.soc <= nightBatterySurplusFloorPct ||\n nightBudgetReached\n );\n\nconst nightBatterySurplusAvailable =\n nightSessionActive &&\n params.nightBatteryChargeEnabled &&\n balanceMorningTargetApplied &&\n Number.isFinite(params.balanceRequiredSocPrudentPct) &&\n !vehicleChargeCompleted &&\n // Le plancher est teste UNE SEULE FOIS, dans `nightBatterySurplusExhausted`.\n // Le retester ici serait redondant — et une redondance ne se teste pas : la\n // mutation du 15/09 l'a montre en ne cassant aucun scenario.\n !nightBatterySurplusExhausted;\n\n// La batterie d'abord, le reseau quand le surplus est epuise. C'est la soupape\n// decrite par le proprietaire — « quitte a fermer EDF entre 6h et 7h24 pour\n// completer » — et elle devient automatique : l'epuisement du surplus rend\n// `nightGridHcPreferred` vrai, donc P7 prend le relais.\n// « Sans EDF » (defaut) : la voiture ne prend JAMAIS le reseau la nuit ; une\n// fois le budget batterie atteint (ou sans budget exploitable), elle attend le\n// soleil. « Charge complete » : EDF en HC finit la charge, comme avant.\nconst nightGridHcPreferred =\n nightSessionActive &&\n params.allowGridHcNight &&\n nightChargeComplete &&\n !vehicleChargeCompleted &&\n !nightBatterySurplusAvailable;\n\nconst nightCarWaitsNoGrid =\n nightSessionActive &&\n !nightChargeComplete &&\n !vehicleChargeCompleted &&\n !nightBatterySurplusAvailable;\n\nconst batteryAssistAllowed =\n nightSessionActive &&\n !nightGridHcPreferred &&\n !nightCarWaitsNoGrid &&\n parsed.soc > batteryHardFloorPct &&\n parsed.soc > gridTakeoverTriggerPct &&\n !nightTakeoverLatched &&\n !batteryRecoveryUrgent;\n\n// =====================================================\n// SESSION RESEAU UNIQUE DE LA NUIT (03/09/2026)\n// =====================================================\n// §26 avait pose l'objectif — « une session reseau unique et dimensionnee » —\n// et designe le bon critere : « un budget d'energie restant a couvrir, il\n// appartient au bilan energetique ». Le bilan est cable depuis ce soir.\n//\n// CE QUI A ETE MESURE AVANT D'ECRIRE CECI, et qui a dicte la conception :\n// la conversion kWh -> points de SOC du bilan est une constante unique\n// (15 kWh). La jauge reelle vaut ~17 kWh au-dessus de 42 % (0,164 a\n// 0,171 kWh/point sur trois fenetres independantes) mais s'effondre en\n// dessous : 12,1 kWh entre 45 et 22 %, 8,2 kWh entre 33 et 20 %. Un point de\n// SOC vaut donc MOITIE MOINS dans la bande ou la nuit se termine, et\n// `required_soc` y est systematiquement trop BAS — c'est pourquoi la batterie\n// est restee sous la ligne toute la nuit du 02-03/09, en descendant 1,75 fois\n// plus vite qu'elle.\n//\n// CONSEQUENCE DE CONCEPTION : le relachement ne doit PAS etre un seuil de SOC.\n// Il porterait sur une grandeur fausse, et tout seuil que le SOC repasse\n// ensuite rouvre l'oscillateur eteint le 27/08.\n//\n// Donc : ENGAGEMENT sur le bilan (batterie sous la cible), RELACHEMENT a la\n// FIN DE LA FENETRE. L'oscillation devient structurellement impossible : la\n// condition de sortie est monotone, une fenetre ne se ferme qu'une fois. Le\n// bit d'engagement est porte par\n// input_boolean.victron_evcs_night_grid_session_committed — sans lui le\n// systeme reevaluerait a chaque cycle et retomberait dans une bande.\n//\n// CE QUE CA FAIT CONCRETEMENT : l'ACM recharge jusqu'a la cible puis tient la\n// batterie a 2 A (evcs_takeover_hold) ; la maison finit la nuit sur le reseau\n// en HEURES CREUSES au lieu de vider la reserve, et la batterie arrive a\n// l'aube pres de sa cible, donc avec de la place pour le solaire du matin —\n// au lieu d'etre a 20 % avec le reseau encore ouvert a 10:33 (03/09).\n//\n// PORTEE : uniquement quand le bilan est exploitable, et uniquement pendant\n// une session nuit (fenetre HC + vehicule branche). Sinon, comportement\n// historique inchange. Le bit se nettoie tout seul a la fin de la session.\nconst nightGridSessionCommittedPrev =\n (input.night_grid_session_committed_prev ?? 'off').toString().trim().toLowerCase() === 'on';\n\nconst nightGridSessionUncovered =\n balanceMorningTargetApplied && batteryBelowMorningTarget;\n\n// ⚠️ SANS VEHICULE AUSSI (12/09/2026).\n// Nuit du 11-12/09, voiture rendue la veille : le bilan a reclame entre 92 %\n// et 59 % de SOC a CHAQUE calcul de la nuit, `reseau_necessaire` vrai partout,\n// et rien ne s'est passe. SOC bloque a 20 % de 23:43 a l'aube, consigne reseau\n// figee a 700 W depuis 18:36 — soit exactement la maison. L'hysteresis avait\n// bien ferme le contacteur (SOC <= 20), mais fermer le contacteur ne suffit\n// pas : il faut DEMANDER la puissance (lecon du 01/09). Personne ne la\n// demandait, parce que la session exigeait un vehicule branche.\n//\n// Or le besoin n'a rien a voir avec la voiture : c'est la maison qui doit\n// traverser la nuit, et le bilan sait exactement ce qu'il lui faut. La\n// fenetre HC suffit. Le relachement reste la FIN DE FENETRE, donc monotone,\n// donc toujours immune a l'oscillation (§40).\n//\n// Sans cette correction, le cout se paye le matin : a 07:24 le SOC est encore\n// sous le seuil OFF de l'hysteresis (30), donc le contacteur reste ferme et\n// la maison passe au PLEIN TARIF jusqu'au croisement PV — le 1,49 kWh deja\n// consigne le 03/09.\n// ⚠️ LA FENETRE DE MIDI EST AUSSI UNE FENETRE D'HEURES CREUSES (18/09/2026).\n// L'engagement de session etait gate sur `night_hc` seul, parce que §40 traitait\n// la nuit. Consequence mesuree : la charge HC manuelle demandee par le\n// proprietaire ne pouvait RIEN faire a midi — le plancher relevait bien la cible\n// du bilan, mais aucune session ne s'engageait, donc le contacteur ne se fermait\n// pas et la consigne restait au repos. Le reglage aurait releve le plancher du\n// soir (donc bloque la voiture) sans jamais charger la batterie.\n//\n// ⚠️ ET LE CRITERE N'EST PAS LE MEME AUX DEUX FENETRES. La nuit, on compare le\n// SOC a `batteryMorningTargetPct`, qui contient le palier de prevision\n// (35/55/85). A midi ce palier n'a aucun sens : il vaut 35 par beau temps, donc\n// un SOC de 30 % en plein soleil engagerait une session reseau — c'est-a-dire la\n// substitution du solaire par le reseau qu'on a mis des semaines a eteindre.\n//\n// A midi le critere honnete est le SOC requis du bilan lui-meme, qui\n// s'auto-regule : le croisement PV etant passe, `energyToCoverKwh` vaut zero et\n// `required_soc` retombe au plancher dur. Releve du 17/09 a 13:51, plein soleil :\n// `required_soc` = 22,0 pour `energy_to_cover` = 0,0. Il ne remonte qu'un jour\n// sombre — ou quand le proprietaire pose un plancher manuel, ce qui est\n// exactement le cas d'usage.\n//\n// ET LA VOITURE (28/09/2026, strategie du proprietaire) : « JEEP branchee\n// engage le reseau en HC midi si l'analyse de production le justifie ». Le\n// bilan fait l'analyse (besoin saisi contre solaire disponible avant le depart,\n// batterie gardee pour tenir jusqu'a 01:24) et publie un verdict ; on le relaie,\n// sous les memes gardes de fraicheur et de statut que la cible.\nconst middayVehicleGridNeeded =\n balanceMorningTargetApplied &&\n params.balanceVehicleGridNeeded &&\n parsed.evcsVehicleConnected;\n\nconst middayGridSessionUncovered =\n balanceMorningTargetApplied &&\n (\n (Number.isFinite(params.balanceRequiredSocPct) &&\n parsed.soc < params.balanceRequiredSocPct) ||\n middayVehicleGridNeeded\n );\n\nconst gridSessionInHcWindow =\n profile === 'night_hc' || profile === 'midday_hc_or_solar';\n\nconst gridSessionUncovered =\n profile === 'night_hc' ? nightGridSessionUncovered : middayGridSessionUncovered;\n\nconst nightGridSessionCommitted =\n gridSessionInHcWindow &&\n (gridSessionUncovered || nightGridSessionCommittedPrev);\n\nconst nightGridTakeoverNeeded =\n nightGridSessionCommitted ||\n (\n nightSessionActive &&\n (\n nightGridSessionCommitted ||\n nightGridHcPreferred ||\n batteryBelowHardFloor ||\n (\n // Fix A : après fin de charge, le seul motif réseau restant est la\n // recovery batterie, et elle ne se ré-engage pas une fois done.\n vehicleChargeCompleted\n ? !nightRecoveryDone\n : (\n batteryAtOrBelowGridTakeoverTrigger ||\n batteryRecoveryUrgent ||\n nightTakeoverLatched\n )\n )\n )\n );\n\nconst nightPreferredSourceMode =\n !nightSessionActive\n ? 'blocked'\n : vehicleChargeCompleted\n ? 'blocked'\n // « Sans EDF » ne bloque la voiture que si EDF n'est PAS deja engage pour\n // la batterie (plancher dur, cible du matin, session du bilan, verrou) :\n // proteger le materiel prime (regle du proprietaire, 05/09), et EDF deja\n // ferme en HC, la voiture en profite au meme tarif.\n : nightCarWaitsNoGrid && !nightGridTakeoverNeeded\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 // Session de midi engagee et contacteur ferme : la voiture charge sur le\n // reseau avec la batterie (« batterie et voiture ensemble », 28/09), meme au\n // soleil. La consigne garde son credit solaire (HA_SERVICE_CALLS_OUTPUT) :\n // elle ne preleve au reseau que ce que les chargeurs ne donnent pas.\n if (profile === 'midday_hc_or_solar' && nightGridSessionCommitted && parsed.gridAvailable) {\n sourceMode = 'grid_hc_allowed';\n }\n\n else if (strongSolarSignal) {\n sourceMode = 'solar_only';\n }\n\n else if (isHighBatteryForPotential && potentialAllowed) {\n sourceMode = 'solar_potential';\n }\n\n else if (profile === 'midday_hc_or_solar' && middayProbeEligible) {\n sourceMode = 'hybrid_probe';\n }\n\n else if (profile === 'morning_solar' && morningProbeEligible) {\n sourceMode = 'hybrid_probe';\n }\n\n else if (profile === 'midday_hc_or_solar' && params.allowGridHcMidday && parsed.gridAvailable) {\n sourceMode = 'grid_hc_allowed';\n }\n\n else {\n sourceMode = 'blocked';\n }\n}\n\nelse if (profile === 'evening_high_soc') {\n if (strongSolarSignal) {\n sourceMode = 'solar_only';\n }\n\n else if (isHighBatteryForPotential && potentialAllowed) {\n sourceMode = 'solar_potential';\n }\n\n else {\n sourceMode = 'blocked';\n }\n}\n\nelse if (profile === 'outside_window') {\n if (strongSolarSignal) {\n sourceMode = 'solar_only';\n }\n\n else if (isHighBatteryForPotential && potentialAllowed) {\n sourceMode = 'solar_potential';\n }\n\n else if (afternoonProbeEligible) {\n sourceMode = 'hybrid_probe';\n }\n\n else {\n // IMPORTANT:\n // hors fenêtre = pas de réseau programmé.\n // Donc on ne bascule plus vers grid_forced juste parce que le réseau existe.\n sourceMode = 'blocked';\n }\n}\n\n// P9 : le réseau avant départ prend le pas sur les modes spéculatifs du jour\n// (blocked / solar_potential / hybrid_probe), mais JAMAIS sur le solaire réel\n// (solar_only), ni sur stop / force_* (déjà traités en tête de cascade).\nif (\n departureUrgentActive &&\n !isStopMode &&\n !forceGridRequested &&\n !forceChargeRequested &&\n profile !== 'night_hc' &&\n sourceMode !== 'solar_only'\n) {\n sourceMode = 'grid_hc_allowed';\n}\n\n// P9 : véhicule plein pendant la fenêtre départ → aucune source.\nif (departureChargeDone && profile !== 'night_hc' && !isStopMode && !forceGridRequested && !forceChargeRequested) {\n sourceMode = 'blocked';\n}\n\n// P10 : véhicule plein en journée (mémoire collante) → aucune source,\n// y compris solaire (charger un véhicule plein ne sert à rien).\nif (dayVehicleFullIdle && profile !== 'night_hc') {\n sourceMode = 'blocked';\n}\n\n// =====================================================\n// PLAFOND DE COURANT EVCS — calculé APRÈS sourceMode (31/08/2026)\n// =====================================================\n// P5 : plafond majoré (input_number.evcs_plafond_courant_edf_on, jusqu'à 28 A\n// en BOOST) quand le réseau alimente réellement AC-IN ; sinon plafond câblé\n// prudent, celui que les deux MultiPlus peuvent porter SEULS.\n//\n// SÉQUENCE D'OUVERTURE (31/08/2026) — pourquoi ce bloc a été déplacé ici.\n// Le plafond majoré n'est légitime que tant que le réseau est là. Or c'est\n// l'hystérésis (cycle 1 min) qui ouvre le contacteur, et le contrôleur EVCS ne\n// l'apprenait qu'après coup : pendant ce temps les deux onduleurs portaient\n// seuls la voiture au courant majoré plus la maison — le mécanisme du W-105.\n//\n// Correctif sans poignée de main entre les deux automates : le plafond majoré\n// exige désormais que l'EVCS DEMANDE lui-même le réseau. Sa demande tombe au\n// cycle où la fenêtre se ferme (30 s), alors que l'hystérésis ne peut ouvrir\n// qu'ensuite (override disparu ET min_on écoulé, cycle 1 min). L'EVCS baisse\n// donc AVANT, mécaniquement, sans rien coordonner.\n// Le calcul doit venir après sourceMode ; rien entre les deux ne l'utilisait.\nconst evcsGridCeilingAllowed =\n parsed.acInAvailable &&\n (\n sourceMode === 'grid_forced' ||\n sourceMode === 'grid_hc_allowed' ||\n forceGridRequested\n );\n\nconst evcsDedicatedCapBaseW = Math.max(0, cfg.evcsDedicatedMaxW - cfg.systemReserveW);\nconst safeEvcsByDedicatedCapW = evcsGridCeilingAllowed\n ? Math.max(0, params.gridCurrentMaxA * acVoltageRef)\n : evcsDedicatedCapBaseW;\nconst safeEvcsByVebusW = Math.max(0, totalVebusSoftCeilingW - vebusNonEvcsW - cfg.systemReserveW);\nconst safeEvcsCombinedW = Math.max(0, Math.min(safeEvcsByDedicatedCapW, safeEvcsByVebusW));\n\nconst systemCapCurrentA = Math.max(0, Math.floor(safeEvcsCombinedW / acVoltageRef));\nconst physicalCapA = clamp(\n systemCapCurrentA,\n 0,\n evcsGridCeilingAllowed\n ? Math.max(params.currentMaxAutoA, params.gridCurrentMaxA)\n : params.currentMaxAutoA\n);\nconst canRunAtMinCurrent = physicalCapA >= params.currentMinA;\n\nconst operatingMode = profile;\n\n// =====================================================\n// OUTPUT\n// =====================================================\nreturn [{\n json: {\n ...prev,\n\n strategy: {\n userStrategy,\n isStopMode,\n isForceMode,\n forceGridRequested,\n forceChargeRequested,\n },\n\n lifecycle: {\n minutesSinceStart,\n minutesSinceStop,\n secondsSinceCurrentChange,\n minutesSinceProbeStart,\n minRunElapsed,\n minStopElapsed,\n currentCooldownElapsed,\n minutesUntilDeparture,\n },\n\n windows: {\n inNightHcWindow,\n inMorningWindow,\n inMiddayWindow,\n inEveningWindow,\n // Temps restant dans la fenetre d'HEURES CREUSES courante (nuit ou midi),\n // null hors de toute fenetre creuse. Consomme par le dimensionnement de\n // la consigne reseau : recharger la batterie sur le reseau n'a de sens\n // economique que la-dedans, donc `null` y vaut « pas de part batterie »\n // sans test supplementaire.\n minutesRemainingInHcWindow:\n minutesRemainingInWindow(now.minNow, params.hcNightStartMin, params.hcNightStopMin)\n ?? minutesRemainingInWindow(now.minNow, params.hcMiddayStartMin, params.hcMiddayStopMin),\n },\n\n derived: {\n acVoltageRef,\n batteryVoltageRef,\n houseNonEvcsW,\n vebusNonEvcsW,\n totalVebusSoftCeilingW,\n totalVebusHardCeilingW,\n safeEvcsByDedicatedCapW,\n safeEvcsByVebusW,\n safeEvcsCombinedW,\n systemCapCurrentA,\n physicalCapA,\n canRunAtMinCurrent,\n evcsGridCeilingAllowed,\n gridSettling,\n gridTransitionAgeSec: parsed.gridTransitionAgeSec,\n endDayGuardSoc,\n energyNeededForEndDayGuardKwh,\n forecastBufferKwh,\n projectedEndSocAfterCharge,\n currentReferenceA,\n anyVebusOverload,\n anyVebusLowBattery,\n vebusAcOutSoftReached,\n vebusAcOutHardReached,\n vebusDcVoltageSoftLow,\n vebusDcVoltageHardLow,\n houseLoadHighVsHeadroom,\n },\n\n context: {\n departurePriorityActive,\n forceChargeDepartureGridOverride,\n manualChargeBypassWindows,\n manualChargeLocalRequested,\n morningProbeEligible,\n middayProbeEligible,\n afternoonProbeEligible,\n pvProvocationEligible,\n isHighBatteryForPotential,\n strongSolarSignal,\n effectivePvStartMinW,\n profile,\n operatingMode,\n sourceMode,\n },\n\n // R1 : source de vérité UNIQUE du moteur nuit.\n // BATTERY_CLOUD_LOCKS consomme cet objet, il ne recalcule plus rien.\n nightEvcsPreview: {\n nightSessionActive,\n nightSessionNeedsCapture,\n nightSessionNeedsClear,\n nightSessionStartSocPct,\n nightSessionStartAt,\n nightSessionStartMs,\n\n batteryHardFloorPct,\n batteryMorningTargetPct,\n batteryMorningTargetBasePct,\n balanceMorningTargetApplied,\n nightBatterySurplusFloorPct,\n nightBatterySurplusAvailable,\n nightBatterySurplusExhausted,\n nightBudgetKwh: nightBudget ? nightBudget.budgetKwh : null,\n nightBudgetDeliveredKwh,\n nightBudgetReached,\n nightBudgetToWrite,\n nightChargeMode: params.nightChargeMode,\n nightCarWaitsNoGrid,\n nightGridSessionCommitted,\n middayVehicleGridNeeded,\n nightGridSessionUncovered,\n balanceTargetAgeMin:\n balanceTargetAgeMin === null ? null : Math.round(balanceTargetAgeMin * 10) / 10,\n batteryMorningTargetRulePct,\n maxBatteryDischargePoints,\n gridTakeoverTriggerPct,\n\n batteryBelowHardFloor,\n batteryBelowMorningTarget,\n batteryAtOrBelowGridTakeoverTrigger,\n\n batteryKwhMissingToTarget,\n batteryRecoveryMinutesNeeded,\n batteryRecoveryUrgent,\n batteryRecoveryAllowedNow,\n batteryTargetReached,\n\n batteryAssistAllowed,\n nightGridTakeoverNeeded,\n nightTakeoverLatched,\n nightGridHcPreferred,\n vehicleChargeCompleted,\n nightRecoveryDone,\n nightPreferredSourceMode,\n\n // P9\n departureUrgentActive,\n departureChargeDone,\n\n // P10\n dayVehicleFullIdle,\n\n // P11\n sessionFresh,\n chargedSessionEnergyKwh,\n chargedSessionEstablished,\n chargedCredible,\n }\n }\n}];\n" }, "type": "n8n-nodes-base.code", "typeVersion": 2,