diff --git a/HANDOFF.md b/HANDOFF.md index a275910..901c328 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -2485,3 +2485,140 @@ Preuve par mutation, les deux sens séparément : --- *Document mis à jour le : 01/09/2026 (~20:15) — tolérance temporelle de la politique batterie branchée, golden 61/61* + +## 36. Session du 03/09/2026 — Un demarrage se decide sur l'excedent, pas sur la production + +### Le releve +Le 03/09, la voiture est branchee a **19:44**. Le controleur lance la charge +dans la seconde. En 33 minutes (19:44 → 20:17, arret par `stop_source_lost`) : + +| | valeur integree | +|---|---:| +| pris par la voiture | **0,897 kWh** | +| rendu par la batterie | **1,479 kWh** | +| produit par le PV | 0,327 kWh | +| SOC | **94 % → 85 %** | + +Au meme moment, le bilan energetique publiait : croisement PV/conso a **9 h**, +**11,66 kWh a couvrir**, SOC requis **99,7 %**, `grid_needed` ON. Dans sa propre +arithmetique (plancher 22 %, 15 kWh utiles), la charge du soir a fait passer le +manque de la nuit de **0,86 kWh a 2,21 kWh** — elle l'a plus que double, pour +mettre dans la voiture 0,9 kWh qu'elle allait de toute facon reprendre en heures +creuses a 01:24. La fenetre du soir ferme a 20:30 ; il n'y a rien entre les deux. + +Precedent immediat, nuit du 02-03/09 : EDF relache a 02:30 a 45 %, batterie a +20 % vers 05:45, reseau maintenu jusqu'a 10:33 — dont **1,49 kWh importes entre +07:24 et 10:33, hors heures creuses**. Marge perdue le soir, rachetee au plein +tarif le matin. + +### La cause +`pv_start_min_w` (700 W) est une **puissance PV absolue**. A 19:44 : PV 758 W, +maison 1202 W, batterie deja a −12,2 A. Le controleur a lu « 758 ≥ 700, donc il +y a du soleil » et a lance 1,6 kW. **L'excedent valait −444 W.** + +Verification dans la vraie chaine (rejeu du releve) : `rawSolarStartOk` ET +`batteryAssistSolarStartOk` etaient TOUS DEUX vrais. Un correctif sur le seul +seuil PV n'aurait donc rien change — l'assistance batterie aurait demarre la +charge de toute facon. Les deux chemins devaient etre traites ensemble. + +Deux temporisations ont ensuite prolonge la faute, sans en etre la cause : +`charging_hold_cloud_tolerance` a pris **le coucher du soleil pour un passage +nuageux** (19:46 → 20:00), puis, la tolerance souple du 01/09 epuisee a 19:57, +`min_run` (29 min) a maintenu la charge **16 minutes de plus**, conditions de +maintien deja declarees perdues. Non traite ici. + +### Le correctif (deploye 03/09 20:51, golden 64/64) +Une seule grandeur, dans `BATTERY_CLOUD_LOCKS.js`. `derived.houseNonEvcsW` +existait deja (calcule dans WINDOWS_SOURCEV2, deja lu par BCL) : **aucun champ +de snapshot, aucun helper, aucune constante nouvelle.** + +```js +const solarSurplusW = parsed.pvPowerW - derived.houseNonEvcsW; +``` + +Sans injection et reseau absent, cette grandeur vaut la puissance batterie au +signe pres. C'est ce qui separe les deux situations que la mesure PV seule +confond (`PV_mesuree = min(ressource, conso + absorption batterie)`) : + +- **CENSURE** — batterie pleine, MPPT bride faute de puits : la production se + cale sur la maison, l'excedent vaut ~0, la batterie ne bouge pas. Il y a de la + ressource REFUSEE : la provoquer avec la voiture est legitime (raison d'etre + de P3 et de l'assistance batterie). +- **DEFICIT** — soir, nuages : l'excedent est NEGATIF, la batterie porte deja la + maison. Charger la voiture, c'est la vider. + +Zero n'est donc pas un seuil de plus, c'est la frontiere physique entre les deux. +Deux tests, parce que les deux chemins n'ont pas le meme metier : + +- **chemin « soleil reel »** (`rawSolarStartOk`) : excedent ≥ `pv_start_min_w`. + Meme helper, meme valeur, appliquee a la bonne grandeur ; +- **chemin « provocation »** (assistance batterie, ou sourceMode + `solar_potential`) : excedent ≥ **0**. On n'exige pas d'excedent mesurable — + il est censure par construction — seulement que la batterie ne porte pas deja + la maison. + +⚠️ **PORTEE : DEMARRAGE UNIQUEMENT.** Les conditions de MAINTIEN sont +inchangees : une charge etablie a deja son plancher de decharge et sa tolerance +temporelle (§35), et couper une charge en cours sur une mesure d'excedent +bruitee recreerait l'oscillateur qu'on venait d'eteindre. + +Verification empirique de la frontiere sur le releve du 03/09 : de 17:00 a 19:41, +`pv_censored` ON et excedent **toujours positif** (+14 a +212 W) ; a partir de +19:42, excedent negatif. La frontiere a zero separe exactement les deux regimes +sur la mesure reelle. + +### Golden 61 → 64, et un scenario corrige +La paire de releves REELS, prise a 54 min d'intervalle, est le coeur du test : + +| scenario | PV | maison | batterie | excedent | attendu | +|---|---:|---:|---:|---:|---| +| `excedent-censure-demarre` (18:50) | 769 W | 755 W | 0 A | **+14 W** | `start_min_current` 6 A | +| `excedent-deficit-ne-demarre-pas` (19:44) | 758 W | 1202 W | −12,2 A | **−444 W** | `idle_wait_conditions` | + +**11 W d'ecart sur la lecture PV.** L'ancien code voyait la meme chose dans les +deux. Sans cette paire, rien ne prouverait que la nouvelle grandeur discrimine +ce que l'ancienne confondait. +Troisieme scenario, `excedent-grosse-production-grosse-maison` : PV 3000 W, +maison 2800 W, SOC 60 — pas de censure possible, la production mesuree EST la +ressource et elle est deja prise. Il exerce le chemin « soleil reel », que la +paire ne touche pas (elle passe par l'assistance). + +**`apresmidi-provocation-p3` a ete corrige** : il portait PV 300 W, maison 600 W, +batterie +2 A, reseau ouvert — un trou de 300 W que rien n'alimente, etat +physiquement inatteignable. Il passait quand meme, precisement parce que le +demarrage ne regardait que la production et jamais le bilan. Remis coherent en +gardant son intention (production sous `pv_hold_min` 500 ET sous `pv_start_min` +700, donc seul `solar_potential` peut demarrer) : PV 430 W, maison 400 W, +excedent +30 W — la vraie signature de la censure. + +Preuve par mutation, dans les deux sens : retour au seuil absolu → 2 echecs +cibles ; garde neutralisee → les 2 memes. Dans les deux cas +`excedent-censure-demarre` reste VERT, ce qui prouve qu'il tient par le chemin +assistance et non par la garde. Restauration → 64/64. + +### Limite connue +Si `sensor.victron_consumption_power_l1` passe `unavailable`, la maison est lue +a 0 et l'excedent redevient la production brute — c'est-a-dire le comportement +d'avant ce correctif. Degradation vers l'existant, jamais pire. + +### Ce que ce correctif ne fait PAS (suite convenue avec le proprietaire) +2. **Lire `input_boolean.evcs_balance_pv_censored`** pour conditionner la + provocation `solar_potential`, au lieu de l'approximer par SOC ≥ 90. Le + 03/09 la censure s'est arretee a 19:30 (`batterie_pleine_sans_puits` → + `production non bridee` a 20:00) : l'approximation disait « oui » au moment + exact ou la chose approximee devenait fausse. +3. **Lire `evcs_balance_required_soc` / `_energy_to_cover_kwh`** pour que le + plancher du soir cesse d'etre un niveau fixe (`evening_soc_start_min` = 85). + +⚠️ **CONSTAT STRUCTUREL, a garder en tete pour la suite** : +`grep evcs_balance workflows/evcs-surplus-controller-v2.6.json` → **0**, idem +pour l'hysteresis. Le bilan energetique calcule et publie l'heure de croisement, +l'energie a couvrir, le SOC requis, `grid_needed` et `pv_censored` — et **aucun +des deux workflows qui decident n'en lit une seule valeur**. Meme contradiction +sur la cible du matin : `night_target_soc` = 35 % (regle sur le TOTAL de demain, +33,9 kWh ≥ 18 → « fort ») contre 99,7 % pour le bilan, qui regarde QUAND +l'energie arrive — demain matin est creux : 0,03 kWh a 7 h, 0,77 a 8 h, 1,81 a +9 h. Deux nombres, memes donnees, facteur 3. + +--- +*Document mis a jour le : 03/09/2026 (~20:55) — demarrage sur l'excedent, golden 64/64, deploye* diff --git a/docs/MANUEL_COMPLET.md b/docs/MANUEL_COMPLET.md index 5e13c3b..391f753 100644 --- a/docs/MANUEL_COMPLET.md +++ b/docs/MANUEL_COMPLET.md @@ -748,6 +748,50 @@ reste négative à chaque échantillon. Golden 56 → 61, dont le contrefactuel `batt-decharge-soutenue-coupe` qui prouve que la garde mord toujours après sa tolérance. Détail : HANDOFF §35. +#### D. Demarrage decide sur la production et non sur l'excedent (03/09/2026) + +Quatrieme membre de la meme famille, et le plus couteux. `pv_start_min_w` +(700 W) est une **puissance PV absolue**. Le 03/09 a 19:44, voiture branchee : +PV 758 W, maison 1202 W, batterie deja a −12,2 A, nuit qui tombe. Le controleur +a lu « 758 ≥ 700, donc il y a du soleil » et a lance 1,6 kW de charge. +L'excedent valait **−444 W**. + +En 33 minutes la voiture a pris 0,897 kWh et la batterie en a rendu 1,479 kWh +(SOC 94 → 85). Dans l'arithmetique du bilan energetique lui-meme, le manque de +la nuit est passe de 0,86 a 2,21 kWh — pour livrer 0,9 kWh que la voiture +reprenait de toute facon en heures creuses a 01:24. + +**La grandeur juste n'est pas la production, c'est ce qui reste une fois la +maison servie.** Sans injection et reseau absent, `PV − maison(hors EVCS)` vaut +la puissance batterie au signe pres, et c'est ce qui separe les deux situations +que la mesure PV seule confond : + +| | production | excedent | batterie | la voiture doit… | +|---|---|---:|---|---| +| **Censure** (batterie pleine, MPPT bride) | se cale sur la maison | **~0** | immobile | …demarrer : ressource refusee | +| **Deficit** (soir, nuages) | basse | **< 0** | decharge | …ne pas demarrer : ce serait la vider | + +Zero n'est donc pas un seuil de plus : c'est la frontiere physique entre les +deux. Verifie sur le releve du 03/09 — excedent positif (+14 a +212 W) sur +toute la plage ou `pv_censored` etait ON (17:00-19:41), negatif ensuite. + +Correctif : deux tests sur cette grandeur, aucune constante nouvelle. Le chemin +« soleil reel » reutilise `pv_start_min_w` tel quel ; le chemin « provocation » +(assistance batterie, `solar_potential`) exige seulement excedent ≥ 0. +**Demarrage uniquement** — les conditions de maintien gardent leur plancher et +leur tolerance temporelle (§C), sous peine de recreer l'oscillateur. + +Golden 61 → 64, autour d'une paire de releves reels pris a 54 minutes d'ecart +dont les lectures PV ne different que de **11 W** (769 puis 758) : le premier +demarre, le second non — ce qui les separe est la maison. Detail : HANDOFF §36. + +⚠️ Point structurel mis au jour par cet incident : le bilan energetique publie +l'heure de croisement, l'energie a couvrir, le SOC requis, `grid_needed` et +`pv_censored` — et **aucun des deux workflows de decision n'en lit une seule +valeur**. La cible du matin en est l'illustration : 35 % cote controleur +(regle sur le total de demain) contre 99,7 % cote bilan (qui regarde quand +l'energie arrive). Memes donnees, facteur 3. + --- --- diff --git a/src/nodes/BATTERY_CLOUD_LOCKS.js b/src/nodes/BATTERY_CLOUD_LOCKS.js index d755959..3a5a79a 100644 --- a/src/nodes/BATTERY_CLOUD_LOCKS.js +++ b/src/nodes/BATTERY_CLOUD_LOCKS.js @@ -570,6 +570,54 @@ const commonSafetyHoldOk = parsed.evcsVehicleConnected && !parsed.evcsModeIsAuto; +// ===================================================== +// EXCEDENT SOLAIRE — LA GRANDEUR QUI DECIDE D'UN DEMARRAGE (03/09/2026) +// ===================================================== +// Incident du 03/09 19:44 : PV 758 W, maison 1202 W, batterie deja a -12 A, +// nuit qui tombe. Le controleur a lance 1,6 kW de charge parce que +// pv_start_min_w (700 W) est une PUISSANCE PV ABSOLUE : "758 >= 700, donc il y +// a du soleil". L'excedent, lui, valait -444 W. En 33 minutes la voiture a pris +// 0,897 kWh et la batterie en a rendu 1,479 kWh, la veille d'une nuit que le +// bilan energetique annoncait deja deficitaire (SOC requis 99,7 %). +// +// La grandeur juste n'est pas la production, c'est ce qui RESTE une fois la +// maison servie. Sans injection et reseau absent, elle vaut la puissance +// batterie au signe pres : +// +// excedent = PV - maison(hors EVCS) ~= puissance batterie +// +// C'est aussi ce qui separe les deux situations que la mesure PV seule confond +// (PV_mesuree = min(ressource, conso + absorption batterie)) : +// +// - CENSURE : batterie pleine, MPPT bride faute de puits. La production se +// cale sur la maison, l'excedent vaut ~0, la batterie ne bouge pas. Il y a +// de la ressource REFUSEE : la provoquer avec la voiture est legitime, et +// c'est toute la raison d'etre de P3 / de l'assistance batterie. +// - DEFICIT reel : soir, nuages. L'excedent est NEGATIF, la batterie porte +// deja la maison. Charger la voiture, c'est la vider. +// +// Zero n'est donc pas un seuil de plus : c'est la frontiere physique entre les +// deux, et elle etait invisible tant qu'on regardait la production seule. +// Aucune constante n'est introduite ; le chemin "soleil reel" reutilise +// pv_start_min_w tel quel, applique a la bonne grandeur. +// +// PORTEE : DEMARRAGE uniquement. Les conditions de MAINTIEN sont inchangees — +// une charge etablie a deja son plancher de decharge et sa tolerance temporelle +// (01/09), et couper une charge en cours sur une mesure d'excedent bruitee +// recreerait l'oscillateur qu'on vient d'eteindre. +const solarSurplusW = parsed.pvPowerW - derived.houseNonEvcsW; + +// Chemin PROVOCATION (batterie haute, ou sourceMode solar_potential) : on +// n'exige pas d'excedent mesurable — il est censure par construction — mais on +// exige que la batterie ne porte pas deja la maison. +const solarSurplusProvocationOk = solarSurplusW >= 0; + +// Chemin SOLEIL REEL : l'excedent doit atteindre le seuil de demarrage. +const solarSurplusStartOk = + context.sourceMode === 'solar_potential' + ? solarSurplusProvocationOk + : solarSurplusW >= context.effectivePvStartMinW; + // ===================================================== // BATTERY-ASSISTED SOLAR START/HOLD // IMPORTANT: @@ -601,6 +649,7 @@ const batteryAssistSolarStartOk = parsed.soc >= Math.max(profileSocStartFloor, batteryAssistSolarStartSocMin) && parsed.soc > batteryAssistSolarHoldSocMin && parsed.batteryCurrentA >= profileBatteryDischargeFloorA && + solarSurplusProvocationOk && cloudDebtNextKwh <= batteryAssistSolarDebtStartMaxKwh && !cloudDebtActive; @@ -629,10 +678,7 @@ const rawSolarStartOk = commonSafetyStartOk && parsed.soc >= effectiveSocStartFloor && parsed.batteryCurrentA >= profileBatteryDischargeFloorA && - ( - context.sourceMode === 'solar_potential' || - parsed.pvPowerW >= context.effectivePvStartMinW - ); + solarSurplusStartOk; const solarStartOk = rawSolarStartOk || batteryAssistSolarStartOk; @@ -972,6 +1018,11 @@ return [{ profileSocHoldFloor, effectiveSocHoldFloor, profileBatteryDischargeFloorA, + + // Excedent solaire (03/09/2026) : la grandeur qui autorise un demarrage. + solarSurplusW, + solarSurplusProvocationOk, + solarSurplusStartOk, batteryCurrentHardExceeded, mustStopHard, gridBatteryProtectionStop, diff --git a/tests/golden/scenarios.mjs b/tests/golden/scenarios.mjs index a7472e3..8134dd0 100644 --- a/tests/golden/scenarios.mjs +++ b/tests/golden/scenarios.mjs @@ -888,13 +888,21 @@ export const scenarios = [ }, { id: 'apresmidi-provocation-p3', - label: 'Après-midi hors fenêtre, SOC 95, PV bridé 300 W (< hold 500) → provocation PV (solar_potential)', + // 03/09/2026 — echantillon rendu COHERENT EN ENERGIE. Il portait PV 300 W, + // maison 600 W et batterie +2 A : un trou de 300 W que rien n'alimente, + // etat physiquement inatteignable reseau ouvert. Il passait quand meme, + // parce que le demarrage ne regardait que la production. Corrige en gardant + // l'intention d'origine — production mesuree SOUS pv_hold_min (500) et sous + // pv_start_min (700), donc seul solar_potential peut demarrer — avec une + // maison qui, elle, ferme le bilan : c'est la signature de la censure + // (batterie pleine, MPPT cale sur la maison, excedent ~0). + label: 'Après-midi hors fenêtre, SOC 95, PV bridé 430 W (< hold 500) mais maison 400 W → censure, excédent +30 W → provocation PV (solar_potential)', time: AFTERNOON, overrides: { evcs_vehicle_connected: 'on', evcs_device_connected: 'on', - soc_percent: '95', pv_power_w: '300', pv_power_10m_avg_w: '300', + soc_percent: '95', pv_power_w: '430', pv_power_10m_avg_w: '430', pv_trend_w: '0', battery_current_a: '2', - house_ac_power_w: '600', vebus_ac_out_l1_power_w: '600', + house_ac_power_w: '400', vebus_ac_out_l1_power_w: '400', }, expect: { profile: 'outside_window', @@ -909,6 +917,80 @@ export const scenarios = [ }, }, + // ===== 03/09/2026 : un demarrage se decide sur l'EXCEDENT, pas sur la PV ===== + // Les deux premiers scenarios sont des releves REELS du 03/09, pris a 54 min + // d'intervalle. Leurs lectures de production ne different que de 11 W + // (769 W puis 758 W) : pour l'ancien code, deux fois "758 >= 700, il y a du + // soleil". Ce qui les separe est la MAISON — 755 W puis 1202 W — donc + // l'excedent : +14 W (production refusee, batterie pleine, rien a absorber) + // puis -444 W (la batterie porte deja la maison, la nuit tombe). + // La paire est le coeur du correctif : sans elle, aucune preuve que la + // nouvelle grandeur discrimine ce que l'ancienne confondait. + { + id: 'excedent-censure-demarre', + label: "Relevé du 03/09 18:50 (pv_censored=ON) : PV 769 W, maison 755 W, batterie 0 A, SOC 94 → excédent +14 W = ressource refusée → la voiture DOIT démarrer", + time: '2026-08-08T18:50:00+02:00', + overrides: { + evcs_vehicle_connected: 'on', evcs_device_connected: 'on', + soc_percent: '94', battery_current_a: '0', + pv_power_w: '769.1', pv_power_10m_avg_w: '800', pv_trend_w: '0', + house_ac_power_w: '755', vebus_ac_out_l1_power_w: '755', + evcs_ac_power_w: '0', + }, + expect: { + profile: 'evening_high_soc', + sourceMode: 'solar_only', + state: 'start_min_current', + decision: 'start', + desiredCharging: true, + currentA: 6, + }, + }, + + { + id: 'excedent-deficit-ne-demarre-pas', + label: "Relevé du 03/09 19:44 (l'incident) : PV 758 W mais maison 1202 W, batterie déjà à -12 A, SOC 94 → excédent -444 W → AUCUN démarrage, ni chemin solaire ni assistance batterie", + time: '2026-08-08T19:44:30+02:00', + overrides: { + evcs_vehicle_connected: 'on', evcs_device_connected: 'on', + soc_percent: '94', battery_current_a: '-12.2', + pv_power_w: '758.3', pv_power_10m_avg_w: '790', pv_trend_w: '0', + house_ac_power_w: '1202', vebus_ac_out_l1_power_w: '1202', + evcs_ac_power_w: '0', + }, + expect: { + profile: 'evening_high_soc', + sourceMode: 'solar_only', + state: 'idle_wait_conditions', + decision: 'hold', + desiredCharging: false, + }, + }, + + { + // Exerce le chemin "soleil reel" (rawSolarStartOk), que la paire ci-dessus + // ne touche pas : elle passe par l'assistance batterie. Batterie a 60 %, + // donc pas de censure possible — la production mesuree EST la ressource, et + // elle est deja prise par la maison. L'ancien code demarrait (3000 >= 700). + id: 'excedent-grosse-production-grosse-maison', + label: "Matin, PV 3000 W mais maison 2800 W et SOC 60 (pas de censure possible) → excédent 200 W < seuil de démarrage → pas de charge malgré une forte production", + time: MORNING, + overrides: { + evcs_vehicle_connected: 'on', evcs_device_connected: 'on', + soc_percent: '60', battery_current_a: '-4', + pv_power_w: '3000', pv_power_10m_avg_w: '3000', pv_trend_w: '0', + house_ac_power_w: '2800', vebus_ac_out_l1_power_w: '2800', + evcs_ac_power_w: '0', + }, + expect: { + profile: 'morning_solar', + sourceMode: 'solar_only', + state: 'idle_wait_conditions', + decision: 'hold', + desiredCharging: false, + }, + }, + // ===== 01/09/2026 : le depassement souple de la batterie doit DURER ===== // Incident du 01/09 (14:26 et 14:56) : SOC 94, batterie en float, courant // oscillant entre +1,8 et -2,6 A d'un echantillon a l'autre. Le controleur diff --git a/workflows/evcs-surplus-controller-v2.6.json b/workflows/evcs-surplus-controller-v2.6.json index 727de05..47a3e51 100644 --- a/workflows/evcs-surplus-controller-v2.6.json +++ b/workflows/evcs-surplus-controller-v2.6.json @@ -81,7 +81,7 @@ }, { "parameters": { - "jsCode": "function clamp(v, min, max) {\n return Math.min(Math.max(v, min), max);\n}\n\nfunction to2(n) {\n return String(n).padStart(2, '0');\n}\n\nfunction getParisParts(ms = Date.now()) {\n const fmt = new Intl.DateTimeFormat('fr-FR', {\n timeZone: 'Europe/Paris',\n year: 'numeric',\n month: '2-digit',\n day: '2-digit',\n hour: '2-digit',\n minute: '2-digit',\n second: '2-digit',\n hour12: false,\n });\n\n const parts = Object.fromEntries(\n fmt.formatToParts(new Date(ms))\n .filter(p => p.type !== 'literal')\n .map(p => [p.type, p.value])\n );\n\n const hour = Number(parts.hour) === 24 ? 0 : Number(parts.hour);\n const date = `${parts.year}-${parts.month}-${parts.day}`;\n\n return {\n date,\n hour,\n minute: Number(parts.minute),\n second: Number(parts.second),\n };\n}\n\nfunction getTzOffsetMs(utcTs, timeZone = 'Europe/Paris') {\n const dtf = new Intl.DateTimeFormat('en-US', {\n timeZone,\n year: 'numeric',\n month: '2-digit',\n day: '2-digit',\n hour: '2-digit',\n minute: '2-digit',\n second: '2-digit',\n hour12: false,\n });\n\n const parts = Object.fromEntries(\n dtf.formatToParts(new Date(utcTs))\n .filter(p => p.type !== 'literal')\n .map(p => [p.type, p.value])\n );\n\n const asIfUtc = Date.UTC(\n Number(parts.year),\n Number(parts.month) - 1,\n Number(parts.day),\n Number(parts.hour) === 24 ? 0 : Number(parts.hour),\n Number(parts.minute),\n Number(parts.second)\n );\n\n const d = new Date(utcTs);\n const actualUtc = Date.UTC(\n d.getUTCFullYear(),\n d.getUTCMonth(),\n d.getUTCDate(),\n d.getUTCHours(),\n d.getUTCMinutes(),\n d.getUTCSeconds()\n );\n\n return asIfUtc - actualUtc;\n}\n\nfunction parseParisStampToMs(s) {\n const text = (s ?? '').toString().trim();\n const m = text.match(/^(\\d{4})-(\\d{2})-(\\d{2}) (\\d{2}):(\\d{2}):(\\d{2})$/);\n if (!m) return null;\n\n const y = Number(m[1]);\n const mo = Number(m[2]);\n const d = Number(m[3]);\n const h = Number(m[4]);\n const mi = Number(m[5]);\n const se = Number(m[6]);\n\n const guessUtc = Date.UTC(y, mo - 1, d, h, mi, se);\n const off1 = getTzOffsetMs(guessUtc, 'Europe/Paris');\n let ts = guessUtc - off1;\n const off2 = getTzOffsetMs(ts, 'Europe/Paris');\n\n if (off2 !== off1) ts = guessUtc - off2;\n return ts;\n}\n\nfunction hhmmssFromMinutes(minOfDay) {\n const mm = ((minOfDay % 1440) + 1440) % 1440;\n const hh = Math.floor(mm / 60);\n const mi = mm % 60;\n return `${to2(hh)}:${to2(mi)}:00`;\n}\n\nfunction nextDailyOccurrenceMs(startMin, refMs) {\n if (!Number.isFinite(startMin)) return null;\n\n const today = getParisParts(refMs).date;\n const todayStamp = `${today} ${hhmmssFromMinutes(startMin)}`;\n const todayMs = parseParisStampToMs(todayStamp);\n\n if (todayMs !== null && todayMs > refMs + 1000) return todayMs;\n\n const tomorrow = getParisParts(refMs + 36 * 3600 * 1000).date;\n const tomorrowStamp = `${tomorrow} ${hhmmssFromMinutes(startMin)}`;\n return parseParisStampToMs(tomorrowStamp);\n}\n\nconst prev = $input.first().json;\n\nconst {\n nowMs,\n cfg,\n parsed,\n params,\n history,\n batteryTune,\n strategy,\n derived,\n context,\n lifecycle,\n} = prev;\n\n// =====================================================\n// SESSION / SOURCE\n// =====================================================\nconst sessionObservedActive =\n parsed.evcsChargingSwitchOn ||\n parsed.controlLatch ||\n parsed.chargePermissionNow;\n\nconst isGridSource =\n context.sourceMode === 'grid_hc_allowed' ||\n context.sourceMode === 'grid_forced';\n\nconst isLocalSolarSource =\n context.sourceMode === 'solar_only' ||\n context.sourceMode === 'solar_potential';\n\n// =====================================================\n// SOC BAND / BATTERY DYNAMIC POLICY\n// =====================================================\nconst socBand =\n parsed.soc > 90 ? 'gt90' :\n parsed.soc > 80 ? '80_90' :\n parsed.soc > 60 ? '60_80' :\n parsed.soc > 40 ? '40_60' :\n parsed.soc > 25 ? '25_40' :\n 'lt25';\n\nconst band = batteryTune.bands[socBand];\n\nconst dischargeModeFactor =\n strategy.forceChargeRequested ? 1.2 :\n strategy.forceGridRequested ? 0.7 :\n strategy.userStrategy === 'auto_solar' ? 0.8 :\n 1.0;\n\nconst chargeReserveModeFactor =\n strategy.forceChargeRequested ? 0.8 :\n strategy.forceGridRequested ? 1.1 :\n strategy.userStrategy === 'auto_solar' ? 1.1 :\n 1.0;\n\nconst toleranceModeFactor =\n strategy.forceChargeRequested ? 1.25 :\n strategy.forceGridRequested ? 0.8 :\n strategy.userStrategy === 'auto_solar' ? 0.8 :\n 1.0;\n\nconst batteryDischargeSoftDynamicA = band.dischargeSoftA * dischargeModeFactor;\nconst batteryDischargeHardDynamicA = band.dischargeHardA * dischargeModeFactor;\nconst batteryChargeReserveDynamicA = Math.max(0, band.chargeReserveA * chargeReserveModeFactor);\nconst batterySoftToleranceMinutes = band.softToleranceMin * toleranceModeFactor;\nconst batteryHardToleranceSeconds = band.hardToleranceSec;\nconst batteryRecoveryMinutes = band.recoveryMin;\n\n// =====================================================\n// CONTEXT HELPERS\n// =====================================================\nconst houseSpikeThresholdW = 1500;\nconst houseSpikeDetected =\n (parsed.houseAcPowerW - parsed.houseAcPowerPrevW) >= houseSpikeThresholdW;\n\nconst solarPotentialContext =\n context.sourceMode === 'solar_potential' ||\n (\n parsed.soc >= 80 &&\n derived.forecastBufferKwh > 0 &&\n parsed.pvPowerW < context.effectivePvStartMinW &&\n derived.houseNonEvcsW < 1200\n );\n\n// =====================================================\n// OUTSIDE-WINDOW LOCAL BYPASS\n// IMPORTANT:\n// en auto_solar_plus ou force_charge_now, un contexte local solaire\n// ne doit plus rester bloqué juste parce qu'on est hors fenêtre.\n// =====================================================\nconst outsideWindowLocalBypass =\n context.profile === 'outside_window' &&\n !strategy.forceGridRequested &&\n (\n strategy.forceChargeRequested ||\n strategy.userStrategy === 'auto_solar_plus'\n ) &&\n isLocalSolarSource;\n\n// =====================================================\n// REASON-BASED LOCKS\n// =====================================================\nconst nextWindowCandidates = [\n nextDailyOccurrenceMs(params.morningStartMin, nowMs),\n nextDailyOccurrenceMs(params.hcMiddayStartMin, nowMs),\n nextDailyOccurrenceMs(params.eveningStartMin, nowMs),\n nextDailyOccurrenceMs(params.hcNightStartMin, nowMs),\n].filter(v => Number.isFinite(v));\n\nconst nextAllowedWindowStartMs =\n nextWindowCandidates.length > 0 ? Math.min(...nextWindowCandidates) : null;\n\nconst blockActive =\n history.blockedUntilMs !== null &&\n history.blockedUntilMs > nowMs + 1000;\n\nconst blockStillRelevant =\n history.lastBlockReason === 'outside_window'\n ? (context.profile === 'outside_window' && !outsideWindowLocalBypass)\n : history.lastBlockReason === 'probe_failed'\n ? context.sourceMode === 'hybrid_probe'\n : history.lastBlockReason === 'battery_protection_stop'\n ? (\n parsed.batteryCurrentA < -params.batteryNeutralBandA ||\n derived.anyVebusLowBattery ||\n derived.vebusDcVoltageSoftLow\n )\n : history.lastBlockReason === 'grid_battery_guard'\n ? isGridSource\n : history.lastBlockReason === 'vebus_overload_persistent'\n ? derived.anyVebusOverload\n : false;\n\nconst startBlockedByReason = blockActive && blockStillRelevant;\n\n// =====================================================\n// CLOUD DEBT\n// =====================================================\nconst batteryNetPowerW = parsed.batteryCurrentA * derived.batteryVoltageRef;\nlet cloudDebtNextKwh = params.cloudDebtPrevKwh;\n\nif (sessionObservedActive) {\n if (batteryNetPowerW < 0 && !isGridSource) {\n cloudDebtNextKwh += (Math.abs(batteryNetPowerW) * cfg.sampleSeconds) / 3600000;\n } else if (batteryNetPowerW > 0) {\n cloudDebtNextKwh -= (batteryNetPowerW * cfg.sampleSeconds) / 3600000;\n }\n} else if (batteryNetPowerW > 0) {\n cloudDebtNextKwh -= (batteryNetPowerW * cfg.sampleSeconds) / 3600000;\n}\n\ncloudDebtNextKwh = clamp(cloudDebtNextKwh, 0, params.cloudDebtMaxKwh * 4);\n\nconst cloudDebtMeaningful =\n cloudDebtNextKwh > cfg.cloudDebtMeaningfulThresholdKwh;\n\nconst cloudEventDischargingNow =\n sessionObservedActive &&\n !isGridSource &&\n parsed.batteryCurrentA < -Math.max(1, params.batteryNeutralBandA) &&\n cloudDebtMeaningful;\n\nconst continuingCloudEvent =\n cloudEventDischargingNow &&\n history.cloudDebtStartedMsPrev !== null &&\n params.cloudDebtPrevKwh > cfg.cloudDebtRecoveryThresholdKwh;\n\nconst enteringCloudDebt =\n cloudEventDischargingNow && !continuingCloudEvent;\n\nconst cloudDebtActive = cloudEventDischargingNow;\n\nconst cloudDebtStartedMs =\n cloudEventDischargingNow\n ? (continuingCloudEvent ? history.cloudDebtStartedMsPrev : nowMs)\n : null;\n\nconst cloudDebtDurationMinutes =\n cloudDebtActive && cloudDebtStartedMs !== null\n ? Math.max(0, (nowMs - cloudDebtStartedMs) / 60000)\n : 0;\n\nconst effectiveCloudToleranceMinutes =\n params.cloudToleranceMinutes +\n (solarPotentialContext ? batteryTune.global.solarPotentialGraceMinutes : 0) +\n (houseSpikeDetected ? batteryTune.global.houseSpikeGraceMinutes : 0);\n\nconst cloudToleranceOk =\n cloudDebtNextKwh <= params.cloudDebtMaxKwh &&\n (\n !cloudDebtActive ||\n cloudDebtDurationMinutes <= effectiveCloudToleranceMinutes\n );\n\n// =====================================================\n// LEGACY / FORECAST MODES\n// =====================================================\nconst batteryFullHarvestMode =\n parsed.forecastModeEnabled &&\n context.profile !== 'night_hc' &&\n context.profile !== 'outside_window' &&\n parsed.soc >= params.batteryFullSoc &&\n parsed.todayRemainingKwh >= params.batteryFullForecastMinKwh &&\n derived.projectedEndSocAfterCharge >= (derived.endDayGuardSoc - 2);\n\nconst forecastCommitMode =\n parsed.forecastModeEnabled &&\n context.profile !== 'night_hc' &&\n context.profile !== 'outside_window' &&\n parsed.soc >= params.forecastCommitSocFloor &&\n parsed.todayRemainingKwh >= params.forecastCommitMinKwh &&\n derived.projectedEndSocAfterCharge >= derived.endDayGuardSoc;\n\nconst batteryPriorityMode =\n !batteryFullHarvestMode && !forecastCommitMode;\n\n// =====================================================\n// EVCS NIGHT ENGINE — R1 (07/08/2026)\n// Le moteur nuit est calculé UNE SEULE FOIS dans WINDOWS_SOURCEV2\n// (payload nightEvcsPreview). Ce node CONSOMME, il ne recalcule plus.\n// TOUTE évolution de la logique nuit se fait dans WINDOWS_SOURCEV2.\n// Fallback sûr si preview absent : session inactive / 'blocked'.\n// =====================================================\nconst nightPrev = prev.nightEvcsPreview || {};\n\nconst nightSessionActive = !!nightPrev.nightSessionActive;\nconst nightSessionNeedsCapture = !!nightPrev.nightSessionNeedsCapture;\nconst nightSessionNeedsClear = !!nightPrev.nightSessionNeedsClear;\nconst nightSessionStartSocPct =\n Number.isFinite(nightPrev.nightSessionStartSocPct) ? nightPrev.nightSessionStartSocPct : null;\nconst nightSessionStartAt = nightPrev.nightSessionStartAt || '';\nconst nightSessionStartMs =\n Number.isFinite(nightPrev.nightSessionStartMs) ? nightPrev.nightSessionStartMs : null;\n\nconst batteryHardFloorPct =\n Number.isFinite(nightPrev.batteryHardFloorPct)\n ? nightPrev.batteryHardFloorPct\n : params.evcsNightBatteryHardFloorPct;\nconst batteryMorningTargetPct =\n Number.isFinite(nightPrev.batteryMorningTargetPct)\n ? nightPrev.batteryMorningTargetPct\n : params.evcsNightTargetWeakForecastPct;\nconst advisorNightTargetApplied = !!nightPrev.advisorNightTargetApplied;\nconst maxBatteryDischargePoints =\n Number.isFinite(nightPrev.maxBatteryDischargePoints) ? nightPrev.maxBatteryDischargePoints : 0;\nconst gridTakeoverTriggerPct =\n Number.isFinite(nightPrev.gridTakeoverTriggerPct)\n ? nightPrev.gridTakeoverTriggerPct\n : Math.max(batteryHardFloorPct, batteryMorningTargetPct);\n\nconst batteryBelowHardFloor = !!nightPrev.batteryBelowHardFloor;\nconst batteryBelowMorningTarget = !!nightPrev.batteryBelowMorningTarget;\nconst batteryAtOrBelowGridTakeoverTrigger = !!nightPrev.batteryAtOrBelowGridTakeoverTrigger;\n\nconst batteryKwhMissingToTarget =\n Number.isFinite(nightPrev.batteryKwhMissingToTarget) ? nightPrev.batteryKwhMissingToTarget : 0;\nconst batteryRecoveryMinutesNeeded =\n Number.isFinite(nightPrev.batteryRecoveryMinutesNeeded) ? nightPrev.batteryRecoveryMinutesNeeded : 0;\nconst batteryRecoveryUrgent = !!nightPrev.batteryRecoveryUrgent;\nconst batteryRecoveryAllowedNow = !!nightPrev.batteryRecoveryAllowedNow;\nconst batteryTargetReached = !!nightPrev.batteryTargetReached;\n\nconst batteryAssistAllowed = !!nightPrev.batteryAssistAllowed;\nconst nightGridTakeoverNeeded = !!nightPrev.nightGridTakeoverNeeded;\nconst nightTakeoverLatched = !!nightPrev.nightTakeoverLatched;\nconst vehicleChargeCompleted = !!nightPrev.vehicleChargeCompleted;\nconst nightRecoveryDone = !!nightPrev.nightRecoveryDone;\nconst departureUrgentActive = !!nightPrev.departureUrgentActive;\nconst departureChargeDone = !!nightPrev.departureChargeDone;\nconst dayVehicleFullIdle = !!nightPrev.dayVehicleFullIdle;\nconst nightPreferredSourceMode = nightPrev.nightPreferredSourceMode || 'blocked';\n\n// =====================================================\n// PROFILE-BASED THRESHOLDS\n// =====================================================\nconst profileSocStartFloor =\n context.profile === 'morning_solar' ? params.morningSocMin :\n context.profile === 'midday_hc_or_solar' ? params.middaySocMin :\n context.profile === 'evening_high_soc' ? params.eveningSocStartMin :\n params.socStartMin;\n\n// P6 ABONDANCE : PV moyen élevé + batterie en charge franche => le plancher SOC\n// de démarrage perd son sens (la batterie continue de monter même avec l'EVCS à 6 A).\n// Jours nuageux ou batterie peu chargée par le PV : rien ne change.\nconst PV_ABUNDANCE_AVG_MIN_W = 4000;\nconst ABUNDANCE_BATTERY_CHARGE_MIN_A = 20;\nconst ABUNDANCE_SOC_FLOOR_PCT = 40;\n\n// 24/08/2026 — L'abondance se PROUVE au démarrage (batterie qui monte franchement,\n// >= +20 A, avant qu'on touche à quoi que ce soit) mais elle se MAINTIENT tant que\n// le PV reste fort ET que la batterie n'est pas devenue fournisseuse. Sans cette\n// distinction, la charge du véhicule — qui détourne précisément le surplus — fait\n// tomber le courant batterie sous +20 A et détruit sa propre condition\n// d'autorisation : système bistable, oscillation à la période de min_run.\nconst solarAbundanceStart =\n parsed.pvPowerAvg10mW >= PV_ABUNDANCE_AVG_MIN_W &&\n parsed.batteryCurrentA >= ABUNDANCE_BATTERY_CHARGE_MIN_A;\nconst solarAbundanceHold =\n parsed.pvPowerAvg10mW >= PV_ABUNDANCE_AVG_MIN_W &&\n parsed.batteryCurrentA >= 0;\nconst solarAbundance = solarAbundanceStart; // conservé pour la traçabilité/debug\n\nconst abundanceSocFloor = (abundant) =>\n abundant ? Math.min(profileSocStartFloor, ABUNDANCE_SOC_FLOOR_PCT) : profileSocStartFloor;\n\nconst effectiveSocStartFloor = abundanceSocFloor(solarAbundanceStart);\n\nconst profileSocHoldFloor =\n context.profile === 'morning_solar' ? Math.max(params.socStopMin, params.morningSocMin - 10) :\n context.profile === 'midday_hc_or_solar' ? Math.max(params.socStopMin, params.middaySocMin - 15) :\n context.profile === 'evening_high_soc' ? params.eveningSocStopMin :\n params.socStopMin;\n\n// INVARIANT (24/08/2026) : on ne doit JAMAIS avoir le droit de DÉMARRER une charge\n// qu'on n'a pas le droit de MAINTENIR. Le plancher de maintien doit donc rester\n// <= au plancher de démarrage effectif.\n// Incident du 24/08 midi : bascule de profil vers midday_hc_or_solar à 12:24 avec\n// SOC 58 % => plancher de démarrage 40 % (abondance P6) mais plancher de maintien\n// max(29, 75-15) = 60 % => démarrage autorisé, maintien refusé, cycle limite de\n// 3 min (min_run) sur toute la bande [40 %, 60 %], et ~2,6 kW de PV écrêtés à\n// chaque creux faute de preneur (ESS sans injection).\nconst effectiveSocHoldFloor = Math.min(\n profileSocHoldFloor,\n abundanceSocFloor(solarAbundanceHold)\n);\n\nconst profileSpecificDischargeFloorA =\n context.profile === 'morning_solar' ? params.morningBatteryMaxDischargeA :\n context.profile === 'midday_hc_or_solar' ? params.middayBatteryMaxDischargeA :\n context.profile === 'evening_high_soc' ? params.eveningBatteryMaxDischargeA :\n params.batteryDischargeSoftA;\n\nconst profileBatteryDischargeFloorA =\n Math.max(profileSpecificDischargeFloorA, batteryDischargeSoftDynamicA);\n\n// =====================================================\n// DEPASSEMENT SOUPLE DU PLANCHER DE DECHARGE (01/09/2026)\n// =====================================================\n// La table des bandes SOC (INPUT_PARSE) declare depuis toujours DEUX regimes :\n// un courant SOUPLE tolere pendant des MINUTES (softToleranceMin : 12 min a\n// SOC > 90, 1 min sous 25) et un courant DUR tolere quelques SECONDES. Seuls\n// les seuils de courant etaient lus ; la dimension temporelle etait calculee,\n// exportee dans le debug, et branchee sur AUCUNE decision. Le controleur\n// comparait donc un echantillon instantane, toutes les 30 s, a un plancher.\n//\n// Incident du 01/09 (14:26 et 14:56, SOC 94, batterie en float) : le courant\n// batterie oscillait entre +1,8 et -2,6 A d'un echantillon a l'autre, soit le\n// bruit de regulation d'une batterie qui ne fait rien. Le test devenait un\n// tirage a pile ou face et la charge du vehicule s'arretait dessus. Chaque\n// arret coutait ~0,45 kWh de production refusee : batterie pleine, le vehicule\n// etait le SEUL preneur, donc la PV s'effondrait de 6,6 a 2,5 kW en 30 s, et\n// la reprise mettait 21 min a remonter de 6 A a 20 A. 1 a 6 arrets par jour\n// sur la semaine du 26 au 31/08.\n//\n// Le correctif est PUREMENT TEMPOREL : aucun seuil n'est deplace, aucune\n// constante n'est ajoutee. On applique la table telle qu'elle est ecrite :\n// un depassement souple doit DURER pour compter. Comme le bruit change de\n// signe en quelques secondes, le compteur se remet a zero de lui-meme ; une\n// vraie decharge, elle, reste negative a chaque echantillon et finit par\n// depasser la tolerance de sa bande.\n//\n// CE QUI N'EST PAS TOUCHE : le plancher de DEMARRAGE reste instantane (ne pas\n// lancer une charge sur une batterie qui se decharge deja, c'est la frontiere\n// \"delestage et non charge prioritaire\"), et le garde DUR reste instantane —\n// hardToleranceSec n'est volontairement PAS branche, cela ne pourrait que\n// ralentir une securite, et c'est le risque qui prime.\nconst batterySoftBreachNow =\n sessionObservedActive &&\n parsed.batteryCurrentA < profileBatteryDischargeFloorA;\n\nconst batterySoftBreachStartedMs = batterySoftBreachNow\n ? (history.batterySoftBreachMsPrev !== null\n ? history.batterySoftBreachMsPrev\n : nowMs)\n : null;\n\nconst batterySoftBreachMinutes =\n batterySoftBreachStartedMs !== null\n ? Math.max(0, (nowMs - batterySoftBreachStartedMs) / 60000)\n : 0;\n\n// Vrai tant que le depassement n'a pas dure plus que la tolerance de sa bande.\n// Une tolerance a 0 minute redonne exactement le comportement instantane.\nconst batterySoftDischargeHoldOk =\n !batterySoftBreachNow ||\n batterySoftBreachMinutes <= batterySoftToleranceMinutes;\n\n// =====================================================\n// GRID RELAX IN force_charge_now\n// =====================================================\n// Assouplissement uniquement pour la garde réseau,\n// sans toucher aux sécurités dures globales.\nconst gridForceChargeRelaxSoftA =\n strategy.forceChargeRequested && isGridSource\n ? (\n parsed.soc >= 90 ? 28 :\n parsed.soc >= 80 ? 16 :\n parsed.soc >= 60 ? 8 :\n parsed.soc >= 40 ? 4 :\n 0\n )\n : 0;\n\nconst gridForceChargeRelaxHardA =\n strategy.forceChargeRequested && isGridSource\n ? (\n parsed.soc >= 90 ? 39 :\n parsed.soc >= 80 ? 29 :\n parsed.soc >= 60 ? 18 :\n parsed.soc >= 40 ? 8 :\n 0\n )\n : 0;\n\nconst gridBatterySoftDynamicABase =\n Math.max(params.gridBatterySoftA, batteryDischargeSoftDynamicA);\n\nconst gridBatteryHardDynamicABase =\n Math.max(params.gridBatteryHardA, batteryDischargeHardDynamicA);\n\nlet gridBatterySoftDynamicA =\n gridBatterySoftDynamicABase - gridForceChargeRelaxSoftA;\n\nlet gridBatteryHardDynamicA =\n gridBatteryHardDynamicABase - gridForceChargeRelaxHardA;\n\n// Garantit que le hard reste plus permissif que le soft d'au moins 3 A\nif (gridBatteryHardDynamicA > (gridBatterySoftDynamicA - 3)) {\n gridBatteryHardDynamicA = gridBatterySoftDynamicA - 3;\n}\n\n// =====================================================\n// BASE SAFETY\n// =====================================================\nconst commonSafetyStartOk =\n derived.canRunAtMinCurrent &&\n !derived.anyVebusOverload &&\n !derived.anyVebusLowBattery &&\n !derived.vebusDcVoltageSoftLow &&\n !derived.vebusAcOutSoftReached &&\n parsed.evcsDeviceConnected &&\n parsed.evcsVehicleConnected &&\n !parsed.evcsModeIsAuto;\n\nconst commonSafetyHoldOk =\n derived.canRunAtMinCurrent &&\n !derived.anyVebusLowBattery &&\n !derived.vebusDcVoltageHardLow &&\n parsed.evcsDeviceConnected &&\n parsed.evcsVehicleConnected &&\n !parsed.evcsModeIsAuto;\n\n// =====================================================\n// BATTERY-ASSISTED SOLAR START/HOLD\n// IMPORTANT:\n// Installation sans injection:\n// le PV visible peut rester bas tant que l'EVCS ne démarre pas.\n// On autorise donc un démarrage/maintien local si la batterie est haute\n// et que la dette nuage reste faible.\n// =====================================================\nconst batteryAssistSolarPolicyEligible =\n !strategy.forceGridRequested &&\n (\n strategy.forceChargeRequested ||\n strategy.userStrategy === 'auto_solar_plus'\n ) &&\n isLocalSolarSource;\n\nconst batteryAssistSolarStartSocMin = 90;\nconst batteryAssistSolarHoldSocMin = 85;\n\nconst batteryAssistSolarDebtStartMaxKwh = Math.max(\n cfg.cloudDebtRecoveryThresholdKwh,\n Math.min(params.cloudDebtMaxKwh, 0.05)\n);\n\nconst batteryAssistSolarStartOk =\n batteryAssistSolarPolicyEligible &&\n commonSafetyStartOk &&\n !isGridSource &&\n parsed.soc >= Math.max(profileSocStartFloor, batteryAssistSolarStartSocMin) &&\n parsed.soc > batteryAssistSolarHoldSocMin &&\n parsed.batteryCurrentA >= profileBatteryDischargeFloorA &&\n cloudDebtNextKwh <= batteryAssistSolarDebtStartMaxKwh &&\n !cloudDebtActive;\n\nconst batteryAssistSolarHoldOk =\n batteryAssistSolarPolicyEligible &&\n commonSafetyHoldOk &&\n !isGridSource &&\n parsed.soc >= Math.max(profileSocHoldFloor, batteryAssistSolarHoldSocMin) &&\n batterySoftDischargeHoldOk &&\n cloudToleranceOk;\n\n// =====================================================\n// START / HOLD CONDITIONS\n// =====================================================\n// FENETRE D'ETABLISSEMENT (31/08/2026) — cf. WINDOWS_SOURCEV2 pour la mesure.\n// Pendant les ~150 s qui suivent l'arrivee du reseau, PV et courant batterie ne\n// mesurent rien d'exploitable. On NE DEMARRE PAS sur ces valeurs, et on ne\n// laisse pas une charge en cours s'arreter a cause d'elles. Les gardes de\n// securite (commonSafety*, planchers SOC, surcharge, pas de tampon) restent\n// evaluees normalement : c'est le risque qui prime, jamais le confort.\nconst gridSettling = derived.gridSettling === true;\n\nconst rawSolarStartOk =\n (context.sourceMode === 'solar_only' || context.sourceMode === 'solar_potential') &&\n !gridSettling &&\n commonSafetyStartOk &&\n parsed.soc >= effectiveSocStartFloor &&\n parsed.batteryCurrentA >= profileBatteryDischargeFloorA &&\n (\n context.sourceMode === 'solar_potential' ||\n parsed.pvPowerW >= context.effectivePvStartMinW\n );\n\nconst solarStartOk =\n rawSolarStartOk || batteryAssistSolarStartOk;\n\nconst rawSolarHoldOk =\n (context.sourceMode === 'solar_only' || context.sourceMode === 'solar_potential') &&\n commonSafetyHoldOk &&\n parsed.soc >= effectiveSocHoldFloor &&\n (\n gridSettling ||\n batterySoftDischargeHoldOk\n ) &&\n (\n gridSettling ||\n parsed.pvPowerW >= params.pvHoldMinW ||\n cloudToleranceOk\n );\n\nconst solarHoldOk =\n rawSolarHoldOk || batteryAssistSolarHoldOk;\n\n// FIX V2.5: Garde SOC - pas de grid pour EVCS si SOC > 35% sauf force_grid_now\n// P9 (09/08/2026) : la fenêtre départ imminent autorise aussi le réseau.\nconst gridAllowedBySoc = \n strategy.forceGridRequested ||\n context.profile === 'night_hc' ||\n departureUrgentActive ||\n parsed.soc <= 35;\n\nconst gridStartOk =\n gridAllowedBySoc &&\n isGridSource &&\n commonSafetyStartOk &&\n parsed.gridAvailable &&\n parsed.batteryCurrentA >= gridBatterySoftDynamicA;\n\nconst gridHoldOk =\n gridAllowedBySoc &&\n isGridSource &&\n commonSafetyHoldOk &&\n parsed.gridAvailable &&\n parsed.batteryCurrentA >= gridBatteryHardDynamicA;\n\nconst batteryAssistNightStartOk =\n context.sourceMode === 'battery_assist_night' &&\n commonSafetyStartOk &&\n nightSessionActive &&\n batteryAssistAllowed &&\n parsed.soc > batteryHardFloorPct &&\n parsed.soc > gridTakeoverTriggerPct &&\n parsed.batteryCurrentA >= profileBatteryDischargeFloorA;\n\nconst batteryAssistNightHoldOk =\n context.sourceMode === 'battery_assist_night' &&\n commonSafetyHoldOk &&\n nightSessionActive &&\n batteryAssistAllowed &&\n parsed.soc > batteryHardFloorPct &&\n parsed.soc > gridTakeoverTriggerPct &&\n parsed.batteryCurrentA >= batteryDischargeHardDynamicA;\n\n// =====================================================\n// PROBE LOGIC — STRICTEMENT LIMITÉE AU MODE PROBE\n// =====================================================\nconst probeModeActive = context.sourceMode === 'hybrid_probe';\n\nconst probeStartOk =\n probeModeActive &&\n commonSafetyStartOk &&\n (context.morningProbeEligible || context.middayProbeEligible || context.afternoonProbeEligible);\n\nconst probeHoldGuardOk =\n probeModeActive &&\n commonSafetyHoldOk &&\n batterySoftDischargeHoldOk;\n\nconst probeWindowActive =\n probeModeActive &&\n sessionObservedActive &&\n history.probeStartedMs !== null &&\n lifecycle.minutesSinceProbeStart !== null &&\n lifecycle.minutesSinceProbeStart < params.probeMinutes;\n\nconst probeElapsedReached =\n probeModeActive &&\n sessionObservedActive &&\n history.probeStartedMs !== null &&\n lifecycle.minutesSinceProbeStart !== null &&\n lifecycle.minutesSinceProbeStart >= params.probeMinutes;\n\nconst probeSuccessNow =\n probeModeActive &&\n sessionObservedActive &&\n history.probeStartedMs !== null &&\n (\n parsed.pvPowerW >= params.pvHoldMinW &&\n parsed.batteryCurrentA >= Math.min(0, batteryDischargeSoftDynamicA)\n );\n\n// Une sonde ne peut pas etre declaree ratee sur une mesure prise pendant\n// l'etablissement : c'est le reseau qui masque la production, pas la sonde qui\n// echoue. On differe le verdict, on ne l'invente pas.\nconst probeFailedNow =\n probeModeActive &&\n probeElapsedReached &&\n !gridSettling &&\n !probeSuccessNow;\n\n// =====================================================\n// STEP-UP LOGIC\n// =====================================================\nconst stepUpByBatteryReserve =\n batteryPriorityMode &&\n parsed.batteryCurrentA >= batteryChargeReserveDynamicA;\n\nconst stepUpByForecastMode =\n !batteryPriorityMode &&\n (\n parsed.pvPowerW >= params.pvHoldMinW ||\n parsed.pvPowerAvg10mW >= params.pvHoldMinW\n ) &&\n derived.forecastBufferKwh >= -1 &&\n derived.projectedEndSocAfterCharge >= (derived.endDayGuardSoc - 5) &&\n cloudToleranceOk;\n\n// Pas de montee en courant sur des mesures d'etablissement. La descente, elle,\n// n'est pas bridee : c'est la direction sure.\nconst solarShouldStepUp =\n !gridSettling &&\n lifecycle.currentCooldownElapsed &&\n derived.currentReferenceA < derived.physicalCapA &&\n derived.physicalCapA >= params.currentMinA &&\n !derived.vebusAcOutSoftReached &&\n !derived.vebusDcVoltageSoftLow &&\n !derived.houseLoadHighVsHeadroom &&\n (\n stepUpByBatteryReserve ||\n stepUpByForecastMode ||\n parsed.pvPowerW >= (context.effectivePvStartMinW + 400)\n );\n\nconst gridShouldStepUp =\n lifecycle.currentCooldownElapsed &&\n derived.currentReferenceA < derived.physicalCapA &&\n derived.physicalCapA >= params.currentMinA &&\n !derived.vebusAcOutSoftReached &&\n !derived.vebusDcVoltageSoftLow &&\n parsed.batteryCurrentA >= gridBatterySoftDynamicA &&\n !derived.houseLoadHighVsHeadroom;\n\nconst batteryAssistNightShouldStepUp =\n context.sourceMode === 'battery_assist_night' &&\n lifecycle.currentCooldownElapsed &&\n derived.currentReferenceA < derived.physicalCapA &&\n derived.physicalCapA >= params.currentMinA &&\n !derived.vebusAcOutSoftReached &&\n !derived.vebusDcVoltageSoftLow &&\n !derived.houseLoadHighVsHeadroom &&\n parsed.batteryCurrentA >= batteryChargeReserveDynamicA &&\n batteryAssistAllowed;\n\n// =====================================================\n// STEP-DOWN LOGIC\n// =====================================================\nconst solarShouldStepDownBattery =\n parsed.batteryCurrentA <= batteryDischargeSoftDynamicA &&\n lifecycle.currentCooldownElapsed &&\n derived.currentReferenceA > params.currentMinA;\n\nconst solarShouldStepDownCloudDebt =\n !cloudToleranceOk &&\n lifecycle.currentCooldownElapsed &&\n derived.currentReferenceA > params.currentMinA;\n\nconst gridShouldStepDownBattery =\n parsed.batteryCurrentA < gridBatterySoftDynamicA &&\n lifecycle.currentCooldownElapsed &&\n derived.currentReferenceA > params.currentMinA;\n\nconst batteryAssistNightShouldStepDown =\n context.sourceMode === 'battery_assist_night' &&\n lifecycle.currentCooldownElapsed &&\n derived.currentReferenceA > params.currentMinA &&\n (\n parsed.batteryCurrentA <= batteryDischargeSoftDynamicA ||\n parsed.soc <= gridTakeoverTriggerPct ||\n batteryRecoveryUrgent\n );\n\nconst shouldStepDownVebusPower =\n derived.vebusAcOutSoftReached &&\n lifecycle.currentCooldownElapsed &&\n derived.currentReferenceA > params.currentMinA;\n\nconst shouldStepDownVebusVoltage =\n derived.vebusDcVoltageSoftLow &&\n lifecycle.currentCooldownElapsed &&\n derived.currentReferenceA > params.currentMinA;\n\nconst shouldStepDownOverload =\n derived.anyVebusOverload &&\n lifecycle.currentCooldownElapsed &&\n derived.currentReferenceA > params.currentMinA;\n\nconst shouldStopOverloadAtMin =\n derived.anyVebusOverload &&\n derived.currentReferenceA <= params.currentMinA &&\n sessionObservedActive;\n\n// =====================================================\n// HARD STOPS / PROTECTIONS\n// =====================================================\nconst batteryCurrentHardExceeded =\n parsed.batteryCurrentA <= batteryDischargeHardDynamicA;\n\n// overload volontairement exclu du hard stop immédiat\nconst mustStopHard =\n derived.anyVebusLowBattery ||\n derived.vebusDcVoltageHardLow ||\n derived.vebusAcOutHardReached ||\n batteryCurrentHardExceeded;\n\nconst gridBatteryProtectionStop =\n sessionObservedActive &&\n isGridSource &&\n parsed.batteryCurrentA < gridBatteryHardDynamicA;\n\n// =====================================================\n// OUTPUT\n// =====================================================\nreturn [{\n json: {\n ...prev,\n\n batteryPolicy: {\n socBand,\n batteryDischargeSoftDynamicA,\n batteryDischargeHardDynamicA,\n batteryChargeReserveDynamicA,\n batterySoftToleranceMinutes,\n batteryHardToleranceSeconds,\n batteryRecoveryMinutes,\n\n // Depassement souple en cours (01/09/2026). startedMs = null quand il\n // n'y a pas de depassement : c'est ce que HA_SERVICE_CALLS_OUTPUT lit\n // pour ecrire ou effacer le helper de persistance.\n batterySoftBreachNow,\n batterySoftBreachStartedMs,\n batterySoftBreachMinutes,\n batterySoftDischargeHoldOk,\n\n gridBatterySoftDynamicA,\n gridBatteryHardDynamicA,\n gridForceChargeRelaxSoftA,\n gridForceChargeRelaxHardA,\n\n batteryAssistSolarStartSocMin,\n batteryAssistSolarHoldSocMin,\n batteryAssistSolarDebtStartMaxKwh,\n\n houseSpikeDetected,\n solarPotentialContext,\n outsideWindowLocalBypass,\n },\n\n locks: {\n nextAllowedWindowStartMs,\n blockActive,\n blockStillRelevant,\n startBlockedByReason,\n },\n\n cloud: {\n sessionObservedActive,\n batteryNetPowerW,\n cloudDebtNextKwh,\n cloudDebtMeaningful,\n cloudEventDischargingNow,\n continuingCloudEvent,\n enteringCloudDebt,\n cloudDebtActive,\n cloudDebtStartedMs,\n cloudDebtDurationMinutes,\n effectiveCloudToleranceMinutes,\n cloudToleranceOk,\n },\n\n modes: {\n batteryFullHarvestMode,\n forecastCommitMode,\n batteryPriorityMode,\n },\n\n nightEvcs: {\n nightSessionActive,\n nightSessionNeedsCapture,\n nightSessionNeedsClear,\n nightSessionStartSocPct,\n nightSessionStartAt,\n nightSessionStartMs,\n\n batteryHardFloorPct,\n batteryMorningTargetPct,\n advisorNightTargetApplied,\n maxBatteryDischargePoints,\n gridTakeoverTriggerPct,\n\n batteryBelowHardFloor,\n batteryBelowMorningTarget,\n batteryAtOrBelowGridTakeoverTrigger,\n\n batteryKwhMissingToTarget,\n batteryRecoveryMinutesNeeded,\n batteryRecoveryUrgent,\n batteryRecoveryAllowedNow,\n batteryTargetReached,\n\n batteryAssistAllowed,\n nightGridTakeoverNeeded,\n nightTakeoverLatched,\n vehicleChargeCompleted,\n nightRecoveryDone,\n departureUrgentActive,\n departureChargeDone,\n dayVehicleFullIdle,\n nightPreferredSourceMode,\n },\n\n safeguards: {\n isGridSource,\n commonSafetyStartOk,\n commonSafetyHoldOk,\n profileSocStartFloor,\n effectiveSocStartFloor,\n solarAbundance,\n solarAbundanceHold,\n profileSocHoldFloor,\n effectiveSocHoldFloor,\n profileBatteryDischargeFloorA,\n batteryCurrentHardExceeded,\n mustStopHard,\n gridBatteryProtectionStop,\n },\n\n conditions: {\n rawSolarStartOk,\n rawSolarHoldOk,\n batteryAssistSolarPolicyEligible,\n batteryAssistSolarStartOk,\n batteryAssistSolarHoldOk,\n\n solarStartOk,\n solarHoldOk,\n gridSettling,\n gridTransitionAgeSec: parsed.gridTransitionAgeSec,\n gridStartOk,\n gridHoldOk,\n\n batteryAssistNightStartOk,\n batteryAssistNightHoldOk,\n\n probeStartOk,\n probeHoldGuardOk,\n probeWindowActive,\n probeElapsedReached,\n probeSuccessNow,\n probeFailedNow,\n\n solarShouldStepUp,\n gridShouldStepUp,\n batteryAssistNightShouldStepUp,\n\n solarShouldStepDownBattery,\n solarShouldStepDownCloudDebt,\n gridShouldStepDownBattery,\n batteryAssistNightShouldStepDown,\n\n shouldStepDownVebusPower,\n shouldStepDownVebusVoltage,\n shouldStepDownOverload,\n shouldStopOverloadAtMin,\n }\n }\n}];\n" + "jsCode": "function clamp(v, min, max) {\n return Math.min(Math.max(v, min), max);\n}\n\nfunction to2(n) {\n return String(n).padStart(2, '0');\n}\n\nfunction getParisParts(ms = Date.now()) {\n const fmt = new Intl.DateTimeFormat('fr-FR', {\n timeZone: 'Europe/Paris',\n year: 'numeric',\n month: '2-digit',\n day: '2-digit',\n hour: '2-digit',\n minute: '2-digit',\n second: '2-digit',\n hour12: false,\n });\n\n const parts = Object.fromEntries(\n fmt.formatToParts(new Date(ms))\n .filter(p => p.type !== 'literal')\n .map(p => [p.type, p.value])\n );\n\n const hour = Number(parts.hour) === 24 ? 0 : Number(parts.hour);\n const date = `${parts.year}-${parts.month}-${parts.day}`;\n\n return {\n date,\n hour,\n minute: Number(parts.minute),\n second: Number(parts.second),\n };\n}\n\nfunction getTzOffsetMs(utcTs, timeZone = 'Europe/Paris') {\n const dtf = new Intl.DateTimeFormat('en-US', {\n timeZone,\n year: 'numeric',\n month: '2-digit',\n day: '2-digit',\n hour: '2-digit',\n minute: '2-digit',\n second: '2-digit',\n hour12: false,\n });\n\n const parts = Object.fromEntries(\n dtf.formatToParts(new Date(utcTs))\n .filter(p => p.type !== 'literal')\n .map(p => [p.type, p.value])\n );\n\n const asIfUtc = Date.UTC(\n Number(parts.year),\n Number(parts.month) - 1,\n Number(parts.day),\n Number(parts.hour) === 24 ? 0 : Number(parts.hour),\n Number(parts.minute),\n Number(parts.second)\n );\n\n const d = new Date(utcTs);\n const actualUtc = Date.UTC(\n d.getUTCFullYear(),\n d.getUTCMonth(),\n d.getUTCDate(),\n d.getUTCHours(),\n d.getUTCMinutes(),\n d.getUTCSeconds()\n );\n\n return asIfUtc - actualUtc;\n}\n\nfunction parseParisStampToMs(s) {\n const text = (s ?? '').toString().trim();\n const m = text.match(/^(\\d{4})-(\\d{2})-(\\d{2}) (\\d{2}):(\\d{2}):(\\d{2})$/);\n if (!m) return null;\n\n const y = Number(m[1]);\n const mo = Number(m[2]);\n const d = Number(m[3]);\n const h = Number(m[4]);\n const mi = Number(m[5]);\n const se = Number(m[6]);\n\n const guessUtc = Date.UTC(y, mo - 1, d, h, mi, se);\n const off1 = getTzOffsetMs(guessUtc, 'Europe/Paris');\n let ts = guessUtc - off1;\n const off2 = getTzOffsetMs(ts, 'Europe/Paris');\n\n if (off2 !== off1) ts = guessUtc - off2;\n return ts;\n}\n\nfunction hhmmssFromMinutes(minOfDay) {\n const mm = ((minOfDay % 1440) + 1440) % 1440;\n const hh = Math.floor(mm / 60);\n const mi = mm % 60;\n return `${to2(hh)}:${to2(mi)}:00`;\n}\n\nfunction nextDailyOccurrenceMs(startMin, refMs) {\n if (!Number.isFinite(startMin)) return null;\n\n const today = getParisParts(refMs).date;\n const todayStamp = `${today} ${hhmmssFromMinutes(startMin)}`;\n const todayMs = parseParisStampToMs(todayStamp);\n\n if (todayMs !== null && todayMs > refMs + 1000) return todayMs;\n\n const tomorrow = getParisParts(refMs + 36 * 3600 * 1000).date;\n const tomorrowStamp = `${tomorrow} ${hhmmssFromMinutes(startMin)}`;\n return parseParisStampToMs(tomorrowStamp);\n}\n\nconst prev = $input.first().json;\n\nconst {\n nowMs,\n cfg,\n parsed,\n params,\n history,\n batteryTune,\n strategy,\n derived,\n context,\n lifecycle,\n} = prev;\n\n// =====================================================\n// SESSION / SOURCE\n// =====================================================\nconst sessionObservedActive =\n parsed.evcsChargingSwitchOn ||\n parsed.controlLatch ||\n parsed.chargePermissionNow;\n\nconst isGridSource =\n context.sourceMode === 'grid_hc_allowed' ||\n context.sourceMode === 'grid_forced';\n\nconst isLocalSolarSource =\n context.sourceMode === 'solar_only' ||\n context.sourceMode === 'solar_potential';\n\n// =====================================================\n// SOC BAND / BATTERY DYNAMIC POLICY\n// =====================================================\nconst socBand =\n parsed.soc > 90 ? 'gt90' :\n parsed.soc > 80 ? '80_90' :\n parsed.soc > 60 ? '60_80' :\n parsed.soc > 40 ? '40_60' :\n parsed.soc > 25 ? '25_40' :\n 'lt25';\n\nconst band = batteryTune.bands[socBand];\n\nconst dischargeModeFactor =\n strategy.forceChargeRequested ? 1.2 :\n strategy.forceGridRequested ? 0.7 :\n strategy.userStrategy === 'auto_solar' ? 0.8 :\n 1.0;\n\nconst chargeReserveModeFactor =\n strategy.forceChargeRequested ? 0.8 :\n strategy.forceGridRequested ? 1.1 :\n strategy.userStrategy === 'auto_solar' ? 1.1 :\n 1.0;\n\nconst toleranceModeFactor =\n strategy.forceChargeRequested ? 1.25 :\n strategy.forceGridRequested ? 0.8 :\n strategy.userStrategy === 'auto_solar' ? 0.8 :\n 1.0;\n\nconst batteryDischargeSoftDynamicA = band.dischargeSoftA * dischargeModeFactor;\nconst batteryDischargeHardDynamicA = band.dischargeHardA * dischargeModeFactor;\nconst batteryChargeReserveDynamicA = Math.max(0, band.chargeReserveA * chargeReserveModeFactor);\nconst batterySoftToleranceMinutes = band.softToleranceMin * toleranceModeFactor;\nconst batteryHardToleranceSeconds = band.hardToleranceSec;\nconst batteryRecoveryMinutes = band.recoveryMin;\n\n// =====================================================\n// CONTEXT HELPERS\n// =====================================================\nconst houseSpikeThresholdW = 1500;\nconst houseSpikeDetected =\n (parsed.houseAcPowerW - parsed.houseAcPowerPrevW) >= houseSpikeThresholdW;\n\nconst solarPotentialContext =\n context.sourceMode === 'solar_potential' ||\n (\n parsed.soc >= 80 &&\n derived.forecastBufferKwh > 0 &&\n parsed.pvPowerW < context.effectivePvStartMinW &&\n derived.houseNonEvcsW < 1200\n );\n\n// =====================================================\n// OUTSIDE-WINDOW LOCAL BYPASS\n// IMPORTANT:\n// en auto_solar_plus ou force_charge_now, un contexte local solaire\n// ne doit plus rester bloqué juste parce qu'on est hors fenêtre.\n// =====================================================\nconst outsideWindowLocalBypass =\n context.profile === 'outside_window' &&\n !strategy.forceGridRequested &&\n (\n strategy.forceChargeRequested ||\n strategy.userStrategy === 'auto_solar_plus'\n ) &&\n isLocalSolarSource;\n\n// =====================================================\n// REASON-BASED LOCKS\n// =====================================================\nconst nextWindowCandidates = [\n nextDailyOccurrenceMs(params.morningStartMin, nowMs),\n nextDailyOccurrenceMs(params.hcMiddayStartMin, nowMs),\n nextDailyOccurrenceMs(params.eveningStartMin, nowMs),\n nextDailyOccurrenceMs(params.hcNightStartMin, nowMs),\n].filter(v => Number.isFinite(v));\n\nconst nextAllowedWindowStartMs =\n nextWindowCandidates.length > 0 ? Math.min(...nextWindowCandidates) : null;\n\nconst blockActive =\n history.blockedUntilMs !== null &&\n history.blockedUntilMs > nowMs + 1000;\n\nconst blockStillRelevant =\n history.lastBlockReason === 'outside_window'\n ? (context.profile === 'outside_window' && !outsideWindowLocalBypass)\n : history.lastBlockReason === 'probe_failed'\n ? context.sourceMode === 'hybrid_probe'\n : history.lastBlockReason === 'battery_protection_stop'\n ? (\n parsed.batteryCurrentA < -params.batteryNeutralBandA ||\n derived.anyVebusLowBattery ||\n derived.vebusDcVoltageSoftLow\n )\n : history.lastBlockReason === 'grid_battery_guard'\n ? isGridSource\n : history.lastBlockReason === 'vebus_overload_persistent'\n ? derived.anyVebusOverload\n : false;\n\nconst startBlockedByReason = blockActive && blockStillRelevant;\n\n// =====================================================\n// CLOUD DEBT\n// =====================================================\nconst batteryNetPowerW = parsed.batteryCurrentA * derived.batteryVoltageRef;\nlet cloudDebtNextKwh = params.cloudDebtPrevKwh;\n\nif (sessionObservedActive) {\n if (batteryNetPowerW < 0 && !isGridSource) {\n cloudDebtNextKwh += (Math.abs(batteryNetPowerW) * cfg.sampleSeconds) / 3600000;\n } else if (batteryNetPowerW > 0) {\n cloudDebtNextKwh -= (batteryNetPowerW * cfg.sampleSeconds) / 3600000;\n }\n} else if (batteryNetPowerW > 0) {\n cloudDebtNextKwh -= (batteryNetPowerW * cfg.sampleSeconds) / 3600000;\n}\n\ncloudDebtNextKwh = clamp(cloudDebtNextKwh, 0, params.cloudDebtMaxKwh * 4);\n\nconst cloudDebtMeaningful =\n cloudDebtNextKwh > cfg.cloudDebtMeaningfulThresholdKwh;\n\nconst cloudEventDischargingNow =\n sessionObservedActive &&\n !isGridSource &&\n parsed.batteryCurrentA < -Math.max(1, params.batteryNeutralBandA) &&\n cloudDebtMeaningful;\n\nconst continuingCloudEvent =\n cloudEventDischargingNow &&\n history.cloudDebtStartedMsPrev !== null &&\n params.cloudDebtPrevKwh > cfg.cloudDebtRecoveryThresholdKwh;\n\nconst enteringCloudDebt =\n cloudEventDischargingNow && !continuingCloudEvent;\n\nconst cloudDebtActive = cloudEventDischargingNow;\n\nconst cloudDebtStartedMs =\n cloudEventDischargingNow\n ? (continuingCloudEvent ? history.cloudDebtStartedMsPrev : nowMs)\n : null;\n\nconst cloudDebtDurationMinutes =\n cloudDebtActive && cloudDebtStartedMs !== null\n ? Math.max(0, (nowMs - cloudDebtStartedMs) / 60000)\n : 0;\n\nconst effectiveCloudToleranceMinutes =\n params.cloudToleranceMinutes +\n (solarPotentialContext ? batteryTune.global.solarPotentialGraceMinutes : 0) +\n (houseSpikeDetected ? batteryTune.global.houseSpikeGraceMinutes : 0);\n\nconst cloudToleranceOk =\n cloudDebtNextKwh <= params.cloudDebtMaxKwh &&\n (\n !cloudDebtActive ||\n cloudDebtDurationMinutes <= effectiveCloudToleranceMinutes\n );\n\n// =====================================================\n// LEGACY / FORECAST MODES\n// =====================================================\nconst batteryFullHarvestMode =\n parsed.forecastModeEnabled &&\n context.profile !== 'night_hc' &&\n context.profile !== 'outside_window' &&\n parsed.soc >= params.batteryFullSoc &&\n parsed.todayRemainingKwh >= params.batteryFullForecastMinKwh &&\n derived.projectedEndSocAfterCharge >= (derived.endDayGuardSoc - 2);\n\nconst forecastCommitMode =\n parsed.forecastModeEnabled &&\n context.profile !== 'night_hc' &&\n context.profile !== 'outside_window' &&\n parsed.soc >= params.forecastCommitSocFloor &&\n parsed.todayRemainingKwh >= params.forecastCommitMinKwh &&\n derived.projectedEndSocAfterCharge >= derived.endDayGuardSoc;\n\nconst batteryPriorityMode =\n !batteryFullHarvestMode && !forecastCommitMode;\n\n// =====================================================\n// EVCS NIGHT ENGINE — R1 (07/08/2026)\n// Le moteur nuit est calculé UNE SEULE FOIS dans WINDOWS_SOURCEV2\n// (payload nightEvcsPreview). Ce node CONSOMME, il ne recalcule plus.\n// TOUTE évolution de la logique nuit se fait dans WINDOWS_SOURCEV2.\n// Fallback sûr si preview absent : session inactive / 'blocked'.\n// =====================================================\nconst nightPrev = prev.nightEvcsPreview || {};\n\nconst nightSessionActive = !!nightPrev.nightSessionActive;\nconst nightSessionNeedsCapture = !!nightPrev.nightSessionNeedsCapture;\nconst nightSessionNeedsClear = !!nightPrev.nightSessionNeedsClear;\nconst nightSessionStartSocPct =\n Number.isFinite(nightPrev.nightSessionStartSocPct) ? nightPrev.nightSessionStartSocPct : null;\nconst nightSessionStartAt = nightPrev.nightSessionStartAt || '';\nconst nightSessionStartMs =\n Number.isFinite(nightPrev.nightSessionStartMs) ? nightPrev.nightSessionStartMs : null;\n\nconst batteryHardFloorPct =\n Number.isFinite(nightPrev.batteryHardFloorPct)\n ? nightPrev.batteryHardFloorPct\n : params.evcsNightBatteryHardFloorPct;\nconst batteryMorningTargetPct =\n Number.isFinite(nightPrev.batteryMorningTargetPct)\n ? nightPrev.batteryMorningTargetPct\n : params.evcsNightTargetWeakForecastPct;\nconst advisorNightTargetApplied = !!nightPrev.advisorNightTargetApplied;\nconst maxBatteryDischargePoints =\n Number.isFinite(nightPrev.maxBatteryDischargePoints) ? nightPrev.maxBatteryDischargePoints : 0;\nconst gridTakeoverTriggerPct =\n Number.isFinite(nightPrev.gridTakeoverTriggerPct)\n ? nightPrev.gridTakeoverTriggerPct\n : Math.max(batteryHardFloorPct, batteryMorningTargetPct);\n\nconst batteryBelowHardFloor = !!nightPrev.batteryBelowHardFloor;\nconst batteryBelowMorningTarget = !!nightPrev.batteryBelowMorningTarget;\nconst batteryAtOrBelowGridTakeoverTrigger = !!nightPrev.batteryAtOrBelowGridTakeoverTrigger;\n\nconst batteryKwhMissingToTarget =\n Number.isFinite(nightPrev.batteryKwhMissingToTarget) ? nightPrev.batteryKwhMissingToTarget : 0;\nconst batteryRecoveryMinutesNeeded =\n Number.isFinite(nightPrev.batteryRecoveryMinutesNeeded) ? nightPrev.batteryRecoveryMinutesNeeded : 0;\nconst batteryRecoveryUrgent = !!nightPrev.batteryRecoveryUrgent;\nconst batteryRecoveryAllowedNow = !!nightPrev.batteryRecoveryAllowedNow;\nconst batteryTargetReached = !!nightPrev.batteryTargetReached;\n\nconst batteryAssistAllowed = !!nightPrev.batteryAssistAllowed;\nconst nightGridTakeoverNeeded = !!nightPrev.nightGridTakeoverNeeded;\nconst nightTakeoverLatched = !!nightPrev.nightTakeoverLatched;\nconst vehicleChargeCompleted = !!nightPrev.vehicleChargeCompleted;\nconst nightRecoveryDone = !!nightPrev.nightRecoveryDone;\nconst departureUrgentActive = !!nightPrev.departureUrgentActive;\nconst departureChargeDone = !!nightPrev.departureChargeDone;\nconst dayVehicleFullIdle = !!nightPrev.dayVehicleFullIdle;\nconst nightPreferredSourceMode = nightPrev.nightPreferredSourceMode || 'blocked';\n\n// =====================================================\n// PROFILE-BASED THRESHOLDS\n// =====================================================\nconst profileSocStartFloor =\n context.profile === 'morning_solar' ? params.morningSocMin :\n context.profile === 'midday_hc_or_solar' ? params.middaySocMin :\n context.profile === 'evening_high_soc' ? params.eveningSocStartMin :\n params.socStartMin;\n\n// P6 ABONDANCE : PV moyen élevé + batterie en charge franche => le plancher SOC\n// de démarrage perd son sens (la batterie continue de monter même avec l'EVCS à 6 A).\n// Jours nuageux ou batterie peu chargée par le PV : rien ne change.\nconst PV_ABUNDANCE_AVG_MIN_W = 4000;\nconst ABUNDANCE_BATTERY_CHARGE_MIN_A = 20;\nconst ABUNDANCE_SOC_FLOOR_PCT = 40;\n\n// 24/08/2026 — L'abondance se PROUVE au démarrage (batterie qui monte franchement,\n// >= +20 A, avant qu'on touche à quoi que ce soit) mais elle se MAINTIENT tant que\n// le PV reste fort ET que la batterie n'est pas devenue fournisseuse. Sans cette\n// distinction, la charge du véhicule — qui détourne précisément le surplus — fait\n// tomber le courant batterie sous +20 A et détruit sa propre condition\n// d'autorisation : système bistable, oscillation à la période de min_run.\nconst solarAbundanceStart =\n parsed.pvPowerAvg10mW >= PV_ABUNDANCE_AVG_MIN_W &&\n parsed.batteryCurrentA >= ABUNDANCE_BATTERY_CHARGE_MIN_A;\nconst solarAbundanceHold =\n parsed.pvPowerAvg10mW >= PV_ABUNDANCE_AVG_MIN_W &&\n parsed.batteryCurrentA >= 0;\nconst solarAbundance = solarAbundanceStart; // conservé pour la traçabilité/debug\n\nconst abundanceSocFloor = (abundant) =>\n abundant ? Math.min(profileSocStartFloor, ABUNDANCE_SOC_FLOOR_PCT) : profileSocStartFloor;\n\nconst effectiveSocStartFloor = abundanceSocFloor(solarAbundanceStart);\n\nconst profileSocHoldFloor =\n context.profile === 'morning_solar' ? Math.max(params.socStopMin, params.morningSocMin - 10) :\n context.profile === 'midday_hc_or_solar' ? Math.max(params.socStopMin, params.middaySocMin - 15) :\n context.profile === 'evening_high_soc' ? params.eveningSocStopMin :\n params.socStopMin;\n\n// INVARIANT (24/08/2026) : on ne doit JAMAIS avoir le droit de DÉMARRER une charge\n// qu'on n'a pas le droit de MAINTENIR. Le plancher de maintien doit donc rester\n// <= au plancher de démarrage effectif.\n// Incident du 24/08 midi : bascule de profil vers midday_hc_or_solar à 12:24 avec\n// SOC 58 % => plancher de démarrage 40 % (abondance P6) mais plancher de maintien\n// max(29, 75-15) = 60 % => démarrage autorisé, maintien refusé, cycle limite de\n// 3 min (min_run) sur toute la bande [40 %, 60 %], et ~2,6 kW de PV écrêtés à\n// chaque creux faute de preneur (ESS sans injection).\nconst effectiveSocHoldFloor = Math.min(\n profileSocHoldFloor,\n abundanceSocFloor(solarAbundanceHold)\n);\n\nconst profileSpecificDischargeFloorA =\n context.profile === 'morning_solar' ? params.morningBatteryMaxDischargeA :\n context.profile === 'midday_hc_or_solar' ? params.middayBatteryMaxDischargeA :\n context.profile === 'evening_high_soc' ? params.eveningBatteryMaxDischargeA :\n params.batteryDischargeSoftA;\n\nconst profileBatteryDischargeFloorA =\n Math.max(profileSpecificDischargeFloorA, batteryDischargeSoftDynamicA);\n\n// =====================================================\n// DEPASSEMENT SOUPLE DU PLANCHER DE DECHARGE (01/09/2026)\n// =====================================================\n// La table des bandes SOC (INPUT_PARSE) declare depuis toujours DEUX regimes :\n// un courant SOUPLE tolere pendant des MINUTES (softToleranceMin : 12 min a\n// SOC > 90, 1 min sous 25) et un courant DUR tolere quelques SECONDES. Seuls\n// les seuils de courant etaient lus ; la dimension temporelle etait calculee,\n// exportee dans le debug, et branchee sur AUCUNE decision. Le controleur\n// comparait donc un echantillon instantane, toutes les 30 s, a un plancher.\n//\n// Incident du 01/09 (14:26 et 14:56, SOC 94, batterie en float) : le courant\n// batterie oscillait entre +1,8 et -2,6 A d'un echantillon a l'autre, soit le\n// bruit de regulation d'une batterie qui ne fait rien. Le test devenait un\n// tirage a pile ou face et la charge du vehicule s'arretait dessus. Chaque\n// arret coutait ~0,45 kWh de production refusee : batterie pleine, le vehicule\n// etait le SEUL preneur, donc la PV s'effondrait de 6,6 a 2,5 kW en 30 s, et\n// la reprise mettait 21 min a remonter de 6 A a 20 A. 1 a 6 arrets par jour\n// sur la semaine du 26 au 31/08.\n//\n// Le correctif est PUREMENT TEMPOREL : aucun seuil n'est deplace, aucune\n// constante n'est ajoutee. On applique la table telle qu'elle est ecrite :\n// un depassement souple doit DURER pour compter. Comme le bruit change de\n// signe en quelques secondes, le compteur se remet a zero de lui-meme ; une\n// vraie decharge, elle, reste negative a chaque echantillon et finit par\n// depasser la tolerance de sa bande.\n//\n// CE QUI N'EST PAS TOUCHE : le plancher de DEMARRAGE reste instantane (ne pas\n// lancer une charge sur une batterie qui se decharge deja, c'est la frontiere\n// \"delestage et non charge prioritaire\"), et le garde DUR reste instantane —\n// hardToleranceSec n'est volontairement PAS branche, cela ne pourrait que\n// ralentir une securite, et c'est le risque qui prime.\nconst batterySoftBreachNow =\n sessionObservedActive &&\n parsed.batteryCurrentA < profileBatteryDischargeFloorA;\n\nconst batterySoftBreachStartedMs = batterySoftBreachNow\n ? (history.batterySoftBreachMsPrev !== null\n ? history.batterySoftBreachMsPrev\n : nowMs)\n : null;\n\nconst batterySoftBreachMinutes =\n batterySoftBreachStartedMs !== null\n ? Math.max(0, (nowMs - batterySoftBreachStartedMs) / 60000)\n : 0;\n\n// Vrai tant que le depassement n'a pas dure plus que la tolerance de sa bande.\n// Une tolerance a 0 minute redonne exactement le comportement instantane.\nconst batterySoftDischargeHoldOk =\n !batterySoftBreachNow ||\n batterySoftBreachMinutes <= batterySoftToleranceMinutes;\n\n// =====================================================\n// GRID RELAX IN force_charge_now\n// =====================================================\n// Assouplissement uniquement pour la garde réseau,\n// sans toucher aux sécurités dures globales.\nconst gridForceChargeRelaxSoftA =\n strategy.forceChargeRequested && isGridSource\n ? (\n parsed.soc >= 90 ? 28 :\n parsed.soc >= 80 ? 16 :\n parsed.soc >= 60 ? 8 :\n parsed.soc >= 40 ? 4 :\n 0\n )\n : 0;\n\nconst gridForceChargeRelaxHardA =\n strategy.forceChargeRequested && isGridSource\n ? (\n parsed.soc >= 90 ? 39 :\n parsed.soc >= 80 ? 29 :\n parsed.soc >= 60 ? 18 :\n parsed.soc >= 40 ? 8 :\n 0\n )\n : 0;\n\nconst gridBatterySoftDynamicABase =\n Math.max(params.gridBatterySoftA, batteryDischargeSoftDynamicA);\n\nconst gridBatteryHardDynamicABase =\n Math.max(params.gridBatteryHardA, batteryDischargeHardDynamicA);\n\nlet gridBatterySoftDynamicA =\n gridBatterySoftDynamicABase - gridForceChargeRelaxSoftA;\n\nlet gridBatteryHardDynamicA =\n gridBatteryHardDynamicABase - gridForceChargeRelaxHardA;\n\n// Garantit que le hard reste plus permissif que le soft d'au moins 3 A\nif (gridBatteryHardDynamicA > (gridBatterySoftDynamicA - 3)) {\n gridBatteryHardDynamicA = gridBatterySoftDynamicA - 3;\n}\n\n// =====================================================\n// BASE SAFETY\n// =====================================================\nconst commonSafetyStartOk =\n derived.canRunAtMinCurrent &&\n !derived.anyVebusOverload &&\n !derived.anyVebusLowBattery &&\n !derived.vebusDcVoltageSoftLow &&\n !derived.vebusAcOutSoftReached &&\n parsed.evcsDeviceConnected &&\n parsed.evcsVehicleConnected &&\n !parsed.evcsModeIsAuto;\n\nconst commonSafetyHoldOk =\n derived.canRunAtMinCurrent &&\n !derived.anyVebusLowBattery &&\n !derived.vebusDcVoltageHardLow &&\n parsed.evcsDeviceConnected &&\n parsed.evcsVehicleConnected &&\n !parsed.evcsModeIsAuto;\n\n// =====================================================\n// EXCEDENT SOLAIRE — LA GRANDEUR QUI DECIDE D'UN DEMARRAGE (03/09/2026)\n// =====================================================\n// Incident du 03/09 19:44 : PV 758 W, maison 1202 W, batterie deja a -12 A,\n// nuit qui tombe. Le controleur a lance 1,6 kW de charge parce que\n// pv_start_min_w (700 W) est une PUISSANCE PV ABSOLUE : \"758 >= 700, donc il y\n// a du soleil\". L'excedent, lui, valait -444 W. En 33 minutes la voiture a pris\n// 0,897 kWh et la batterie en a rendu 1,479 kWh, la veille d'une nuit que le\n// bilan energetique annoncait deja deficitaire (SOC requis 99,7 %).\n//\n// La grandeur juste n'est pas la production, c'est ce qui RESTE une fois la\n// maison servie. Sans injection et reseau absent, elle vaut la puissance\n// batterie au signe pres :\n//\n// excedent = PV - maison(hors EVCS) ~= puissance batterie\n//\n// C'est aussi ce qui separe les deux situations que la mesure PV seule confond\n// (PV_mesuree = min(ressource, conso + absorption batterie)) :\n//\n// - CENSURE : batterie pleine, MPPT bride faute de puits. La production se\n// cale sur la maison, l'excedent vaut ~0, la batterie ne bouge pas. Il y a\n// de la ressource REFUSEE : la provoquer avec la voiture est legitime, et\n// c'est toute la raison d'etre de P3 / de l'assistance batterie.\n// - DEFICIT reel : soir, nuages. L'excedent est NEGATIF, la batterie porte\n// deja la maison. Charger la voiture, c'est la vider.\n//\n// Zero n'est donc pas un seuil de plus : c'est la frontiere physique entre les\n// deux, et elle etait invisible tant qu'on regardait la production seule.\n// Aucune constante n'est introduite ; le chemin \"soleil reel\" reutilise\n// pv_start_min_w tel quel, applique a la bonne grandeur.\n//\n// PORTEE : DEMARRAGE uniquement. Les conditions de MAINTIEN sont inchangees —\n// une charge etablie a deja son plancher de decharge et sa tolerance temporelle\n// (01/09), et couper une charge en cours sur une mesure d'excedent bruitee\n// recreerait l'oscillateur qu'on vient d'eteindre.\nconst solarSurplusW = parsed.pvPowerW - derived.houseNonEvcsW;\n\n// Chemin PROVOCATION (batterie haute, ou sourceMode solar_potential) : on\n// n'exige pas d'excedent mesurable — il est censure par construction — mais on\n// exige que la batterie ne porte pas deja la maison.\nconst solarSurplusProvocationOk = solarSurplusW >= 0;\n\n// Chemin SOLEIL REEL : l'excedent doit atteindre le seuil de demarrage.\nconst solarSurplusStartOk =\n context.sourceMode === 'solar_potential'\n ? solarSurplusProvocationOk\n : solarSurplusW >= context.effectivePvStartMinW;\n\n// =====================================================\n// BATTERY-ASSISTED SOLAR START/HOLD\n// IMPORTANT:\n// Installation sans injection:\n// le PV visible peut rester bas tant que l'EVCS ne démarre pas.\n// On autorise donc un démarrage/maintien local si la batterie est haute\n// et que la dette nuage reste faible.\n// =====================================================\nconst batteryAssistSolarPolicyEligible =\n !strategy.forceGridRequested &&\n (\n strategy.forceChargeRequested ||\n strategy.userStrategy === 'auto_solar_plus'\n ) &&\n isLocalSolarSource;\n\nconst batteryAssistSolarStartSocMin = 90;\nconst batteryAssistSolarHoldSocMin = 85;\n\nconst batteryAssistSolarDebtStartMaxKwh = Math.max(\n cfg.cloudDebtRecoveryThresholdKwh,\n Math.min(params.cloudDebtMaxKwh, 0.05)\n);\n\nconst batteryAssistSolarStartOk =\n batteryAssistSolarPolicyEligible &&\n commonSafetyStartOk &&\n !isGridSource &&\n parsed.soc >= Math.max(profileSocStartFloor, batteryAssistSolarStartSocMin) &&\n parsed.soc > batteryAssistSolarHoldSocMin &&\n parsed.batteryCurrentA >= profileBatteryDischargeFloorA &&\n solarSurplusProvocationOk &&\n cloudDebtNextKwh <= batteryAssistSolarDebtStartMaxKwh &&\n !cloudDebtActive;\n\nconst batteryAssistSolarHoldOk =\n batteryAssistSolarPolicyEligible &&\n commonSafetyHoldOk &&\n !isGridSource &&\n parsed.soc >= Math.max(profileSocHoldFloor, batteryAssistSolarHoldSocMin) &&\n batterySoftDischargeHoldOk &&\n cloudToleranceOk;\n\n// =====================================================\n// START / HOLD CONDITIONS\n// =====================================================\n// FENETRE D'ETABLISSEMENT (31/08/2026) — cf. WINDOWS_SOURCEV2 pour la mesure.\n// Pendant les ~150 s qui suivent l'arrivee du reseau, PV et courant batterie ne\n// mesurent rien d'exploitable. On NE DEMARRE PAS sur ces valeurs, et on ne\n// laisse pas une charge en cours s'arreter a cause d'elles. Les gardes de\n// securite (commonSafety*, planchers SOC, surcharge, pas de tampon) restent\n// evaluees normalement : c'est le risque qui prime, jamais le confort.\nconst gridSettling = derived.gridSettling === true;\n\nconst rawSolarStartOk =\n (context.sourceMode === 'solar_only' || context.sourceMode === 'solar_potential') &&\n !gridSettling &&\n commonSafetyStartOk &&\n parsed.soc >= effectiveSocStartFloor &&\n parsed.batteryCurrentA >= profileBatteryDischargeFloorA &&\n solarSurplusStartOk;\n\nconst solarStartOk =\n rawSolarStartOk || batteryAssistSolarStartOk;\n\nconst rawSolarHoldOk =\n (context.sourceMode === 'solar_only' || context.sourceMode === 'solar_potential') &&\n commonSafetyHoldOk &&\n parsed.soc >= effectiveSocHoldFloor &&\n (\n gridSettling ||\n batterySoftDischargeHoldOk\n ) &&\n (\n gridSettling ||\n parsed.pvPowerW >= params.pvHoldMinW ||\n cloudToleranceOk\n );\n\nconst solarHoldOk =\n rawSolarHoldOk || batteryAssistSolarHoldOk;\n\n// FIX V2.5: Garde SOC - pas de grid pour EVCS si SOC > 35% sauf force_grid_now\n// P9 (09/08/2026) : la fenêtre départ imminent autorise aussi le réseau.\nconst gridAllowedBySoc = \n strategy.forceGridRequested ||\n context.profile === 'night_hc' ||\n departureUrgentActive ||\n parsed.soc <= 35;\n\nconst gridStartOk =\n gridAllowedBySoc &&\n isGridSource &&\n commonSafetyStartOk &&\n parsed.gridAvailable &&\n parsed.batteryCurrentA >= gridBatterySoftDynamicA;\n\nconst gridHoldOk =\n gridAllowedBySoc &&\n isGridSource &&\n commonSafetyHoldOk &&\n parsed.gridAvailable &&\n parsed.batteryCurrentA >= gridBatteryHardDynamicA;\n\nconst batteryAssistNightStartOk =\n context.sourceMode === 'battery_assist_night' &&\n commonSafetyStartOk &&\n nightSessionActive &&\n batteryAssistAllowed &&\n parsed.soc > batteryHardFloorPct &&\n parsed.soc > gridTakeoverTriggerPct &&\n parsed.batteryCurrentA >= profileBatteryDischargeFloorA;\n\nconst batteryAssistNightHoldOk =\n context.sourceMode === 'battery_assist_night' &&\n commonSafetyHoldOk &&\n nightSessionActive &&\n batteryAssistAllowed &&\n parsed.soc > batteryHardFloorPct &&\n parsed.soc > gridTakeoverTriggerPct &&\n parsed.batteryCurrentA >= batteryDischargeHardDynamicA;\n\n// =====================================================\n// PROBE LOGIC — STRICTEMENT LIMITÉE AU MODE PROBE\n// =====================================================\nconst probeModeActive = context.sourceMode === 'hybrid_probe';\n\nconst probeStartOk =\n probeModeActive &&\n commonSafetyStartOk &&\n (context.morningProbeEligible || context.middayProbeEligible || context.afternoonProbeEligible);\n\nconst probeHoldGuardOk =\n probeModeActive &&\n commonSafetyHoldOk &&\n batterySoftDischargeHoldOk;\n\nconst probeWindowActive =\n probeModeActive &&\n sessionObservedActive &&\n history.probeStartedMs !== null &&\n lifecycle.minutesSinceProbeStart !== null &&\n lifecycle.minutesSinceProbeStart < params.probeMinutes;\n\nconst probeElapsedReached =\n probeModeActive &&\n sessionObservedActive &&\n history.probeStartedMs !== null &&\n lifecycle.minutesSinceProbeStart !== null &&\n lifecycle.minutesSinceProbeStart >= params.probeMinutes;\n\nconst probeSuccessNow =\n probeModeActive &&\n sessionObservedActive &&\n history.probeStartedMs !== null &&\n (\n parsed.pvPowerW >= params.pvHoldMinW &&\n parsed.batteryCurrentA >= Math.min(0, batteryDischargeSoftDynamicA)\n );\n\n// Une sonde ne peut pas etre declaree ratee sur une mesure prise pendant\n// l'etablissement : c'est le reseau qui masque la production, pas la sonde qui\n// echoue. On differe le verdict, on ne l'invente pas.\nconst probeFailedNow =\n probeModeActive &&\n probeElapsedReached &&\n !gridSettling &&\n !probeSuccessNow;\n\n// =====================================================\n// STEP-UP LOGIC\n// =====================================================\nconst stepUpByBatteryReserve =\n batteryPriorityMode &&\n parsed.batteryCurrentA >= batteryChargeReserveDynamicA;\n\nconst stepUpByForecastMode =\n !batteryPriorityMode &&\n (\n parsed.pvPowerW >= params.pvHoldMinW ||\n parsed.pvPowerAvg10mW >= params.pvHoldMinW\n ) &&\n derived.forecastBufferKwh >= -1 &&\n derived.projectedEndSocAfterCharge >= (derived.endDayGuardSoc - 5) &&\n cloudToleranceOk;\n\n// Pas de montee en courant sur des mesures d'etablissement. La descente, elle,\n// n'est pas bridee : c'est la direction sure.\nconst solarShouldStepUp =\n !gridSettling &&\n lifecycle.currentCooldownElapsed &&\n derived.currentReferenceA < derived.physicalCapA &&\n derived.physicalCapA >= params.currentMinA &&\n !derived.vebusAcOutSoftReached &&\n !derived.vebusDcVoltageSoftLow &&\n !derived.houseLoadHighVsHeadroom &&\n (\n stepUpByBatteryReserve ||\n stepUpByForecastMode ||\n parsed.pvPowerW >= (context.effectivePvStartMinW + 400)\n );\n\nconst gridShouldStepUp =\n lifecycle.currentCooldownElapsed &&\n derived.currentReferenceA < derived.physicalCapA &&\n derived.physicalCapA >= params.currentMinA &&\n !derived.vebusAcOutSoftReached &&\n !derived.vebusDcVoltageSoftLow &&\n parsed.batteryCurrentA >= gridBatterySoftDynamicA &&\n !derived.houseLoadHighVsHeadroom;\n\nconst batteryAssistNightShouldStepUp =\n context.sourceMode === 'battery_assist_night' &&\n lifecycle.currentCooldownElapsed &&\n derived.currentReferenceA < derived.physicalCapA &&\n derived.physicalCapA >= params.currentMinA &&\n !derived.vebusAcOutSoftReached &&\n !derived.vebusDcVoltageSoftLow &&\n !derived.houseLoadHighVsHeadroom &&\n parsed.batteryCurrentA >= batteryChargeReserveDynamicA &&\n batteryAssistAllowed;\n\n// =====================================================\n// STEP-DOWN LOGIC\n// =====================================================\nconst solarShouldStepDownBattery =\n parsed.batteryCurrentA <= batteryDischargeSoftDynamicA &&\n lifecycle.currentCooldownElapsed &&\n derived.currentReferenceA > params.currentMinA;\n\nconst solarShouldStepDownCloudDebt =\n !cloudToleranceOk &&\n lifecycle.currentCooldownElapsed &&\n derived.currentReferenceA > params.currentMinA;\n\nconst gridShouldStepDownBattery =\n parsed.batteryCurrentA < gridBatterySoftDynamicA &&\n lifecycle.currentCooldownElapsed &&\n derived.currentReferenceA > params.currentMinA;\n\nconst batteryAssistNightShouldStepDown =\n context.sourceMode === 'battery_assist_night' &&\n lifecycle.currentCooldownElapsed &&\n derived.currentReferenceA > params.currentMinA &&\n (\n parsed.batteryCurrentA <= batteryDischargeSoftDynamicA ||\n parsed.soc <= gridTakeoverTriggerPct ||\n batteryRecoveryUrgent\n );\n\nconst shouldStepDownVebusPower =\n derived.vebusAcOutSoftReached &&\n lifecycle.currentCooldownElapsed &&\n derived.currentReferenceA > params.currentMinA;\n\nconst shouldStepDownVebusVoltage =\n derived.vebusDcVoltageSoftLow &&\n lifecycle.currentCooldownElapsed &&\n derived.currentReferenceA > params.currentMinA;\n\nconst shouldStepDownOverload =\n derived.anyVebusOverload &&\n lifecycle.currentCooldownElapsed &&\n derived.currentReferenceA > params.currentMinA;\n\nconst shouldStopOverloadAtMin =\n derived.anyVebusOverload &&\n derived.currentReferenceA <= params.currentMinA &&\n sessionObservedActive;\n\n// =====================================================\n// HARD STOPS / PROTECTIONS\n// =====================================================\nconst batteryCurrentHardExceeded =\n parsed.batteryCurrentA <= batteryDischargeHardDynamicA;\n\n// overload volontairement exclu du hard stop immédiat\nconst mustStopHard =\n derived.anyVebusLowBattery ||\n derived.vebusDcVoltageHardLow ||\n derived.vebusAcOutHardReached ||\n batteryCurrentHardExceeded;\n\nconst gridBatteryProtectionStop =\n sessionObservedActive &&\n isGridSource &&\n parsed.batteryCurrentA < gridBatteryHardDynamicA;\n\n// =====================================================\n// OUTPUT\n// =====================================================\nreturn [{\n json: {\n ...prev,\n\n batteryPolicy: {\n socBand,\n batteryDischargeSoftDynamicA,\n batteryDischargeHardDynamicA,\n batteryChargeReserveDynamicA,\n batterySoftToleranceMinutes,\n batteryHardToleranceSeconds,\n batteryRecoveryMinutes,\n\n // Depassement souple en cours (01/09/2026). startedMs = null quand il\n // n'y a pas de depassement : c'est ce que HA_SERVICE_CALLS_OUTPUT lit\n // pour ecrire ou effacer le helper de persistance.\n batterySoftBreachNow,\n batterySoftBreachStartedMs,\n batterySoftBreachMinutes,\n batterySoftDischargeHoldOk,\n\n gridBatterySoftDynamicA,\n gridBatteryHardDynamicA,\n gridForceChargeRelaxSoftA,\n gridForceChargeRelaxHardA,\n\n batteryAssistSolarStartSocMin,\n batteryAssistSolarHoldSocMin,\n batteryAssistSolarDebtStartMaxKwh,\n\n houseSpikeDetected,\n solarPotentialContext,\n outsideWindowLocalBypass,\n },\n\n locks: {\n nextAllowedWindowStartMs,\n blockActive,\n blockStillRelevant,\n startBlockedByReason,\n },\n\n cloud: {\n sessionObservedActive,\n batteryNetPowerW,\n cloudDebtNextKwh,\n cloudDebtMeaningful,\n cloudEventDischargingNow,\n continuingCloudEvent,\n enteringCloudDebt,\n cloudDebtActive,\n cloudDebtStartedMs,\n cloudDebtDurationMinutes,\n effectiveCloudToleranceMinutes,\n cloudToleranceOk,\n },\n\n modes: {\n batteryFullHarvestMode,\n forecastCommitMode,\n batteryPriorityMode,\n },\n\n nightEvcs: {\n nightSessionActive,\n nightSessionNeedsCapture,\n nightSessionNeedsClear,\n nightSessionStartSocPct,\n nightSessionStartAt,\n nightSessionStartMs,\n\n batteryHardFloorPct,\n batteryMorningTargetPct,\n advisorNightTargetApplied,\n maxBatteryDischargePoints,\n gridTakeoverTriggerPct,\n\n batteryBelowHardFloor,\n batteryBelowMorningTarget,\n batteryAtOrBelowGridTakeoverTrigger,\n\n batteryKwhMissingToTarget,\n batteryRecoveryMinutesNeeded,\n batteryRecoveryUrgent,\n batteryRecoveryAllowedNow,\n batteryTargetReached,\n\n batteryAssistAllowed,\n nightGridTakeoverNeeded,\n nightTakeoverLatched,\n vehicleChargeCompleted,\n nightRecoveryDone,\n departureUrgentActive,\n departureChargeDone,\n dayVehicleFullIdle,\n nightPreferredSourceMode,\n },\n\n safeguards: {\n isGridSource,\n commonSafetyStartOk,\n commonSafetyHoldOk,\n profileSocStartFloor,\n effectiveSocStartFloor,\n solarAbundance,\n solarAbundanceHold,\n profileSocHoldFloor,\n effectiveSocHoldFloor,\n profileBatteryDischargeFloorA,\n\n // Excedent solaire (03/09/2026) : la grandeur qui autorise un demarrage.\n solarSurplusW,\n solarSurplusProvocationOk,\n solarSurplusStartOk,\n batteryCurrentHardExceeded,\n mustStopHard,\n gridBatteryProtectionStop,\n },\n\n conditions: {\n rawSolarStartOk,\n rawSolarHoldOk,\n batteryAssistSolarPolicyEligible,\n batteryAssistSolarStartOk,\n batteryAssistSolarHoldOk,\n\n solarStartOk,\n solarHoldOk,\n gridSettling,\n gridTransitionAgeSec: parsed.gridTransitionAgeSec,\n gridStartOk,\n gridHoldOk,\n\n batteryAssistNightStartOk,\n batteryAssistNightHoldOk,\n\n probeStartOk,\n probeHoldGuardOk,\n probeWindowActive,\n probeElapsedReached,\n probeSuccessNow,\n probeFailedNow,\n\n solarShouldStepUp,\n gridShouldStepUp,\n batteryAssistNightShouldStepUp,\n\n solarShouldStepDownBattery,\n solarShouldStepDownCloudDebt,\n gridShouldStepDownBattery,\n batteryAssistNightShouldStepDown,\n\n shouldStepDownVebusPower,\n shouldStepDownVebusVoltage,\n shouldStepDownOverload,\n shouldStopOverloadAtMin,\n }\n }\n}];\n" }, "type": "n8n-nodes-base.code", "typeVersion": 2,