diff --git a/HANDOFF.md b/HANDOFF.md index cad4c2c..5683b99 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -1628,5 +1628,40 @@ reseau + decharge - charge) : elle inclut la borne et les pertes de l'onduleur. a lieu un jour : verifier le selecteur vehicule (§47) et que les deux integrales batterie repartent de leur derniere valeur (elles restaurent leur etat). +### 04/10 matin — Nuit du budget : la JEEP chargee a 100 % sur EDF, deux defauts de ma conception +**Ce qui s'est passe** (mode sans_edf) : budget fige a 01:25 = **1,41 kWh** (SOC 47) ; JEEP sur +batterie, **arret au budget a 02:31** (1,40 kWh) — l'arret au compteur MARCHE. Puis a 02:57 SOC +39 sous la cible du matin 39,5 : reprise reseau de nuit, EDF ferme (02:58, session unique +jusqu'a 07:24) ; l'exception du 30/09 (« EDF deja engage pour la batterie -> la voiture en +profite ») a relance la JEEP sur le reseau : **4,7 kWh jusqu'au plein (04:02)**. Import depuis +02:58 : 5,6 kWh (HC). JEEP debranchee 05:57, pleine (54 % + 1,4 + 4,7 kWh). +**Cause 1** : le budget (creux de la trajectoire p80 au-dessus de 22 %) ne respectait pas le +critere qui ferme EDF : a 01:20, requis 42,5 (p50) / 43,6 (prudent) pour SOC 47 -> marge +~1,0 kWh, budget 1,41. La voiture a mange toute la marge ; une maison conforme au profil a +suffi a toucher la cible. Deux calculs du bilan jamais confrontes. +**Cause 2** : l'exception de securite du 30/09 etait trop large — elle devait garantir que +« sans EDF » n'empeche jamais EDF de proteger la BATTERIE, elle laissait aussi la VOITURE +charger sur le reseau. +**Correctifs (accord du proprietaire, deployes 06:08)** : +1. `BALANCE.js` : budget = min(creux, energie au-dessus du SOC requis PRUDENT) — le plancher + meme de P12. Bilan 76/76 : soleil 4,21 -> **3,75** et SOC bas 0,61 -> **0,15** kWh, verifies + a la main ((57 - 44,5) x 0,30 et (45 - 44,5) x 0,30), mutation qui mord. +2. Option A : en sans_edf la voiture reste `blocked` meme quand EDF se ferme pour la batterie. + Piege rencontre : la fermeture d'EDF (`edf_general_ok_on`) et la consigne (`gridSessionActive`) + suivaient la source de la VOITURE — bloquer la voiture coupait aussi la batterie (golden + `budget-sans-edf-plancher-dur-edf-protege` et `depart-matin-…-sous-la-cible` tombes). Nouveau + drapeau `nightBatteryGridOnly` (session de nuit && voiture en attente sans EDF && takeover) : + EDF + session reseau pour la batterie seule (WINDOWS, passe par BATTERY_CLOUD_LOCKS, + HA_SERVICE_CALLS_OUTPUT ; raison « sans EDF : EDF ferme pour la batterie seule, la voiture + attend le soleil »). Golden **136/136** (+1 : la nuit du 03-04/10 rejouee), attendus des deux + scenarios passes a `blocked` + EDF ferme, deux mutations qui mordent (3 scenarios chacune). + golden-acm 20/20, hysteresis 24/24. Executions n8n en succes apres deploiement. +**Tableau Energie, defaut de mon import** : l'import des statistiques AVANT la premiere heure +compilee ne suffit pas — HA est reparti de zero a 01:00 (sauts -1 615 / -335 / -481 / -465 kWh +sur la journee du 04/10). Corrige par `recorder/adjust_sum_statistics` a partir de 23:00Z +(+1 614,90 / +335,00 / +481,29 / +466,00, decharge 01:00-02:00 = 1,50 kWh reels d'apres la +base) ; heures continues verifiees. ⚠️ Lecon : apres un import sur une entite neuve, +verifier la premiere heure compilee et corriger par ajustement. + --- -*Document mis a jour le : 04/10/2026 (~01:40) — tableau Energie complete (Sud/Ouest, batterie par integration, borne ; aucun redemarrage HA) ; kilometrage des vehicules (km/kWh, capacite, releves de reference, agent cable 15/15) ; a verifier le 04/10 au matin : nuit du budget JEEP ; concierge : vehicule non confirme demande, heure de saisie ; Grafana vehicules en tableau ; Grafana vehicules : ZERO et Invite ajoutes, session 16:46 reetiquetee ZERO ; prevision SOC 10 h ~35 % ; concierge fiable : niveau note par le script, une ligne, dedoublonnage, simulation par drapeau ; agent : message incompris = question, bourse sur demande explicite (banc final 6/6) ; a verifier : nuit du samedi 03 au dimanche 04/10 (budget < besoin JEEP), cron orange-cloture lundi 05/10 apres 17:40, comparaison VRM / Forecast.Solar lundi 05/10* +*Document mis a jour le : 04/10/2026 (~06:15) — nuit du budget : budget borne au plancher prudent, sans EDF = voiture bloquee meme EDF ferme (batterie seule) ; Energie : sauts corriges ; tableau Energie complete (Sud/Ouest, batterie par integration, borne ; aucun redemarrage HA) ; kilometrage des vehicules (km/kWh, capacite, releves de reference, agent cable 15/15) ; a verifier le 04/10 au matin : nuit du budget JEEP ; concierge : vehicule non confirme demande, heure de saisie ; Grafana vehicules en tableau ; Grafana vehicules : ZERO et Invite ajoutes, session 16:46 reetiquetee ZERO ; prevision SOC 10 h ~35 % ; concierge fiable : niveau note par le script, une ligne, dedoublonnage, simulation par drapeau ; agent : message incompris = question, bourse sur demande explicite (banc final 6/6) ; a verifier : nuit du samedi 03 au dimanche 04/10 (budget < besoin JEEP), cron orange-cloture lundi 05/10 apres 17:40, comparaison VRM / Forecast.Solar lundi 05/10* diff --git a/src/energy-balance/BALANCE.js b/src/energy-balance/BALANCE.js index 09ab82e..9b42479 100644 --- a/src/energy-balance/BALANCE.js +++ b/src/energy-balance/BALANCE.js @@ -460,7 +460,17 @@ if (nightHcActive && Number.isFinite(socPct)) { if (coupes[i + 1] >= depuis) creux = Math.min(creux, reserve); } } - nightVehicleBudgetKwh = Number.isFinite(creux) ? Math.max(0, creux) : null; + // BORNE PAR LE PLANCHER PRUDENT (04/10/2026). Nuit du 03-04/10 : SOC 47, + // prudent 43,6 -> ~1,0 kWh de marge, mais le creux a donne 1,41 kWh. La + // voiture a pris toute la marge, la batterie a touche la cible du matin a + // 02:57 et la reprise reseau a ferme EDF. Le budget ne peut donc jamais + // depasser l'energie au-dessus du SOC requis PRUDENT, celui-la meme que la + // voiture n'a pas le droit de franchir en assistance batterie (P12) : les + // deux criteres sont desormais les memes par construction. + const margePrudente = Number.isFinite(prudent.requiredSocPct) + ? (socPct > prudent.requiredSocPct ? energyBetweenSoc(prudent.requiredSocPct, socPct) : 0) + : 0; + nightVehicleBudgetKwh = Number.isFinite(creux) ? Math.max(0, Math.min(creux, margePrudente)) : null; } // --------------------------------------------------------------------- diff --git a/src/nodes/BATTERY_CLOUD_LOCKS.js b/src/nodes/BATTERY_CLOUD_LOCKS.js index ef02ed0..7d3fe65 100644 --- a/src/nodes/BATTERY_CLOUD_LOCKS.js +++ b/src/nodes/BATTERY_CLOUD_LOCKS.js @@ -1134,6 +1134,7 @@ return [{ nightBudgetToWrite: nightPrev.nightBudgetToWrite ?? null, nightChargeMode: nightPrev.nightChargeMode || 'sans_edf', nightCarWaitsNoGrid: !!nightPrev.nightCarWaitsNoGrid, + nightBatteryGridOnly: !!nightPrev.nightBatteryGridOnly, maxBatteryDischargePoints, gridTakeoverTriggerPct, diff --git a/src/nodes/HA_SERVICE_CALLS_OUTPUT.js b/src/nodes/HA_SERVICE_CALLS_OUTPUT.js index 17a5ab5..1de60ba 100644 --- a/src/nodes/HA_SERVICE_CALLS_OUTPUT.js +++ b/src/nodes/HA_SERVICE_CALLS_OUTPUT.js @@ -304,7 +304,8 @@ const reason = [...decision.reason]; if (night.nightBudgetKwh !== null && night.nightBudgetKwh !== undefined) { const f = (x) => String(Math.round(x * 100) / 100).replace('.', ','); reason.push(`Budget batterie nuit ${f(night.nightBudgetDeliveredKwh ?? 0)}/${f(night.nightBudgetKwh)} kWh` + - (night.nightCarWaitsNoGrid ? ' — atteint, sans EDF : la voiture attend le soleil' + (night.nightBatteryGridOnly ? ' — sans EDF : EDF ferme pour la batterie seule, la voiture attend le soleil' + : night.nightCarWaitsNoGrid ? ' — atteint, sans EDF : la voiture attend le soleil' : night.nightBudgetReached ? ' — atteint, EDF finit la charge' : '')); } else if (night.nightCarWaitsNoGrid) { reason.push('Pas de budget batterie exploitable, sans EDF : la voiture attend le soleil'); @@ -365,7 +366,9 @@ const gridSessionActive = ( context.sourceMode === 'grid_forced' || context.sourceMode === 'grid_hc_allowed' || - night.nightGridSessionCommitted === true + night.nightGridSessionCommitted === true || + // Batterie seule sur le reseau, voiture bloquee « sans EDF » (04/10). + night.nightBatteryGridOnly === true ); // ===================================================== @@ -1471,7 +1474,8 @@ return [{ // ================================================= edf_general_ok_on: { execute: - ['grid_forced', 'grid_hc_allowed'].includes(context.sourceMode) && + (['grid_forced', 'grid_hc_allowed'].includes(context.sourceMode) || + night.nightBatteryGridOnly === true) && !parsed.gridAvailable, domain: 'switch', service: 'turn_on', diff --git a/src/nodes/WINDOWS_SOURCEV2.js b/src/nodes/WINDOWS_SOURCEV2.js index 8fd0495..5b7ba68 100644 --- a/src/nodes/WINDOWS_SOURCEV2.js +++ b/src/nodes/WINDOWS_SOURCEV2.js @@ -859,11 +859,14 @@ const nightPreferredSourceMode = ? 'blocked' : vehicleChargeCompleted ? 'blocked' - // « Sans EDF » ne bloque la voiture que si EDF n'est PAS deja engage pour - // la batterie (plancher dur, cible du matin, session du bilan, verrou) : - // proteger le materiel prime (regle du proprietaire, 05/09), et EDF deja - // ferme en HC, la voiture en profite au meme tarif. - : nightCarWaitsNoGrid && !nightGridTakeoverNeeded + // « Sans EDF » : la voiture ne prend JAMAIS le reseau la nuit, MEME quand + // EDF se ferme pour la batterie (plancher dur, cible du matin, session du + // bilan, verrou). Decision du proprietaire le 04/10 (option A) : la nuit du + // 03-04/10, EDF ferme a 02:57 pour la batterie, l'ancienne exception a + // laisse la JEEP prendre 4,7 kWh au reseau jusqu'au plein. La protection + // du materiel reste entiere : EDF se ferme quand meme (takeover), seule la + // voiture attend le soleil. + : nightCarWaitsNoGrid ? 'blocked' : batteryAssistAllowed ? 'battery_assist_night' @@ -875,6 +878,16 @@ const nightPreferredSourceMode = ? 'grid_forced' : 'grid_hc_allowed'; +// « Sans EDF » ET EDF necessaire pour la BATTERIE (04/10/2026, option A du +// proprietaire) : la voiture reste bloquee, mais la batterie a sa session +// reseau. Sans ce drapeau, la fermeture d'EDF et la consigne de recharge, qui +// suivent la source de la VOITURE, seraient perdues avec elle (golden +// budget-sans-edf-plancher-dur-edf-protege, depart-matin-…-sous-la-cible). +const nightBatteryGridOnly = + nightSessionActive && + nightCarWaitsNoGrid && + nightGridTakeoverNeeded; + const batteryRecoveryAllowedNow = nightSessionActive && nightGridTakeoverNeeded && @@ -1265,6 +1278,7 @@ return [{ nightBudgetToWrite, nightChargeMode: params.nightChargeMode, nightCarWaitsNoGrid, + nightBatteryGridOnly, nightGridSessionCommitted, middayVehicleGridNeeded, nightGridSessionUncovered, diff --git a/tests/energy-balance/scenarios-balance.mjs b/tests/energy-balance/scenarios-balance.mjs index 5876f46..3f0304d 100644 --- a/tests/energy-balance/scenarios-balance.mjs +++ b/tests/energy-balance/scenarios-balance.mjs @@ -610,16 +610,16 @@ export const balanceScenarios = [ { node: 'BALANCE', id: 'bilan-budget-voiture-nuit-soleil', - label: '01:25, SOC 57, lendemain ensoleille : la voiture peut prendre 4,21 kWh sur la batterie', + label: '01:25, SOC 57, lendemain ensoleille : budget borne par le plancher prudent 44,5 % -> (57 - 44,5) x 0,30 = 3,75 kWh (creux seul : 4,21, avant le 04/10)', snapshot: soleil2809(Object.assign(snap2709('2026-09-28T01:25:00+02:00', 57, null), NUIT_HC_2809)), - expect: { usable: true, nightVehicleBudgetActive: true, nightVehicleBudgetKwh: 4.21 }, + expect: { usable: true, nightVehicleBudgetActive: true, nightVehicleBudgetKwh: 3.75, prudentRequiredSocPct: 44.5 }, }, { node: 'BALANCE', id: 'bilan-budget-voiture-nuit-soc-bas', - label: '01:25, SOC 45 : il ne reste que 0,61 kWh au-dessus du creux de 9 h', + label: '01:25, SOC 45 : borne par le plancher prudent -> (45 - 44,5) x 0,30 = 0,15 kWh (creux seul : 0,61, avant le 04/10)', snapshot: soleil2809(Object.assign(snap2709('2026-09-28T01:25:00+02:00', 45, null), NUIT_HC_2809)), - expect: { usable: true, nightVehicleBudgetActive: true, nightVehicleBudgetKwh: 0.61 }, + expect: { usable: true, nightVehicleBudgetActive: true, nightVehicleBudgetKwh: 0.15, prudentRequiredSocPct: 44.5 }, }, { node: 'BALANCE', diff --git a/tests/golden/scenarios.mjs b/tests/golden/scenarios.mjs index 42436a6..601b75b 100644 --- a/tests/golden/scenarios.mjs +++ b/tests/golden/scenarios.mjs @@ -3122,7 +3122,18 @@ export const scenarios = [ time: '2026-09-30T04:00:00+02:00', overrides: { ...BUDGET_NUIT, balance_last_run: '30/09 03:55', soc_percent: '20', night_charge_mode: 'sans_edf', night_budget_prev: '3.7|4000', evcs_total_energy_kwh: '4003.75' }, - expect: { sourceMode: 'grid_forced', edfOnAction: true }, + // 04/10 (option A du proprietaire) : EDF se ferme pour la BATTERIE, la + // voiture reste bloquee « sans EDF » (avant : grid_forced, voiture sur EDF). + expect: { sourceMode: 'blocked', desiredCharging: false, edfOnAction: true, takeoverNeeded: true }, + }, + { + id: 'budget-sans-edf-reprise-reseau-voiture-bloquee', + label: 'Nuit du 03-04/10 rejouee : budget 1,41 atteint, SOC 39 sous la cible 39,5 a 02:57 : EDF ferme pour la batterie, la JEEP ne prend PAS le reseau', + time: '2026-10-04T02:57:00+02:00', + overrides: { ...BUDGET_NUIT, balance_last_run: '04/10 02:55', soc_percent: '39', + balance_required_soc: '39.5', balance_required_soc_prudent: '40.4', + night_charge_mode: 'sans_edf', night_budget_prev: '1.41|4038.8', evcs_total_energy_kwh: '4040.2' }, + expect: { sourceMode: 'blocked', desiredCharging: false, edfOnAction: true, takeoverNeeded: true }, }, { id: 'budget-bilan-perime-sans-edf-rien', @@ -3170,7 +3181,8 @@ export const scenarios = [ balance_required_soc: '28.6', balance_required_soc_prudent: '29.7', evcs_vehicle: 'JEEP', departure_time_jeep: '06:30:00', departure_day_ok: 'on', night_charge_mode: 'sans_edf', night_budget_prev: '5.93|4010', evcs_total_energy_kwh: '4015' }, - expect: { sourceMode: 'grid_forced', edfOnAction: true }, + // « sans EDF » : l'urgence ferme EDF pour la batterie, la voiture reste bloquee (04/10). + expect: { sourceMode: 'blocked', desiredCharging: false, edfOnAction: true }, }, { id: 'depart-matin-pas-un-jour-de-depart', diff --git a/workflows/evcs-energy-balance-v1.json b/workflows/evcs-energy-balance-v1.json index 0ed361c..1d0fbd1 100644 --- a/workflows/evcs-energy-balance-v1.json +++ b/workflows/evcs-energy-balance-v1.json @@ -111,7 +111,7 @@ 120 ], "parameters": { - "jsCode": "// EVCS Energy Balance — BALANCE\n// =====================================================================\n// Rôle : produire les FAITS qui remplacent les seuils fixes. Rien d'autre.\n// Ce nœud ne pilote ni EDF, ni la borne : il publie des grandeurs que les deux\n// contrôleurs liront à la place de leurs constantes.\n//\n// La question à laquelle il répond n'est pas « le SOC est-il au-dessus de\n// 30 % ? » mais « la batterie contient-elle de quoi tenir jusqu'à ce que le\n// solaire prenne le relais ? ». Le 24/08/2026 à 06:43, la réponse du seuil fixe\n// et celle du bilan diffèrent : la production ne couvrait la maison qu'à partir\n// de 09 h, il fallait donc environ 0,8 kWh de réserve, soit ~5 points de SOC —\n// et non un palier arbitraire à 30 %.\n//\n// Entrées (toutes fournies, aucune constante physique en dur) :\n// consumption_p50[24] profil horaire mesuré, valeur typique (CONSUMPTION_PROFILE)\n// consumption_p80[24] idem, valeur prudente\n// horizon[] production attendue heure par heure (RESOURCE_ESTIMATE)\n// soc_percent SOC lissé pour le pilotage\n// usable_capacity_kwh capacité réellement disponible du parc batterie\n// soc_floor_percent plancher dur de sécurité\n//\n// Sorties : heure de croisement · énergie à couvrir · SOC requis · marge\n// disponible pour le véhicule · niveau de confiance.\n// =====================================================================\n\nfunction num(v, fallback = NaN) {\n const n = Number.parseFloat(v);\n return Number.isFinite(n) ? n : fallback;\n}\n\nfunction round1(v) {\n return Number.isFinite(v) ? Math.round(v * 10) / 10 : null;\n}\n\nfunction round2(v) {\n return Number.isFinite(v) ? Math.round(v * 100) / 100 : null;\n}\n\nconst input = $input.first().json;\n\nconst CFG = {\n TIMEZONE: 'Europe/Paris',\n // Au-delà de cet horizon sans croisement trouvé, on cesse de chercher : la\n // réponse n'aurait plus de sens opérationnel.\n SEARCH_HOURS: 40,\n // PAS de marge de sécurité supplémentaire. La prudence est déjà portée,\n // entièrement, par le choix du percentile du profil de consommation (p80),\n // qui est mesuré. Vérifié le 24/08/2026 : sur toutes les fenêtres testées,\n // p80(somme des heures) est égal a somme(p80 par heure) — les heures de\n // consommation sont fortement corrélées, une matinée chargée l'étant sur\n // plusieurs heures d'affilée. Sommer les p80 ne cumule donc PAS deux\n // pessimismes, et ajouter un coefficient par-dessus ne serait qu'une\n // constante arbitraire de plus.\n};\n\nfunction profileOf(key) {\n const a = input[key];\n return Array.isArray(a) && a.length === 24 ? a.map(v => Math.max(0, num(v, 0))) : null;\n}\nconst consumptionP50 = profileOf('consumption_p50');\nconst consumptionP80 = profileOf('consumption_p80');\n\nconst horizon = Array.isArray(input.horizon) ? input.horizon : null;\n\nconst socPct = num(input.soc_percent, NaN);\nconst usableCapacityKwh = num(input.usable_capacity_kwh, NaN);\nconst socFloorPct = num(input.soc_floor_percent, 22);\n\n// =====================================================\n// CHARGE HC MANUELLE — UN PLANCHER, PAS UNE CIBLE (18/09/2026)\n// =====================================================\n// Le proprietaire veut pouvoir demander une recharge batterie en heures creuses\n// quand le bilan ne l'a pas jugee necessaire — journee d'hiver courte, ciel\n// couvert. La decision qu'il a tranchee : CONTRAINDRE le bilan plutot que de\n// l'ignorer, pour garder un seul arbitre. Ce projet a deja paye cher les\n// automates concurrents (charges programmees de l'ESS §26, consigne a 4200 W\n// §27, plancher SOC du GX §27).\n//\n// ⚠️ LE FORCAGE DOIT ETRE ICI, EN AMONT, ET NULLE PART AILLEURS. Mesure du\n// 18/09 sur banc : ecrire directement dans `input_number.evcs_balance_required_soc`\n// engage bien la session — contacteur ferme, takeover ON — mais laisse la\n// consigne reseau a 700 W, donc la batterie recoit une centaine de watts. C'est\n// le defaut du 01/09 et du 04/09 a l'identique, et c'est le seul resultat des\n// trois qui soit INDIAGNOSTICABLE depuis le tableau de bord : il a l'air de\n// marcher. La raison : `required_soc` ENGAGE la session, `grid_energy_needed_kwh`\n// la FINANCE, et les deux sont publiees separement. Pose ici, dans `coverage()`,\n// le plancher fait bouger les deux ENSEMBLE par construction — c'est une\n// consequence de la forme du code et non une discipline a tenir.\n//\n// ⚠️ ET CE PLANCHER EST DEJA FILTRE PAR L'HEURE QUAND IL ARRIVE ICI. Le bilan\n// ignore totalement les fenetres d'heures creuses (aucune occurrence de\n// `hc_night` / `hc_midday` dans ce sous-systeme) et doit le rester : il repond a\n// « de combien la batterie a-t-elle besoin », pas a « quand est-ce bon marche ».\n// C'est Home Assistant qui detient le calendrier tarifaire et qui ne publie une\n// valeur non nulle que pendant une fenetre creuse. Sans ce filtrage en amont, un\n// plancher a 85 % fuirait dans la journee et mordrait sur\n// `BATTERY_CLOUD_LOCKS.js` — `eveningSocStartFloorPct = max(eveningSocStartMin,\n// balanceRequiredSocPct)` — au point de bloquer la charge du vehicule le soir.\nconst manualHcFloorPct = (() => {\n const v = num(input.manual_hc_floor_percent, 0);\n return Number.isFinite(v) && v > socFloorPct && v <= 100 ? v : 0;\n})();\n\n// Sans l'une de ces entrées, le bilan ne peut rien affirmer. Il le dit, et les\n// contrôleurs retombent sur leurs règles historiques.\nconst missing = [];\nif (!consumptionP50) missing.push('consumption_p50');\nif (!consumptionP80) missing.push('consumption_p80');\nif (!horizon || !horizon.length) missing.push('horizon');\nif (!Number.isFinite(socPct)) missing.push('soc_percent');\nif (!Number.isFinite(usableCapacityKwh) || usableCapacityKwh <= 0) missing.push('usable_capacity_kwh');\n\nif (missing.length) {\n return [{\n json: {\n usable: false,\n missing,\n reason: `Bilan indisponible : ${missing.join(', ')}. Les contrôleurs doivent conserver leurs règles.`,\n cfg: CFG,\n },\n }];\n}\n\n// ---------------------------------------------------------------------\n// Bilan heure par heure, calculé DEUX FOIS.\n//\n// Pourquoi deux niveaux de prudence et pas un seul : la décision « faut-il du\n// réseau maintenant ? » est réévaluée à chaque cycle et reste réversible. Se\n// tromper par optimisme ne coûte qu'un démarrage réseau une heure plus tard,\n// le plancher dur garantissant la batterie ; se tromper par excès de prudence\n// coûte un import réseau bien réel, immédiatement.\n//\n// Vérifié sur la matinée du 24/08/2026 : avec le profil prudent, le bilan\n// exigerait 48 % de SOC pour franchir la matinée, alors que la consommation\n// réelle de ce jour-là n'a demandé que 0,8 kWh — le seuil fixe de 30 % aurait\n// été MOINS coûteux. Un bilan « intelligent » plus gourmand que le seuil bête\n// qu'il remplace n'a aucun intérêt.\n//\n// - profil TYPIQUE (p50) → décision réseau immédiate, réévaluée en continu.\n// - profil PRUDENT (p80) → planification : cible de nuit, dimensionnement\n// du stockage, choix qui engagent plusieurs heures.\n// ---------------------------------------------------------------------\n// ---------------------------------------------------------------------\n// LA NUIT PREVUE PAR LA SOIREE (28/09/2026, demande du proprietaire)\n//\n// Le profil p50 est une mediane HORAIRE : il ignore la soiree qui vient de se\n// passer. Or sur 24 nuits, la maison consomme d'autant plus de 21:30 a 09:00\n// que la soiree 18:00-21:30 a ete chargee (four) : nuit = 6,98 + 0,74 x soiree,\n// r2 = 0,40. Le service d'historisation publie le FACTEUR nuit prevue / nuit\n// moyenne (sensor.evcs_nuit_maison_prevue_kwh) ; on multiplie le profil par ce\n// facteur sur la seule fenetre 21:30 -> 09:00, au prorata des heures a cheval.\n// Le 28/09 : soiree 6,27 kWh -> facteur 1,09.\n//\n// Un FACTEUR et non des kWh : le profil vient des compteurs d'energie, la\n// regression des puissances integrees ; le rapport annule l'ecart de methode.\n// Dans les deux sens pour le profil TYPIQUE (c'est un estimateur, une soiree\n// legere allege la nuit) ; le PRUDENT ne descend jamais : max(p80, p50 recale).\n// Filet : facteur borne a [0,75 ; 1,45], les extremes des 24 nuits observees\n// (0,77 et 1,41 fois la moyenne) — hors de la, c'est une mesure fausse, pas une\n// nuit. Facteur absent, perime (> 30 min) ou hors fenetre : profil inchange.\nconst NIGHT_FACTOR_MIN = 0.75;\nconst NIGHT_FACTOR_MAX = 1.45;\nconst nowMsNight = num(input.nowMs, Date.now());\nconst nightStartMs = num(input.night_house_start_ms, NaN);\nconst nightEndMs = num(input.night_house_end_ms, NaN);\nconst nightFactorRaw = num(input.night_house_factor, NaN);\nconst nightComputedMs = num(input.night_house_computed_ms, NaN);\nconst nightModelActive =\n Number.isFinite(nightFactorRaw) && nightFactorRaw > 0 &&\n Number.isFinite(nightStartMs) && Number.isFinite(nightEndMs) && nightEndMs > nightStartMs &&\n nowMsNight >= nightStartMs && nowMsNight < nightEndMs &&\n Number.isFinite(nightComputedMs) && nowMsNight - nightComputedMs <= 30 * 60000;\nconst nightFactor = nightModelActive\n ? Math.min(NIGHT_FACTOR_MAX, Math.max(NIGHT_FACTOR_MIN, nightFactorRaw))\n : 1;\nfunction nightShare(startMs) {\n const debut = num(startMs, NaN);\n if (!nightModelActive || !Number.isFinite(debut)) return 0;\n const a = Math.max(debut, nightStartMs), b = Math.min(debut + 3600000, nightEndMs);\n return b > a ? (b - a) / 3600000 : 0;\n}\nfunction nightScaled(profile, slot) {\n const h = num(slot.hour, 0);\n return (profile[h] ?? 0) * (1 + (nightFactor - 1) * nightShare(slot.startMs));\n}\n\nfunction buildSlots(consumption, prudent = false) {\n return horizon.slice(0, CFG.SEARCH_HOURS).map(slot => {\n const hour = num(slot.hour, 0);\n const productionKwh = Math.max(0, num(slot.expectedKwh, 0));\n const consumptionKwh = prudent\n ? Math.max(consumption[hour] ?? 0, nightScaled(consumptionP50, slot))\n : nightScaled(consumption, slot);\n return {\n startMs: slot.startMs,\n hour,\n productionKwh: round2(productionKwh),\n consumptionKwh: round2(consumptionKwh),\n netKwh: round2(productionKwh - consumptionKwh),\n shapeQuality: slot.shapeQuality || 'inconnue',\n };\n });\n}\n\n// =====================================================================\n// COURBE D'ENERGIE DE LA BATTERIE — MESUREE, PAS SUPPOSEE (03/09/2026)\n// =====================================================================\n// La conversion energie <-> points de SOC etait une CONSTANTE\n// (`usable_capacity_kwh`, 15 kWh) la ou la realite est une COURBE.\n//\n// Mesure : 576 paliers de SOC sur 8 jours (`tools/mesure-courbe-batterie.mjs`).\n// Pour chaque palier ou le SOC reste a une valeur puis DESCEND d'un point, on\n// integre la puissance batterie — en ecartant les paliers ou la batterie a\n// charge ou ou le reseau a apporte quoi que ce soit.\n//\n// tranche paliers kWh/point\n// 21-31 88 0,097 <- effondrement pres du genou\n// 35-44 78 0,171\n// 45-54 76 0,169\n// 55-64 85 0,163\n// 65-74 80 0,171\n// 75-84 81 0,170\n// 85-94 62 0,169\n//\n// Plate a 0,169 de 33 a 93, genou net a 32-33, puis 0,097 : sous le genou, un\n// point de SOC vaut 57 % de ce qu'il vaut au-dessus. Coherent avec §0 (un\n// module en panne sur huit, un autre qui faiblit) et avec l'effondrement de la\n// jauge deja signale au §28 — ceci lui donne sa courbe.\n//\n// CE QUE LA CONSTANTE FAISAIT : 15 kWh se situe ENTRE les deux pentes, donc\n// l'erreur CHANGE DE SIGNE selon l'energie a couvrir —\n// 1 kWh a couvrir : SOC requis 28,7 % annonce contre 32,3 % reel (-3,6)\n// 4,3 kWh : 50,7 contre 52,1 (-1,4)\n// 8 kWh : 75,3 contre 74,0 (+1,3)\n// 11,7 kWh : 99,7 contre 95,7 (+4,1)\n// Trop BAS en fin de nuit (la direction dangereuse), trop HAUT en debut.\n//\n// Energie utile totale de 100 a 22 % : 12,39 kWh mesures, contre 11,70 vus par\n// le bilan et 13,65 par INPUT_PARSE. Les deux constantes existantes etaient\n// fausses, dans des sens opposes.\n//\n// ⚠️ A RE-MESURER apres toute evolution du parc (extension prevue cet hiver) :\n// `node tools/mesure-courbe-batterie.mjs 8`, puis mettre a jour ces trois\n// nombres. Ce ne sont pas des reglages, ce sont des resultats de mesure.\n//\n// ⚠️ CES PENTES ONT ETE MESUREES SUR SEPT MODULES. Retirer ou ajouter un module\n// divise ou multiplie la capacite sans changer la FORME de la courbe : on les\n// exprime donc par module, et on les multiplie par le nombre reellement en\n// service. Ce nombre se lit dans un helper — `input_number\n// .evcs_balance_modules_en_service` — et non dans le code : le parc a change\n// deux fois en une semaine et changera encore cet hiver. Helper absent ou\n// illisible => 7, la valeur sur laquelle la mesure a ete faite, donc aucun\n// changement de comportement tant que personne ne l'ecrit.\nconst MODULES_MESURE = 7;\nconst modulesEnService = (() => {\n const n = num(input.modules_en_service, MODULES_MESURE);\n return Number.isFinite(n) && n >= 1 && n <= 24 ? n : MODULES_MESURE;\n})();\nconst echelleParc = modulesEnService / MODULES_MESURE;\n\n// 20/09/2026 — RE-MESURE sur le parc ASSAINI (13 equivalents US2000 : 4 US5000\n// + 5 US2000C, cellules a 3-6 mV). `tools/mesure-courbe-batterie.mjs 8` :\n// 0,30 kWh/point PLAT de 25 a 97 %, PLUS DE GENOU. Le genou a 33 % etait bien\n// la signature de la cellule 12 morte du pack ancien (§50) — il a disparu avec\n// elle, exactement comme le predisait la note de prudence du 03/09. La reserve\n// reelle a quasiment double (12,4 -> ~23 kWh utiles de 100 a 22 %).\n// Base normalisee a 7 modules (MODULES_MESURE) : 0,30 x 7/13 = 0,1615 par point.\n// x echelleParc(13/7) = 0,30 au parc actuel. Pente unique => plus de genou.\n// A REMESURER apres toute evolution du parc (2e cable console en commande pour\n// surveiller aussi les cellules du pack 1 US5000).\nconst BATTERY_CURVE = {\n kneePct: 0,\n kwhPerPointAbove: 0.1615 * echelleParc,\n kwhPerPointBelow: 0.1615 * echelleParc,\n};\n\n// Energie que la batterie peut delivrer en descendant de socHi a socLo.\nfunction energyBetweenSoc(socLo, socHi) {\n if (!Number.isFinite(socLo) || !Number.isFinite(socHi) || socHi <= socLo) return 0;\n const knee = BATTERY_CURVE.kneePct;\n const lowSpan = Math.max(0, Math.min(socHi, knee) - Math.min(socLo, knee));\n const highSpan = Math.max(0, Math.max(socHi, knee) - Math.max(socLo, knee));\n return lowSpan * BATTERY_CURVE.kwhPerPointBelow\n + highSpan * BATTERY_CURVE.kwhPerPointAbove;\n}\n\n// SOC necessaire pour delivrer energyKwh en descendant jusqu'a floorPct.\n// NON ecrete : au-dela de 100 la valeur reste le signal de sous-dimensionnement\n// (cf. §26). L'extrapolation au-dessus de 100 utilise la pente haute.\nfunction socForEnergy(energyKwh, floorPct) {\n if (!Number.isFinite(energyKwh) || energyKwh <= 0) return floorPct;\n const knee = Math.max(floorPct, BATTERY_CURVE.kneePct);\n const belowKwh = (knee - floorPct) * BATTERY_CURVE.kwhPerPointBelow;\n if (energyKwh <= belowKwh) {\n return floorPct + energyKwh / BATTERY_CURVE.kwhPerPointBelow;\n }\n return knee + (energyKwh - belowKwh) / BATTERY_CURVE.kwhPerPointAbove;\n}\n\n// SOC atteint en ajoutant addKwh a partir de socPct.\nfunction socAfterEnergy(socPct, addKwh) {\n if (!Number.isFinite(addKwh) || addKwh <= 0) return socPct;\n return socForEnergy(energyBetweenSoc(0, socPct) + addKwh, 0);\n}\n\n// =====================================================\n// TENIR JUSQU'A LA PROCHAINE FENETRE HC (28/09/2026)\n// =====================================================\n// Strategie du proprietaire, apres la nuit du 26-27/09 : P12 avait charge la\n// JEEP sur la batterie (72 -> 44 %) parce qu'il restait assez pour atteindre le\n// solaire du matin — alors que ce meme bilan annoncait deja « prochaine\n// traversee 17,35 kWh des 18 h -> SOC requis 79,8 % ». La journee (22 kWh reels)\n// n'a pas pu reconstituer la reserve. Sa regle : une voiture branchee se charge\n// en heures creuses sur le reseau, et la batterie doit TENIR JUSQU'A LA\n// PROCHAINE FENETRE HC ; si elle ne le peut pas, la fenetre HC de midi la\n// complete.\n//\n// Traduction : PENDANT une fenetre HC, l'exigence vaut au moins le SOC qu'il\n// faut avoir A LA FIN de cette fenetre pour atteindre le DEBUT de la suivante\n// sans passer sous le plancher, production prevue comprise (tranches horaires\n// ci-dessus, heure par heure, profil de conso du profil calcule).\n//\n// ⚠️ LE CALENDRIER RESTE DANS HOME ASSISTANT (§73) : le bilan ne recoit que deux\n// instants, la fin de la fenetre en cours (absente hors fenetre) et le debut de\n// la suivante. Hors fenetre HC, rien ne change.\n//\n// ⚠️ INSERE DANS coverage(), COMME LE PLANCHER MANUEL : la cible (required_soc,\n// qui ENGAGE la session et releve le plancher de P12) et le manque en kWh (qui\n// FINANCE la consigne) bougent ensemble par construction — jamais de session\n// muette (§73). Sens unique : il ne peut que RELEVER l'exigence.\nconst hcWindowEndMs = num(input.hc_window_end_ms, NaN);\nconst nextHcStartMs = num(input.next_hc_start_ms, NaN);\nconst nextNightHcStartMs = num(input.next_night_hc_start_ms, NaN);\nconst hcReachActive = Number.isFinite(hcWindowEndMs) && Number.isFinite(nextHcStartMs)\n && nextHcStartMs > hcWindowEndMs;\n// DEUX HORIZONS, DEUX USAGES (decision du proprietaire, 28/09) :\n// - recharger la batterie au reseau : tenir jusqu'a la PROCHAINE fenetre HC\n// (la nuit : midi ; a midi : 01:24). Midi complete si besoin, et la matinee\n// garde de la place pour le solaire. -> required_soc (engage ET finance).\n// - laisser la voiture puiser dans la batterie la nuit (P12) : seulement si la\n// batterie tient ensuite jusqu'a la NUIT SUIVANTE (01:24), sans compter sur\n// midi. Sinon la voiture se charge sur le reseau en HC. -> required_soc_prudent,\n// qui n'est lu que comme plancher de P12 (et par le conseiller, pour info).\nconst hcNightReachActive = Number.isFinite(hcWindowEndMs) && Number.isFinite(nextNightHcStartMs)\n && nextNightHcStartMs > hcWindowEndMs;\n\n// Energie a avoir en reserve a la fin de la fenetre : parcours A REBOURS des\n// tranches comprises entre les deux instants (au prorata de leur recouvrement).\n// Un surplus ne rembourse qu'un deficit POSTERIEUR — d'ou le sens du parcours.\nfunction hcReachFloorPct(slots, untilMs = nextHcStartMs, fromMs = hcWindowEndMs) {\n if (!Number.isFinite(fromMs) || !Number.isFinite(untilMs) || untilMs <= fromMs) return 0;\n let needKwh = 0;\n for (let i = slots.length - 1; i >= 0; i--) {\n const s = slots[i];\n const debut = num(s.startMs, NaN);\n if (!Number.isFinite(debut)) continue;\n const a = Math.max(debut, fromMs);\n const b = Math.min(debut + 3600000, untilMs);\n if (b <= a) continue;\n needKwh = Math.max(0, needKwh - s.netKwh * (b - a) / 3600000);\n }\n return needKwh > 0 ? socForEnergy(needKwh, socFloorPct) : 0;\n}\n\nfunction coverage(slots, hcFloorPct = 0) {\n const crossingIndex = slots.findIndex(s => s.netKwh > 0);\n const found = crossingIndex >= 0;\n const deficitSlots = found ? slots.slice(0, crossingIndex) : slots;\n const energyToCoverKwh = deficitSlots.reduce((sum, s) => sum + Math.max(0, -s.netKwh), 0);\n // Le plancher manuel ne peut que RELEVER l'exigence, jamais l'abaisser — meme\n // sens unique que les cablages de §38, §39 et §40 : le bilan ne pousse jamais\n // dans la direction « couvre moins bien ».\n const requiredSocRawPct = Math.max(\n socForEnergy(energyToCoverKwh, socFloorPct),\n manualHcFloorPct,\n hcFloorPct\n );\n const socNeededPct = requiredSocRawPct - socFloorPct;\n const requiredSocPct = Math.min(100, requiredSocRawPct);\n const socMarginPct = socPct - requiredSocPct;\n return {\n crossingIndex, found, deficitSlots, slots,\n energyToCoverKwh, socNeededPct, requiredSocPct, socMarginPct,\n gridNeeded: socMarginPct < 0,\n gridEnergyNeededKwh: socMarginPct < 0 ? energyBetweenSoc(socPct, requiredSocPct) : 0,\n };\n}\n\nconst slotsP50 = buildSlots(consumptionP50);\nconst slotsP80 = buildSlots(consumptionP80, true);\nconst hcFloorTypicalPct = hcReachFloorPct(slotsP50, nextHcStartMs);\nconst hcFloorNightPrudentPct = hcNightReachActive ? hcReachFloorPct(slotsP80, nextNightHcStartMs) : 0;\nconst hcFloorPrudentPct = Math.max(hcReachFloorPct(slotsP80, nextHcStartMs), hcFloorNightPrudentPct);\nconst typical = coverage(slotsP50, hcFloorTypicalPct);\nconst prudent = coverage(slotsP80, hcFloorPrudentPct);\n\n// ---------------------------------------------------------------------\n// CE QUE LA SESSION DE NUIT ACHETERA AU RESEAU (29/09/2026)\n//\n// Incident du 28/09 : la prevision annoncait « 0,14 kWh a prendre sur EDF » et\n// le reseau en a fourni 4,6. Les deux etaient justes, mais le premier est le\n// MANQUE de la batterie : une fois engagee, la session tient la batterie et la\n// maison passe TOUTE la fenetre HC de nuit sur le reseau (§40). Le chiffre qui\n// repond a « combien vais-je acheter » est donc la maison non couverte par le\n// solaire sur la fenetre (profil habituel, recale sur la soiree), depuis\n// maintenant si la fenetre est ouverte. Le manque batterie s'y ajoute, a part.\n// ---------------------------------------------------------------------\nconst nightHcStartMs = num(input.night_hc_start_ms, NaN);\nconst nightHcEndMs = num(input.night_hc_end_ms, NaN);\nconst nightHcActive = Number.isFinite(nightHcStartMs) && Number.isFinite(nightHcEndMs)\n && nightHcEndMs > nightHcStartMs;\nlet nightHcHouseKwh = null;\nif (nightHcActive) {\n const depuis = Math.max(nightHcStartMs, num(input.nowMs, Date.now()));\n nightHcHouseKwh = 0;\n for (const sl of slotsP50) {\n const debut = num(sl.startMs, NaN);\n if (!Number.isFinite(debut)) continue;\n const a = Math.max(debut, depuis), b = Math.min(debut + 3600000, nightHcEndMs);\n if (b > a) nightHcHouseKwh += Math.max(0, -sl.netKwh) * (b - a) / 3600000;\n }\n}\n\n// ---------------------------------------------------------------------\n// BUDGET VOITURE DE LA NUIT (30/09/2026, decisions du proprietaire)\n//\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. » Choix du\n// 30/09 : profil PRUDENT (p80) et plancher du bilan (22 %) ; par defaut « sans\n// EDF » ; en charge complete, le budget batterie d'abord, EDF pour le reste.\n//\n// Budget = la plus petite reserve au-dessus du plancher que la batterie\n// connaitra entre l'ouverture de la fenetre HC de nuit (ou maintenant si elle\n// est ouverte) et le debut de la fenetre de nuit SUIVANTE, sur le profil\n// prudent. Retirer X kWh a l'ouverture abaisse toute la trajectoire de X :\n// le budget est donc le creux de la trajectoire. L'horizon jusqu'a la nuit\n// suivante applique « la batterie tient jusqu'a la prochaine HC » ; la\n// batterie pleine ecrete la recharge (on ne stocke pas au-dela de 100 %).\n// Le 30/09 01:25 : SOC 57, prudent 44,7 -> ~3,7 kWh.\n// ---------------------------------------------------------------------\nlet nightVehicleBudgetKwh = null;\nif (nightHcActive && Number.isFinite(socPct)) {\n const maintenant = num(input.nowMs, Date.now());\n const depuis = Math.max(nightHcStartMs, maintenant);\n const jusqua = nightHcStartMs + 24 * 3600000;\n const plein = energyBetweenSoc(socFloorPct, 100);\n let reserve = socPct >= socFloorPct\n ? energyBetweenSoc(socFloorPct, socPct) : -energyBetweenSoc(socPct, socFloorPct);\n let creux = maintenant >= depuis ? reserve : Infinity;\n for (const sl of slotsP80) {\n const debut = num(sl.startMs, NaN);\n if (!Number.isFinite(debut)) continue;\n const a = Math.max(debut, maintenant), b = Math.min(debut + 3600000, jusqua);\n if (b <= a) continue;\n // La tranche qui contient l'ouverture est coupee en deux : le creux ne se\n // lit qu'a partir de l'ouverture.\n const coupes = depuis > a && depuis < b ? [a, depuis, b] : [a, b];\n for (let i = 0; i + 1 < coupes.length; i++) {\n reserve = Math.min(plein, reserve + sl.netKwh * (coupes[i + 1] - coupes[i]) / 3600000);\n if (coupes[i + 1] >= depuis) creux = Math.min(creux, reserve);\n }\n }\n nightVehicleBudgetKwh = Number.isFinite(creux) ? Math.max(0, creux) : null;\n}\n\n// ---------------------------------------------------------------------\n// VEHICULE BRANCHE EN HC AVEC UN DEPART AVANT LA PROCHAINE HC (28/09/2026)\n//\n// Regle du proprietaire : « JEEP branchee engage le reseau en HC midi si\n// l'analyse de production le justifie ». La JEEP repart a 15:30 certains jours\n// (calendrier dans HA) : ce qu'elle n'a pas pris avant, elle ne le prendra pas\n// en HC la nuit. Ailleurs, elle reste branchee et la HC de 01:24 la finit au\n// meme tarif — fermer le contacteur a midi n'y gagnerait rien et etoufferait le\n// solaire (installation sans injection).\n//\n// Ce que le solaire peut donner a la voiture avant son depart SANS toucher a\n// la reserve de la nuit : l'energie de la batterie au-dessus du plancher\n// « tenir jusqu'a la prochaine HC » evalue AU DEPART, plus le bilan net prevu\n// (production - maison) d'ici le depart. Profil p50, comme required_soc.\n// Le reseau est justifie quand le besoin de la voiture, borne par ce que son\n// chargeur peut absorber avant le depart, depasse ce solaire.\n//\n// Besoin = (100 - niveau saisi) % x capacite MESUREE au compteur de la borne,\n// moins ce que la borne a deja delivre depuis la saisie. Aucun capteur ne donne\n// le niveau de la voiture : sans saisie valable, la regle se tait.\n//\n// Vaut pour les TROIS vehicules (JEEP, VW, moto ZERO) : capacite, puissance de\n// charge et calendrier viennent de HA pour le vehicule selectionne.\n// La puissance du chargeur EMBARQUE (plateau mesure : JEEP 3,66 kW, VW\n// 3,68 kW) borne ce que le vehicule peut prendre avant son depart ; inconnue\n// (0), on ne borne pas — l'erreur va alors vers « reseau justifie », c'est-a-\n// dire vers le vehicule charge, jamais vers la batterie videe.\n// Seuil de 0,5 kWh : en dessous une session ne se justifie pas — une fermeture\n// du contacteur coute 50 a 150 Wh de transitoire (§57), on exige un gain d'au\n// moins trois fois ce cout.\nconst VEHICLE_GRID_MIN_SHORTFALL_KWH = 0.5;\nconst vehicleChargerKw = num(input.vehicle_charger_kw, NaN);\nconst nowMsBalance = num(input.nowMs, Date.now());\nconst vehicleConnected = input.vehicle_connected === true || input.vehicle_connected === 'true';\nconst vehicleDepartureMs = num(input.vehicle_departure_ms, NaN);\nconst vehicleLevelPct = num(input.vehicle_level_pct, NaN);\nconst vehicleLevelValid = input.vehicle_level_valid === true || input.vehicle_level_valid === 'true';\nconst vehicleCapacityKwh = num(input.vehicle_capacity_kwh, NaN);\nconst vehicleDeliveredKwh = Math.max(0, num(input.vehicle_delivered_since_level_kwh, 0));\nconst vehicleNeedKwh =\n vehicleLevelValid && Number.isFinite(vehicleLevelPct) && vehicleLevelPct >= 0 && vehicleLevelPct <= 100\n && Number.isFinite(vehicleCapacityKwh) && vehicleCapacityKwh > 0\n ? Math.max(0, (100 - vehicleLevelPct) / 100 * vehicleCapacityKwh - vehicleDeliveredKwh)\n : NaN;\nconst vehicleRuleActive =\n hcReachActive && vehicleConnected && Number.isFinite(vehicleNeedKwh)\n && Number.isFinite(vehicleDepartureMs) && vehicleDepartureMs > nowMsBalance\n && vehicleDepartureMs < nextHcStartMs;\nlet vehicleSolarKwh = null;\nlet vehicleReachableKwh = null;\nlet vehicleShortfallKwh = null;\nlet vehicleFloorAtDeparturePct = null;\nif (vehicleRuleActive) {\n vehicleFloorAtDeparturePct = hcReachFloorPct(slotsP50, nextHcStartMs, vehicleDepartureMs);\n let netKwh = 0;\n for (const sl of slotsP50) {\n const debut = num(sl.startMs, NaN);\n if (!Number.isFinite(debut)) continue;\n const a = Math.max(debut, nowMsBalance);\n const b = Math.min(debut + 3600000, vehicleDepartureMs);\n if (b > a) netKwh += sl.netKwh * (b - a) / 3600000;\n }\n // Difference SIGNEE : une batterie SOUS son plancher doit d'abord etre\n // remontee par ce meme surplus avant que la voiture n'en voie un kWh.\n const plancherDepart = Math.max(socFloorPct, vehicleFloorAtDeparturePct);\n const batterieAuDessusKwh = socPct >= plancherDepart\n ? energyBetweenSoc(plancherDepart, socPct)\n : -energyBetweenSoc(socPct, plancherDepart);\n vehicleSolarKwh = Math.max(0, batterieAuDessusKwh + netKwh);\n vehicleReachableKwh = Number.isFinite(vehicleChargerKw) && vehicleChargerKw > 0\n ? Math.min(vehicleNeedKwh, vehicleChargerKw * (vehicleDepartureMs - nowMsBalance) / 3600000)\n : vehicleNeedKwh;\n vehicleShortfallKwh = Math.max(0, vehicleReachableKwh - vehicleSolarKwh);\n}\nconst vehicleGridNeeded = vehicleRuleActive && vehicleShortfallKwh > VEHICLE_GRID_MIN_SHORTFALL_KWH;\n\n// ---------------------------------------------------------------------\n// PROCHAINE FENÊTRE DE DÉFICIT (ajout du 24/08/2026)\n//\n// Le calcul ci-dessus répond à « combien d'énergie jusqu'à ce que le solaire\n// prenne le relais ». À 15 h, le solaire a déjà pris le relais : la réponse est\n// zéro et le SOC requis retombe sur le plancher. C'est exact mais inutile à\n// cette heure-là, car le déficit qui approche est celui de la nuit.\n//\n// On cherche donc AUSSI la prochaine fenêtre de déficit après le surplus en\n// cours : son heure de début, son énergie, et le SOC qu'il faudra avoir atteint\n// à ce moment-là. C'est cette grandeur qui remplace la cible de nuit fixe, et\n// qui dit combien de surplus il faut avoir stocké avant le coucher du soleil.\n//\n// Profil PRUDENT : contrairement à la décision réseau immédiate, cette\n// projection engage plusieurs heures et n'est pas révisable à volonté — la nuit\n// tombée, il n'y a plus de solaire pour rattraper une sous-estimation.\n// ---------------------------------------------------------------------\nfunction nextDeficitWindow(slots) {\n const firstSurplus = slots.findIndex(s => s.netKwh > 0);\n if (firstSurplus < 0) return { found: false, reason: 'deficit_en_cours' };\n\n const start = slots.findIndex((s, i) => i > firstSurplus && s.netKwh < 0);\n if (start < 0) return { found: false, reason: 'aucun_deficit_sur_horizon' };\n\n let end = slots.findIndex((s, i) => i > start && s.netKwh > 0);\n if (end < 0) end = slots.length;\n\n const energyKwh = slots.slice(start, end)\n .reduce((sum, s) => sum + Math.max(0, -s.netKwh), 0);\n\n // Surplus encore récupérable d'ici là : de quoi remplir, ou pas.\n const surplusBeforeKwh = slots.slice(0, start)\n .reduce((sum, s) => sum + Math.max(0, s.netKwh), 0);\n\n return {\n found: true,\n startHour: slots[start].hour,\n startMs: slots[start].startMs,\n hoursFromNow: start,\n endHour: end < slots.length ? slots[end].hour : null,\n complete: end < slots.length,\n energyKwh,\n // NON écrêté à 100 volontairement : une valeur au-dessus de 100 signifie\n // que même une batterie pleine ne suffit pas à traverser la fenêtre. C'est\n // exactement le signal de sous-dimensionnement du stockage — l'écrêter le\n // masquerait.\n requiredSocAtStartPct: socForEnergy(energyKwh, socFloorPct),\n surplusBeforeKwh,\n };\n}\n\nconst nextDeficit = nextDeficitWindow(prudent.slots);\n\n// ---------------------------------------------------------------------\n// RÉSERVE D'ORAGE (27/08/2026)\n//\n// Les coupures réseau sont fréquentes ici lors des orages. Point de départ\n// souvent mal posé : une coupure n'arrête PAS l'installation. Le solaire et\n// la batterie continuent de fonctionner, seul le filet de secours disparaît.\n// La question n'est donc pas « combien stocker pour survivre » mais « la\n// batterie traverse-t-elle la prochaine fenêtre de déficit SANS le réseau ».\n//\n// C'est exactement la couverture PRUDENTE (p80) déjà calculée plus haut :\n// rien à inventer, aucune constante de plus. Le profil prudent est le bon\n// choix ici parce qu'une coupure retire précisément la possibilité de\n// rattraper une sous-estimation.\n//\n// Le niveau vient de la vigilance Météo-France, dont l'échelle officielle est\n// vert/jaune/orange/rouge. « Actif » à partir d'orange : c'est le seuil de\n// l'émetteur, pas un nombre choisi ici.\n// ---------------------------------------------------------------------\nconst STORM_SCALE = ['vert', 'jaune', 'orange', 'rouge'];\nconst STORM_ACTIVE_FROM = STORM_SCALE.indexOf('orange');\n\nconst stormRaw = typeof input.storm_alert_level === 'string'\n ? input.storm_alert_level.trim().toLowerCase()\n : '';\nconst stormIndex = STORM_SCALE.indexOf(stormRaw);\nconst stormKnown = stormIndex >= 0;\nconst stormActive = stormIndex >= STORM_ACTIVE_FROM;\n\n// Phénomènes atteignant le niveau d'alerte, s'ils sont exposés en attributs\n// (« Orages », « Vent violent »…). Purement informatif.\nconst stormDetail = (input.storm_alert_detail && typeof input.storm_alert_detail === 'object')\n ? input.storm_alert_detail\n : {};\nconst stormPhenomena = Object.keys(stormDetail)\n .filter(k => typeof stormDetail[k] === 'string'\n && STORM_SCALE.indexOf(stormDetail[k].trim().toLowerCase()) >= STORM_ACTIVE_FROM)\n .sort();\n\n// En surplus, la fenêtre qui compte est la prochaine (la nuit qui vient) ;\n// en déficit, c'est celle qu'on est en train de traverser.\nconst inSurplusNow = prudent.found && prudent.crossingIndex === 0;\nconst stormWindow = (inSurplusNow && nextDeficit.found)\n ? {\n kwh: nextDeficit.energyKwh,\n socPct: nextDeficit.requiredSocAtStartPct,\n startHour: nextDeficit.startHour,\n }\n : {\n kwh: prudent.energyToCoverKwh,\n socPct: prudent.requiredSocPct,\n startHour: null,\n };\nconst stormMarginPct = socPct - stormWindow.socPct;\n\nconst slots = typical.slots;\nconst crossingFound = typical.found;\nconst crossingIndex = typical.crossingIndex;\nconst crossing = crossingFound ? slots[crossingIndex] : null;\nconst deficitSlots = typical.deficitSlots;\n\n// ---------------------------------------------------------------------\n// Marge disponible pour le véhicule : surplus attendu après le croisement,\n// une fois la maison servie. C'est ce qui peut être chargé sans toucher au\n// réseau ni entamer la réserve.\n// ---------------------------------------------------------------------\nconst surplusAfterCrossingKwh = crossingFound\n ? slots.slice(crossingIndex).reduce((sum, s) => sum + Math.max(0, s.netKwh), 0)\n : 0;\n\n// ---------------------------------------------------------------------\n// Confiance : la part du calcul qui repose sur une forme de production minorée\n// (après-midi censuré). Elle ne dégrade PAS la réponse « faut-il du réseau »,\n// qui se joue sur les heures de déficit — celles-ci ne sont jamais censurées,\n// une heure censurée étant par construction une heure de surplus.\n// ---------------------------------------------------------------------\nconst deficitOnWeakShape = deficitSlots.filter(s => s.shapeQuality === 'minoree').length;\nconst confidence = {\n deficitHoursOnWeakShape: deficitOnWeakShape,\n deficitTrustworthy: deficitOnWeakShape === 0,\n surplusTrustworthy: !slots.slice(crossingFound ? crossingIndex : 0)\n .some(s => s.shapeQuality === 'minoree'),\n};\n\nreturn [{\n json: {\n usable: true,\n generatedAt: new Date(num(input.nowMs, Date.now())).toISOString(),\n\n crossing: {\n found: crossingFound,\n hour: crossing ? crossing.hour : null,\n startMs: crossing ? crossing.startMs : null,\n hoursFromNow: crossingFound ? crossingIndex : null,\n },\n\n coverage: {\n // Décision réseau immédiate — remplace input_number.victron_grid_soc_off.\n energyToCoverKwh: round2(typical.energyToCoverKwh),\n requiredSocPct: round1(typical.requiredSocPct),\n currentSocPct: round1(socPct),\n socMarginPct: round1(typical.socMarginPct),\n gridNeeded: typical.gridNeeded,\n gridEnergyNeededKwh: round2(typical.gridEnergyNeededKwh),\n\n // Planification (cible de nuit, dimensionnement) — ne pilote PAS le réseau.\n prudent: {\n energyToCoverKwh: round2(prudent.energyToCoverKwh),\n requiredSocPct: round1(prudent.requiredSocPct),\n socMarginPct: round1(prudent.socMarginPct),\n crossingHour: prudent.found ? prudent.slots[prudent.crossingIndex].hour : null,\n },\n },\n\n // Ce qu'il faudra avoir stocké avant la prochaine traversée déficitaire.\n nextDeficit: nextDeficit.found ? {\n found: true,\n startHour: nextDeficit.startHour,\n hoursFromNow: nextDeficit.hoursFromNow,\n endHour: nextDeficit.endHour,\n complete: nextDeficit.complete,\n energyKwh: round2(nextDeficit.energyKwh),\n requiredSocAtStartPct: round1(nextDeficit.requiredSocAtStartPct),\n surplusBeforeKwh: round2(nextDeficit.surplusBeforeKwh),\n // Franchissable avec le stockage actuel, batterie pleine ?\n feasibleWithCurrentStorage: nextDeficit.requiredSocAtStartPct <= 100,\n // Si non : capacité utile manquante. C'est le chiffre du dimensionnement.\n storageShortfallKwh: energyBetweenSoc(100, nextDeficit.requiredSocAtStartPct),\n reachable: nextDeficit.surplusBeforeKwh >= 0\n && socAfterEnergy(socPct, nextDeficit.surplusBeforeKwh)\n >= nextDeficit.requiredSocAtStartPct,\n } : { found: false, reason: nextDeficit.reason },\n\n vehicle: {\n surplusAvailableKwh: round2(surplusAfterCrossingKwh),\n },\n\n // Nuit prevue par la soiree (28/09) : facteur applique au profil.\n nightModel: {\n active: nightModelActive,\n factor: Math.round(nightFactor * 1000) / 1000,\n rawFactor: Number.isFinite(nightFactorRaw) ? nightFactorRaw : null,\n forecastKwh: num(input.night_house_forecast_kwh, NaN) || null,\n },\n\n // Maison sur le reseau pendant la fenetre HC de nuit, si la session s'engage (29/09).\n nightHcImport: {\n active: nightHcActive,\n startMs: nightHcActive ? nightHcStartMs : null,\n endMs: nightHcActive ? nightHcEndMs : null,\n houseKwh: nightHcHouseKwh === null ? null : round2(nightHcHouseKwh),\n },\n\n // Ce que la voiture peut prendre sur la batterie cette nuit sans EDF (30/09).\n nightVehicleBudget: {\n active: nightVehicleBudgetKwh !== null,\n kwh: nightVehicleBudgetKwh === null ? null : round2(nightVehicleBudgetKwh),\n },\n\n // Voiture branchee en HC, depart avant la prochaine HC (28/09).\n vehicleMidday: {\n active: vehicleRuleActive,\n needKwh: Number.isFinite(vehicleNeedKwh) ? round2(vehicleNeedKwh) : null,\n departureMs: Number.isFinite(vehicleDepartureMs) ? vehicleDepartureMs : null,\n solarKwh: vehicleSolarKwh === null ? null : round2(vehicleSolarKwh),\n reachableKwh: vehicleReachableKwh === null ? null : round2(vehicleReachableKwh),\n shortfallKwh: vehicleShortfallKwh === null ? null : round2(vehicleShortfallKwh),\n floorAtDeparturePct: vehicleFloorAtDeparturePct === null ? null : round1(vehicleFloorAtDeparturePct),\n gridNeeded: vehicleGridNeeded,\n },\n\n // Tenir jusqu'a la prochaine fenetre HC (28/09) : SOC a avoir en fin de\n // fenetre HC en cours. 0 hors fenetre ou si la production suffit.\n hcReach: {\n active: hcReachActive,\n windowEndMs: hcReachActive ? hcWindowEndMs : null,\n nextHcStartMs: hcReachActive ? nextHcStartMs : null,\n floorPct: round1(Math.min(100, hcFloorTypicalPct)),\n floorPrudentPct: round1(Math.min(100, hcFloorPrudentPct)),\n nextNightHcStartMs: hcNightReachActive ? nextNightHcStartMs : null,\n nightFloorPrudentPct: round1(Math.min(100, hcFloorNightPrudentPct)),\n raisesRequirement: hcReachActive && hcFloorTypicalPct >= typical.requiredSocPct - 0.05\n && hcFloorTypicalPct > socForEnergy(typical.energyToCoverKwh, socFloorPct),\n },\n\n // Réserve à tenir pour traverser SANS réseau la fenêtre de déficit qui\n // compte. Publiée en permanence — elle documente le risque même par temps\n // calme ; `active` dit seulement si la vigilance la rend d'actualité.\n storm: {\n level: stormKnown ? stormRaw : 'indisponible',\n known: stormKnown,\n active: stormActive,\n phenomena: stormPhenomena,\n windowStartHour: stormWindow.startHour,\n reserveKwh: round2(stormWindow.kwh),\n reserveSocPct: round1(stormWindow.socPct),\n socMarginPct: round1(stormMarginPct),\n covered: stormMarginPct >= 0,\n shortfallKwh: stormMarginPct < 0\n ? round2(energyBetweenSoc(socPct, stormWindow.socPct))\n : 0,\n },\n\n confidence,\n slots,\n\n inputs: {\n usableCapacityKwh: round2(usableCapacityKwh),\n socFloorPct: round1(socFloorPct),\n },\n\n cfg: CFG,\n },\n}];\n" + "jsCode": "// EVCS Energy Balance — BALANCE\n// =====================================================================\n// Rôle : produire les FAITS qui remplacent les seuils fixes. Rien d'autre.\n// Ce nœud ne pilote ni EDF, ni la borne : il publie des grandeurs que les deux\n// contrôleurs liront à la place de leurs constantes.\n//\n// La question à laquelle il répond n'est pas « le SOC est-il au-dessus de\n// 30 % ? » mais « la batterie contient-elle de quoi tenir jusqu'à ce que le\n// solaire prenne le relais ? ». Le 24/08/2026 à 06:43, la réponse du seuil fixe\n// et celle du bilan diffèrent : la production ne couvrait la maison qu'à partir\n// de 09 h, il fallait donc environ 0,8 kWh de réserve, soit ~5 points de SOC —\n// et non un palier arbitraire à 30 %.\n//\n// Entrées (toutes fournies, aucune constante physique en dur) :\n// consumption_p50[24] profil horaire mesuré, valeur typique (CONSUMPTION_PROFILE)\n// consumption_p80[24] idem, valeur prudente\n// horizon[] production attendue heure par heure (RESOURCE_ESTIMATE)\n// soc_percent SOC lissé pour le pilotage\n// usable_capacity_kwh capacité réellement disponible du parc batterie\n// soc_floor_percent plancher dur de sécurité\n//\n// Sorties : heure de croisement · énergie à couvrir · SOC requis · marge\n// disponible pour le véhicule · niveau de confiance.\n// =====================================================================\n\nfunction num(v, fallback = NaN) {\n const n = Number.parseFloat(v);\n return Number.isFinite(n) ? n : fallback;\n}\n\nfunction round1(v) {\n return Number.isFinite(v) ? Math.round(v * 10) / 10 : null;\n}\n\nfunction round2(v) {\n return Number.isFinite(v) ? Math.round(v * 100) / 100 : null;\n}\n\nconst input = $input.first().json;\n\nconst CFG = {\n TIMEZONE: 'Europe/Paris',\n // Au-delà de cet horizon sans croisement trouvé, on cesse de chercher : la\n // réponse n'aurait plus de sens opérationnel.\n SEARCH_HOURS: 40,\n // PAS de marge de sécurité supplémentaire. La prudence est déjà portée,\n // entièrement, par le choix du percentile du profil de consommation (p80),\n // qui est mesuré. Vérifié le 24/08/2026 : sur toutes les fenêtres testées,\n // p80(somme des heures) est égal a somme(p80 par heure) — les heures de\n // consommation sont fortement corrélées, une matinée chargée l'étant sur\n // plusieurs heures d'affilée. Sommer les p80 ne cumule donc PAS deux\n // pessimismes, et ajouter un coefficient par-dessus ne serait qu'une\n // constante arbitraire de plus.\n};\n\nfunction profileOf(key) {\n const a = input[key];\n return Array.isArray(a) && a.length === 24 ? a.map(v => Math.max(0, num(v, 0))) : null;\n}\nconst consumptionP50 = profileOf('consumption_p50');\nconst consumptionP80 = profileOf('consumption_p80');\n\nconst horizon = Array.isArray(input.horizon) ? input.horizon : null;\n\nconst socPct = num(input.soc_percent, NaN);\nconst usableCapacityKwh = num(input.usable_capacity_kwh, NaN);\nconst socFloorPct = num(input.soc_floor_percent, 22);\n\n// =====================================================\n// CHARGE HC MANUELLE — UN PLANCHER, PAS UNE CIBLE (18/09/2026)\n// =====================================================\n// Le proprietaire veut pouvoir demander une recharge batterie en heures creuses\n// quand le bilan ne l'a pas jugee necessaire — journee d'hiver courte, ciel\n// couvert. La decision qu'il a tranchee : CONTRAINDRE le bilan plutot que de\n// l'ignorer, pour garder un seul arbitre. Ce projet a deja paye cher les\n// automates concurrents (charges programmees de l'ESS §26, consigne a 4200 W\n// §27, plancher SOC du GX §27).\n//\n// ⚠️ LE FORCAGE DOIT ETRE ICI, EN AMONT, ET NULLE PART AILLEURS. Mesure du\n// 18/09 sur banc : ecrire directement dans `input_number.evcs_balance_required_soc`\n// engage bien la session — contacteur ferme, takeover ON — mais laisse la\n// consigne reseau a 700 W, donc la batterie recoit une centaine de watts. C'est\n// le defaut du 01/09 et du 04/09 a l'identique, et c'est le seul resultat des\n// trois qui soit INDIAGNOSTICABLE depuis le tableau de bord : il a l'air de\n// marcher. La raison : `required_soc` ENGAGE la session, `grid_energy_needed_kwh`\n// la FINANCE, et les deux sont publiees separement. Pose ici, dans `coverage()`,\n// le plancher fait bouger les deux ENSEMBLE par construction — c'est une\n// consequence de la forme du code et non une discipline a tenir.\n//\n// ⚠️ ET CE PLANCHER EST DEJA FILTRE PAR L'HEURE QUAND IL ARRIVE ICI. Le bilan\n// ignore totalement les fenetres d'heures creuses (aucune occurrence de\n// `hc_night` / `hc_midday` dans ce sous-systeme) et doit le rester : il repond a\n// « de combien la batterie a-t-elle besoin », pas a « quand est-ce bon marche ».\n// C'est Home Assistant qui detient le calendrier tarifaire et qui ne publie une\n// valeur non nulle que pendant une fenetre creuse. Sans ce filtrage en amont, un\n// plancher a 85 % fuirait dans la journee et mordrait sur\n// `BATTERY_CLOUD_LOCKS.js` — `eveningSocStartFloorPct = max(eveningSocStartMin,\n// balanceRequiredSocPct)` — au point de bloquer la charge du vehicule le soir.\nconst manualHcFloorPct = (() => {\n const v = num(input.manual_hc_floor_percent, 0);\n return Number.isFinite(v) && v > socFloorPct && v <= 100 ? v : 0;\n})();\n\n// Sans l'une de ces entrées, le bilan ne peut rien affirmer. Il le dit, et les\n// contrôleurs retombent sur leurs règles historiques.\nconst missing = [];\nif (!consumptionP50) missing.push('consumption_p50');\nif (!consumptionP80) missing.push('consumption_p80');\nif (!horizon || !horizon.length) missing.push('horizon');\nif (!Number.isFinite(socPct)) missing.push('soc_percent');\nif (!Number.isFinite(usableCapacityKwh) || usableCapacityKwh <= 0) missing.push('usable_capacity_kwh');\n\nif (missing.length) {\n return [{\n json: {\n usable: false,\n missing,\n reason: `Bilan indisponible : ${missing.join(', ')}. Les contrôleurs doivent conserver leurs règles.`,\n cfg: CFG,\n },\n }];\n}\n\n// ---------------------------------------------------------------------\n// Bilan heure par heure, calculé DEUX FOIS.\n//\n// Pourquoi deux niveaux de prudence et pas un seul : la décision « faut-il du\n// réseau maintenant ? » est réévaluée à chaque cycle et reste réversible. Se\n// tromper par optimisme ne coûte qu'un démarrage réseau une heure plus tard,\n// le plancher dur garantissant la batterie ; se tromper par excès de prudence\n// coûte un import réseau bien réel, immédiatement.\n//\n// Vérifié sur la matinée du 24/08/2026 : avec le profil prudent, le bilan\n// exigerait 48 % de SOC pour franchir la matinée, alors que la consommation\n// réelle de ce jour-là n'a demandé que 0,8 kWh — le seuil fixe de 30 % aurait\n// été MOINS coûteux. Un bilan « intelligent » plus gourmand que le seuil bête\n// qu'il remplace n'a aucun intérêt.\n//\n// - profil TYPIQUE (p50) → décision réseau immédiate, réévaluée en continu.\n// - profil PRUDENT (p80) → planification : cible de nuit, dimensionnement\n// du stockage, choix qui engagent plusieurs heures.\n// ---------------------------------------------------------------------\n// ---------------------------------------------------------------------\n// LA NUIT PREVUE PAR LA SOIREE (28/09/2026, demande du proprietaire)\n//\n// Le profil p50 est une mediane HORAIRE : il ignore la soiree qui vient de se\n// passer. Or sur 24 nuits, la maison consomme d'autant plus de 21:30 a 09:00\n// que la soiree 18:00-21:30 a ete chargee (four) : nuit = 6,98 + 0,74 x soiree,\n// r2 = 0,40. Le service d'historisation publie le FACTEUR nuit prevue / nuit\n// moyenne (sensor.evcs_nuit_maison_prevue_kwh) ; on multiplie le profil par ce\n// facteur sur la seule fenetre 21:30 -> 09:00, au prorata des heures a cheval.\n// Le 28/09 : soiree 6,27 kWh -> facteur 1,09.\n//\n// Un FACTEUR et non des kWh : le profil vient des compteurs d'energie, la\n// regression des puissances integrees ; le rapport annule l'ecart de methode.\n// Dans les deux sens pour le profil TYPIQUE (c'est un estimateur, une soiree\n// legere allege la nuit) ; le PRUDENT ne descend jamais : max(p80, p50 recale).\n// Filet : facteur borne a [0,75 ; 1,45], les extremes des 24 nuits observees\n// (0,77 et 1,41 fois la moyenne) — hors de la, c'est une mesure fausse, pas une\n// nuit. Facteur absent, perime (> 30 min) ou hors fenetre : profil inchange.\nconst NIGHT_FACTOR_MIN = 0.75;\nconst NIGHT_FACTOR_MAX = 1.45;\nconst nowMsNight = num(input.nowMs, Date.now());\nconst nightStartMs = num(input.night_house_start_ms, NaN);\nconst nightEndMs = num(input.night_house_end_ms, NaN);\nconst nightFactorRaw = num(input.night_house_factor, NaN);\nconst nightComputedMs = num(input.night_house_computed_ms, NaN);\nconst nightModelActive =\n Number.isFinite(nightFactorRaw) && nightFactorRaw > 0 &&\n Number.isFinite(nightStartMs) && Number.isFinite(nightEndMs) && nightEndMs > nightStartMs &&\n nowMsNight >= nightStartMs && nowMsNight < nightEndMs &&\n Number.isFinite(nightComputedMs) && nowMsNight - nightComputedMs <= 30 * 60000;\nconst nightFactor = nightModelActive\n ? Math.min(NIGHT_FACTOR_MAX, Math.max(NIGHT_FACTOR_MIN, nightFactorRaw))\n : 1;\nfunction nightShare(startMs) {\n const debut = num(startMs, NaN);\n if (!nightModelActive || !Number.isFinite(debut)) return 0;\n const a = Math.max(debut, nightStartMs), b = Math.min(debut + 3600000, nightEndMs);\n return b > a ? (b - a) / 3600000 : 0;\n}\nfunction nightScaled(profile, slot) {\n const h = num(slot.hour, 0);\n return (profile[h] ?? 0) * (1 + (nightFactor - 1) * nightShare(slot.startMs));\n}\n\nfunction buildSlots(consumption, prudent = false) {\n return horizon.slice(0, CFG.SEARCH_HOURS).map(slot => {\n const hour = num(slot.hour, 0);\n const productionKwh = Math.max(0, num(slot.expectedKwh, 0));\n const consumptionKwh = prudent\n ? Math.max(consumption[hour] ?? 0, nightScaled(consumptionP50, slot))\n : nightScaled(consumption, slot);\n return {\n startMs: slot.startMs,\n hour,\n productionKwh: round2(productionKwh),\n consumptionKwh: round2(consumptionKwh),\n netKwh: round2(productionKwh - consumptionKwh),\n shapeQuality: slot.shapeQuality || 'inconnue',\n };\n });\n}\n\n// =====================================================================\n// COURBE D'ENERGIE DE LA BATTERIE — MESUREE, PAS SUPPOSEE (03/09/2026)\n// =====================================================================\n// La conversion energie <-> points de SOC etait une CONSTANTE\n// (`usable_capacity_kwh`, 15 kWh) la ou la realite est une COURBE.\n//\n// Mesure : 576 paliers de SOC sur 8 jours (`tools/mesure-courbe-batterie.mjs`).\n// Pour chaque palier ou le SOC reste a une valeur puis DESCEND d'un point, on\n// integre la puissance batterie — en ecartant les paliers ou la batterie a\n// charge ou ou le reseau a apporte quoi que ce soit.\n//\n// tranche paliers kWh/point\n// 21-31 88 0,097 <- effondrement pres du genou\n// 35-44 78 0,171\n// 45-54 76 0,169\n// 55-64 85 0,163\n// 65-74 80 0,171\n// 75-84 81 0,170\n// 85-94 62 0,169\n//\n// Plate a 0,169 de 33 a 93, genou net a 32-33, puis 0,097 : sous le genou, un\n// point de SOC vaut 57 % de ce qu'il vaut au-dessus. Coherent avec §0 (un\n// module en panne sur huit, un autre qui faiblit) et avec l'effondrement de la\n// jauge deja signale au §28 — ceci lui donne sa courbe.\n//\n// CE QUE LA CONSTANTE FAISAIT : 15 kWh se situe ENTRE les deux pentes, donc\n// l'erreur CHANGE DE SIGNE selon l'energie a couvrir —\n// 1 kWh a couvrir : SOC requis 28,7 % annonce contre 32,3 % reel (-3,6)\n// 4,3 kWh : 50,7 contre 52,1 (-1,4)\n// 8 kWh : 75,3 contre 74,0 (+1,3)\n// 11,7 kWh : 99,7 contre 95,7 (+4,1)\n// Trop BAS en fin de nuit (la direction dangereuse), trop HAUT en debut.\n//\n// Energie utile totale de 100 a 22 % : 12,39 kWh mesures, contre 11,70 vus par\n// le bilan et 13,65 par INPUT_PARSE. Les deux constantes existantes etaient\n// fausses, dans des sens opposes.\n//\n// ⚠️ A RE-MESURER apres toute evolution du parc (extension prevue cet hiver) :\n// `node tools/mesure-courbe-batterie.mjs 8`, puis mettre a jour ces trois\n// nombres. Ce ne sont pas des reglages, ce sont des resultats de mesure.\n//\n// ⚠️ CES PENTES ONT ETE MESUREES SUR SEPT MODULES. Retirer ou ajouter un module\n// divise ou multiplie la capacite sans changer la FORME de la courbe : on les\n// exprime donc par module, et on les multiplie par le nombre reellement en\n// service. Ce nombre se lit dans un helper — `input_number\n// .evcs_balance_modules_en_service` — et non dans le code : le parc a change\n// deux fois en une semaine et changera encore cet hiver. Helper absent ou\n// illisible => 7, la valeur sur laquelle la mesure a ete faite, donc aucun\n// changement de comportement tant que personne ne l'ecrit.\nconst MODULES_MESURE = 7;\nconst modulesEnService = (() => {\n const n = num(input.modules_en_service, MODULES_MESURE);\n return Number.isFinite(n) && n >= 1 && n <= 24 ? n : MODULES_MESURE;\n})();\nconst echelleParc = modulesEnService / MODULES_MESURE;\n\n// 20/09/2026 — RE-MESURE sur le parc ASSAINI (13 equivalents US2000 : 4 US5000\n// + 5 US2000C, cellules a 3-6 mV). `tools/mesure-courbe-batterie.mjs 8` :\n// 0,30 kWh/point PLAT de 25 a 97 %, PLUS DE GENOU. Le genou a 33 % etait bien\n// la signature de la cellule 12 morte du pack ancien (§50) — il a disparu avec\n// elle, exactement comme le predisait la note de prudence du 03/09. La reserve\n// reelle a quasiment double (12,4 -> ~23 kWh utiles de 100 a 22 %).\n// Base normalisee a 7 modules (MODULES_MESURE) : 0,30 x 7/13 = 0,1615 par point.\n// x echelleParc(13/7) = 0,30 au parc actuel. Pente unique => plus de genou.\n// A REMESURER apres toute evolution du parc (2e cable console en commande pour\n// surveiller aussi les cellules du pack 1 US5000).\nconst BATTERY_CURVE = {\n kneePct: 0,\n kwhPerPointAbove: 0.1615 * echelleParc,\n kwhPerPointBelow: 0.1615 * echelleParc,\n};\n\n// Energie que la batterie peut delivrer en descendant de socHi a socLo.\nfunction energyBetweenSoc(socLo, socHi) {\n if (!Number.isFinite(socLo) || !Number.isFinite(socHi) || socHi <= socLo) return 0;\n const knee = BATTERY_CURVE.kneePct;\n const lowSpan = Math.max(0, Math.min(socHi, knee) - Math.min(socLo, knee));\n const highSpan = Math.max(0, Math.max(socHi, knee) - Math.max(socLo, knee));\n return lowSpan * BATTERY_CURVE.kwhPerPointBelow\n + highSpan * BATTERY_CURVE.kwhPerPointAbove;\n}\n\n// SOC necessaire pour delivrer energyKwh en descendant jusqu'a floorPct.\n// NON ecrete : au-dela de 100 la valeur reste le signal de sous-dimensionnement\n// (cf. §26). L'extrapolation au-dessus de 100 utilise la pente haute.\nfunction socForEnergy(energyKwh, floorPct) {\n if (!Number.isFinite(energyKwh) || energyKwh <= 0) return floorPct;\n const knee = Math.max(floorPct, BATTERY_CURVE.kneePct);\n const belowKwh = (knee - floorPct) * BATTERY_CURVE.kwhPerPointBelow;\n if (energyKwh <= belowKwh) {\n return floorPct + energyKwh / BATTERY_CURVE.kwhPerPointBelow;\n }\n return knee + (energyKwh - belowKwh) / BATTERY_CURVE.kwhPerPointAbove;\n}\n\n// SOC atteint en ajoutant addKwh a partir de socPct.\nfunction socAfterEnergy(socPct, addKwh) {\n if (!Number.isFinite(addKwh) || addKwh <= 0) return socPct;\n return socForEnergy(energyBetweenSoc(0, socPct) + addKwh, 0);\n}\n\n// =====================================================\n// TENIR JUSQU'A LA PROCHAINE FENETRE HC (28/09/2026)\n// =====================================================\n// Strategie du proprietaire, apres la nuit du 26-27/09 : P12 avait charge la\n// JEEP sur la batterie (72 -> 44 %) parce qu'il restait assez pour atteindre le\n// solaire du matin — alors que ce meme bilan annoncait deja « prochaine\n// traversee 17,35 kWh des 18 h -> SOC requis 79,8 % ». La journee (22 kWh reels)\n// n'a pas pu reconstituer la reserve. Sa regle : une voiture branchee se charge\n// en heures creuses sur le reseau, et la batterie doit TENIR JUSQU'A LA\n// PROCHAINE FENETRE HC ; si elle ne le peut pas, la fenetre HC de midi la\n// complete.\n//\n// Traduction : PENDANT une fenetre HC, l'exigence vaut au moins le SOC qu'il\n// faut avoir A LA FIN de cette fenetre pour atteindre le DEBUT de la suivante\n// sans passer sous le plancher, production prevue comprise (tranches horaires\n// ci-dessus, heure par heure, profil de conso du profil calcule).\n//\n// ⚠️ LE CALENDRIER RESTE DANS HOME ASSISTANT (§73) : le bilan ne recoit que deux\n// instants, la fin de la fenetre en cours (absente hors fenetre) et le debut de\n// la suivante. Hors fenetre HC, rien ne change.\n//\n// ⚠️ INSERE DANS coverage(), COMME LE PLANCHER MANUEL : la cible (required_soc,\n// qui ENGAGE la session et releve le plancher de P12) et le manque en kWh (qui\n// FINANCE la consigne) bougent ensemble par construction — jamais de session\n// muette (§73). Sens unique : il ne peut que RELEVER l'exigence.\nconst hcWindowEndMs = num(input.hc_window_end_ms, NaN);\nconst nextHcStartMs = num(input.next_hc_start_ms, NaN);\nconst nextNightHcStartMs = num(input.next_night_hc_start_ms, NaN);\nconst hcReachActive = Number.isFinite(hcWindowEndMs) && Number.isFinite(nextHcStartMs)\n && nextHcStartMs > hcWindowEndMs;\n// DEUX HORIZONS, DEUX USAGES (decision du proprietaire, 28/09) :\n// - recharger la batterie au reseau : tenir jusqu'a la PROCHAINE fenetre HC\n// (la nuit : midi ; a midi : 01:24). Midi complete si besoin, et la matinee\n// garde de la place pour le solaire. -> required_soc (engage ET finance).\n// - laisser la voiture puiser dans la batterie la nuit (P12) : seulement si la\n// batterie tient ensuite jusqu'a la NUIT SUIVANTE (01:24), sans compter sur\n// midi. Sinon la voiture se charge sur le reseau en HC. -> required_soc_prudent,\n// qui n'est lu que comme plancher de P12 (et par le conseiller, pour info).\nconst hcNightReachActive = Number.isFinite(hcWindowEndMs) && Number.isFinite(nextNightHcStartMs)\n && nextNightHcStartMs > hcWindowEndMs;\n\n// Energie a avoir en reserve a la fin de la fenetre : parcours A REBOURS des\n// tranches comprises entre les deux instants (au prorata de leur recouvrement).\n// Un surplus ne rembourse qu'un deficit POSTERIEUR — d'ou le sens du parcours.\nfunction hcReachFloorPct(slots, untilMs = nextHcStartMs, fromMs = hcWindowEndMs) {\n if (!Number.isFinite(fromMs) || !Number.isFinite(untilMs) || untilMs <= fromMs) return 0;\n let needKwh = 0;\n for (let i = slots.length - 1; i >= 0; i--) {\n const s = slots[i];\n const debut = num(s.startMs, NaN);\n if (!Number.isFinite(debut)) continue;\n const a = Math.max(debut, fromMs);\n const b = Math.min(debut + 3600000, untilMs);\n if (b <= a) continue;\n needKwh = Math.max(0, needKwh - s.netKwh * (b - a) / 3600000);\n }\n return needKwh > 0 ? socForEnergy(needKwh, socFloorPct) : 0;\n}\n\nfunction coverage(slots, hcFloorPct = 0) {\n const crossingIndex = slots.findIndex(s => s.netKwh > 0);\n const found = crossingIndex >= 0;\n const deficitSlots = found ? slots.slice(0, crossingIndex) : slots;\n const energyToCoverKwh = deficitSlots.reduce((sum, s) => sum + Math.max(0, -s.netKwh), 0);\n // Le plancher manuel ne peut que RELEVER l'exigence, jamais l'abaisser — meme\n // sens unique que les cablages de §38, §39 et §40 : le bilan ne pousse jamais\n // dans la direction « couvre moins bien ».\n const requiredSocRawPct = Math.max(\n socForEnergy(energyToCoverKwh, socFloorPct),\n manualHcFloorPct,\n hcFloorPct\n );\n const socNeededPct = requiredSocRawPct - socFloorPct;\n const requiredSocPct = Math.min(100, requiredSocRawPct);\n const socMarginPct = socPct - requiredSocPct;\n return {\n crossingIndex, found, deficitSlots, slots,\n energyToCoverKwh, socNeededPct, requiredSocPct, socMarginPct,\n gridNeeded: socMarginPct < 0,\n gridEnergyNeededKwh: socMarginPct < 0 ? energyBetweenSoc(socPct, requiredSocPct) : 0,\n };\n}\n\nconst slotsP50 = buildSlots(consumptionP50);\nconst slotsP80 = buildSlots(consumptionP80, true);\nconst hcFloorTypicalPct = hcReachFloorPct(slotsP50, nextHcStartMs);\nconst hcFloorNightPrudentPct = hcNightReachActive ? hcReachFloorPct(slotsP80, nextNightHcStartMs) : 0;\nconst hcFloorPrudentPct = Math.max(hcReachFloorPct(slotsP80, nextHcStartMs), hcFloorNightPrudentPct);\nconst typical = coverage(slotsP50, hcFloorTypicalPct);\nconst prudent = coverage(slotsP80, hcFloorPrudentPct);\n\n// ---------------------------------------------------------------------\n// CE QUE LA SESSION DE NUIT ACHETERA AU RESEAU (29/09/2026)\n//\n// Incident du 28/09 : la prevision annoncait « 0,14 kWh a prendre sur EDF » et\n// le reseau en a fourni 4,6. Les deux etaient justes, mais le premier est le\n// MANQUE de la batterie : une fois engagee, la session tient la batterie et la\n// maison passe TOUTE la fenetre HC de nuit sur le reseau (§40). Le chiffre qui\n// repond a « combien vais-je acheter » est donc la maison non couverte par le\n// solaire sur la fenetre (profil habituel, recale sur la soiree), depuis\n// maintenant si la fenetre est ouverte. Le manque batterie s'y ajoute, a part.\n// ---------------------------------------------------------------------\nconst nightHcStartMs = num(input.night_hc_start_ms, NaN);\nconst nightHcEndMs = num(input.night_hc_end_ms, NaN);\nconst nightHcActive = Number.isFinite(nightHcStartMs) && Number.isFinite(nightHcEndMs)\n && nightHcEndMs > nightHcStartMs;\nlet nightHcHouseKwh = null;\nif (nightHcActive) {\n const depuis = Math.max(nightHcStartMs, num(input.nowMs, Date.now()));\n nightHcHouseKwh = 0;\n for (const sl of slotsP50) {\n const debut = num(sl.startMs, NaN);\n if (!Number.isFinite(debut)) continue;\n const a = Math.max(debut, depuis), b = Math.min(debut + 3600000, nightHcEndMs);\n if (b > a) nightHcHouseKwh += Math.max(0, -sl.netKwh) * (b - a) / 3600000;\n }\n}\n\n// ---------------------------------------------------------------------\n// BUDGET VOITURE DE LA NUIT (30/09/2026, decisions du proprietaire)\n//\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. » Choix du\n// 30/09 : profil PRUDENT (p80) et plancher du bilan (22 %) ; par defaut « sans\n// EDF » ; en charge complete, le budget batterie d'abord, EDF pour le reste.\n//\n// Budget = la plus petite reserve au-dessus du plancher que la batterie\n// connaitra entre l'ouverture de la fenetre HC de nuit (ou maintenant si elle\n// est ouverte) et le debut de la fenetre de nuit SUIVANTE, sur le profil\n// prudent. Retirer X kWh a l'ouverture abaisse toute la trajectoire de X :\n// le budget est donc le creux de la trajectoire. L'horizon jusqu'a la nuit\n// suivante applique « la batterie tient jusqu'a la prochaine HC » ; la\n// batterie pleine ecrete la recharge (on ne stocke pas au-dela de 100 %).\n// Le 30/09 01:25 : SOC 57, prudent 44,7 -> ~3,7 kWh.\n// ---------------------------------------------------------------------\nlet nightVehicleBudgetKwh = null;\nif (nightHcActive && Number.isFinite(socPct)) {\n const maintenant = num(input.nowMs, Date.now());\n const depuis = Math.max(nightHcStartMs, maintenant);\n const jusqua = nightHcStartMs + 24 * 3600000;\n const plein = energyBetweenSoc(socFloorPct, 100);\n let reserve = socPct >= socFloorPct\n ? energyBetweenSoc(socFloorPct, socPct) : -energyBetweenSoc(socPct, socFloorPct);\n let creux = maintenant >= depuis ? reserve : Infinity;\n for (const sl of slotsP80) {\n const debut = num(sl.startMs, NaN);\n if (!Number.isFinite(debut)) continue;\n const a = Math.max(debut, maintenant), b = Math.min(debut + 3600000, jusqua);\n if (b <= a) continue;\n // La tranche qui contient l'ouverture est coupee en deux : le creux ne se\n // lit qu'a partir de l'ouverture.\n const coupes = depuis > a && depuis < b ? [a, depuis, b] : [a, b];\n for (let i = 0; i + 1 < coupes.length; i++) {\n reserve = Math.min(plein, reserve + sl.netKwh * (coupes[i + 1] - coupes[i]) / 3600000);\n if (coupes[i + 1] >= depuis) creux = Math.min(creux, reserve);\n }\n }\n // BORNE PAR LE PLANCHER PRUDENT (04/10/2026). Nuit du 03-04/10 : SOC 47,\n // prudent 43,6 -> ~1,0 kWh de marge, mais le creux a donne 1,41 kWh. La\n // voiture a pris toute la marge, la batterie a touche la cible du matin a\n // 02:57 et la reprise reseau a ferme EDF. Le budget ne peut donc jamais\n // depasser l'energie au-dessus du SOC requis PRUDENT, celui-la meme que la\n // voiture n'a pas le droit de franchir en assistance batterie (P12) : les\n // deux criteres sont desormais les memes par construction.\n const margePrudente = Number.isFinite(prudent.requiredSocPct)\n ? (socPct > prudent.requiredSocPct ? energyBetweenSoc(prudent.requiredSocPct, socPct) : 0)\n : 0;\n nightVehicleBudgetKwh = Number.isFinite(creux) ? Math.max(0, Math.min(creux, margePrudente)) : null;\n}\n\n// ---------------------------------------------------------------------\n// VEHICULE BRANCHE EN HC AVEC UN DEPART AVANT LA PROCHAINE HC (28/09/2026)\n//\n// Regle du proprietaire : « JEEP branchee engage le reseau en HC midi si\n// l'analyse de production le justifie ». La JEEP repart a 15:30 certains jours\n// (calendrier dans HA) : ce qu'elle n'a pas pris avant, elle ne le prendra pas\n// en HC la nuit. Ailleurs, elle reste branchee et la HC de 01:24 la finit au\n// meme tarif — fermer le contacteur a midi n'y gagnerait rien et etoufferait le\n// solaire (installation sans injection).\n//\n// Ce que le solaire peut donner a la voiture avant son depart SANS toucher a\n// la reserve de la nuit : l'energie de la batterie au-dessus du plancher\n// « tenir jusqu'a la prochaine HC » evalue AU DEPART, plus le bilan net prevu\n// (production - maison) d'ici le depart. Profil p50, comme required_soc.\n// Le reseau est justifie quand le besoin de la voiture, borne par ce que son\n// chargeur peut absorber avant le depart, depasse ce solaire.\n//\n// Besoin = (100 - niveau saisi) % x capacite MESUREE au compteur de la borne,\n// moins ce que la borne a deja delivre depuis la saisie. Aucun capteur ne donne\n// le niveau de la voiture : sans saisie valable, la regle se tait.\n//\n// Vaut pour les TROIS vehicules (JEEP, VW, moto ZERO) : capacite, puissance de\n// charge et calendrier viennent de HA pour le vehicule selectionne.\n// La puissance du chargeur EMBARQUE (plateau mesure : JEEP 3,66 kW, VW\n// 3,68 kW) borne ce que le vehicule peut prendre avant son depart ; inconnue\n// (0), on ne borne pas — l'erreur va alors vers « reseau justifie », c'est-a-\n// dire vers le vehicule charge, jamais vers la batterie videe.\n// Seuil de 0,5 kWh : en dessous une session ne se justifie pas — une fermeture\n// du contacteur coute 50 a 150 Wh de transitoire (§57), on exige un gain d'au\n// moins trois fois ce cout.\nconst VEHICLE_GRID_MIN_SHORTFALL_KWH = 0.5;\nconst vehicleChargerKw = num(input.vehicle_charger_kw, NaN);\nconst nowMsBalance = num(input.nowMs, Date.now());\nconst vehicleConnected = input.vehicle_connected === true || input.vehicle_connected === 'true';\nconst vehicleDepartureMs = num(input.vehicle_departure_ms, NaN);\nconst vehicleLevelPct = num(input.vehicle_level_pct, NaN);\nconst vehicleLevelValid = input.vehicle_level_valid === true || input.vehicle_level_valid === 'true';\nconst vehicleCapacityKwh = num(input.vehicle_capacity_kwh, NaN);\nconst vehicleDeliveredKwh = Math.max(0, num(input.vehicle_delivered_since_level_kwh, 0));\nconst vehicleNeedKwh =\n vehicleLevelValid && Number.isFinite(vehicleLevelPct) && vehicleLevelPct >= 0 && vehicleLevelPct <= 100\n && Number.isFinite(vehicleCapacityKwh) && vehicleCapacityKwh > 0\n ? Math.max(0, (100 - vehicleLevelPct) / 100 * vehicleCapacityKwh - vehicleDeliveredKwh)\n : NaN;\nconst vehicleRuleActive =\n hcReachActive && vehicleConnected && Number.isFinite(vehicleNeedKwh)\n && Number.isFinite(vehicleDepartureMs) && vehicleDepartureMs > nowMsBalance\n && vehicleDepartureMs < nextHcStartMs;\nlet vehicleSolarKwh = null;\nlet vehicleReachableKwh = null;\nlet vehicleShortfallKwh = null;\nlet vehicleFloorAtDeparturePct = null;\nif (vehicleRuleActive) {\n vehicleFloorAtDeparturePct = hcReachFloorPct(slotsP50, nextHcStartMs, vehicleDepartureMs);\n let netKwh = 0;\n for (const sl of slotsP50) {\n const debut = num(sl.startMs, NaN);\n if (!Number.isFinite(debut)) continue;\n const a = Math.max(debut, nowMsBalance);\n const b = Math.min(debut + 3600000, vehicleDepartureMs);\n if (b > a) netKwh += sl.netKwh * (b - a) / 3600000;\n }\n // Difference SIGNEE : une batterie SOUS son plancher doit d'abord etre\n // remontee par ce meme surplus avant que la voiture n'en voie un kWh.\n const plancherDepart = Math.max(socFloorPct, vehicleFloorAtDeparturePct);\n const batterieAuDessusKwh = socPct >= plancherDepart\n ? energyBetweenSoc(plancherDepart, socPct)\n : -energyBetweenSoc(socPct, plancherDepart);\n vehicleSolarKwh = Math.max(0, batterieAuDessusKwh + netKwh);\n vehicleReachableKwh = Number.isFinite(vehicleChargerKw) && vehicleChargerKw > 0\n ? Math.min(vehicleNeedKwh, vehicleChargerKw * (vehicleDepartureMs - nowMsBalance) / 3600000)\n : vehicleNeedKwh;\n vehicleShortfallKwh = Math.max(0, vehicleReachableKwh - vehicleSolarKwh);\n}\nconst vehicleGridNeeded = vehicleRuleActive && vehicleShortfallKwh > VEHICLE_GRID_MIN_SHORTFALL_KWH;\n\n// ---------------------------------------------------------------------\n// PROCHAINE FENÊTRE DE DÉFICIT (ajout du 24/08/2026)\n//\n// Le calcul ci-dessus répond à « combien d'énergie jusqu'à ce que le solaire\n// prenne le relais ». À 15 h, le solaire a déjà pris le relais : la réponse est\n// zéro et le SOC requis retombe sur le plancher. C'est exact mais inutile à\n// cette heure-là, car le déficit qui approche est celui de la nuit.\n//\n// On cherche donc AUSSI la prochaine fenêtre de déficit après le surplus en\n// cours : son heure de début, son énergie, et le SOC qu'il faudra avoir atteint\n// à ce moment-là. C'est cette grandeur qui remplace la cible de nuit fixe, et\n// qui dit combien de surplus il faut avoir stocké avant le coucher du soleil.\n//\n// Profil PRUDENT : contrairement à la décision réseau immédiate, cette\n// projection engage plusieurs heures et n'est pas révisable à volonté — la nuit\n// tombée, il n'y a plus de solaire pour rattraper une sous-estimation.\n// ---------------------------------------------------------------------\nfunction nextDeficitWindow(slots) {\n const firstSurplus = slots.findIndex(s => s.netKwh > 0);\n if (firstSurplus < 0) return { found: false, reason: 'deficit_en_cours' };\n\n const start = slots.findIndex((s, i) => i > firstSurplus && s.netKwh < 0);\n if (start < 0) return { found: false, reason: 'aucun_deficit_sur_horizon' };\n\n let end = slots.findIndex((s, i) => i > start && s.netKwh > 0);\n if (end < 0) end = slots.length;\n\n const energyKwh = slots.slice(start, end)\n .reduce((sum, s) => sum + Math.max(0, -s.netKwh), 0);\n\n // Surplus encore récupérable d'ici là : de quoi remplir, ou pas.\n const surplusBeforeKwh = slots.slice(0, start)\n .reduce((sum, s) => sum + Math.max(0, s.netKwh), 0);\n\n return {\n found: true,\n startHour: slots[start].hour,\n startMs: slots[start].startMs,\n hoursFromNow: start,\n endHour: end < slots.length ? slots[end].hour : null,\n complete: end < slots.length,\n energyKwh,\n // NON écrêté à 100 volontairement : une valeur au-dessus de 100 signifie\n // que même une batterie pleine ne suffit pas à traverser la fenêtre. C'est\n // exactement le signal de sous-dimensionnement du stockage — l'écrêter le\n // masquerait.\n requiredSocAtStartPct: socForEnergy(energyKwh, socFloorPct),\n surplusBeforeKwh,\n };\n}\n\nconst nextDeficit = nextDeficitWindow(prudent.slots);\n\n// ---------------------------------------------------------------------\n// RÉSERVE D'ORAGE (27/08/2026)\n//\n// Les coupures réseau sont fréquentes ici lors des orages. Point de départ\n// souvent mal posé : une coupure n'arrête PAS l'installation. Le solaire et\n// la batterie continuent de fonctionner, seul le filet de secours disparaît.\n// La question n'est donc pas « combien stocker pour survivre » mais « la\n// batterie traverse-t-elle la prochaine fenêtre de déficit SANS le réseau ».\n//\n// C'est exactement la couverture PRUDENTE (p80) déjà calculée plus haut :\n// rien à inventer, aucune constante de plus. Le profil prudent est le bon\n// choix ici parce qu'une coupure retire précisément la possibilité de\n// rattraper une sous-estimation.\n//\n// Le niveau vient de la vigilance Météo-France, dont l'échelle officielle est\n// vert/jaune/orange/rouge. « Actif » à partir d'orange : c'est le seuil de\n// l'émetteur, pas un nombre choisi ici.\n// ---------------------------------------------------------------------\nconst STORM_SCALE = ['vert', 'jaune', 'orange', 'rouge'];\nconst STORM_ACTIVE_FROM = STORM_SCALE.indexOf('orange');\n\nconst stormRaw = typeof input.storm_alert_level === 'string'\n ? input.storm_alert_level.trim().toLowerCase()\n : '';\nconst stormIndex = STORM_SCALE.indexOf(stormRaw);\nconst stormKnown = stormIndex >= 0;\nconst stormActive = stormIndex >= STORM_ACTIVE_FROM;\n\n// Phénomènes atteignant le niveau d'alerte, s'ils sont exposés en attributs\n// (« Orages », « Vent violent »…). Purement informatif.\nconst stormDetail = (input.storm_alert_detail && typeof input.storm_alert_detail === 'object')\n ? input.storm_alert_detail\n : {};\nconst stormPhenomena = Object.keys(stormDetail)\n .filter(k => typeof stormDetail[k] === 'string'\n && STORM_SCALE.indexOf(stormDetail[k].trim().toLowerCase()) >= STORM_ACTIVE_FROM)\n .sort();\n\n// En surplus, la fenêtre qui compte est la prochaine (la nuit qui vient) ;\n// en déficit, c'est celle qu'on est en train de traverser.\nconst inSurplusNow = prudent.found && prudent.crossingIndex === 0;\nconst stormWindow = (inSurplusNow && nextDeficit.found)\n ? {\n kwh: nextDeficit.energyKwh,\n socPct: nextDeficit.requiredSocAtStartPct,\n startHour: nextDeficit.startHour,\n }\n : {\n kwh: prudent.energyToCoverKwh,\n socPct: prudent.requiredSocPct,\n startHour: null,\n };\nconst stormMarginPct = socPct - stormWindow.socPct;\n\nconst slots = typical.slots;\nconst crossingFound = typical.found;\nconst crossingIndex = typical.crossingIndex;\nconst crossing = crossingFound ? slots[crossingIndex] : null;\nconst deficitSlots = typical.deficitSlots;\n\n// ---------------------------------------------------------------------\n// Marge disponible pour le véhicule : surplus attendu après le croisement,\n// une fois la maison servie. C'est ce qui peut être chargé sans toucher au\n// réseau ni entamer la réserve.\n// ---------------------------------------------------------------------\nconst surplusAfterCrossingKwh = crossingFound\n ? slots.slice(crossingIndex).reduce((sum, s) => sum + Math.max(0, s.netKwh), 0)\n : 0;\n\n// ---------------------------------------------------------------------\n// Confiance : la part du calcul qui repose sur une forme de production minorée\n// (après-midi censuré). Elle ne dégrade PAS la réponse « faut-il du réseau »,\n// qui se joue sur les heures de déficit — celles-ci ne sont jamais censurées,\n// une heure censurée étant par construction une heure de surplus.\n// ---------------------------------------------------------------------\nconst deficitOnWeakShape = deficitSlots.filter(s => s.shapeQuality === 'minoree').length;\nconst confidence = {\n deficitHoursOnWeakShape: deficitOnWeakShape,\n deficitTrustworthy: deficitOnWeakShape === 0,\n surplusTrustworthy: !slots.slice(crossingFound ? crossingIndex : 0)\n .some(s => s.shapeQuality === 'minoree'),\n};\n\nreturn [{\n json: {\n usable: true,\n generatedAt: new Date(num(input.nowMs, Date.now())).toISOString(),\n\n crossing: {\n found: crossingFound,\n hour: crossing ? crossing.hour : null,\n startMs: crossing ? crossing.startMs : null,\n hoursFromNow: crossingFound ? crossingIndex : null,\n },\n\n coverage: {\n // Décision réseau immédiate — remplace input_number.victron_grid_soc_off.\n energyToCoverKwh: round2(typical.energyToCoverKwh),\n requiredSocPct: round1(typical.requiredSocPct),\n currentSocPct: round1(socPct),\n socMarginPct: round1(typical.socMarginPct),\n gridNeeded: typical.gridNeeded,\n gridEnergyNeededKwh: round2(typical.gridEnergyNeededKwh),\n\n // Planification (cible de nuit, dimensionnement) — ne pilote PAS le réseau.\n prudent: {\n energyToCoverKwh: round2(prudent.energyToCoverKwh),\n requiredSocPct: round1(prudent.requiredSocPct),\n socMarginPct: round1(prudent.socMarginPct),\n crossingHour: prudent.found ? prudent.slots[prudent.crossingIndex].hour : null,\n },\n },\n\n // Ce qu'il faudra avoir stocké avant la prochaine traversée déficitaire.\n nextDeficit: nextDeficit.found ? {\n found: true,\n startHour: nextDeficit.startHour,\n hoursFromNow: nextDeficit.hoursFromNow,\n endHour: nextDeficit.endHour,\n complete: nextDeficit.complete,\n energyKwh: round2(nextDeficit.energyKwh),\n requiredSocAtStartPct: round1(nextDeficit.requiredSocAtStartPct),\n surplusBeforeKwh: round2(nextDeficit.surplusBeforeKwh),\n // Franchissable avec le stockage actuel, batterie pleine ?\n feasibleWithCurrentStorage: nextDeficit.requiredSocAtStartPct <= 100,\n // Si non : capacité utile manquante. C'est le chiffre du dimensionnement.\n storageShortfallKwh: energyBetweenSoc(100, nextDeficit.requiredSocAtStartPct),\n reachable: nextDeficit.surplusBeforeKwh >= 0\n && socAfterEnergy(socPct, nextDeficit.surplusBeforeKwh)\n >= nextDeficit.requiredSocAtStartPct,\n } : { found: false, reason: nextDeficit.reason },\n\n vehicle: {\n surplusAvailableKwh: round2(surplusAfterCrossingKwh),\n },\n\n // Nuit prevue par la soiree (28/09) : facteur applique au profil.\n nightModel: {\n active: nightModelActive,\n factor: Math.round(nightFactor * 1000) / 1000,\n rawFactor: Number.isFinite(nightFactorRaw) ? nightFactorRaw : null,\n forecastKwh: num(input.night_house_forecast_kwh, NaN) || null,\n },\n\n // Maison sur le reseau pendant la fenetre HC de nuit, si la session s'engage (29/09).\n nightHcImport: {\n active: nightHcActive,\n startMs: nightHcActive ? nightHcStartMs : null,\n endMs: nightHcActive ? nightHcEndMs : null,\n houseKwh: nightHcHouseKwh === null ? null : round2(nightHcHouseKwh),\n },\n\n // Ce que la voiture peut prendre sur la batterie cette nuit sans EDF (30/09).\n nightVehicleBudget: {\n active: nightVehicleBudgetKwh !== null,\n kwh: nightVehicleBudgetKwh === null ? null : round2(nightVehicleBudgetKwh),\n },\n\n // Voiture branchee en HC, depart avant la prochaine HC (28/09).\n vehicleMidday: {\n active: vehicleRuleActive,\n needKwh: Number.isFinite(vehicleNeedKwh) ? round2(vehicleNeedKwh) : null,\n departureMs: Number.isFinite(vehicleDepartureMs) ? vehicleDepartureMs : null,\n solarKwh: vehicleSolarKwh === null ? null : round2(vehicleSolarKwh),\n reachableKwh: vehicleReachableKwh === null ? null : round2(vehicleReachableKwh),\n shortfallKwh: vehicleShortfallKwh === null ? null : round2(vehicleShortfallKwh),\n floorAtDeparturePct: vehicleFloorAtDeparturePct === null ? null : round1(vehicleFloorAtDeparturePct),\n gridNeeded: vehicleGridNeeded,\n },\n\n // Tenir jusqu'a la prochaine fenetre HC (28/09) : SOC a avoir en fin de\n // fenetre HC en cours. 0 hors fenetre ou si la production suffit.\n hcReach: {\n active: hcReachActive,\n windowEndMs: hcReachActive ? hcWindowEndMs : null,\n nextHcStartMs: hcReachActive ? nextHcStartMs : null,\n floorPct: round1(Math.min(100, hcFloorTypicalPct)),\n floorPrudentPct: round1(Math.min(100, hcFloorPrudentPct)),\n nextNightHcStartMs: hcNightReachActive ? nextNightHcStartMs : null,\n nightFloorPrudentPct: round1(Math.min(100, hcFloorNightPrudentPct)),\n raisesRequirement: hcReachActive && hcFloorTypicalPct >= typical.requiredSocPct - 0.05\n && hcFloorTypicalPct > socForEnergy(typical.energyToCoverKwh, socFloorPct),\n },\n\n // Réserve à tenir pour traverser SANS réseau la fenêtre de déficit qui\n // compte. Publiée en permanence — elle documente le risque même par temps\n // calme ; `active` dit seulement si la vigilance la rend d'actualité.\n storm: {\n level: stormKnown ? stormRaw : 'indisponible',\n known: stormKnown,\n active: stormActive,\n phenomena: stormPhenomena,\n windowStartHour: stormWindow.startHour,\n reserveKwh: round2(stormWindow.kwh),\n reserveSocPct: round1(stormWindow.socPct),\n socMarginPct: round1(stormMarginPct),\n covered: stormMarginPct >= 0,\n shortfallKwh: stormMarginPct < 0\n ? round2(energyBetweenSoc(socPct, stormWindow.socPct))\n : 0,\n },\n\n confidence,\n slots,\n\n inputs: {\n usableCapacityKwh: round2(usableCapacityKwh),\n socFloorPct: round1(socFloorPct),\n },\n\n cfg: CFG,\n },\n}];\n" } }, { diff --git a/workflows/evcs-surplus-controller-v2.6.json b/workflows/evcs-surplus-controller-v2.6.json index cba6fc8..f83b7e2 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 // Garde batterie declenche RESEAU ABSENT (18/09) : sa cause est le\n // trou entre la demande de reseau et la fermeture du contacteur.\n // Le reseau arrive => la cause a disparu => le verrou se leve. Si la\n // batterie est encore sollicitee au retry, le garde re-mord et pose\n // cette fois 'grid_battery_guard' (reseau present) — verrou plein.\n // Convergence en une tentative, meme patron que Fix H.\n : history.lastBlockReason === 'grid_battery_guard_no_grid'\n ? (isGridSource && !parsed.gridAvailable)\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 balanceMorningTargetApplied = !!nightPrev.balanceMorningTargetApplied;\nconst nightGridSessionCommitted = !!nightPrev.nightGridSessionCommitted;\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// =====================================================\n// =====================================================\n// PLANCHER DU SOIR : UN NIVEAU FIXE, REMPLACE PAR L'ENERGIE A COUVRIR (03/09/2026)\n// =====================================================\n// `evening_soc_start_min` = 85 % est une tentative de dire « garde de quoi\n// passer la nuit ». C'est un NIVEAU, pris a un instant, et il ne sait rien de\n// la nuit qui vient : ni sa duree, ni la consommation a couvrir, ni l'heure a\n// laquelle le solaire prendra le relais.\n//\n// Le bilan energetique calcule exactement cela et le publie —\n// `evcs_balance_required_soc`, deduit de l'energie a couvrir jusqu'au\n// croisement production/consommation. Personne ne le lisait : `grep\n// evcs_balance` sur les deux workflows de decision renvoyait 0.\n//\n// Le 03/09 a 19:44, les deux nombres coexistaient dans le meme systeme :\n// plancher du soir 85 % (SOC 94, donc charge autorisee) contre SOC requis\n// 99,7 % pour tenir jusqu'au croisement de 9 h. La charge du soir a fait\n// passer le manque de la nuit de 0,86 a 2,21 kWh.\n//\n// SENS UNIQUE, ET C'EST DELIBERE : le bilan ne peut que RENFORCER le plancher,\n// jamais l'abaisser (Math.max). L'assouplir — le bilan dira souvent moins de\n// 85 % par forte prevision d'ete — autoriserait des charges qui n'ont pas lieu\n// aujourd'hui : c'est une decision distincte, avec ses propres risques, qui\n// doit etre validee pour elle-meme et non glissee dans ce correctif.\n// Corollaire : le max rend la garde robuste sans borne ni ecretage — une\n// valeur nulle, negative ou aberrante ne peut pas relacher le plancher.\n//\n// PORTEE : plancher de DEMARRAGE. Le plancher de maintien reste\n// min(profileSocHoldFloor, ...) donc inchange a 80 % : relever le plancher de\n// demarrage ne coupe aucune charge en cours.\n//\n// L'abondance P6 continue de primer : PV moyen >= 4 kW ET batterie qui monte a\n// >= +20 A est une PREUVE MESUREE que la ressource depasse ce que le systeme\n// absorbe — le cas ou la voiture est effectivement gratuite.\n//\n// REPLI : lecture absente, statut non 'ok', ou bilan plus vieux que six de ses\n// cycles => regle historique inchangee. Le kill-switch existe deja et n'a pas\n// besoin d'etre double : `input_boolean.evcs_balance_enabled` a OFF arrete les\n// ecritures du bilan, `balance_last_run` se fige, et le plancher retombe seul\n// sur 85 % en une demi-heure.\nconst BALANCE_MAX_AGE_MIN = 30;\nconst balanceAgeMin =\n params.balanceLastRunMs !== null && params.balanceLastRunMs !== undefined\n ? (nowMs - params.balanceLastRunMs) / 60000\n : null;\nconst balanceFresh =\n balanceAgeMin !== null &&\n balanceAgeMin >= -5 &&\n balanceAgeMin <= BALANCE_MAX_AGE_MIN;\nconst balanceEveningFloorUsable =\n balanceFresh &&\n params.balanceStatusOk &&\n Number.isFinite(params.balanceRequiredSocPct);\n\nconst eveningSocStartFloorPct = balanceEveningFloorUsable\n ? Math.max(params.eveningSocStartMin, params.balanceRequiredSocPct)\n : params.eveningSocStartMin;\nconst eveningFloorFromBalance =\n balanceEveningFloorUsable && eveningSocStartFloorPct > params.eveningSocStartMin;\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' ? eveningSocStartFloorPct :\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// LA PROVOCATION DOIT ETRE JUSTIFIEE PAR LES CHARGEURS EUX-MEMES (03/09/2026)\n// =====================================================================\n// Un excedent proche de zero est AMBIGU : c'est la signature de la censure\n// (production bridee, calee sur la maison) ET celle de l'equilibre de fin de\n// journee (production reelle qui rejoint la consommation). Le releve du 03/09\n// contient les deux a 51 minutes d'ecart, avec le meme excedent :\n//\n// 18:50 PV 769 W, maison 755 W, excedent +14 W -> 7 trackers LIMITES\n// 19:41 PV 817 W, maison 816 W, excedent +1 W -> 7 trackers MPPT_ACTIVE\n//\n// Le premier cache de la ressource refusee, le second n'a plus rien a donner.\n// Aucun seuil de puissance ne les separe. Les chargeurs, eux, le disent :\n// 'mppt_active' = je suis a mon point de puissance maximale, je donne tout ;\n// 'voltage_current_limited' / 'off' = je me retiens faute de preneur.\n//\n// Signal DIRECT (le materiel lui-meme, pas un proxy), INSTANTANE (30 s, la\n// cadence de la boucle) et, sur l'incident du 03/09, il a bascule a 19:20 —\n// 24 minutes avant le drapeau `pv_censored` du bilan energetique, qui tourne\n// toutes les 5 min et dont l'un des motifs est justement le proxy SOC >= 90\n// qu'on cherchait a remplacer.\n//\n// ⚠️ DEMARRAGE UNIQUEMENT, et c'est structurel, pas une precaution :\n// gager le sourceMode ou le MAINTIEN sur ce signal ferait que la charge, en\n// donnant un preneur au MPPT, detruirait sa propre condition d'autorisation —\n// exactement le bistable de l'abondance P6 corrige le 24/08. La garde n'est\n// lue que quand la voiture est a l'arret, instant ou l'etat des trackers\n// decrit la situation SANS elle. Verifie le 01/09 a 14:22-14:34 : les trackers\n// restaient limites PENDANT une charge de 3,4 kW sous 6,9 kW de PV — a midi la\n// ressource depasse ce que la voiture peut prendre, le signal ne s'auto-annule\n// donc pas non plus.\n//\n// Aucune lecture exploitable (integration muette) => on ne sait pas => on\n// retombe sur le comportement anterieur. On ne devine jamais un etat.\nconst mpptReadable = parsed.mpptReadableCount > 0;\nconst mpptAllAtMaxPower = mpptReadable && parsed.mpptHoldingBackCount === 0;\nconst solarProvocationJustified = !mpptAllAtMaxPower;\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 && solarProvocationJustified)\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 solarProvocationJustified &&\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\n// P12 (15/09/2026) — le mode `battery_assist_night` etait MORT depuis toujours.\n// Il exige `batteryCurrentA >= plancher de decharge du profil` (0 A), condition\n// impossible la nuit puisque la maison tire sur la batterie (§11, ~ -14 A). Il\n// n'a donc jamais demarre une seule fois.\n//\n// Quand P12 est actif, on remplace ce test de COURANT par un test d'ENERGIE :\n// rester au-dessus de ce qu'il faut pour tenir jusqu'au croisement PV. Le\n// plancher vient du bilan (`required_soc_prudent`, profil p80), il est calcule\n// dans WINDOWS_SOURCEV2 et transmis ici — ce node CONSOMME, il ne recalcule pas.\n//\n// ⚠️ LES GARDES DE COURANT NE SONT PAS SUPPRIMEES POUR AUTANT :\n// - `commonSafetyStartOk` / `commonSafetyHoldOk` restent evalues normalement ;\n// - le MAINTIEN garde `batteryDischargeHardDynamicA`, l'arret dur, qui n'est\n// jamais contourne ;\n// - la garde SOUPLE et sa tolerance temporelle (§35) continuent de s'appliquer\n// par `batterySoftDischargeHoldOk` en aval, et c'est elle qui arretera la\n// charge quand la bande de SOC deviendra trop basse pour le courant tire.\n// Ce qu'on retire, c'est uniquement le plancher de PROFIL, qui interdisait de\n// demarrer quoi que ce soit des que la batterie n'etait pas a l'equilibre.\nconst nightBatterySurplusAvailable = !!nightPrev.nightBatterySurplusAvailable;\nconst nightBatterySurplusFloorPct = Number.isFinite(nightPrev.nightBatterySurplusFloorPct)\n ? nightPrev.nightBatterySurplusFloorPct\n : Infinity;\n\nconst batteryAssistNightStartOk =\n context.sourceMode === 'battery_assist_night' &&\n commonSafetyStartOk &&\n nightSessionActive &&\n batteryAssistAllowed &&\n parsed.soc > batteryHardFloorPct &&\n (\n nightBatterySurplusAvailable\n ? parsed.soc > nightBatterySurplusFloorPct\n : (\n parsed.soc > gridTakeoverTriggerPct &&\n parsed.batteryCurrentA >= profileBatteryDischargeFloorA\n )\n );\n\nconst batteryAssistNightHoldOk =\n context.sourceMode === 'battery_assist_night' &&\n commonSafetyHoldOk &&\n nightSessionActive &&\n batteryAssistAllowed &&\n parsed.soc > batteryHardFloorPct &&\n parsed.batteryCurrentA >= batteryDischargeHardDynamicA &&\n (\n nightBatterySurplusAvailable\n ? parsed.soc > nightBatterySurplusFloorPct\n : parsed.soc > gridTakeoverTriggerPct\n );\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 balanceMorningTargetApplied,\n nightGridSessionCommitted,\n // P12 — passe-plat du verrou anti-oscillateur vers la sortie.\n nightBatterySurplusExhausted: !!nightPrev.nightBatterySurplusExhausted,\n nightBatterySurplusAvailable: !!nightPrev.nightBatterySurplusAvailable,\n // Budget voiture de la nuit (30/09) — passe-plat vers la sortie.\n nightBudgetKwh: nightPrev.nightBudgetKwh ?? null,\n nightBudgetDeliveredKwh: nightPrev.nightBudgetDeliveredKwh ?? null,\n nightBudgetReached: !!nightPrev.nightBudgetReached,\n nightBudgetToWrite: nightPrev.nightBudgetToWrite ?? null,\n nightChargeMode: nightPrev.nightChargeMode || 'sans_edf',\n nightCarWaitsNoGrid: !!nightPrev.nightCarWaitsNoGrid,\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 // Plancher du soir issu du bilan energetique (03/09/2026).\n eveningSocStartFloorPct,\n eveningFloorFromBalance,\n balanceRequiredSocPct: params.balanceRequiredSocPct,\n balanceEnergyToCoverKwh: params.balanceEnergyToCoverKwh,\n balanceAgeMin: balanceAgeMin === null ? null : Math.round(balanceAgeMin * 10) / 10,\n balanceEveningFloorUsable,\n\n solarSurplusW,\n solarSurplusProvocationOk,\n solarSurplusStartOk,\n\n // Etat declare par les chargeurs solaires (03/09/2026).\n mpptReadableCount: parsed.mpptReadableCount,\n mpptHoldingBackCount: parsed.mpptHoldingBackCount,\n mpptAllAtMaxPower,\n solarProvocationJustified,\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 nightBatterySurplusAvailable,\n nightBatterySurplusFloorPct,\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 // Garde batterie declenche RESEAU ABSENT (18/09) : sa cause est le\n // trou entre la demande de reseau et la fermeture du contacteur.\n // Le reseau arrive => la cause a disparu => le verrou se leve. Si la\n // batterie est encore sollicitee au retry, le garde re-mord et pose\n // cette fois 'grid_battery_guard' (reseau present) — verrou plein.\n // Convergence en une tentative, meme patron que Fix H.\n : history.lastBlockReason === 'grid_battery_guard_no_grid'\n ? (isGridSource && !parsed.gridAvailable)\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 balanceMorningTargetApplied = !!nightPrev.balanceMorningTargetApplied;\nconst nightGridSessionCommitted = !!nightPrev.nightGridSessionCommitted;\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// =====================================================\n// =====================================================\n// PLANCHER DU SOIR : UN NIVEAU FIXE, REMPLACE PAR L'ENERGIE A COUVRIR (03/09/2026)\n// =====================================================\n// `evening_soc_start_min` = 85 % est une tentative de dire « garde de quoi\n// passer la nuit ». C'est un NIVEAU, pris a un instant, et il ne sait rien de\n// la nuit qui vient : ni sa duree, ni la consommation a couvrir, ni l'heure a\n// laquelle le solaire prendra le relais.\n//\n// Le bilan energetique calcule exactement cela et le publie —\n// `evcs_balance_required_soc`, deduit de l'energie a couvrir jusqu'au\n// croisement production/consommation. Personne ne le lisait : `grep\n// evcs_balance` sur les deux workflows de decision renvoyait 0.\n//\n// Le 03/09 a 19:44, les deux nombres coexistaient dans le meme systeme :\n// plancher du soir 85 % (SOC 94, donc charge autorisee) contre SOC requis\n// 99,7 % pour tenir jusqu'au croisement de 9 h. La charge du soir a fait\n// passer le manque de la nuit de 0,86 a 2,21 kWh.\n//\n// SENS UNIQUE, ET C'EST DELIBERE : le bilan ne peut que RENFORCER le plancher,\n// jamais l'abaisser (Math.max). L'assouplir — le bilan dira souvent moins de\n// 85 % par forte prevision d'ete — autoriserait des charges qui n'ont pas lieu\n// aujourd'hui : c'est une decision distincte, avec ses propres risques, qui\n// doit etre validee pour elle-meme et non glissee dans ce correctif.\n// Corollaire : le max rend la garde robuste sans borne ni ecretage — une\n// valeur nulle, negative ou aberrante ne peut pas relacher le plancher.\n//\n// PORTEE : plancher de DEMARRAGE. Le plancher de maintien reste\n// min(profileSocHoldFloor, ...) donc inchange a 80 % : relever le plancher de\n// demarrage ne coupe aucune charge en cours.\n//\n// L'abondance P6 continue de primer : PV moyen >= 4 kW ET batterie qui monte a\n// >= +20 A est une PREUVE MESUREE que la ressource depasse ce que le systeme\n// absorbe — le cas ou la voiture est effectivement gratuite.\n//\n// REPLI : lecture absente, statut non 'ok', ou bilan plus vieux que six de ses\n// cycles => regle historique inchangee. Le kill-switch existe deja et n'a pas\n// besoin d'etre double : `input_boolean.evcs_balance_enabled` a OFF arrete les\n// ecritures du bilan, `balance_last_run` se fige, et le plancher retombe seul\n// sur 85 % en une demi-heure.\nconst BALANCE_MAX_AGE_MIN = 30;\nconst balanceAgeMin =\n params.balanceLastRunMs !== null && params.balanceLastRunMs !== undefined\n ? (nowMs - params.balanceLastRunMs) / 60000\n : null;\nconst balanceFresh =\n balanceAgeMin !== null &&\n balanceAgeMin >= -5 &&\n balanceAgeMin <= BALANCE_MAX_AGE_MIN;\nconst balanceEveningFloorUsable =\n balanceFresh &&\n params.balanceStatusOk &&\n Number.isFinite(params.balanceRequiredSocPct);\n\nconst eveningSocStartFloorPct = balanceEveningFloorUsable\n ? Math.max(params.eveningSocStartMin, params.balanceRequiredSocPct)\n : params.eveningSocStartMin;\nconst eveningFloorFromBalance =\n balanceEveningFloorUsable && eveningSocStartFloorPct > params.eveningSocStartMin;\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' ? eveningSocStartFloorPct :\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// LA PROVOCATION DOIT ETRE JUSTIFIEE PAR LES CHARGEURS EUX-MEMES (03/09/2026)\n// =====================================================================\n// Un excedent proche de zero est AMBIGU : c'est la signature de la censure\n// (production bridee, calee sur la maison) ET celle de l'equilibre de fin de\n// journee (production reelle qui rejoint la consommation). Le releve du 03/09\n// contient les deux a 51 minutes d'ecart, avec le meme excedent :\n//\n// 18:50 PV 769 W, maison 755 W, excedent +14 W -> 7 trackers LIMITES\n// 19:41 PV 817 W, maison 816 W, excedent +1 W -> 7 trackers MPPT_ACTIVE\n//\n// Le premier cache de la ressource refusee, le second n'a plus rien a donner.\n// Aucun seuil de puissance ne les separe. Les chargeurs, eux, le disent :\n// 'mppt_active' = je suis a mon point de puissance maximale, je donne tout ;\n// 'voltage_current_limited' / 'off' = je me retiens faute de preneur.\n//\n// Signal DIRECT (le materiel lui-meme, pas un proxy), INSTANTANE (30 s, la\n// cadence de la boucle) et, sur l'incident du 03/09, il a bascule a 19:20 —\n// 24 minutes avant le drapeau `pv_censored` du bilan energetique, qui tourne\n// toutes les 5 min et dont l'un des motifs est justement le proxy SOC >= 90\n// qu'on cherchait a remplacer.\n//\n// ⚠️ DEMARRAGE UNIQUEMENT, et c'est structurel, pas une precaution :\n// gager le sourceMode ou le MAINTIEN sur ce signal ferait que la charge, en\n// donnant un preneur au MPPT, detruirait sa propre condition d'autorisation —\n// exactement le bistable de l'abondance P6 corrige le 24/08. La garde n'est\n// lue que quand la voiture est a l'arret, instant ou l'etat des trackers\n// decrit la situation SANS elle. Verifie le 01/09 a 14:22-14:34 : les trackers\n// restaient limites PENDANT une charge de 3,4 kW sous 6,9 kW de PV — a midi la\n// ressource depasse ce que la voiture peut prendre, le signal ne s'auto-annule\n// donc pas non plus.\n//\n// Aucune lecture exploitable (integration muette) => on ne sait pas => on\n// retombe sur le comportement anterieur. On ne devine jamais un etat.\nconst mpptReadable = parsed.mpptReadableCount > 0;\nconst mpptAllAtMaxPower = mpptReadable && parsed.mpptHoldingBackCount === 0;\nconst solarProvocationJustified = !mpptAllAtMaxPower;\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 && solarProvocationJustified)\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 solarProvocationJustified &&\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\n// P12 (15/09/2026) — le mode `battery_assist_night` etait MORT depuis toujours.\n// Il exige `batteryCurrentA >= plancher de decharge du profil` (0 A), condition\n// impossible la nuit puisque la maison tire sur la batterie (§11, ~ -14 A). Il\n// n'a donc jamais demarre une seule fois.\n//\n// Quand P12 est actif, on remplace ce test de COURANT par un test d'ENERGIE :\n// rester au-dessus de ce qu'il faut pour tenir jusqu'au croisement PV. Le\n// plancher vient du bilan (`required_soc_prudent`, profil p80), il est calcule\n// dans WINDOWS_SOURCEV2 et transmis ici — ce node CONSOMME, il ne recalcule pas.\n//\n// ⚠️ LES GARDES DE COURANT NE SONT PAS SUPPRIMEES POUR AUTANT :\n// - `commonSafetyStartOk` / `commonSafetyHoldOk` restent evalues normalement ;\n// - le MAINTIEN garde `batteryDischargeHardDynamicA`, l'arret dur, qui n'est\n// jamais contourne ;\n// - la garde SOUPLE et sa tolerance temporelle (§35) continuent de s'appliquer\n// par `batterySoftDischargeHoldOk` en aval, et c'est elle qui arretera la\n// charge quand la bande de SOC deviendra trop basse pour le courant tire.\n// Ce qu'on retire, c'est uniquement le plancher de PROFIL, qui interdisait de\n// demarrer quoi que ce soit des que la batterie n'etait pas a l'equilibre.\nconst nightBatterySurplusAvailable = !!nightPrev.nightBatterySurplusAvailable;\nconst nightBatterySurplusFloorPct = Number.isFinite(nightPrev.nightBatterySurplusFloorPct)\n ? nightPrev.nightBatterySurplusFloorPct\n : Infinity;\n\nconst batteryAssistNightStartOk =\n context.sourceMode === 'battery_assist_night' &&\n commonSafetyStartOk &&\n nightSessionActive &&\n batteryAssistAllowed &&\n parsed.soc > batteryHardFloorPct &&\n (\n nightBatterySurplusAvailable\n ? parsed.soc > nightBatterySurplusFloorPct\n : (\n parsed.soc > gridTakeoverTriggerPct &&\n parsed.batteryCurrentA >= profileBatteryDischargeFloorA\n )\n );\n\nconst batteryAssistNightHoldOk =\n context.sourceMode === 'battery_assist_night' &&\n commonSafetyHoldOk &&\n nightSessionActive &&\n batteryAssistAllowed &&\n parsed.soc > batteryHardFloorPct &&\n parsed.batteryCurrentA >= batteryDischargeHardDynamicA &&\n (\n nightBatterySurplusAvailable\n ? parsed.soc > nightBatterySurplusFloorPct\n : parsed.soc > gridTakeoverTriggerPct\n );\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 balanceMorningTargetApplied,\n nightGridSessionCommitted,\n // P12 — passe-plat du verrou anti-oscillateur vers la sortie.\n nightBatterySurplusExhausted: !!nightPrev.nightBatterySurplusExhausted,\n nightBatterySurplusAvailable: !!nightPrev.nightBatterySurplusAvailable,\n // Budget voiture de la nuit (30/09) — passe-plat vers la sortie.\n nightBudgetKwh: nightPrev.nightBudgetKwh ?? null,\n nightBudgetDeliveredKwh: nightPrev.nightBudgetDeliveredKwh ?? null,\n nightBudgetReached: !!nightPrev.nightBudgetReached,\n nightBudgetToWrite: nightPrev.nightBudgetToWrite ?? null,\n nightChargeMode: nightPrev.nightChargeMode || 'sans_edf',\n nightCarWaitsNoGrid: !!nightPrev.nightCarWaitsNoGrid,\n nightBatteryGridOnly: !!nightPrev.nightBatteryGridOnly,\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 // Plancher du soir issu du bilan energetique (03/09/2026).\n eveningSocStartFloorPct,\n eveningFloorFromBalance,\n balanceRequiredSocPct: params.balanceRequiredSocPct,\n balanceEnergyToCoverKwh: params.balanceEnergyToCoverKwh,\n balanceAgeMin: balanceAgeMin === null ? null : Math.round(balanceAgeMin * 10) / 10,\n balanceEveningFloorUsable,\n\n solarSurplusW,\n solarSurplusProvocationOk,\n solarSurplusStartOk,\n\n // Etat declare par les chargeurs solaires (03/09/2026).\n mpptReadableCount: parsed.mpptReadableCount,\n mpptHoldingBackCount: parsed.mpptHoldingBackCount,\n mpptAllAtMaxPower,\n solarProvocationJustified,\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 nightBatterySurplusAvailable,\n nightBatterySurplusFloorPct,\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, @@ -107,7 +107,7 @@ }, { "parameters": { - "jsCode": "// Bornes de la consigne de puissance reseau. Le maximum est celui declare par\n// l'entite elle-meme (number.airtria_mpii_v1_ac_power_setpoint_l1, max 10000 W),\n// pas un chiffre choisi ici. La bande morte evite de reecrire la meme valeur\n// toutes les 30 s sans empecher la correction d'une derive externe.\nconst GRID_SETPOINT_MAX_W = 10000;\nconst GRID_SETPOINT_DEADBAND_W = 100;\n\nfunction round1(v) {\n return Math.round(v * 10) / 10;\n}\n\nfunction round2(v) {\n return Math.round(v * 100) / 100;\n}\n\nfunction round3(v) {\n return Math.round(v * 1000) / 1000;\n}\n\nfunction clamp(v, min, max) {\n return Math.min(Math.max(v, min), max);\n}\n\nfunction to2(n) {\n return String(n).padStart(2, '0');\n}\n\nfunction formatParisStampFromMs(ms) {\n const fmt = new Intl.DateTimeFormat('fr-FR', {\n timeZone: 'Europe/Paris',\n year: 'numeric',\n month: '2-digit',\n day: '2-digit',\n hour: '2-digit',\n minute: '2-digit',\n second: '2-digit',\n hour12: false,\n });\n\n const parts = Object.fromEntries(\n fmt.formatToParts(new Date(ms))\n .filter(p => p.type !== 'literal')\n .map(p => [p.type, p.value])\n );\n\n const hour = Number(parts.hour) === 24 ? 0 : Number(parts.hour);\n return `${parts.year}-${parts.month}-${parts.day} ${to2(hour)}:${parts.minute}:${parts.second}`;\n}\n\nconst prev = $input.first().json;\n\nconst {\n input,\n now,\n nowMs,\n cfg,\n parsed,\n params,\n history,\n strategy,\n windows,\n lifecycle,\n context,\n derived,\n batteryTune,\n batteryPolicy,\n locks,\n cloud,\n modes,\n safeguards,\n conditions,\n decision,\n nightEvcs,\n vehicleDemandFilter,\n} = prev;\n\n// =====================================================\n// SAFE ACCESS NIGHT EVCS\n// =====================================================\nconst night = nightEvcs ?? {\n nightSessionActive: false,\n nightSessionNeedsCapture: false,\n nightSessionNeedsClear: false,\n nightSessionStartSocPct: null,\n nightSessionStartAt: '',\n batteryHardFloorPct: null,\n batteryMorningTargetPct: null,\n maxBatteryDischargePoints: null,\n gridTakeoverTriggerPct: null,\n batteryRecoveryUrgent: false,\n nightGridTakeoverNeeded: false,\n nightPreferredSourceMode: 'blocked',\n};\n\n// =====================================================\n// SAFE ACCESS VEHICLE DEMAND FILTER\n// =====================================================\nconst vdf = vehicleDemandFilter ?? {\n dtSec: null,\n commandCurrentA: null,\n realCurrentA: null,\n expectedPowerW: null,\n realPowerW: null,\n currentDeliveryRatioRaw: null,\n powerDeliveryRatioRaw: null,\n deliveryRatioRaw: null,\n deliveryRatioEma: null,\n deliveryNoiseEma: null,\n adaptiveQuietBand: null,\n noDemandEvidenceRaw: null,\n sessionElapsedSec: null,\n sessionArmFactor: null,\n commandFactor: null,\n sourceDeliverableForNoDemand: false,\n evcsSessionArmedForNoDemand: false,\n noDemandScoreRaw: null,\n noDemandScoreEma: null,\n noDemandScoreOn: null,\n noDemandScoreOff: null,\n evcsVehicleNoDemandSemantic: false,\n evcsNoDemandSinceMs: null,\n evcsNoDemandSinceMinutes: null,\n nextHistory: {\n evcsDeliveryRatioEma: null,\n evcsDeliveryNoiseEma: null,\n evcsNoDemandScoreEma: null,\n evcsNoDemandSemanticActive: false,\n evcsNoDemandSinceMs: null,\n evcsNoDemandFilterEvalMs: null\n }\n};\n\n// =====================================================\n// FINAL ACTIONS\n// =====================================================\nlet desiredCurrentA = decision.desiredCurrentA;\n\ndesiredCurrentA = decision.desiredCharging\n ? clamp(\n desiredCurrentA,\n params.currentMinA,\n Math.max(params.currentMinA, derived.physicalCapA || params.currentMinA)\n )\n : params.currentMinA;\n\nconst chargingAction =\n decision.desiredCharging === parsed.evcsChargingSwitchOn\n ? 'none'\n : decision.desiredCharging\n ? 'turn_on'\n : 'turn_off';\n\nconst permissionAction =\n decision.desiredPermission === parsed.chargePermissionNow\n ? 'none'\n : decision.desiredPermission\n ? 'turn_on'\n : 'turn_off';\n\nconst latchAction =\n decision.desiredLatch === parsed.controlLatch\n ? 'none'\n : decision.desiredLatch\n ? 'turn_on'\n : 'turn_off';\n\nconst autoStartAction =\n parsed.controllerEnabled &&\n parsed.evcsVehicleConnected &&\n parsed.evcsAutoStartSwitchOn\n ? 'turn_off'\n : 'none';\n\nconst currentAction =\n decision.desiredCharging &&\n (\n !Number.isFinite(derived.currentReferenceA) ||\n Math.abs(desiredCurrentA - derived.currentReferenceA) >= 0.5\n )\n ? 'set'\n : 'none';\n\nconst setLastStartAt = chargingAction === 'turn_on';\nconst setLastStopAt = chargingAction === 'turn_off';\nconst setLastCurrentChangeAt = currentAction === 'set';\n\n// =====================================================\n// CLEAN BLOCKING LOGIC\n// =====================================================\nconst blockedUntilToWrite =\n decision.nextBlockReason === 'none'\n ? now.stamp\n : decision.nextBlockedUntilStamp;\n\nconst needsBlockReasonUpdate =\n decision.nextBlockReason !== history.lastBlockReason;\n\n// =====================================================\n// GLOBAL EVCS PRIORITY\n// AUTO + OVERRIDE MANUEL\n// =====================================================\nconst forceWindowMinutes = Math.max(\n 30,\n batteryTune?.global?.forceModeAutoRevertMinutes ?? 120\n);\n\nconst activeChargeIntent =\n decision.desiredCharging ||\n ['start', 'hold', 'step_up', 'step_down', 'step_down_fast'].includes(decision.currentDecision);\n\nconst evcsVehicleNotRequestingPowerState =\n decision.state === 'vehicle_full_or_paused' ||\n decision.currentDecision === 'stop_vehicle_full_or_paused';\n\n// -------------------------\n// PRIORITÉ AUTO\n// -------------------------\nlet autoEvcsPriorityActive = false;\nlet autoEnergyPriorityMode = 'normal';\nlet autoEnergyPriorityReason = 'Aucune priorité EVCS';\nlet autoEvcsPriorityUntil = now.stamp;\n\nif (\n parsed.evcsVehicleConnected &&\n !strategy.isStopMode &&\n !evcsVehicleNotRequestingPowerState\n) {\n if (strategy.forceChargeRequested || strategy.forceGridRequested) {\n autoEvcsPriorityActive = true;\n autoEnergyPriorityMode = 'evcs_priority';\n autoEnergyPriorityReason =\n strategy.forceGridRequested\n ? 'EVCS prioritaire : force_grid_now'\n : 'EVCS prioritaire : force_charge_now';\n autoEvcsPriorityUntil = formatParisStampFromMs(nowMs + forceWindowMinutes * 60000);\n }\n\n else if (night.nightSessionActive) {\n autoEvcsPriorityActive = true;\n autoEnergyPriorityMode = 'evcs_priority';\n autoEnergyPriorityReason =\n night.batteryRecoveryUrgent\n ? 'EVCS prioritaire : session nuit + urgence départ'\n : night.nightGridTakeoverNeeded\n ? 'EVCS prioritaire : session nuit + takeover réseau'\n : 'EVCS prioritaire : session nuit active';\n\n autoEvcsPriorityUntil =\n Number.isFinite(lifecycle.minutesUntilDeparture)\n ? formatParisStampFromMs(\n nowMs + Math.max(1, lifecycle.minutesUntilDeparture) * 60000\n )\n : formatParisStampFromMs(nowMs + forceWindowMinutes * 60000);\n }\n\n else if (context.departurePriorityActive && Number.isFinite(lifecycle.minutesUntilDeparture)) {\n autoEvcsPriorityActive = true;\n autoEnergyPriorityMode = 'evcs_priority';\n autoEnergyPriorityReason = `EVCS prioritaire : départ dans ${Math.round(lifecycle.minutesUntilDeparture)} min`;\n autoEvcsPriorityUntil = formatParisStampFromMs(\n nowMs + Math.max(1, lifecycle.minutesUntilDeparture) * 60000\n );\n }\n\n else if (activeChargeIntent && context.profile === 'midday_hc_or_solar') {\n autoEvcsPriorityActive = true;\n autoEnergyPriorityMode = 'evcs_priority';\n autoEnergyPriorityReason = 'EVCS prioritaire : recharge active fenêtre midi';\n autoEvcsPriorityUntil = formatParisStampFromMs(nowMs + 90 * 60000);\n }\n}\n\n// -------------------------\n// OVERRIDE MANUEL\n// -------------------------\nconst manualPriorityOverrideActive =\n parsed.evcsPriorityManualOverride &&\n parsed.evcsPriorityManualMode === 'evcs_priority' &&\n history.evcsPriorityManualUntilMs !== null &&\n history.evcsPriorityManualUntilMs > nowMs + 1000;\n\nlet evcsPriorityActive = autoEvcsPriorityActive;\nlet energyPriorityMode = autoEnergyPriorityMode;\nlet energyPriorityReason = autoEnergyPriorityReason;\nlet evcsPriorityUntil = autoEvcsPriorityUntil;\nlet evcsPrioritySource = 'auto';\n\nif (manualPriorityOverrideActive && !evcsVehicleNotRequestingPowerState) {\n evcsPriorityActive = true;\n energyPriorityMode = 'evcs_priority';\n energyPriorityReason =\n parsed.evcsPriorityManualReason || 'EVCS prioritaire : override manuel';\n evcsPriorityUntil =\n history.evcsPriorityManualUntilAt || autoEvcsPriorityUntil;\n evcsPrioritySource = 'manual';\n}\n\n// =====================================================\n// REASONS / LABELS / SIGNATURE\n// =====================================================\nconst reason = [...decision.reason];\n\n// Budget voiture de la nuit (30/09) : dire pourquoi la voiture s'arrete.\nif (night.nightBudgetKwh !== null && night.nightBudgetKwh !== undefined) {\n const f = (x) => String(Math.round(x * 100) / 100).replace('.', ',');\n reason.push(`Budget batterie nuit ${f(night.nightBudgetDeliveredKwh ?? 0)}/${f(night.nightBudgetKwh)} kWh` +\n (night.nightCarWaitsNoGrid ? ' — atteint, sans EDF : la voiture attend le soleil'\n : night.nightBudgetReached ? ' — atteint, EDF finit la charge' : ''));\n} else if (night.nightCarWaitsNoGrid) {\n reason.push('Pas de budget batterie exploitable, sans EDF : la voiture attend le soleil');\n}\nreason.push(`Stratégie=${strategy.userStrategy}`);\nreason.push(`Profil=${context.profile}`);\nreason.push(`Source=${context.sourceMode}`);\nreason.push(\n `Mode=${\n modes.batteryFullHarvestMode\n ? 'battery_full_harvest_mode'\n : modes.forecastCommitMode\n ? 'forecast_commit_mode'\n : 'battery_priority_mode'\n }`\n);\nreason.push(`SOC=${round1(parsed.soc)}%`);\nreason.push(`Batt=${round1(parsed.batteryCurrentA)}A`);\nreason.push(`PV=${Math.round(parsed.pvPowerW)}W`);\nreason.push(`PV10=${Math.round(parsed.pvPowerAvg10mW)}W`);\n// =====================================================\n// CONSIGNE DE PUISSANCE RESEAU (01/09/2026)\n// =====================================================\n// Fermer le contacteur ne suffit PAS. Au-dessus du SOC minimal, l'ESS en\n// autoconsommation ne preleve que `ac_power_setpoint` et laisse la batterie\n// porter tout le reste : nuit du 01/09, contacteur ferme, consigne 50 W,\n// batterie a -803 W pour 761 W de maison, voiture arretee par le garde\n// \"batterie trop sollicitee\". La consigne est donc le levier qui rend une\n// session reseau REELLE, et il doit appartenir au controleur, pas rester un\n// reglage manuel permanent (celui-ci provoquait la substitution du solaire\n// par le reseau en journee, cf. HANDOFF 27/28).\n//\n// La valeur n'est pas une constante : c'est le besoin mesure du moment, la\n// maison telle qu'elle consomme plus la voiture au courant que le controleur\n// s'autorise. Le plafond joue le role de marge : quand la voiture n'est pas a\n// son plafond, la consigne est deja genereuse, et l'exces ne peut pas partir\n// dans la batterie (DVCC tenue a 2 A par l'Auto Charge Manager pendant un\n// takeover). Hors session, retour a la valeur de repos.\n// ⚠️ LA SESSION ENGAGEE COMPTE AUSSI (04/09/2026).\n// Nuit du 03-04/09 : la session reseau unique a tenu le contacteur toute la\n// nuit — une seule bascule, comme voulu. Mais a 02:50, la voiture finie, le\n// sourceMode est passe a 'blocked' et la consigne est retombee de 5078 W a\n// 50 W. Contacteur ferme, reseau disponible, heures creuses, et l'ESS ne\n// demandait que 50 W pendant que la maison tirait 770 W SUR LA BATTERIE.\n// Trois heures et demie, SOC 44 % -> 17 %, 2,60 kWh pris a la batterie quand\n// le reseau etait la et bon marche.\n//\n// C'est le defaut du 01/09 revenu ailleurs : fermer le contacteur ne suffit\n// pas, la consigne doit demander la puissance. J'avais conditionne la consigne\n// au sourceMode, puis cree la veille une NOUVELLE raison de garder le\n// contacteur ferme — l'engagement de session — sans la brancher ici. La\n// session tenait le contacteur, pas l'energie.\n//\n// Regle : tant que le contacteur est ferme A NOTRE DEMANDE, la consigne doit\n// couvrir la maison. Peu importe laquelle des deux raisons le tient.\nconst gridSessionActive =\n parsed.acInAvailable &&\n (\n context.sourceMode === 'grid_forced' ||\n context.sourceMode === 'grid_hc_allowed' ||\n night.nightGridSessionCommitted === true\n );\n\n// =====================================================\n// DIMENSIONNEMENT SUR LE DEFICIT DU BILAN (18/09/2026)\n// =====================================================\n// Ce que la consigne remplace : `maison + plafond de la borne`, ou le plafond\n// de la borne n'a AUCUNE relation physique avec ce dont la batterie a besoin.\n// Qu'il tombe juste est un accident — releve du 17/09 a 13:51, voiture partie\n// depuis cinq minutes, consigne a 7032 W pour une maison a 933 W.\n//\n// La consigne additionne desormais des besoins NOMMES :\n// maison toujours ;\n// voiture seulement si un vehicule est branche ;\n// batterie seulement dans une fenetre d'heures creuses.\n//\n// ⚠️ MAX ET NON ADDITION, et ce n'est pas de la prudence. La consigne est une\n// AUTORISATION, pas une prediction de consommation — le commentaire de la bande\n// asymetrique le dit plus bas : « la consigne dit ce que le reseau a le DROIT\n// de servir, DVCC dit ce que la batterie a le droit de prendre ». Additionner\n// deux autorisations independantes n'est pas plus honnete, seulement plus\n// permissif. Mesure a l'appui : avec l'addition,\n// `consigne-session-engagee-couvre-la-maison` passe de 5595 a 6341 W, soit\n// 746 W d'autorisation en plus sur une nuit engagee qui fonctionne deja — une\n// regression sur le chemin DOMINANT pour corriger un chemin emprunte zero fois\n// en treize jours.\n//\n// ⚠️ ET LE PLAFOND DU TERME BATTERIE EST DVCC, PAS LE BMS. Le BMS declare\n// 250 A, soit 12,2 kW a 49 V : au-dessus de GRID_SETPOINT_MAX_W, donc il ne\n// bornerait rien. Mesure : en fin de fenetre (14 min restantes, manque\n// 5,672 kWh) la division rendait 17 kW, ecretes a 10 000 — PIRE que les 5437\n// d'aujourd'hui. Avec DVCC le terme vaut 1538 W et la consigne 2380 W.\n// Composer les deux leviers, ce n'est pas les confondre : le reseau n'est\n// autorise a servir la batterie qu'a hauteur de ce que la batterie est\n// autorisee a prendre.\n//\n// Corollaire : aucun plancher d'horizon n'est necessaire. Un horizon qui tend\n// vers zero donne `min(enorme, DVCC) = DVCC`. Il ne reste qu'une garde d'une\n// minute, qui est de l'arithmetique defensive et non une constante calibree.\n//\n// INVARIANT TENU PAR LA FORME : `maison + max(...)` ne peut jamais descendre\n// sous la maison, quel que soit DVCC. La regle du 01/09 et du 04/09 — « tant\n// que le contacteur est ferme A NOTRE DEMANDE, la consigne doit couvrir la\n// maison » — est donc garantie par construction. A DVCC nul, le plafond vaut\n// exactement la maison.\nconst SETPOINT_BALANCE_MAX_AGE_MIN = 30;\nconst setpointBalanceAgeMin = Number.isFinite(params.balanceLastRunMs)\n ? (nowMs - params.balanceLastRunMs) / 60000\n : null;\n\n// Le manque, pas l'energie a couvrir : voir le commentaire d'INPUT_PARSE.\nconst balanceUsableForSetpoint =\n params.balanceStatusOk &&\n Number.isFinite(params.balanceGridEnergyNeededKwh) &&\n setpointBalanceAgeMin !== null &&\n setpointBalanceAgeMin >= -5 &&\n setpointBalanceAgeMin <= SETPOINT_BALANCE_MAX_AGE_MIN;\n\nconst dvccChargeLimitW =\n Number.isFinite(params.dvccChargeLimitA) && params.dvccChargeLimitA >= 0\n ? params.dvccChargeLimitA * derived.batteryVoltageRef\n : null;\n\n// Toute lecture manquante retombe sur la formule d'avant : degradation vers\n// l'existant, jamais pire.\nconst setpointFromBalance = balanceUsableForSetpoint && dvccChargeLimitW !== null;\n\nconst evcsSetpointShareW = parsed.evcsVehicleConnected\n ? derived.physicalCapA * derived.acVoltageRef\n : 0;\n\n// Recharger la batterie sur le reseau n'a de sens economique QUE dans une\n// fenetre creuse. Hors fenetre le terme vaut zero — ce n'est pas un repli\n// faute d'horizon, c'est le comportement voulu, et le probleme de la division\n// par un temps qui tend vers zero disparait par construction.\nconst hcMinutesLeft = Number.isFinite(windows.minutesRemainingInHcWindow)\n ? windows.minutesRemainingInHcWindow\n : null;\n\nconst batterySetpointShareW =\n setpointFromBalance && hcMinutesLeft !== null && params.balanceGridEnergyNeededKwh > 0\n ? Math.min(\n (params.balanceGridEnergyNeededKwh * 1000) / Math.max(hcMinutesLeft / 60, 1 / 60),\n dvccChargeLimitW\n )\n : 0;\n\n// =====================================================\n// LE REPLI NE DOIT PAS RESSUSCITER LE DEFAUT (18/09/2026)\n// =====================================================\n// Quand la garde du bilan tombe -- statut degrade, mesure perimee, champ\n// illisible -- la consigne revenait a `maison + plafond de borne`, y compris\n// sans voiture. C'est-a-dire au defaut d'origine, et precisement au moment ou\n// le systeme est deja degrade. Mesure sur le chemin concerne (midi, fenetre HC\n// autorisee, aucun vehicule, bilan indisponible) : 5725 W autorises pour une\n// maison a 900 W. Avec la part voiture : 1130 W.\n//\n// La branche de repli emploie donc la meme part voiture que la branche\n// nominale. L'invariant du 01/09 et du 04/09 reste tenu PAR LA FORME : le\n// plafond vaut `maison + un terme positif ou nul`, il ne descend donc jamais\n// sous la maison, quelle que soit la branche empruntee.\n//\n// ⚠️ NE PAS REINTRODUIRE UN DISJOINT `nightGridSessionCommitted ||\n// forceGridRequested` SUR LA PART VOITURE. Il a ete ecrit, mesure, puis\n// retire : il ECRASE le terme batterie du bilan, puisque `max(4825 emprunte,\n// 1538 mesure)` retient l'emprunt. Cout mesure, trois attendus figes d'un coup :\n// nuit-sans-vehicule-session-engage 2380 -> 5437\n// consigne-bilan-fin-de-fenetre-bornee-par-dvcc 2380 -> 5437\n// consigne-bilan-plancher-nuage-exclut-la-batterie 4219 -> 6301\n// Ce disjoint avait un sens tant que la consigne n'avait AUCUN terme batterie.\n// Depuis qu'elle en a un, dimensionne sur le manque reel, il n'est plus une\n// protection mais un court-circuit.\n//\n// Et si l'idee revient malgre tout : pendant une panne de bilan,\n// `nightGridSessionCommitted` ne peut engager aucune session NOUVELLE, il ne\n// survit que par `committedPrev`. Dans le repli, ce sont donc\n// `forceGridRequested` et le terme vehicule qui portent reellement.\nconst gridSetpointCeilingW = clamp(\n Math.round(\n setpointFromBalance\n ? derived.houseNonEvcsW + Math.max(evcsSetpointShareW, batterySetpointShareW)\n : derived.houseNonEvcsW + evcsSetpointShareW\n ),\n params.gridSetpointIdleW,\n GRID_SETPOINT_MAX_W\n);\n\nconst gridSetpointCurrentW = Number.isFinite(parsed.gridSetpointW)\n ? parsed.gridSetpointW\n : null;\n\n// =====================================================\n// CREDIT SOLAIRE SUR LA CONSIGNE (11/09/2026)\n// =====================================================\n// Defaut mesure ce jour-la : BOOST 28 A en plein soleil, consigne calculee a\n// 8242 W (maison 1802 + 28 A x 230). L'ESS n'a qu'un moyen de prelever 8,2 kW\n// quand le soleil en donne 4 : brider les MPPT. Releve 17:02 -- PV 121 W,\n// reseau 7697 W, batterie 0 A, SOC 96 %. La production etait refusee, et par\n// NOTRE consigne. Le commentaire ci-dessus (\"une consigne trop HAUTE ne coute\n// rien\") a ete ecrit la nuit du 01/09, ou la PV valait zero : il n'y avait\n// alors rien a evincer. En plein jour il est faux.\n//\n// ⚠️ ON NE PEUT PAS SOUSTRAIRE LA PV MESUREE. Elle vaut 121 W *a cause* de\n// cette consigne : mesure censuree, la soustraire ne bouge rien et le systeme\n// reste sur son mauvais equilibre. Preuve le meme jour a 17:22 -- consigne\n// descendue A LA MAIN a 2280 W : PV 4758 W en 50 secondes, batterie +9 A. La\n// ressource etait la, seule la consigne l'evincait.\n//\n// On DESCEND donc, et on laisse la physique repondre. Deux signaux, tous deux\n// deja lus et tous deux INCENSURABLES :\n// - les chargeurs declarent eux-memes s'ils se BRIDENT (mpptLimitedCount).\n// `off` ne compte pas : la nuit ils sont tous `off` et il n'y a rien a\n// liberer -- c'est ce qui garantit que le comportement de nuit (§31, §57)\n// est strictement inchange ;\n// - le courant batterie dit si on est descendu trop bas, via la politique\n// par bande de SOC deja calibree (batterySoftBreachNow, §35).\n// La valeur courante vit dans le GX : l'etat est deja persiste, aucun helper\n// nouveau n'est necessaire.\n//\n// PLANCHER : la consigne ne descend JAMAIS sous la maison hors voiture. C'est\n// la garantie du 01/09 et du 04/09 -- le reseau, quand le contacteur est ferme\n// a notre demande, couvre la maison. On ne libere que la part VOITURE, qui est\n// exactement celle qui evince le solaire. Le solaire pourrait souvent couvrir\n// la maison en plus, mais l'affirmer demanderait ses propres scenarios.\n// ⚠️ SECOND PLANCHER : LE NUAGE (ajoute 11/09 18:05, apres incident).\n// Premiere version de la descente : consigne tombee a 1281 W alors que le\n// solaire portait tout (PV 6058 W, reseau 1163 W, voiture a 28 A). Un nuage a\n// fait chuter la PV de 4,6 kW en une minute ; le reseau etant plafonne a\n// 1281 W, la difference est tombee entierement sur la batterie -- 87 A pour\n// une limite dure de 70 A -> `battery_current_hard_stop`, charge coupee.\n//\n// Sans injection, la production peut s'effondrer de plusieurs kW en quelques\n// secondes. Une boucle a 30 s ne peut pas rattraper cela : il faut que le\n// reseau soit DEJA autorise a prendre le relais. La consigne ne descend donc\n// jamais au point ou la perte totale du solaire depasserait ce que la batterie\n// sait encaisser.\n//\n// Le budget vient de la politique batterie deja calibree par bande de SOC\n// (§35), pas d'une constante nouvelle, et on garde 20 % de marge sous la\n// limite DURE -- c'est elle qui coupe la charge, et une coupure est plus\n// couteuse que quelques centaines de watts de solaire non captes.\n//\n// Contrepartie assumee, et elle est reelle : voiture a 28 A, la descente est\n// nettement moins profonde qu'a 10 A. C'est proportionne -- c'est le courant\n// de la voiture qui cree l'exposition. Et c'est la regle du proprietaire :\n// protéger le materiel prime sur l'economie de kilowattheures.\n// `derived.batteryVoltageRef` est deja la tension retenue en amont (mesure\n// vebus, repli 51,2 V) : meme grandeur que celle qui sert a convertir le\n// courant batterie en puissance dans BATTERY_CLOUD_LOCKS.\nconst batterySurgeBudgetW = Math.max(\n 0,\n Math.abs(batteryPolicy.batteryDischargeHardDynamicA) * derived.batteryVoltageRef * 0.8\n);\n// ⚠️ LE PLANCHER NUAGE SUIT LA VOITURE, PAS LA BATTERIE. Il existe pour que la\n// consigne ne descende pas au point ou la perte totale du solaire depasserait\n// ce que la batterie sait encaisser — et le commentaire ci-dessus dit pourquoi :\n// « c'est le courant de la voiture qui cree l'exposition ». Une batterie qu'on\n// recharge depuis le reseau n'est PAS une exposition : si le solaire s'effondre,\n// la recharge ralentit et personne ne se fait couper. L'y inclure remonterait le\n// plancher sans aucune contrepartie de securite, et contre l'objectif.\n// Une seule branche depuis le 18/09/2026 : le plancher nuage se construit sur\n// la maison et la voiture dans les deux cas. La distinction n'avait de sens que\n// tant que le repli gardait le plafond de borne emprunte.\nconst gridSetpointLoadW = derived.houseNonEvcsW + evcsSetpointShareW;\nconst gridSetpointCloudFloorW = Math.round(\n Math.max(0, gridSetpointLoadW - batterySurgeBudgetW)\n);\n\nconst gridSetpointHouseFloorW = clamp(\n Math.max(Math.round(derived.houseNonEvcsW), gridSetpointCloudFloorW),\n params.gridSetpointIdleW,\n GRID_SETPOINT_MAX_W\n);\n\nconst solarResourceRefused =\n parsed.mpptReadableCount > 0 && parsed.mpptLimitedCount > 0;\n\n// Pendant la fenetre d'etablissement (§29) les mesures PV et batterie ne\n// decrivent aucun regime permanent : on ne descend pas sur elles.\nconst gridSetpointDescendOk =\n gridSessionActive &&\n solarResourceRefused &&\n !batteryPolicy.batterySoftBreachNow &&\n !derived.gridSettling &&\n gridSetpointCurrentW !== null;\n\n// ⚠️ LE PLAFOND EST UN PLAFOND, PAS UNE CIBLE (corrige 11/09 17:52).\n// Premiere version de ce correctif, en production 12 minutes : la consigne\n// descendait bien a 2241 W et la PV remontait a 7074 W -- puis, tous les\n// chargeurs repassant `mppt_active`, la condition de descente devenait fausse,\n// le besoin revenait au plafond et la consigne etait REECRITE a 7100 W. La PV\n// s'effondrait a 1785 W, les chargeurs se bridaient, on redescendait.\n// Oscillateur de periode ~5 min. Releve : 17:40:30 -> 2241 / 17:47:19 -> 7100\n// / 17:47:28 PV 1785 / 17:48:30 -> 7368.\n//\n// Une fois la consigne descendue, on la TIENT tant que le solaire produit. Ce\n// n'est pas un etat a memoriser : la valeur courante est dans le GX, et le\n// plancher maison la fait remonter d'elle-meme si la maison grossit.\n//\n// La nuit, les chargeurs sont `off` (ils ne produisent pas) : on retombe sur le\n// comportement historique -- la consigne monte au plafond pour que le reseau\n// porte maison et voiture (§31, §57). C'est cette distinction qui empeche le\n// « on tient » de casser les nuits HC.\nconst solarProducing =\n parsed.mpptReadableCount > 0 && parsed.mpptAtMaxPowerCount > 0;\n\nlet gridSetpointNeedW;\nlet gridSetpointDescending = false;\n\nif (!gridSessionActive) {\n gridSetpointNeedW = params.gridSetpointIdleW;\n} else if (solarProducing && !batteryPolicy.batterySoftBreachNow && gridSetpointCurrentW !== null) {\n // On tient la valeur acquise, bornee par la maison et par le plafond.\n gridSetpointNeedW = clamp(\n Math.min(gridSetpointCurrentW, gridSetpointCeilingW),\n gridSetpointHouseFloorW,\n gridSetpointCeilingW\n );\n} else {\n gridSetpointNeedW = gridSetpointCeilingW;\n}\n\nif (gridSetpointDescendOk) {\n // Un chargeur bride PROUVE qu'il reste de la ressource : on peut descendre\n // franchement. Une descente trop forte est rattrapee au cycle suivant (30 s\n // ou la batterie porte l'ecart, soit quelques dizaines de Wh), alors qu'une\n // descente trop timide laisse des kW de solaire refuses pendant dix minutes.\n const minStepW = Math.max(\n GRID_SETPOINT_DEADBAND_W,\n Math.round(params.currentStepUpA * derived.acVoltageRef)\n );\n const baseW = Math.min(gridSetpointCurrentW, gridSetpointCeilingW);\n const gapW = baseW - gridSetpointHouseFloorW;\n if (gapW > 0) {\n const stepW = Math.max(minStepW, Math.round(gapW / 2));\n gridSetpointNeedW = Math.max(gridSetpointHouseFloorW, baseW - stepW);\n gridSetpointDescending = true;\n }\n}\n\n// La bande est ASYMETRIQUE, et c'est le point. Une consigne trop BASSE fait\n// porter la difference par la batterie : c'est le defaut de la nuit du 01/09,\n// il se corrige immediatement. Une consigne trop HAUTE ne coute rien : l'ESS\n// ne preleve que ce qu'il peut placer, et le courant de charge de la batterie\n// est tenu par ailleurs (DVCC, Auto Charge Manager). Deux leviers, deux roles\n// -- la consigne dit ce que le reseau a le DROIT de servir, DVCC dit ce que la\n// batterie a le droit de prendre. Ne pas confondre les deux.\n//\n// On ecrit donc avec une marge d'un pas de courant EVCS (grandeur du systeme,\n// pas une constante inventee) pour que le bruit de la maison ne rende pas la\n// consigne insuffisante au cycle suivant, et on ne redescend que si elle est\n// devenue franchement inutile. Sans cela la boucle 30 s reecrivait a chaque\n// cycle en poursuivant le bruit.\nconst gridSetpointSlackW = gridSessionActive && !gridSetpointDescending\n ? Math.max(GRID_SETPOINT_DEADBAND_W, Math.round(params.currentStepUpA * derived.acVoltageRef))\n : 0;\n\nconst gridSetpointTargetW = clamp(\n gridSetpointNeedW + gridSetpointSlackW,\n 0,\n GRID_SETPOINT_MAX_W\n);\n\nconst gridSetpointNeedsWrite =\n gridSetpointCurrentW === null ||\n gridSetpointCurrentW < gridSetpointNeedW ||\n gridSetpointCurrentW > gridSetpointNeedW + 2 * gridSetpointSlackW + GRID_SETPOINT_DEADBAND_W;\n\nreason.push(`Grid=${parsed.gridAvailable ? 'on' : 'off'} (${Math.round(parsed.gridPowerW)}W)`);\n// Trace de la fenetre d'etablissement : sans elle, une lecture du logbook\n// pendant le transitoire donne l'impression que le systeme decide n'importe\n// quoi. Il decide, au contraire, de ne PAS decider sur ces mesures.\nif (derived.gridSettling) {\n reason.push(`ETABLISSEMENT reseau ${Math.round(parsed.gridTransitionAgeSec)}s/${params.gridSettleSeconds}s (mesures PV/batterie non fiables)`);\n}\nif (gridSessionActive) {\n reason.push(\n gridSetpointDescending\n ? `Consigne reseau=${gridSetpointTargetW}W (CREDIT SOLAIRE, ${parsed.mpptLimitedCount}/${parsed.mpptReadableCount} brides)`\n : `Consigne reseau=${gridSetpointTargetW}W`\n );\n}\n// Visible dans le logbook uniquement pendant un depassement souple : c'est le\n// seul moment ou l'information sert, et la raison est tronquee a 255 caracteres.\nif (batteryPolicy.batterySoftBreachNow) {\n reason.push(\n `Batt souple ${round1(batteryPolicy.batterySoftBreachMinutes)}/${round1(batteryPolicy.batterySoftToleranceMinutes)}min`\n );\n}\n// Idem : visible seulement quand le bilan energetique DURCIT le plancher du\n// soir. Sans cette trace, un blocage de charge un soir de SOC 94 % est\n// incomprehensible a la lecture du logbook.\nif (safeguards.eveningFloorFromBalance) {\n reason.push(\n `Plancher soir=${round1(safeguards.eveningSocStartFloorPct)}% (BILAN, ${round1(safeguards.balanceEnergyToCoverKwh)}kWh a couvrir)`\n );\n}\nreason.push(`Cap=${derived.physicalCapA}A`);\nreason.push(`Ref=${round1(derived.currentReferenceA)}A`);\nreason.push(`Target=${round1(desiredCurrentA)}A`);\nreason.push(`Debt=${round2(cloud.cloudDebtNextKwh)}kWh`);\nreason.push(`Vehicle=${parsed.evcsVehicleConnected ? 'on' : 'off'}`);\nreason.push(`EVCS mode=${parsed.evcsModeRaw}`);\nreason.push(`VE.Bus AC=${Math.round(parsed.vebusAcOutPowerW)}W`);\nreason.push(\n `VE.Bus DC=${\n Number.isFinite(parsed.vebusDcVoltageV)\n ? round1(parsed.vebusDcVoltageV)\n : 'n/a'\n }V`\n);\nreason.push(`PrioEVCS=${evcsPriorityActive ? 'on' : 'off'}(${evcsPrioritySource})`);\nreason.push(`VDF delivery=${Number.isFinite(vdf.deliveryRatioEma) ? round3(vdf.deliveryRatioEma) : 'na'}`);\nreason.push(`VDF noise=${Number.isFinite(vdf.deliveryNoiseEma) ? round3(vdf.deliveryNoiseEma) : 'na'}`);\nreason.push(`VDF score=${Number.isFinite(vdf.noDemandScoreEma) ? round3(vdf.noDemandScoreEma) : 'na'}`);\nreason.push(`VDF noDemand=${vdf.evcsVehicleNoDemandSemantic ? 'on' : 'off'}`);\n\nif (night.nightSessionActive) {\n reason.push(`NightTarget=${round1(night.batteryMorningTargetPct ?? 0)}%`);\n reason.push(`NightTrigger=${round1(night.gridTakeoverTriggerPct ?? 0)}%`);\n\n if (Number.isFinite(night.nightSessionStartSocPct)) {\n reason.push(`StartSOC=${round1(night.nightSessionStartSocPct)}%`);\n reason.push(`AllowDrop=${round1(night.maxBatteryDischargePoints ?? 0)}pts`);\n }\n\n reason.push(`NightPreferred=${night.nightPreferredSourceMode ?? 'blocked'}`);\n reason.push(`NightTakeover=${night.nightGridTakeoverNeeded ? 'on' : 'off'}`);\n reason.push(`NightUrgent=${night.batteryRecoveryUrgent ? 'on' : 'off'}`);\n}\n\nconst shortReason = reason.join(' | ').slice(0, 255);\n\nconst sourceModeLabel =\n context.sourceMode === 'blocked' ? 'Bloqué' :\n context.sourceMode === 'solar_only' ? 'Solaire' :\n context.sourceMode === 'solar_potential' ? 'Potentiel solaire' :\n context.sourceMode === 'hybrid_probe' ? 'Probe hybride' :\n context.sourceMode === 'grid_hc_allowed' ? 'Réseau HC autorisé' :\n context.sourceMode === 'grid_forced' ? 'Réseau forcé' :\n context.sourceMode === 'battery_assist_night' ? 'Assistance batterie nuit' :\n context.sourceMode;\n\nconst signature = [\n decision.state,\n `profile=${context.profile}`,\n `source=${context.sourceMode}`,\n `chg=${decision.desiredCharging ? '1' : '0'}`,\n `perm=${decision.desiredPermission ? '1' : '0'}`,\n `l=${decision.desiredLatch ? '1' : '0'}`,\n `cur=${round1(desiredCurrentA)}`,\n `block=${decision.nextBlockReason}`,\n `stop=${decision.mustStop ? '1' : '0'}`,\n `prio=${evcsPriorityActive ? '1' : '0'}`,\n `night=${night.nightSessionActive ? '1' : '0'}`,\n `ntrg=${Number.isFinite(night.gridTakeoverTriggerPct) ? round1(night.gridTakeoverTriggerPct) : 'na'}`,\n `nurg=${night.batteryRecoveryUrgent ? '1' : '0'}`,\n `vdf=${vdf.evcsVehicleNoDemandSemantic ? '1' : '0'}`,\n `vdfs=${Number.isFinite(vdf.noDemandScoreEma) ? round3(vdf.noDemandScoreEma) : 'na'}`\n].join('|');\n\nconst signatureChanged = signature !== history.lastSignature;\n\n// =====================================================\n// NOTIFICATIONS\n// =====================================================\nlet notifyWanted = false;\nlet notifyTitle = 'Victron EVCS Surplus';\nlet notifyMessage = shortReason;\n\nif (parsed.notificationsEnabled && signatureChanged) {\n if (decision.currentDecision === 'start') {\n notifyWanted = true;\n notifyTitle = '⚡ EVCS démarrage';\n } else if (decision.currentDecision === 'step_up') {\n notifyWanted = true;\n notifyTitle = '⬆️ EVCS hausse courant';\n } else if (\n decision.currentDecision === 'step_down' ||\n decision.currentDecision === 'step_down_fast'\n ) {\n notifyWanted = true;\n notifyTitle = '⬇️ EVCS baisse courant';\n } else if (\n decision.mustStop ||\n decision.currentDecision === 'stop' ||\n decision.currentDecision === 'stop_hard' ||\n decision.currentDecision === 'stop_power_cap'\n ) {\n notifyWanted = true;\n notifyTitle = '⛔ EVCS arrêt';\n } else if (decision.state === 'mode_auto_conflict') {\n notifyWanted = true;\n notifyTitle = '⚠️ EVCS mode Auto';\n } else if (decision.state === 'vehicle_full_or_paused') {\n notifyWanted = true;\n notifyTitle = '🚗 EVCS véhicule plein ou en pause';\n }\n}\n\n// =====================================================\n// TIMESTAMPS TO WRITE\n// =====================================================\nconst cloudDebtStartedAtToWrite =\n cloud.cloudDebtStartedMs\n ? formatParisStampFromMs(cloud.cloudDebtStartedMs)\n : now.stamp;\n\n// DEPASSEMENT SOUPLE BATTERIE (01/09/2026) : chaine vide quand il n'y a pas\n// de depassement en cours. Le helper n'est reecrit que sur changement reel\n// (debut ou fin), pas a chaque cycle : la valeur du debut doit RESTER figee\n// pendant toute la duree du depassement, sinon le compteur ne monte jamais.\nconst batterySoftBreachAtToWrite =\n batteryPolicy.batterySoftBreachStartedMs !== null &&\n batteryPolicy.batterySoftBreachStartedMs !== undefined\n ? formatParisStampFromMs(batteryPolicy.batterySoftBreachStartedMs)\n : '';\n\nconst setBatterySoftBreachAt =\n batterySoftBreachAtToWrite !== (history.batterySoftBreachAt || '');\n\nconst probeStartedAtToWrite = now.stamp;\nconst lastStartAtToWrite = now.stamp;\nconst lastStopAtToWrite = now.stamp;\nconst lastCurrentChangeAtToWrite = now.stamp;\n\n// =====================================================\n// NIGHT SESSION MEMORY\n// =====================================================\nconst nightSessionStartSocToWrite =\n decision.clearNightSessionStart\n ? ''\n : decision.setNightSessionStart\n ? String(round1(parsed.soc))\n : (\n Number.isFinite(history.nightSessionStartSocPct)\n ? String(round1(history.nightSessionStartSocPct))\n : ''\n );\n\nconst nightSessionStartAtToWrite =\n decision.clearNightSessionStart\n ? ''\n : decision.setNightSessionStart\n ? now.stamp\n : (history.nightSessionStartAt || '');\n\n// =====================================================\n// OUTPUT\n// =====================================================\nreturn [{\n json: {\n timestamp_paris: now.stamp,\n inputs: input,\n\n computed: {\n operating_mode: context.operatingMode,\n profile: context.profile,\n source_mode: context.sourceMode,\n source_mode_label: sourceModeLabel,\n user_strategy: strategy.userStrategy,\n\n in_night_hc_window: windows.inNightHcWindow,\n in_morning_window: windows.inMorningWindow,\n in_midday_window: windows.inMiddayWindow,\n in_evening_window: windows.inEveningWindow,\n\n // D'ou vient la consigne reseau. Sans cette trace, un repli est\n // SILENCIEUX : la consigne reprend la formule d'avant sans que rien ne le\n // dise, et c'est precisement l'information qui manquera le jour ou elle se\n // comportera mal. `bilan` = dimensionnee sur le manque, `heritee` = repli.\n setpoint_source: setpointFromBalance ? 'bilan' : 'heritee',\n setpoint_battery_share_w: Math.round(batterySetpointShareW),\n setpoint_evcs_share_w: Math.round(evcsSetpointShareW),\n dvcc_limit_w: dvccChargeLimitW === null ? null : Math.round(dvccChargeLimitW),\n hc_minutes_left: hcMinutesLeft,\n\n departure_priority_active: context.departurePriorityActive,\n minutes_until_departure: lifecycle.minutesUntilDeparture,\n\n battery_priority_mode: modes.batteryPriorityMode,\n battery_full_harvest_mode: modes.batteryFullHarvestMode,\n forecast_commit_mode: modes.forecastCommitMode,\n\n physical_cap_current_a: derived.physicalCapA,\n can_run_at_min_current: derived.canRunAtMinCurrent,\n\n forecast_buffer_kwh: round2(derived.forecastBufferKwh),\n projected_end_soc_after_charge: round1(derived.projectedEndSocAfterCharge),\n\n cloud_debt_next_kwh: round2(cloud.cloudDebtNextKwh),\n cloud_debt_duration_minutes: round1(cloud.cloudDebtDurationMinutes),\n cloud_debt_active: cloud.cloudDebtActive,\n cloud_tolerance_ok: cloud.cloudToleranceOk,\n\n start_blocked_by_reason: locks.startBlockedByReason,\n\n probe_window_active: conditions.probeWindowActive,\n probe_success_now: conditions.probeSuccessNow,\n probe_failed_now: conditions.probeFailedNow,\n\n any_vebus_overload: derived.anyVebusOverload,\n any_vebus_low_battery: derived.anyVebusLowBattery,\n vebus_ac_out_soft_reached: derived.vebusAcOutSoftReached,\n vebus_ac_out_hard_reached: derived.vebusAcOutHardReached,\n vebus_dc_voltage_soft_low: derived.vebusDcVoltageSoftLow,\n vebus_dc_voltage_hard_low: derived.vebusDcVoltageHardLow,\n\n soc_band: batteryPolicy.socBand,\n batt_discharge_soft_dynamic_a: round1(batteryPolicy.batteryDischargeSoftDynamicA),\n batt_discharge_hard_dynamic_a: round1(batteryPolicy.batteryDischargeHardDynamicA),\n batt_charge_reserve_dynamic_a: round1(batteryPolicy.batteryChargeReserveDynamicA),\n batt_soft_tolerance_min: round1(batteryPolicy.batterySoftToleranceMinutes),\n batt_hard_tolerance_sec: round1(batteryPolicy.batteryHardToleranceSeconds),\n batt_recovery_min: round1(batteryPolicy.batteryRecoveryMinutes),\n grid_battery_soft_dynamic_a: round1(batteryPolicy.gridBatterySoftDynamicA),\n grid_battery_hard_dynamic_a: round1(batteryPolicy.gridBatteryHardDynamicA),\n house_spike_detected: batteryPolicy.houseSpikeDetected,\n solar_potential_context: batteryPolicy.solarPotentialContext,\n\n batt_soft_breach_now: !!batteryPolicy.batterySoftBreachNow,\n batt_soft_breach_minutes: round1(batteryPolicy.batterySoftBreachMinutes || 0),\n batt_soft_discharge_hold_ok: batteryPolicy.batterySoftDischargeHoldOk !== false,\n\n evcs_priority_active: evcsPriorityActive,\n energy_priority_mode: energyPriorityMode,\n energy_priority_reason: energyPriorityReason,\n evcs_priority_until: evcsPriorityUntil,\n evcs_priority_source: evcsPrioritySource,\n evcs_priority_manual_override_active: manualPriorityOverrideActive,\n\n night_session_active: night.nightSessionActive,\n night_session_needs_capture: !!night.nightSessionNeedsCapture,\n night_session_needs_clear: !!night.nightSessionNeedsClear,\n night_session_start_soc_pct: Number.isFinite(night.nightSessionStartSocPct)\n ? round1(night.nightSessionStartSocPct)\n : null,\n night_session_start_at: night.nightSessionStartAt || null,\n\n battery_hard_floor_pct: Number.isFinite(night.batteryHardFloorPct)\n ? round1(night.batteryHardFloorPct)\n : null,\n battery_morning_target_pct: Number.isFinite(night.batteryMorningTargetPct)\n ? round1(night.batteryMorningTargetPct)\n : null,\n max_battery_discharge_points: Number.isFinite(night.maxBatteryDischargePoints)\n ? round1(night.maxBatteryDischargePoints)\n : null,\n grid_takeover_trigger_pct: Number.isFinite(night.gridTakeoverTriggerPct)\n ? round1(night.gridTakeoverTriggerPct)\n : null,\n\n battery_recovery_urgent: !!night.batteryRecoveryUrgent,\n night_grid_takeover_needed: !!night.nightGridTakeoverNeeded,\n night_preferred_source_mode: night.nightPreferredSourceMode || 'blocked',\n\n evcs_delivery_ratio_ema: Number.isFinite(vdf.deliveryRatioEma) ? round3(vdf.deliveryRatioEma) : null,\n evcs_delivery_noise_ema: Number.isFinite(vdf.deliveryNoiseEma) ? round3(vdf.deliveryNoiseEma) : null,\n evcs_no_demand_score_ema: Number.isFinite(vdf.noDemandScoreEma) ? round3(vdf.noDemandScoreEma) : null,\n evcs_vehicle_no_demand_semantic: !!vdf.evcsVehicleNoDemandSemantic,\n evcs_no_demand_since_minutes: Number.isFinite(vdf.evcsNoDemandSinceMinutes) ? round3(vdf.evcsNoDemandSinceMinutes) : null,\n evcs_source_deliverable_for_no_demand: !!vdf.sourceDeliverableForNoDemand,\n evcs_session_armed_for_no_demand: !!vdf.evcsSessionArmedForNoDemand,\n evcs_no_demand_score_on: Number.isFinite(vdf.noDemandScoreOn) ? round3(vdf.noDemandScoreOn) : null,\n evcs_no_demand_score_off: Number.isFinite(vdf.noDemandScoreOff) ? round3(vdf.noDemandScoreOff) : null\n },\n\n decision: {\n state: decision.state,\n current_decision: decision.currentDecision,\n desired_charging: decision.desiredCharging,\n desired_permission: decision.desiredPermission,\n desired_latch: decision.desiredLatch,\n desired_current_a: round1(desiredCurrentA),\n\n charging_action: chargingAction,\n permission_action: permissionAction,\n latch_action: latchAction,\n current_action: currentAction,\n auto_start_action: autoStartAction,\n\n set_last_start_at: setLastStartAt,\n set_last_stop_at: setLastStopAt,\n set_last_current_change_at: setLastCurrentChangeAt,\n set_cloud_debt_started_at: decision.setCloudDebtStartedAt,\n set_probe_started_at: decision.setProbeStartedAt,\n\n set_night_session_start: !!decision.setNightSessionStart,\n clear_night_session_start: !!decision.clearNightSessionStart,\n\n set_block_reason: needsBlockReasonUpdate || !parsed.controllerEnabled,\n block_reason: decision.nextBlockReason,\n\n set_blocked_until: true,\n blocked_until: blockedUntilToWrite,\n\n dynamic_ok: decision.dynamicOk,\n must_stop: decision.mustStop,\n\n short_reason: shortReason,\n reason,\n signature,\n signature_changed: signatureChanged,\n\n should_notify: notifyWanted,\n notify_title: notifyTitle,\n notify_message: notifyMessage,\n\n evcs_priority_active: evcsPriorityActive,\n energy_priority_mode: energyPriorityMode,\n energy_priority_reason: energyPriorityReason,\n evcs_priority_until: evcsPriorityUntil,\n evcs_priority_source: evcsPrioritySource\n },\n\n ha_service_calls: {\n helper_state: {\n execute: true,\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.victron_evcs_surplus_state',\n value: decision.state\n }\n },\n\n helper_current_decision: {\n execute: true,\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.victron_evcs_surplus_current_decision',\n value: decision.currentDecision\n }\n },\n\n helper_reason: {\n execute: true,\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.victron_evcs_surplus_reason',\n value: shortReason\n }\n },\n\n helper_operating_mode: {\n execute: true,\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.victron_evcs_operating_mode',\n value: context.operatingMode\n }\n },\n\n helper_source_mode: {\n execute: true,\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.victron_evcs_source_mode',\n value: sourceModeLabel\n }\n },\n\n helper_target_current: {\n execute: true,\n domain: 'input_number',\n service: 'set_value',\n data: {\n entity_id: 'input_number.victron_evcs_target_current_auto',\n value: round1(desiredCurrentA)\n }\n },\n\n helper_budget_remaining: {\n execute: true,\n domain: 'input_number',\n service: 'set_value',\n data: {\n entity_id: 'input_number.victron_evcs_budget_remaining_kwh',\n value: round2(derived.forecastBufferKwh)\n }\n },\n\n helper_projected_soc: {\n execute: true,\n domain: 'input_number',\n service: 'set_value',\n data: {\n entity_id: 'input_number.victron_evcs_projected_end_soc_after_charge',\n value: round1(derived.projectedEndSocAfterCharge)\n }\n },\n\n helper_cloud_debt: {\n execute: true,\n domain: 'input_number',\n service: 'set_value',\n data: {\n entity_id: 'input_number.victron_evcs_cloud_debt_kwh',\n value: round2(cloud.cloudDebtNextKwh)\n }\n },\n\n helper_dynamic_ok: {\n execute: true,\n domain: 'input_boolean',\n service: decision.dynamicOk ? 'turn_on' : 'turn_off',\n data: {\n entity_id: 'input_boolean.victron_evcs_dynamic_ok'\n }\n },\n\n helper_must_stop: {\n execute: true,\n domain: 'input_boolean',\n service: decision.mustStop ? 'turn_on' : 'turn_off',\n data: {\n entity_id: 'input_boolean.victron_evcs_must_stop'\n }\n },\n\n // Bouton reset borne (19/09/2026) : momentane. Il a deja fait son effet\n // dans WINDOWS (sessionFresh forcee ce cycle) ; on le rebascule off pour\n // qu'il ne force pas un retest en boucle. N'ecrit que s'il etait ON.\n helper_reset_borne_ack: {\n execute: !!params.resetBorneRequested,\n domain: 'input_boolean',\n service: 'turn_off',\n data: {\n entity_id: 'input_boolean.evcs_reset_borne'\n }\n },\n\n helper_cloud_debt_started_at: {\n execute: true,\n domain: 'input_datetime',\n service: 'set_datetime',\n data: {\n entity_id: 'input_datetime.victron_evcs_cloud_debt_started_at',\n datetime: cloudDebtStartedAtToWrite\n }\n },\n\n helper_battery_soft_breach_at: {\n execute: setBatterySoftBreachAt,\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.victron_evcs_battery_soft_breach_at',\n value: batterySoftBreachAtToWrite\n }\n },\n\n helper_last_current_change_at: {\n execute: setLastCurrentChangeAt,\n domain: 'input_datetime',\n service: 'set_datetime',\n data: {\n entity_id: 'input_datetime.victron_evcs_last_current_change_at',\n datetime: lastCurrentChangeAtToWrite\n }\n },\n\n helper_last_start_at: {\n execute: setLastStartAt,\n domain: 'input_datetime',\n service: 'set_datetime',\n data: {\n entity_id: 'input_datetime.victron_evcs_last_start_at',\n datetime: lastStartAtToWrite\n }\n },\n\n helper_last_stop_at: {\n execute: setLastStopAt,\n domain: 'input_datetime',\n service: 'set_datetime',\n data: {\n entity_id: 'input_datetime.victron_evcs_last_stop_at',\n datetime: lastStopAtToWrite\n }\n },\n\n helper_signature: {\n execute: true,\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.victron_evcs_control_last_signature',\n value: signature\n }\n },\n\n helper_block_reason: {\n execute: needsBlockReasonUpdate || !parsed.controllerEnabled,\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.victron_evcs_last_block_reason',\n value: decision.nextBlockReason\n }\n },\n\n helper_blocked_until: {\n execute: true,\n domain: 'input_datetime',\n service: 'set_datetime',\n data: {\n entity_id: 'input_datetime.victron_evcs_blocked_until',\n datetime: blockedUntilToWrite\n }\n },\n\n helper_probe_started_at: {\n execute: decision.setProbeStartedAt,\n domain: 'input_datetime',\n service: 'set_datetime',\n data: {\n entity_id: 'input_datetime.victron_evcs_probe_started_at',\n datetime: probeStartedAtToWrite\n }\n },\n\n helper_evcs_priority_active: {\n execute: true,\n domain: 'input_boolean',\n service: evcsPriorityActive ? 'turn_on' : 'turn_off',\n data: {\n entity_id: 'input_boolean.victron_evcs_priority_active'\n }\n },\n\n helper_energy_priority_mode: {\n execute: true,\n domain: 'input_select',\n service: 'select_option',\n data: {\n entity_id: 'input_select.victron_energy_priority_mode',\n option: energyPriorityMode\n }\n },\n\n helper_energy_priority_reason: {\n execute: true,\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.victron_energy_priority_reason',\n value: energyPriorityReason\n }\n },\n\n helper_evcs_priority_until: {\n execute: true,\n domain: 'input_datetime',\n service: 'set_datetime',\n data: {\n entity_id: 'input_datetime.victron_evcs_priority_until',\n datetime: evcsPriorityUntil\n }\n },\n\n helper_night_target_soc: {\n execute: true,\n domain: 'input_number',\n service: 'set_value',\n data: {\n entity_id: 'input_number.victron_evcs_night_target_soc',\n value: round1(\n Number.isFinite(night.batteryMorningTargetPct)\n ? night.batteryMorningTargetPct\n : 0\n )\n }\n },\n\n helper_night_grid_trigger_soc: {\n execute: true,\n domain: 'input_number',\n service: 'set_value',\n data: {\n entity_id: 'input_number.victron_evcs_night_grid_trigger_soc',\n value: round1(\n Number.isFinite(night.gridTakeoverTriggerPct)\n ? night.gridTakeoverTriggerPct\n : 0\n )\n }\n },\n\n helper_night_preferred_source_mode: {\n execute: true,\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.victron_evcs_night_preferred_source_mode',\n value: night.nightPreferredSourceMode || 'blocked'\n }\n },\n\n // SESSION RESEAU UNIQUE (03/09/2026) : ce bit est ce qui rend la session\n // UNIQUE. Sans persistance, l'engagement serait reevalue a chaque cycle\n // et le systeme retomberait dans une bande — donc dans un oscillateur.\n // Ecrit uniquement sur changement reel.\n helper_night_grid_session_committed: {\n execute: night.nightGridSessionCommitted !== history.nightGridSessionCommittedPrev,\n domain: 'input_boolean',\n service: night.nightGridSessionCommitted ? 'turn_on' : 'turn_off',\n data: {\n entity_id: 'input_boolean.victron_evcs_night_grid_session_committed'\n }\n },\n\n // P12 — LE VERROU ANTI-OSCILLATEUR.\n //\n // Le plancher de surplus (`required_soc_prudent`) DESCEND pendant la nuit,\n // et le SOC aussi. Mesure du 15/09 : voiture en charge, le SOC descend de\n // 7,1 pt/h et le plancher de 3,7 ; voiture ARRETEE, le SOC ne descend plus\n // que de 2,6 pt/h quand le plancher continue a 3,7. Le surplus\n // REAPPARAITRAIT donc tout seul et la charge repartirait — cinquieme\n // oscillateur du projet, par construction.\n //\n // Une seule passe de surplus par session de nuit, comme la recovery\n // batterie depuis le 27/08. Ecrit uniquement sur changement reel.\n helper_night_battery_done: {\n execute: !!night.nightBatterySurplusExhausted !== !!params.nightBatteryDonePrev,\n domain: 'input_boolean',\n service: night.nightBatterySurplusExhausted ? 'turn_on' : 'turn_off',\n data: {\n entity_id: 'input_boolean.victron_evcs_night_battery_done'\n }\n },\n\n helper_night_grid_takeover_needed: {\n execute: true,\n domain: 'input_boolean',\n // P9 : la fenêtre \"départ imminent\" (jour) tient aussi le helper\n // takeover → override hystérésis + maintien batterie ACM pendant\n // la charge réseau pré-départ.\n service: (night.nightGridTakeoverNeeded || night.departureUrgentActive)\n ? 'turn_on'\n : 'turn_off',\n data: {\n entity_id: 'input_boolean.victron_evcs_night_grid_takeover_needed'\n }\n },\n\n helper_night_battery_recovery_urgent: {\n execute: true,\n domain: 'input_boolean',\n service: night.batteryRecoveryUrgent ? 'turn_on' : 'turn_off',\n data: {\n entity_id: 'input_boolean.victron_evcs_night_battery_recovery_urgent'\n }\n },\n\n helper_night_session_active: {\n execute: true,\n domain: 'input_boolean',\n service: night.nightSessionActive ? 'turn_on' : 'turn_off',\n data: {\n entity_id: 'input_boolean.victron_evcs_night_session_active'\n }\n },\n\n helper_night_session_start_soc: {\n execute: decision.setNightSessionStart || decision.clearNightSessionStart,\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.victron_evcs_night_session_start_soc',\n value: nightSessionStartSocToWrite\n }\n },\n\n // Budget voiture de la nuit (30/09) : fige a l'ouverture, efface en fin.\n helper_night_budget: {\n execute: night.nightBudgetToWrite !== null && night.nightBudgetToWrite !== undefined,\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.victron_evcs_night_budget',\n value: night.nightBudgetToWrite ?? ''\n }\n },\n\n helper_night_session_start_at: {\n execute: decision.setNightSessionStart || decision.clearNightSessionStart,\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.victron_evcs_night_session_start_at',\n value: nightSessionStartAtToWrite\n }\n },\n\n // =================================================\n // VEHICLE DEMAND FILTER HISTORY PERSISTENCE\n // =================================================\n helper_evcs_delivery_ratio_ema: {\n execute: Number.isFinite(vdf?.nextHistory?.evcsDeliveryRatioEma),\n domain: 'input_number',\n service: 'set_value',\n data: {\n entity_id: 'input_number.victron_evcs_delivery_ratio_ema',\n value: round3(vdf.nextHistory.evcsDeliveryRatioEma)\n }\n },\n\n helper_evcs_delivery_noise_ema: {\n execute: Number.isFinite(vdf?.nextHistory?.evcsDeliveryNoiseEma),\n domain: 'input_number',\n service: 'set_value',\n data: {\n entity_id: 'input_number.victron_evcs_delivery_noise_ema',\n value: round3(vdf.nextHistory.evcsDeliveryNoiseEma)\n }\n },\n\n helper_evcs_no_demand_score_ema: {\n execute: Number.isFinite(vdf?.nextHistory?.evcsNoDemandScoreEma),\n domain: 'input_number',\n service: 'set_value',\n data: {\n entity_id: 'input_number.victron_evcs_no_demand_score_ema',\n value: round3(vdf.nextHistory.evcsNoDemandScoreEma)\n }\n },\n\n helper_evcs_no_demand_semantic_active: {\n execute: true,\n domain: 'input_boolean',\n service: vdf?.nextHistory?.evcsNoDemandSemanticActive ? 'turn_on' : 'turn_off',\n data: {\n entity_id: 'input_boolean.victron_evcs_no_demand_semantic_active'\n }\n },\n\n helper_evcs_no_demand_since_at: {\n execute: Number.isFinite(vdf?.nextHistory?.evcsNoDemandSinceMs),\n domain: 'input_datetime',\n service: 'set_datetime',\n data: {\n entity_id: 'input_datetime.victron_evcs_no_demand_since_at',\n datetime: formatParisStampFromMs(vdf.nextHistory.evcsNoDemandSinceMs)\n }\n },\n\n helper_evcs_no_demand_filter_eval_at: {\n execute: Number.isFinite(vdf?.nextHistory?.evcsNoDemandFilterEvalMs),\n domain: 'input_datetime',\n service: 'set_datetime',\n data: {\n entity_id: 'input_datetime.victron_evcs_no_demand_filter_eval_at',\n datetime: formatParisStampFromMs(vdf.nextHistory.evcsNoDemandFilterEvalMs)\n }\n },\n\n // =================================================\n // PHYSICAL EDF GRID TAKEOVER\n // =================================================\n edf_general_ok_on: {\n execute:\n ['grid_forced', 'grid_hc_allowed'].includes(context.sourceMode) &&\n !parsed.gridAvailable,\n domain: 'switch',\n service: 'turn_on',\n data: {\n entity_id: 'switch.edf_general_ok_switch'\n }\n },\n\n grid_power_setpoint: {\n execute: gridSetpointNeedsWrite,\n domain: 'number',\n service: 'set_value',\n data: {\n entity_id: 'number.gx_device_ac_power_setpoint',\n value: gridSetpointTargetW\n }\n },\n\n evcs_auto_start_off: {\n execute: autoStartAction === 'turn_off',\n domain: 'switch',\n service: 'turn_off',\n data: {\n entity_id: 'switch.victron_evcs_auto_start'\n }\n },\n\n evcs_charging: {\n execute: chargingAction !== 'none',\n domain: 'switch',\n service: decision.desiredCharging ? 'turn_on' : 'turn_off',\n data: {\n entity_id: 'switch.victron_evcs_charging'\n }\n },\n\n helper_permission: {\n execute: permissionAction !== 'none',\n domain: 'input_boolean',\n service: decision.desiredPermission ? 'turn_on' : 'turn_off',\n data: {\n entity_id: 'input_boolean.victron_evcs_charge_permission_now'\n }\n },\n\n helper_latch: {\n execute: latchAction !== 'none',\n domain: 'input_boolean',\n service: decision.desiredLatch ? 'turn_on' : 'turn_off',\n data: {\n entity_id: 'input_boolean.victron_evcs_control_latch'\n }\n },\n\n evcs_set_current: {\n execute: currentAction === 'set',\n domain: 'number',\n service: 'set_value',\n data: {\n entity_id: 'number.victron_evcs_set_current_command',\n value: round1(desiredCurrentA)\n }\n },\n\n logbook: {\n execute: signatureChanged,\n domain: 'logbook',\n service: 'log',\n data: {\n name: 'Victron EVCS Surplus',\n message: shortReason\n }\n },\n\n mobile_notification: {\n execute: notifyWanted,\n domain: cfg.mobileNotifyDomain,\n service: cfg.mobileNotifyService,\n data: {\n title: notifyTitle,\n message: notifyMessage || shortReason\n }\n }\n }\n }\n}];\n" + "jsCode": "// Bornes de la consigne de puissance reseau. Le maximum est celui declare par\n// l'entite elle-meme (number.airtria_mpii_v1_ac_power_setpoint_l1, max 10000 W),\n// pas un chiffre choisi ici. La bande morte evite de reecrire la meme valeur\n// toutes les 30 s sans empecher la correction d'une derive externe.\nconst GRID_SETPOINT_MAX_W = 10000;\nconst GRID_SETPOINT_DEADBAND_W = 100;\n\nfunction round1(v) {\n return Math.round(v * 10) / 10;\n}\n\nfunction round2(v) {\n return Math.round(v * 100) / 100;\n}\n\nfunction round3(v) {\n return Math.round(v * 1000) / 1000;\n}\n\nfunction clamp(v, min, max) {\n return Math.min(Math.max(v, min), max);\n}\n\nfunction to2(n) {\n return String(n).padStart(2, '0');\n}\n\nfunction formatParisStampFromMs(ms) {\n const fmt = new Intl.DateTimeFormat('fr-FR', {\n timeZone: 'Europe/Paris',\n year: 'numeric',\n month: '2-digit',\n day: '2-digit',\n hour: '2-digit',\n minute: '2-digit',\n second: '2-digit',\n hour12: false,\n });\n\n const parts = Object.fromEntries(\n fmt.formatToParts(new Date(ms))\n .filter(p => p.type !== 'literal')\n .map(p => [p.type, p.value])\n );\n\n const hour = Number(parts.hour) === 24 ? 0 : Number(parts.hour);\n return `${parts.year}-${parts.month}-${parts.day} ${to2(hour)}:${parts.minute}:${parts.second}`;\n}\n\nconst prev = $input.first().json;\n\nconst {\n input,\n now,\n nowMs,\n cfg,\n parsed,\n params,\n history,\n strategy,\n windows,\n lifecycle,\n context,\n derived,\n batteryTune,\n batteryPolicy,\n locks,\n cloud,\n modes,\n safeguards,\n conditions,\n decision,\n nightEvcs,\n vehicleDemandFilter,\n} = prev;\n\n// =====================================================\n// SAFE ACCESS NIGHT EVCS\n// =====================================================\nconst night = nightEvcs ?? {\n nightSessionActive: false,\n nightSessionNeedsCapture: false,\n nightSessionNeedsClear: false,\n nightSessionStartSocPct: null,\n nightSessionStartAt: '',\n batteryHardFloorPct: null,\n batteryMorningTargetPct: null,\n maxBatteryDischargePoints: null,\n gridTakeoverTriggerPct: null,\n batteryRecoveryUrgent: false,\n nightGridTakeoverNeeded: false,\n nightPreferredSourceMode: 'blocked',\n};\n\n// =====================================================\n// SAFE ACCESS VEHICLE DEMAND FILTER\n// =====================================================\nconst vdf = vehicleDemandFilter ?? {\n dtSec: null,\n commandCurrentA: null,\n realCurrentA: null,\n expectedPowerW: null,\n realPowerW: null,\n currentDeliveryRatioRaw: null,\n powerDeliveryRatioRaw: null,\n deliveryRatioRaw: null,\n deliveryRatioEma: null,\n deliveryNoiseEma: null,\n adaptiveQuietBand: null,\n noDemandEvidenceRaw: null,\n sessionElapsedSec: null,\n sessionArmFactor: null,\n commandFactor: null,\n sourceDeliverableForNoDemand: false,\n evcsSessionArmedForNoDemand: false,\n noDemandScoreRaw: null,\n noDemandScoreEma: null,\n noDemandScoreOn: null,\n noDemandScoreOff: null,\n evcsVehicleNoDemandSemantic: false,\n evcsNoDemandSinceMs: null,\n evcsNoDemandSinceMinutes: null,\n nextHistory: {\n evcsDeliveryRatioEma: null,\n evcsDeliveryNoiseEma: null,\n evcsNoDemandScoreEma: null,\n evcsNoDemandSemanticActive: false,\n evcsNoDemandSinceMs: null,\n evcsNoDemandFilterEvalMs: null\n }\n};\n\n// =====================================================\n// FINAL ACTIONS\n// =====================================================\nlet desiredCurrentA = decision.desiredCurrentA;\n\ndesiredCurrentA = decision.desiredCharging\n ? clamp(\n desiredCurrentA,\n params.currentMinA,\n Math.max(params.currentMinA, derived.physicalCapA || params.currentMinA)\n )\n : params.currentMinA;\n\nconst chargingAction =\n decision.desiredCharging === parsed.evcsChargingSwitchOn\n ? 'none'\n : decision.desiredCharging\n ? 'turn_on'\n : 'turn_off';\n\nconst permissionAction =\n decision.desiredPermission === parsed.chargePermissionNow\n ? 'none'\n : decision.desiredPermission\n ? 'turn_on'\n : 'turn_off';\n\nconst latchAction =\n decision.desiredLatch === parsed.controlLatch\n ? 'none'\n : decision.desiredLatch\n ? 'turn_on'\n : 'turn_off';\n\nconst autoStartAction =\n parsed.controllerEnabled &&\n parsed.evcsVehicleConnected &&\n parsed.evcsAutoStartSwitchOn\n ? 'turn_off'\n : 'none';\n\nconst currentAction =\n decision.desiredCharging &&\n (\n !Number.isFinite(derived.currentReferenceA) ||\n Math.abs(desiredCurrentA - derived.currentReferenceA) >= 0.5\n )\n ? 'set'\n : 'none';\n\nconst setLastStartAt = chargingAction === 'turn_on';\nconst setLastStopAt = chargingAction === 'turn_off';\nconst setLastCurrentChangeAt = currentAction === 'set';\n\n// =====================================================\n// CLEAN BLOCKING LOGIC\n// =====================================================\nconst blockedUntilToWrite =\n decision.nextBlockReason === 'none'\n ? now.stamp\n : decision.nextBlockedUntilStamp;\n\nconst needsBlockReasonUpdate =\n decision.nextBlockReason !== history.lastBlockReason;\n\n// =====================================================\n// GLOBAL EVCS PRIORITY\n// AUTO + OVERRIDE MANUEL\n// =====================================================\nconst forceWindowMinutes = Math.max(\n 30,\n batteryTune?.global?.forceModeAutoRevertMinutes ?? 120\n);\n\nconst activeChargeIntent =\n decision.desiredCharging ||\n ['start', 'hold', 'step_up', 'step_down', 'step_down_fast'].includes(decision.currentDecision);\n\nconst evcsVehicleNotRequestingPowerState =\n decision.state === 'vehicle_full_or_paused' ||\n decision.currentDecision === 'stop_vehicle_full_or_paused';\n\n// -------------------------\n// PRIORITÉ AUTO\n// -------------------------\nlet autoEvcsPriorityActive = false;\nlet autoEnergyPriorityMode = 'normal';\nlet autoEnergyPriorityReason = 'Aucune priorité EVCS';\nlet autoEvcsPriorityUntil = now.stamp;\n\nif (\n parsed.evcsVehicleConnected &&\n !strategy.isStopMode &&\n !evcsVehicleNotRequestingPowerState\n) {\n if (strategy.forceChargeRequested || strategy.forceGridRequested) {\n autoEvcsPriorityActive = true;\n autoEnergyPriorityMode = 'evcs_priority';\n autoEnergyPriorityReason =\n strategy.forceGridRequested\n ? 'EVCS prioritaire : force_grid_now'\n : 'EVCS prioritaire : force_charge_now';\n autoEvcsPriorityUntil = formatParisStampFromMs(nowMs + forceWindowMinutes * 60000);\n }\n\n else if (night.nightSessionActive) {\n autoEvcsPriorityActive = true;\n autoEnergyPriorityMode = 'evcs_priority';\n autoEnergyPriorityReason =\n night.batteryRecoveryUrgent\n ? 'EVCS prioritaire : session nuit + urgence départ'\n : night.nightGridTakeoverNeeded\n ? 'EVCS prioritaire : session nuit + takeover réseau'\n : 'EVCS prioritaire : session nuit active';\n\n autoEvcsPriorityUntil =\n Number.isFinite(lifecycle.minutesUntilDeparture)\n ? formatParisStampFromMs(\n nowMs + Math.max(1, lifecycle.minutesUntilDeparture) * 60000\n )\n : formatParisStampFromMs(nowMs + forceWindowMinutes * 60000);\n }\n\n else if (context.departurePriorityActive && Number.isFinite(lifecycle.minutesUntilDeparture)) {\n autoEvcsPriorityActive = true;\n autoEnergyPriorityMode = 'evcs_priority';\n autoEnergyPriorityReason = `EVCS prioritaire : départ dans ${Math.round(lifecycle.minutesUntilDeparture)} min`;\n autoEvcsPriorityUntil = formatParisStampFromMs(\n nowMs + Math.max(1, lifecycle.minutesUntilDeparture) * 60000\n );\n }\n\n else if (activeChargeIntent && context.profile === 'midday_hc_or_solar') {\n autoEvcsPriorityActive = true;\n autoEnergyPriorityMode = 'evcs_priority';\n autoEnergyPriorityReason = 'EVCS prioritaire : recharge active fenêtre midi';\n autoEvcsPriorityUntil = formatParisStampFromMs(nowMs + 90 * 60000);\n }\n}\n\n// -------------------------\n// OVERRIDE MANUEL\n// -------------------------\nconst manualPriorityOverrideActive =\n parsed.evcsPriorityManualOverride &&\n parsed.evcsPriorityManualMode === 'evcs_priority' &&\n history.evcsPriorityManualUntilMs !== null &&\n history.evcsPriorityManualUntilMs > nowMs + 1000;\n\nlet evcsPriorityActive = autoEvcsPriorityActive;\nlet energyPriorityMode = autoEnergyPriorityMode;\nlet energyPriorityReason = autoEnergyPriorityReason;\nlet evcsPriorityUntil = autoEvcsPriorityUntil;\nlet evcsPrioritySource = 'auto';\n\nif (manualPriorityOverrideActive && !evcsVehicleNotRequestingPowerState) {\n evcsPriorityActive = true;\n energyPriorityMode = 'evcs_priority';\n energyPriorityReason =\n parsed.evcsPriorityManualReason || 'EVCS prioritaire : override manuel';\n evcsPriorityUntil =\n history.evcsPriorityManualUntilAt || autoEvcsPriorityUntil;\n evcsPrioritySource = 'manual';\n}\n\n// =====================================================\n// REASONS / LABELS / SIGNATURE\n// =====================================================\nconst reason = [...decision.reason];\n\n// Budget voiture de la nuit (30/09) : dire pourquoi la voiture s'arrete.\nif (night.nightBudgetKwh !== null && night.nightBudgetKwh !== undefined) {\n const f = (x) => String(Math.round(x * 100) / 100).replace('.', ',');\n reason.push(`Budget batterie nuit ${f(night.nightBudgetDeliveredKwh ?? 0)}/${f(night.nightBudgetKwh)} kWh` +\n (night.nightBatteryGridOnly ? ' — sans EDF : EDF ferme pour la batterie seule, la voiture attend le soleil'\n : night.nightCarWaitsNoGrid ? ' — atteint, sans EDF : la voiture attend le soleil'\n : night.nightBudgetReached ? ' — atteint, EDF finit la charge' : ''));\n} else if (night.nightCarWaitsNoGrid) {\n reason.push('Pas de budget batterie exploitable, sans EDF : la voiture attend le soleil');\n}\nreason.push(`Stratégie=${strategy.userStrategy}`);\nreason.push(`Profil=${context.profile}`);\nreason.push(`Source=${context.sourceMode}`);\nreason.push(\n `Mode=${\n modes.batteryFullHarvestMode\n ? 'battery_full_harvest_mode'\n : modes.forecastCommitMode\n ? 'forecast_commit_mode'\n : 'battery_priority_mode'\n }`\n);\nreason.push(`SOC=${round1(parsed.soc)}%`);\nreason.push(`Batt=${round1(parsed.batteryCurrentA)}A`);\nreason.push(`PV=${Math.round(parsed.pvPowerW)}W`);\nreason.push(`PV10=${Math.round(parsed.pvPowerAvg10mW)}W`);\n// =====================================================\n// CONSIGNE DE PUISSANCE RESEAU (01/09/2026)\n// =====================================================\n// Fermer le contacteur ne suffit PAS. Au-dessus du SOC minimal, l'ESS en\n// autoconsommation ne preleve que `ac_power_setpoint` et laisse la batterie\n// porter tout le reste : nuit du 01/09, contacteur ferme, consigne 50 W,\n// batterie a -803 W pour 761 W de maison, voiture arretee par le garde\n// \"batterie trop sollicitee\". La consigne est donc le levier qui rend une\n// session reseau REELLE, et il doit appartenir au controleur, pas rester un\n// reglage manuel permanent (celui-ci provoquait la substitution du solaire\n// par le reseau en journee, cf. HANDOFF 27/28).\n//\n// La valeur n'est pas une constante : c'est le besoin mesure du moment, la\n// maison telle qu'elle consomme plus la voiture au courant que le controleur\n// s'autorise. Le plafond joue le role de marge : quand la voiture n'est pas a\n// son plafond, la consigne est deja genereuse, et l'exces ne peut pas partir\n// dans la batterie (DVCC tenue a 2 A par l'Auto Charge Manager pendant un\n// takeover). Hors session, retour a la valeur de repos.\n// ⚠️ LA SESSION ENGAGEE COMPTE AUSSI (04/09/2026).\n// Nuit du 03-04/09 : la session reseau unique a tenu le contacteur toute la\n// nuit — une seule bascule, comme voulu. Mais a 02:50, la voiture finie, le\n// sourceMode est passe a 'blocked' et la consigne est retombee de 5078 W a\n// 50 W. Contacteur ferme, reseau disponible, heures creuses, et l'ESS ne\n// demandait que 50 W pendant que la maison tirait 770 W SUR LA BATTERIE.\n// Trois heures et demie, SOC 44 % -> 17 %, 2,60 kWh pris a la batterie quand\n// le reseau etait la et bon marche.\n//\n// C'est le defaut du 01/09 revenu ailleurs : fermer le contacteur ne suffit\n// pas, la consigne doit demander la puissance. J'avais conditionne la consigne\n// au sourceMode, puis cree la veille une NOUVELLE raison de garder le\n// contacteur ferme — l'engagement de session — sans la brancher ici. La\n// session tenait le contacteur, pas l'energie.\n//\n// Regle : tant que le contacteur est ferme A NOTRE DEMANDE, la consigne doit\n// couvrir la maison. Peu importe laquelle des deux raisons le tient.\nconst gridSessionActive =\n parsed.acInAvailable &&\n (\n context.sourceMode === 'grid_forced' ||\n context.sourceMode === 'grid_hc_allowed' ||\n night.nightGridSessionCommitted === true ||\n // Batterie seule sur le reseau, voiture bloquee « sans EDF » (04/10).\n night.nightBatteryGridOnly === true\n );\n\n// =====================================================\n// DIMENSIONNEMENT SUR LE DEFICIT DU BILAN (18/09/2026)\n// =====================================================\n// Ce que la consigne remplace : `maison + plafond de la borne`, ou le plafond\n// de la borne n'a AUCUNE relation physique avec ce dont la batterie a besoin.\n// Qu'il tombe juste est un accident — releve du 17/09 a 13:51, voiture partie\n// depuis cinq minutes, consigne a 7032 W pour une maison a 933 W.\n//\n// La consigne additionne desormais des besoins NOMMES :\n// maison toujours ;\n// voiture seulement si un vehicule est branche ;\n// batterie seulement dans une fenetre d'heures creuses.\n//\n// ⚠️ MAX ET NON ADDITION, et ce n'est pas de la prudence. La consigne est une\n// AUTORISATION, pas une prediction de consommation — le commentaire de la bande\n// asymetrique le dit plus bas : « la consigne dit ce que le reseau a le DROIT\n// de servir, DVCC dit ce que la batterie a le droit de prendre ». Additionner\n// deux autorisations independantes n'est pas plus honnete, seulement plus\n// permissif. Mesure a l'appui : avec l'addition,\n// `consigne-session-engagee-couvre-la-maison` passe de 5595 a 6341 W, soit\n// 746 W d'autorisation en plus sur une nuit engagee qui fonctionne deja — une\n// regression sur le chemin DOMINANT pour corriger un chemin emprunte zero fois\n// en treize jours.\n//\n// ⚠️ ET LE PLAFOND DU TERME BATTERIE EST DVCC, PAS LE BMS. Le BMS declare\n// 250 A, soit 12,2 kW a 49 V : au-dessus de GRID_SETPOINT_MAX_W, donc il ne\n// bornerait rien. Mesure : en fin de fenetre (14 min restantes, manque\n// 5,672 kWh) la division rendait 17 kW, ecretes a 10 000 — PIRE que les 5437\n// d'aujourd'hui. Avec DVCC le terme vaut 1538 W et la consigne 2380 W.\n// Composer les deux leviers, ce n'est pas les confondre : le reseau n'est\n// autorise a servir la batterie qu'a hauteur de ce que la batterie est\n// autorisee a prendre.\n//\n// Corollaire : aucun plancher d'horizon n'est necessaire. Un horizon qui tend\n// vers zero donne `min(enorme, DVCC) = DVCC`. Il ne reste qu'une garde d'une\n// minute, qui est de l'arithmetique defensive et non une constante calibree.\n//\n// INVARIANT TENU PAR LA FORME : `maison + max(...)` ne peut jamais descendre\n// sous la maison, quel que soit DVCC. La regle du 01/09 et du 04/09 — « tant\n// que le contacteur est ferme A NOTRE DEMANDE, la consigne doit couvrir la\n// maison » — est donc garantie par construction. A DVCC nul, le plafond vaut\n// exactement la maison.\nconst SETPOINT_BALANCE_MAX_AGE_MIN = 30;\nconst setpointBalanceAgeMin = Number.isFinite(params.balanceLastRunMs)\n ? (nowMs - params.balanceLastRunMs) / 60000\n : null;\n\n// Le manque, pas l'energie a couvrir : voir le commentaire d'INPUT_PARSE.\nconst balanceUsableForSetpoint =\n params.balanceStatusOk &&\n Number.isFinite(params.balanceGridEnergyNeededKwh) &&\n setpointBalanceAgeMin !== null &&\n setpointBalanceAgeMin >= -5 &&\n setpointBalanceAgeMin <= SETPOINT_BALANCE_MAX_AGE_MIN;\n\nconst dvccChargeLimitW =\n Number.isFinite(params.dvccChargeLimitA) && params.dvccChargeLimitA >= 0\n ? params.dvccChargeLimitA * derived.batteryVoltageRef\n : null;\n\n// Toute lecture manquante retombe sur la formule d'avant : degradation vers\n// l'existant, jamais pire.\nconst setpointFromBalance = balanceUsableForSetpoint && dvccChargeLimitW !== null;\n\nconst evcsSetpointShareW = parsed.evcsVehicleConnected\n ? derived.physicalCapA * derived.acVoltageRef\n : 0;\n\n// Recharger la batterie sur le reseau n'a de sens economique QUE dans une\n// fenetre creuse. Hors fenetre le terme vaut zero — ce n'est pas un repli\n// faute d'horizon, c'est le comportement voulu, et le probleme de la division\n// par un temps qui tend vers zero disparait par construction.\nconst hcMinutesLeft = Number.isFinite(windows.minutesRemainingInHcWindow)\n ? windows.minutesRemainingInHcWindow\n : null;\n\nconst batterySetpointShareW =\n setpointFromBalance && hcMinutesLeft !== null && params.balanceGridEnergyNeededKwh > 0\n ? Math.min(\n (params.balanceGridEnergyNeededKwh * 1000) / Math.max(hcMinutesLeft / 60, 1 / 60),\n dvccChargeLimitW\n )\n : 0;\n\n// =====================================================\n// LE REPLI NE DOIT PAS RESSUSCITER LE DEFAUT (18/09/2026)\n// =====================================================\n// Quand la garde du bilan tombe -- statut degrade, mesure perimee, champ\n// illisible -- la consigne revenait a `maison + plafond de borne`, y compris\n// sans voiture. C'est-a-dire au defaut d'origine, et precisement au moment ou\n// le systeme est deja degrade. Mesure sur le chemin concerne (midi, fenetre HC\n// autorisee, aucun vehicule, bilan indisponible) : 5725 W autorises pour une\n// maison a 900 W. Avec la part voiture : 1130 W.\n//\n// La branche de repli emploie donc la meme part voiture que la branche\n// nominale. L'invariant du 01/09 et du 04/09 reste tenu PAR LA FORME : le\n// plafond vaut `maison + un terme positif ou nul`, il ne descend donc jamais\n// sous la maison, quelle que soit la branche empruntee.\n//\n// ⚠️ NE PAS REINTRODUIRE UN DISJOINT `nightGridSessionCommitted ||\n// forceGridRequested` SUR LA PART VOITURE. Il a ete ecrit, mesure, puis\n// retire : il ECRASE le terme batterie du bilan, puisque `max(4825 emprunte,\n// 1538 mesure)` retient l'emprunt. Cout mesure, trois attendus figes d'un coup :\n// nuit-sans-vehicule-session-engage 2380 -> 5437\n// consigne-bilan-fin-de-fenetre-bornee-par-dvcc 2380 -> 5437\n// consigne-bilan-plancher-nuage-exclut-la-batterie 4219 -> 6301\n// Ce disjoint avait un sens tant que la consigne n'avait AUCUN terme batterie.\n// Depuis qu'elle en a un, dimensionne sur le manque reel, il n'est plus une\n// protection mais un court-circuit.\n//\n// Et si l'idee revient malgre tout : pendant une panne de bilan,\n// `nightGridSessionCommitted` ne peut engager aucune session NOUVELLE, il ne\n// survit que par `committedPrev`. Dans le repli, ce sont donc\n// `forceGridRequested` et le terme vehicule qui portent reellement.\nconst gridSetpointCeilingW = clamp(\n Math.round(\n setpointFromBalance\n ? derived.houseNonEvcsW + Math.max(evcsSetpointShareW, batterySetpointShareW)\n : derived.houseNonEvcsW + evcsSetpointShareW\n ),\n params.gridSetpointIdleW,\n GRID_SETPOINT_MAX_W\n);\n\nconst gridSetpointCurrentW = Number.isFinite(parsed.gridSetpointW)\n ? parsed.gridSetpointW\n : null;\n\n// =====================================================\n// CREDIT SOLAIRE SUR LA CONSIGNE (11/09/2026)\n// =====================================================\n// Defaut mesure ce jour-la : BOOST 28 A en plein soleil, consigne calculee a\n// 8242 W (maison 1802 + 28 A x 230). L'ESS n'a qu'un moyen de prelever 8,2 kW\n// quand le soleil en donne 4 : brider les MPPT. Releve 17:02 -- PV 121 W,\n// reseau 7697 W, batterie 0 A, SOC 96 %. La production etait refusee, et par\n// NOTRE consigne. Le commentaire ci-dessus (\"une consigne trop HAUTE ne coute\n// rien\") a ete ecrit la nuit du 01/09, ou la PV valait zero : il n'y avait\n// alors rien a evincer. En plein jour il est faux.\n//\n// ⚠️ ON NE PEUT PAS SOUSTRAIRE LA PV MESUREE. Elle vaut 121 W *a cause* de\n// cette consigne : mesure censuree, la soustraire ne bouge rien et le systeme\n// reste sur son mauvais equilibre. Preuve le meme jour a 17:22 -- consigne\n// descendue A LA MAIN a 2280 W : PV 4758 W en 50 secondes, batterie +9 A. La\n// ressource etait la, seule la consigne l'evincait.\n//\n// On DESCEND donc, et on laisse la physique repondre. Deux signaux, tous deux\n// deja lus et tous deux INCENSURABLES :\n// - les chargeurs declarent eux-memes s'ils se BRIDENT (mpptLimitedCount).\n// `off` ne compte pas : la nuit ils sont tous `off` et il n'y a rien a\n// liberer -- c'est ce qui garantit que le comportement de nuit (§31, §57)\n// est strictement inchange ;\n// - le courant batterie dit si on est descendu trop bas, via la politique\n// par bande de SOC deja calibree (batterySoftBreachNow, §35).\n// La valeur courante vit dans le GX : l'etat est deja persiste, aucun helper\n// nouveau n'est necessaire.\n//\n// PLANCHER : la consigne ne descend JAMAIS sous la maison hors voiture. C'est\n// la garantie du 01/09 et du 04/09 -- le reseau, quand le contacteur est ferme\n// a notre demande, couvre la maison. On ne libere que la part VOITURE, qui est\n// exactement celle qui evince le solaire. Le solaire pourrait souvent couvrir\n// la maison en plus, mais l'affirmer demanderait ses propres scenarios.\n// ⚠️ SECOND PLANCHER : LE NUAGE (ajoute 11/09 18:05, apres incident).\n// Premiere version de la descente : consigne tombee a 1281 W alors que le\n// solaire portait tout (PV 6058 W, reseau 1163 W, voiture a 28 A). Un nuage a\n// fait chuter la PV de 4,6 kW en une minute ; le reseau etant plafonne a\n// 1281 W, la difference est tombee entierement sur la batterie -- 87 A pour\n// une limite dure de 70 A -> `battery_current_hard_stop`, charge coupee.\n//\n// Sans injection, la production peut s'effondrer de plusieurs kW en quelques\n// secondes. Une boucle a 30 s ne peut pas rattraper cela : il faut que le\n// reseau soit DEJA autorise a prendre le relais. La consigne ne descend donc\n// jamais au point ou la perte totale du solaire depasserait ce que la batterie\n// sait encaisser.\n//\n// Le budget vient de la politique batterie deja calibree par bande de SOC\n// (§35), pas d'une constante nouvelle, et on garde 20 % de marge sous la\n// limite DURE -- c'est elle qui coupe la charge, et une coupure est plus\n// couteuse que quelques centaines de watts de solaire non captes.\n//\n// Contrepartie assumee, et elle est reelle : voiture a 28 A, la descente est\n// nettement moins profonde qu'a 10 A. C'est proportionne -- c'est le courant\n// de la voiture qui cree l'exposition. Et c'est la regle du proprietaire :\n// protéger le materiel prime sur l'economie de kilowattheures.\n// `derived.batteryVoltageRef` est deja la tension retenue en amont (mesure\n// vebus, repli 51,2 V) : meme grandeur que celle qui sert a convertir le\n// courant batterie en puissance dans BATTERY_CLOUD_LOCKS.\nconst batterySurgeBudgetW = Math.max(\n 0,\n Math.abs(batteryPolicy.batteryDischargeHardDynamicA) * derived.batteryVoltageRef * 0.8\n);\n// ⚠️ LE PLANCHER NUAGE SUIT LA VOITURE, PAS LA BATTERIE. Il existe pour que la\n// consigne ne descende pas au point ou la perte totale du solaire depasserait\n// ce que la batterie sait encaisser — et le commentaire ci-dessus dit pourquoi :\n// « c'est le courant de la voiture qui cree l'exposition ». Une batterie qu'on\n// recharge depuis le reseau n'est PAS une exposition : si le solaire s'effondre,\n// la recharge ralentit et personne ne se fait couper. L'y inclure remonterait le\n// plancher sans aucune contrepartie de securite, et contre l'objectif.\n// Une seule branche depuis le 18/09/2026 : le plancher nuage se construit sur\n// la maison et la voiture dans les deux cas. La distinction n'avait de sens que\n// tant que le repli gardait le plafond de borne emprunte.\nconst gridSetpointLoadW = derived.houseNonEvcsW + evcsSetpointShareW;\nconst gridSetpointCloudFloorW = Math.round(\n Math.max(0, gridSetpointLoadW - batterySurgeBudgetW)\n);\n\nconst gridSetpointHouseFloorW = clamp(\n Math.max(Math.round(derived.houseNonEvcsW), gridSetpointCloudFloorW),\n params.gridSetpointIdleW,\n GRID_SETPOINT_MAX_W\n);\n\nconst solarResourceRefused =\n parsed.mpptReadableCount > 0 && parsed.mpptLimitedCount > 0;\n\n// Pendant la fenetre d'etablissement (§29) les mesures PV et batterie ne\n// decrivent aucun regime permanent : on ne descend pas sur elles.\nconst gridSetpointDescendOk =\n gridSessionActive &&\n solarResourceRefused &&\n !batteryPolicy.batterySoftBreachNow &&\n !derived.gridSettling &&\n gridSetpointCurrentW !== null;\n\n// ⚠️ LE PLAFOND EST UN PLAFOND, PAS UNE CIBLE (corrige 11/09 17:52).\n// Premiere version de ce correctif, en production 12 minutes : la consigne\n// descendait bien a 2241 W et la PV remontait a 7074 W -- puis, tous les\n// chargeurs repassant `mppt_active`, la condition de descente devenait fausse,\n// le besoin revenait au plafond et la consigne etait REECRITE a 7100 W. La PV\n// s'effondrait a 1785 W, les chargeurs se bridaient, on redescendait.\n// Oscillateur de periode ~5 min. Releve : 17:40:30 -> 2241 / 17:47:19 -> 7100\n// / 17:47:28 PV 1785 / 17:48:30 -> 7368.\n//\n// Une fois la consigne descendue, on la TIENT tant que le solaire produit. Ce\n// n'est pas un etat a memoriser : la valeur courante est dans le GX, et le\n// plancher maison la fait remonter d'elle-meme si la maison grossit.\n//\n// La nuit, les chargeurs sont `off` (ils ne produisent pas) : on retombe sur le\n// comportement historique -- la consigne monte au plafond pour que le reseau\n// porte maison et voiture (§31, §57). C'est cette distinction qui empeche le\n// « on tient » de casser les nuits HC.\nconst solarProducing =\n parsed.mpptReadableCount > 0 && parsed.mpptAtMaxPowerCount > 0;\n\nlet gridSetpointNeedW;\nlet gridSetpointDescending = false;\n\nif (!gridSessionActive) {\n gridSetpointNeedW = params.gridSetpointIdleW;\n} else if (solarProducing && !batteryPolicy.batterySoftBreachNow && gridSetpointCurrentW !== null) {\n // On tient la valeur acquise, bornee par la maison et par le plafond.\n gridSetpointNeedW = clamp(\n Math.min(gridSetpointCurrentW, gridSetpointCeilingW),\n gridSetpointHouseFloorW,\n gridSetpointCeilingW\n );\n} else {\n gridSetpointNeedW = gridSetpointCeilingW;\n}\n\nif (gridSetpointDescendOk) {\n // Un chargeur bride PROUVE qu'il reste de la ressource : on peut descendre\n // franchement. Une descente trop forte est rattrapee au cycle suivant (30 s\n // ou la batterie porte l'ecart, soit quelques dizaines de Wh), alors qu'une\n // descente trop timide laisse des kW de solaire refuses pendant dix minutes.\n const minStepW = Math.max(\n GRID_SETPOINT_DEADBAND_W,\n Math.round(params.currentStepUpA * derived.acVoltageRef)\n );\n const baseW = Math.min(gridSetpointCurrentW, gridSetpointCeilingW);\n const gapW = baseW - gridSetpointHouseFloorW;\n if (gapW > 0) {\n const stepW = Math.max(minStepW, Math.round(gapW / 2));\n gridSetpointNeedW = Math.max(gridSetpointHouseFloorW, baseW - stepW);\n gridSetpointDescending = true;\n }\n}\n\n// La bande est ASYMETRIQUE, et c'est le point. Une consigne trop BASSE fait\n// porter la difference par la batterie : c'est le defaut de la nuit du 01/09,\n// il se corrige immediatement. Une consigne trop HAUTE ne coute rien : l'ESS\n// ne preleve que ce qu'il peut placer, et le courant de charge de la batterie\n// est tenu par ailleurs (DVCC, Auto Charge Manager). Deux leviers, deux roles\n// -- la consigne dit ce que le reseau a le DROIT de servir, DVCC dit ce que la\n// batterie a le droit de prendre. Ne pas confondre les deux.\n//\n// On ecrit donc avec une marge d'un pas de courant EVCS (grandeur du systeme,\n// pas une constante inventee) pour que le bruit de la maison ne rende pas la\n// consigne insuffisante au cycle suivant, et on ne redescend que si elle est\n// devenue franchement inutile. Sans cela la boucle 30 s reecrivait a chaque\n// cycle en poursuivant le bruit.\nconst gridSetpointSlackW = gridSessionActive && !gridSetpointDescending\n ? Math.max(GRID_SETPOINT_DEADBAND_W, Math.round(params.currentStepUpA * derived.acVoltageRef))\n : 0;\n\nconst gridSetpointTargetW = clamp(\n gridSetpointNeedW + gridSetpointSlackW,\n 0,\n GRID_SETPOINT_MAX_W\n);\n\nconst gridSetpointNeedsWrite =\n gridSetpointCurrentW === null ||\n gridSetpointCurrentW < gridSetpointNeedW ||\n gridSetpointCurrentW > gridSetpointNeedW + 2 * gridSetpointSlackW + GRID_SETPOINT_DEADBAND_W;\n\nreason.push(`Grid=${parsed.gridAvailable ? 'on' : 'off'} (${Math.round(parsed.gridPowerW)}W)`);\n// Trace de la fenetre d'etablissement : sans elle, une lecture du logbook\n// pendant le transitoire donne l'impression que le systeme decide n'importe\n// quoi. Il decide, au contraire, de ne PAS decider sur ces mesures.\nif (derived.gridSettling) {\n reason.push(`ETABLISSEMENT reseau ${Math.round(parsed.gridTransitionAgeSec)}s/${params.gridSettleSeconds}s (mesures PV/batterie non fiables)`);\n}\nif (gridSessionActive) {\n reason.push(\n gridSetpointDescending\n ? `Consigne reseau=${gridSetpointTargetW}W (CREDIT SOLAIRE, ${parsed.mpptLimitedCount}/${parsed.mpptReadableCount} brides)`\n : `Consigne reseau=${gridSetpointTargetW}W`\n );\n}\n// Visible dans le logbook uniquement pendant un depassement souple : c'est le\n// seul moment ou l'information sert, et la raison est tronquee a 255 caracteres.\nif (batteryPolicy.batterySoftBreachNow) {\n reason.push(\n `Batt souple ${round1(batteryPolicy.batterySoftBreachMinutes)}/${round1(batteryPolicy.batterySoftToleranceMinutes)}min`\n );\n}\n// Idem : visible seulement quand le bilan energetique DURCIT le plancher du\n// soir. Sans cette trace, un blocage de charge un soir de SOC 94 % est\n// incomprehensible a la lecture du logbook.\nif (safeguards.eveningFloorFromBalance) {\n reason.push(\n `Plancher soir=${round1(safeguards.eveningSocStartFloorPct)}% (BILAN, ${round1(safeguards.balanceEnergyToCoverKwh)}kWh a couvrir)`\n );\n}\nreason.push(`Cap=${derived.physicalCapA}A`);\nreason.push(`Ref=${round1(derived.currentReferenceA)}A`);\nreason.push(`Target=${round1(desiredCurrentA)}A`);\nreason.push(`Debt=${round2(cloud.cloudDebtNextKwh)}kWh`);\nreason.push(`Vehicle=${parsed.evcsVehicleConnected ? 'on' : 'off'}`);\nreason.push(`EVCS mode=${parsed.evcsModeRaw}`);\nreason.push(`VE.Bus AC=${Math.round(parsed.vebusAcOutPowerW)}W`);\nreason.push(\n `VE.Bus DC=${\n Number.isFinite(parsed.vebusDcVoltageV)\n ? round1(parsed.vebusDcVoltageV)\n : 'n/a'\n }V`\n);\nreason.push(`PrioEVCS=${evcsPriorityActive ? 'on' : 'off'}(${evcsPrioritySource})`);\nreason.push(`VDF delivery=${Number.isFinite(vdf.deliveryRatioEma) ? round3(vdf.deliveryRatioEma) : 'na'}`);\nreason.push(`VDF noise=${Number.isFinite(vdf.deliveryNoiseEma) ? round3(vdf.deliveryNoiseEma) : 'na'}`);\nreason.push(`VDF score=${Number.isFinite(vdf.noDemandScoreEma) ? round3(vdf.noDemandScoreEma) : 'na'}`);\nreason.push(`VDF noDemand=${vdf.evcsVehicleNoDemandSemantic ? 'on' : 'off'}`);\n\nif (night.nightSessionActive) {\n reason.push(`NightTarget=${round1(night.batteryMorningTargetPct ?? 0)}%`);\n reason.push(`NightTrigger=${round1(night.gridTakeoverTriggerPct ?? 0)}%`);\n\n if (Number.isFinite(night.nightSessionStartSocPct)) {\n reason.push(`StartSOC=${round1(night.nightSessionStartSocPct)}%`);\n reason.push(`AllowDrop=${round1(night.maxBatteryDischargePoints ?? 0)}pts`);\n }\n\n reason.push(`NightPreferred=${night.nightPreferredSourceMode ?? 'blocked'}`);\n reason.push(`NightTakeover=${night.nightGridTakeoverNeeded ? 'on' : 'off'}`);\n reason.push(`NightUrgent=${night.batteryRecoveryUrgent ? 'on' : 'off'}`);\n}\n\nconst shortReason = reason.join(' | ').slice(0, 255);\n\nconst sourceModeLabel =\n context.sourceMode === 'blocked' ? 'Bloqué' :\n context.sourceMode === 'solar_only' ? 'Solaire' :\n context.sourceMode === 'solar_potential' ? 'Potentiel solaire' :\n context.sourceMode === 'hybrid_probe' ? 'Probe hybride' :\n context.sourceMode === 'grid_hc_allowed' ? 'Réseau HC autorisé' :\n context.sourceMode === 'grid_forced' ? 'Réseau forcé' :\n context.sourceMode === 'battery_assist_night' ? 'Assistance batterie nuit' :\n context.sourceMode;\n\nconst signature = [\n decision.state,\n `profile=${context.profile}`,\n `source=${context.sourceMode}`,\n `chg=${decision.desiredCharging ? '1' : '0'}`,\n `perm=${decision.desiredPermission ? '1' : '0'}`,\n `l=${decision.desiredLatch ? '1' : '0'}`,\n `cur=${round1(desiredCurrentA)}`,\n `block=${decision.nextBlockReason}`,\n `stop=${decision.mustStop ? '1' : '0'}`,\n `prio=${evcsPriorityActive ? '1' : '0'}`,\n `night=${night.nightSessionActive ? '1' : '0'}`,\n `ntrg=${Number.isFinite(night.gridTakeoverTriggerPct) ? round1(night.gridTakeoverTriggerPct) : 'na'}`,\n `nurg=${night.batteryRecoveryUrgent ? '1' : '0'}`,\n `vdf=${vdf.evcsVehicleNoDemandSemantic ? '1' : '0'}`,\n `vdfs=${Number.isFinite(vdf.noDemandScoreEma) ? round3(vdf.noDemandScoreEma) : 'na'}`\n].join('|');\n\nconst signatureChanged = signature !== history.lastSignature;\n\n// =====================================================\n// NOTIFICATIONS\n// =====================================================\nlet notifyWanted = false;\nlet notifyTitle = 'Victron EVCS Surplus';\nlet notifyMessage = shortReason;\n\nif (parsed.notificationsEnabled && signatureChanged) {\n if (decision.currentDecision === 'start') {\n notifyWanted = true;\n notifyTitle = '⚡ EVCS démarrage';\n } else if (decision.currentDecision === 'step_up') {\n notifyWanted = true;\n notifyTitle = '⬆️ EVCS hausse courant';\n } else if (\n decision.currentDecision === 'step_down' ||\n decision.currentDecision === 'step_down_fast'\n ) {\n notifyWanted = true;\n notifyTitle = '⬇️ EVCS baisse courant';\n } else if (\n decision.mustStop ||\n decision.currentDecision === 'stop' ||\n decision.currentDecision === 'stop_hard' ||\n decision.currentDecision === 'stop_power_cap'\n ) {\n notifyWanted = true;\n notifyTitle = '⛔ EVCS arrêt';\n } else if (decision.state === 'mode_auto_conflict') {\n notifyWanted = true;\n notifyTitle = '⚠️ EVCS mode Auto';\n } else if (decision.state === 'vehicle_full_or_paused') {\n notifyWanted = true;\n notifyTitle = '🚗 EVCS véhicule plein ou en pause';\n }\n}\n\n// =====================================================\n// TIMESTAMPS TO WRITE\n// =====================================================\nconst cloudDebtStartedAtToWrite =\n cloud.cloudDebtStartedMs\n ? formatParisStampFromMs(cloud.cloudDebtStartedMs)\n : now.stamp;\n\n// DEPASSEMENT SOUPLE BATTERIE (01/09/2026) : chaine vide quand il n'y a pas\n// de depassement en cours. Le helper n'est reecrit que sur changement reel\n// (debut ou fin), pas a chaque cycle : la valeur du debut doit RESTER figee\n// pendant toute la duree du depassement, sinon le compteur ne monte jamais.\nconst batterySoftBreachAtToWrite =\n batteryPolicy.batterySoftBreachStartedMs !== null &&\n batteryPolicy.batterySoftBreachStartedMs !== undefined\n ? formatParisStampFromMs(batteryPolicy.batterySoftBreachStartedMs)\n : '';\n\nconst setBatterySoftBreachAt =\n batterySoftBreachAtToWrite !== (history.batterySoftBreachAt || '');\n\nconst probeStartedAtToWrite = now.stamp;\nconst lastStartAtToWrite = now.stamp;\nconst lastStopAtToWrite = now.stamp;\nconst lastCurrentChangeAtToWrite = now.stamp;\n\n// =====================================================\n// NIGHT SESSION MEMORY\n// =====================================================\nconst nightSessionStartSocToWrite =\n decision.clearNightSessionStart\n ? ''\n : decision.setNightSessionStart\n ? String(round1(parsed.soc))\n : (\n Number.isFinite(history.nightSessionStartSocPct)\n ? String(round1(history.nightSessionStartSocPct))\n : ''\n );\n\nconst nightSessionStartAtToWrite =\n decision.clearNightSessionStart\n ? ''\n : decision.setNightSessionStart\n ? now.stamp\n : (history.nightSessionStartAt || '');\n\n// =====================================================\n// OUTPUT\n// =====================================================\nreturn [{\n json: {\n timestamp_paris: now.stamp,\n inputs: input,\n\n computed: {\n operating_mode: context.operatingMode,\n profile: context.profile,\n source_mode: context.sourceMode,\n source_mode_label: sourceModeLabel,\n user_strategy: strategy.userStrategy,\n\n in_night_hc_window: windows.inNightHcWindow,\n in_morning_window: windows.inMorningWindow,\n in_midday_window: windows.inMiddayWindow,\n in_evening_window: windows.inEveningWindow,\n\n // D'ou vient la consigne reseau. Sans cette trace, un repli est\n // SILENCIEUX : la consigne reprend la formule d'avant sans que rien ne le\n // dise, et c'est precisement l'information qui manquera le jour ou elle se\n // comportera mal. `bilan` = dimensionnee sur le manque, `heritee` = repli.\n setpoint_source: setpointFromBalance ? 'bilan' : 'heritee',\n setpoint_battery_share_w: Math.round(batterySetpointShareW),\n setpoint_evcs_share_w: Math.round(evcsSetpointShareW),\n dvcc_limit_w: dvccChargeLimitW === null ? null : Math.round(dvccChargeLimitW),\n hc_minutes_left: hcMinutesLeft,\n\n departure_priority_active: context.departurePriorityActive,\n minutes_until_departure: lifecycle.minutesUntilDeparture,\n\n battery_priority_mode: modes.batteryPriorityMode,\n battery_full_harvest_mode: modes.batteryFullHarvestMode,\n forecast_commit_mode: modes.forecastCommitMode,\n\n physical_cap_current_a: derived.physicalCapA,\n can_run_at_min_current: derived.canRunAtMinCurrent,\n\n forecast_buffer_kwh: round2(derived.forecastBufferKwh),\n projected_end_soc_after_charge: round1(derived.projectedEndSocAfterCharge),\n\n cloud_debt_next_kwh: round2(cloud.cloudDebtNextKwh),\n cloud_debt_duration_minutes: round1(cloud.cloudDebtDurationMinutes),\n cloud_debt_active: cloud.cloudDebtActive,\n cloud_tolerance_ok: cloud.cloudToleranceOk,\n\n start_blocked_by_reason: locks.startBlockedByReason,\n\n probe_window_active: conditions.probeWindowActive,\n probe_success_now: conditions.probeSuccessNow,\n probe_failed_now: conditions.probeFailedNow,\n\n any_vebus_overload: derived.anyVebusOverload,\n any_vebus_low_battery: derived.anyVebusLowBattery,\n vebus_ac_out_soft_reached: derived.vebusAcOutSoftReached,\n vebus_ac_out_hard_reached: derived.vebusAcOutHardReached,\n vebus_dc_voltage_soft_low: derived.vebusDcVoltageSoftLow,\n vebus_dc_voltage_hard_low: derived.vebusDcVoltageHardLow,\n\n soc_band: batteryPolicy.socBand,\n batt_discharge_soft_dynamic_a: round1(batteryPolicy.batteryDischargeSoftDynamicA),\n batt_discharge_hard_dynamic_a: round1(batteryPolicy.batteryDischargeHardDynamicA),\n batt_charge_reserve_dynamic_a: round1(batteryPolicy.batteryChargeReserveDynamicA),\n batt_soft_tolerance_min: round1(batteryPolicy.batterySoftToleranceMinutes),\n batt_hard_tolerance_sec: round1(batteryPolicy.batteryHardToleranceSeconds),\n batt_recovery_min: round1(batteryPolicy.batteryRecoveryMinutes),\n grid_battery_soft_dynamic_a: round1(batteryPolicy.gridBatterySoftDynamicA),\n grid_battery_hard_dynamic_a: round1(batteryPolicy.gridBatteryHardDynamicA),\n house_spike_detected: batteryPolicy.houseSpikeDetected,\n solar_potential_context: batteryPolicy.solarPotentialContext,\n\n batt_soft_breach_now: !!batteryPolicy.batterySoftBreachNow,\n batt_soft_breach_minutes: round1(batteryPolicy.batterySoftBreachMinutes || 0),\n batt_soft_discharge_hold_ok: batteryPolicy.batterySoftDischargeHoldOk !== false,\n\n evcs_priority_active: evcsPriorityActive,\n energy_priority_mode: energyPriorityMode,\n energy_priority_reason: energyPriorityReason,\n evcs_priority_until: evcsPriorityUntil,\n evcs_priority_source: evcsPrioritySource,\n evcs_priority_manual_override_active: manualPriorityOverrideActive,\n\n night_session_active: night.nightSessionActive,\n night_session_needs_capture: !!night.nightSessionNeedsCapture,\n night_session_needs_clear: !!night.nightSessionNeedsClear,\n night_session_start_soc_pct: Number.isFinite(night.nightSessionStartSocPct)\n ? round1(night.nightSessionStartSocPct)\n : null,\n night_session_start_at: night.nightSessionStartAt || null,\n\n battery_hard_floor_pct: Number.isFinite(night.batteryHardFloorPct)\n ? round1(night.batteryHardFloorPct)\n : null,\n battery_morning_target_pct: Number.isFinite(night.batteryMorningTargetPct)\n ? round1(night.batteryMorningTargetPct)\n : null,\n max_battery_discharge_points: Number.isFinite(night.maxBatteryDischargePoints)\n ? round1(night.maxBatteryDischargePoints)\n : null,\n grid_takeover_trigger_pct: Number.isFinite(night.gridTakeoverTriggerPct)\n ? round1(night.gridTakeoverTriggerPct)\n : null,\n\n battery_recovery_urgent: !!night.batteryRecoveryUrgent,\n night_grid_takeover_needed: !!night.nightGridTakeoverNeeded,\n night_preferred_source_mode: night.nightPreferredSourceMode || 'blocked',\n\n evcs_delivery_ratio_ema: Number.isFinite(vdf.deliveryRatioEma) ? round3(vdf.deliveryRatioEma) : null,\n evcs_delivery_noise_ema: Number.isFinite(vdf.deliveryNoiseEma) ? round3(vdf.deliveryNoiseEma) : null,\n evcs_no_demand_score_ema: Number.isFinite(vdf.noDemandScoreEma) ? round3(vdf.noDemandScoreEma) : null,\n evcs_vehicle_no_demand_semantic: !!vdf.evcsVehicleNoDemandSemantic,\n evcs_no_demand_since_minutes: Number.isFinite(vdf.evcsNoDemandSinceMinutes) ? round3(vdf.evcsNoDemandSinceMinutes) : null,\n evcs_source_deliverable_for_no_demand: !!vdf.sourceDeliverableForNoDemand,\n evcs_session_armed_for_no_demand: !!vdf.evcsSessionArmedForNoDemand,\n evcs_no_demand_score_on: Number.isFinite(vdf.noDemandScoreOn) ? round3(vdf.noDemandScoreOn) : null,\n evcs_no_demand_score_off: Number.isFinite(vdf.noDemandScoreOff) ? round3(vdf.noDemandScoreOff) : null\n },\n\n decision: {\n state: decision.state,\n current_decision: decision.currentDecision,\n desired_charging: decision.desiredCharging,\n desired_permission: decision.desiredPermission,\n desired_latch: decision.desiredLatch,\n desired_current_a: round1(desiredCurrentA),\n\n charging_action: chargingAction,\n permission_action: permissionAction,\n latch_action: latchAction,\n current_action: currentAction,\n auto_start_action: autoStartAction,\n\n set_last_start_at: setLastStartAt,\n set_last_stop_at: setLastStopAt,\n set_last_current_change_at: setLastCurrentChangeAt,\n set_cloud_debt_started_at: decision.setCloudDebtStartedAt,\n set_probe_started_at: decision.setProbeStartedAt,\n\n set_night_session_start: !!decision.setNightSessionStart,\n clear_night_session_start: !!decision.clearNightSessionStart,\n\n set_block_reason: needsBlockReasonUpdate || !parsed.controllerEnabled,\n block_reason: decision.nextBlockReason,\n\n set_blocked_until: true,\n blocked_until: blockedUntilToWrite,\n\n dynamic_ok: decision.dynamicOk,\n must_stop: decision.mustStop,\n\n short_reason: shortReason,\n reason,\n signature,\n signature_changed: signatureChanged,\n\n should_notify: notifyWanted,\n notify_title: notifyTitle,\n notify_message: notifyMessage,\n\n evcs_priority_active: evcsPriorityActive,\n energy_priority_mode: energyPriorityMode,\n energy_priority_reason: energyPriorityReason,\n evcs_priority_until: evcsPriorityUntil,\n evcs_priority_source: evcsPrioritySource\n },\n\n ha_service_calls: {\n helper_state: {\n execute: true,\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.victron_evcs_surplus_state',\n value: decision.state\n }\n },\n\n helper_current_decision: {\n execute: true,\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.victron_evcs_surplus_current_decision',\n value: decision.currentDecision\n }\n },\n\n helper_reason: {\n execute: true,\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.victron_evcs_surplus_reason',\n value: shortReason\n }\n },\n\n helper_operating_mode: {\n execute: true,\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.victron_evcs_operating_mode',\n value: context.operatingMode\n }\n },\n\n helper_source_mode: {\n execute: true,\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.victron_evcs_source_mode',\n value: sourceModeLabel\n }\n },\n\n helper_target_current: {\n execute: true,\n domain: 'input_number',\n service: 'set_value',\n data: {\n entity_id: 'input_number.victron_evcs_target_current_auto',\n value: round1(desiredCurrentA)\n }\n },\n\n helper_budget_remaining: {\n execute: true,\n domain: 'input_number',\n service: 'set_value',\n data: {\n entity_id: 'input_number.victron_evcs_budget_remaining_kwh',\n value: round2(derived.forecastBufferKwh)\n }\n },\n\n helper_projected_soc: {\n execute: true,\n domain: 'input_number',\n service: 'set_value',\n data: {\n entity_id: 'input_number.victron_evcs_projected_end_soc_after_charge',\n value: round1(derived.projectedEndSocAfterCharge)\n }\n },\n\n helper_cloud_debt: {\n execute: true,\n domain: 'input_number',\n service: 'set_value',\n data: {\n entity_id: 'input_number.victron_evcs_cloud_debt_kwh',\n value: round2(cloud.cloudDebtNextKwh)\n }\n },\n\n helper_dynamic_ok: {\n execute: true,\n domain: 'input_boolean',\n service: decision.dynamicOk ? 'turn_on' : 'turn_off',\n data: {\n entity_id: 'input_boolean.victron_evcs_dynamic_ok'\n }\n },\n\n helper_must_stop: {\n execute: true,\n domain: 'input_boolean',\n service: decision.mustStop ? 'turn_on' : 'turn_off',\n data: {\n entity_id: 'input_boolean.victron_evcs_must_stop'\n }\n },\n\n // Bouton reset borne (19/09/2026) : momentane. Il a deja fait son effet\n // dans WINDOWS (sessionFresh forcee ce cycle) ; on le rebascule off pour\n // qu'il ne force pas un retest en boucle. N'ecrit que s'il etait ON.\n helper_reset_borne_ack: {\n execute: !!params.resetBorneRequested,\n domain: 'input_boolean',\n service: 'turn_off',\n data: {\n entity_id: 'input_boolean.evcs_reset_borne'\n }\n },\n\n helper_cloud_debt_started_at: {\n execute: true,\n domain: 'input_datetime',\n service: 'set_datetime',\n data: {\n entity_id: 'input_datetime.victron_evcs_cloud_debt_started_at',\n datetime: cloudDebtStartedAtToWrite\n }\n },\n\n helper_battery_soft_breach_at: {\n execute: setBatterySoftBreachAt,\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.victron_evcs_battery_soft_breach_at',\n value: batterySoftBreachAtToWrite\n }\n },\n\n helper_last_current_change_at: {\n execute: setLastCurrentChangeAt,\n domain: 'input_datetime',\n service: 'set_datetime',\n data: {\n entity_id: 'input_datetime.victron_evcs_last_current_change_at',\n datetime: lastCurrentChangeAtToWrite\n }\n },\n\n helper_last_start_at: {\n execute: setLastStartAt,\n domain: 'input_datetime',\n service: 'set_datetime',\n data: {\n entity_id: 'input_datetime.victron_evcs_last_start_at',\n datetime: lastStartAtToWrite\n }\n },\n\n helper_last_stop_at: {\n execute: setLastStopAt,\n domain: 'input_datetime',\n service: 'set_datetime',\n data: {\n entity_id: 'input_datetime.victron_evcs_last_stop_at',\n datetime: lastStopAtToWrite\n }\n },\n\n helper_signature: {\n execute: true,\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.victron_evcs_control_last_signature',\n value: signature\n }\n },\n\n helper_block_reason: {\n execute: needsBlockReasonUpdate || !parsed.controllerEnabled,\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.victron_evcs_last_block_reason',\n value: decision.nextBlockReason\n }\n },\n\n helper_blocked_until: {\n execute: true,\n domain: 'input_datetime',\n service: 'set_datetime',\n data: {\n entity_id: 'input_datetime.victron_evcs_blocked_until',\n datetime: blockedUntilToWrite\n }\n },\n\n helper_probe_started_at: {\n execute: decision.setProbeStartedAt,\n domain: 'input_datetime',\n service: 'set_datetime',\n data: {\n entity_id: 'input_datetime.victron_evcs_probe_started_at',\n datetime: probeStartedAtToWrite\n }\n },\n\n helper_evcs_priority_active: {\n execute: true,\n domain: 'input_boolean',\n service: evcsPriorityActive ? 'turn_on' : 'turn_off',\n data: {\n entity_id: 'input_boolean.victron_evcs_priority_active'\n }\n },\n\n helper_energy_priority_mode: {\n execute: true,\n domain: 'input_select',\n service: 'select_option',\n data: {\n entity_id: 'input_select.victron_energy_priority_mode',\n option: energyPriorityMode\n }\n },\n\n helper_energy_priority_reason: {\n execute: true,\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.victron_energy_priority_reason',\n value: energyPriorityReason\n }\n },\n\n helper_evcs_priority_until: {\n execute: true,\n domain: 'input_datetime',\n service: 'set_datetime',\n data: {\n entity_id: 'input_datetime.victron_evcs_priority_until',\n datetime: evcsPriorityUntil\n }\n },\n\n helper_night_target_soc: {\n execute: true,\n domain: 'input_number',\n service: 'set_value',\n data: {\n entity_id: 'input_number.victron_evcs_night_target_soc',\n value: round1(\n Number.isFinite(night.batteryMorningTargetPct)\n ? night.batteryMorningTargetPct\n : 0\n )\n }\n },\n\n helper_night_grid_trigger_soc: {\n execute: true,\n domain: 'input_number',\n service: 'set_value',\n data: {\n entity_id: 'input_number.victron_evcs_night_grid_trigger_soc',\n value: round1(\n Number.isFinite(night.gridTakeoverTriggerPct)\n ? night.gridTakeoverTriggerPct\n : 0\n )\n }\n },\n\n helper_night_preferred_source_mode: {\n execute: true,\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.victron_evcs_night_preferred_source_mode',\n value: night.nightPreferredSourceMode || 'blocked'\n }\n },\n\n // SESSION RESEAU UNIQUE (03/09/2026) : ce bit est ce qui rend la session\n // UNIQUE. Sans persistance, l'engagement serait reevalue a chaque cycle\n // et le systeme retomberait dans une bande — donc dans un oscillateur.\n // Ecrit uniquement sur changement reel.\n helper_night_grid_session_committed: {\n execute: night.nightGridSessionCommitted !== history.nightGridSessionCommittedPrev,\n domain: 'input_boolean',\n service: night.nightGridSessionCommitted ? 'turn_on' : 'turn_off',\n data: {\n entity_id: 'input_boolean.victron_evcs_night_grid_session_committed'\n }\n },\n\n // P12 — LE VERROU ANTI-OSCILLATEUR.\n //\n // Le plancher de surplus (`required_soc_prudent`) DESCEND pendant la nuit,\n // et le SOC aussi. Mesure du 15/09 : voiture en charge, le SOC descend de\n // 7,1 pt/h et le plancher de 3,7 ; voiture ARRETEE, le SOC ne descend plus\n // que de 2,6 pt/h quand le plancher continue a 3,7. Le surplus\n // REAPPARAITRAIT donc tout seul et la charge repartirait — cinquieme\n // oscillateur du projet, par construction.\n //\n // Une seule passe de surplus par session de nuit, comme la recovery\n // batterie depuis le 27/08. Ecrit uniquement sur changement reel.\n helper_night_battery_done: {\n execute: !!night.nightBatterySurplusExhausted !== !!params.nightBatteryDonePrev,\n domain: 'input_boolean',\n service: night.nightBatterySurplusExhausted ? 'turn_on' : 'turn_off',\n data: {\n entity_id: 'input_boolean.victron_evcs_night_battery_done'\n }\n },\n\n helper_night_grid_takeover_needed: {\n execute: true,\n domain: 'input_boolean',\n // P9 : la fenêtre \"départ imminent\" (jour) tient aussi le helper\n // takeover → override hystérésis + maintien batterie ACM pendant\n // la charge réseau pré-départ.\n service: (night.nightGridTakeoverNeeded || night.departureUrgentActive)\n ? 'turn_on'\n : 'turn_off',\n data: {\n entity_id: 'input_boolean.victron_evcs_night_grid_takeover_needed'\n }\n },\n\n helper_night_battery_recovery_urgent: {\n execute: true,\n domain: 'input_boolean',\n service: night.batteryRecoveryUrgent ? 'turn_on' : 'turn_off',\n data: {\n entity_id: 'input_boolean.victron_evcs_night_battery_recovery_urgent'\n }\n },\n\n helper_night_session_active: {\n execute: true,\n domain: 'input_boolean',\n service: night.nightSessionActive ? 'turn_on' : 'turn_off',\n data: {\n entity_id: 'input_boolean.victron_evcs_night_session_active'\n }\n },\n\n helper_night_session_start_soc: {\n execute: decision.setNightSessionStart || decision.clearNightSessionStart,\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.victron_evcs_night_session_start_soc',\n value: nightSessionStartSocToWrite\n }\n },\n\n // Budget voiture de la nuit (30/09) : fige a l'ouverture, efface en fin.\n helper_night_budget: {\n execute: night.nightBudgetToWrite !== null && night.nightBudgetToWrite !== undefined,\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.victron_evcs_night_budget',\n value: night.nightBudgetToWrite ?? ''\n }\n },\n\n helper_night_session_start_at: {\n execute: decision.setNightSessionStart || decision.clearNightSessionStart,\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.victron_evcs_night_session_start_at',\n value: nightSessionStartAtToWrite\n }\n },\n\n // =================================================\n // VEHICLE DEMAND FILTER HISTORY PERSISTENCE\n // =================================================\n helper_evcs_delivery_ratio_ema: {\n execute: Number.isFinite(vdf?.nextHistory?.evcsDeliveryRatioEma),\n domain: 'input_number',\n service: 'set_value',\n data: {\n entity_id: 'input_number.victron_evcs_delivery_ratio_ema',\n value: round3(vdf.nextHistory.evcsDeliveryRatioEma)\n }\n },\n\n helper_evcs_delivery_noise_ema: {\n execute: Number.isFinite(vdf?.nextHistory?.evcsDeliveryNoiseEma),\n domain: 'input_number',\n service: 'set_value',\n data: {\n entity_id: 'input_number.victron_evcs_delivery_noise_ema',\n value: round3(vdf.nextHistory.evcsDeliveryNoiseEma)\n }\n },\n\n helper_evcs_no_demand_score_ema: {\n execute: Number.isFinite(vdf?.nextHistory?.evcsNoDemandScoreEma),\n domain: 'input_number',\n service: 'set_value',\n data: {\n entity_id: 'input_number.victron_evcs_no_demand_score_ema',\n value: round3(vdf.nextHistory.evcsNoDemandScoreEma)\n }\n },\n\n helper_evcs_no_demand_semantic_active: {\n execute: true,\n domain: 'input_boolean',\n service: vdf?.nextHistory?.evcsNoDemandSemanticActive ? 'turn_on' : 'turn_off',\n data: {\n entity_id: 'input_boolean.victron_evcs_no_demand_semantic_active'\n }\n },\n\n helper_evcs_no_demand_since_at: {\n execute: Number.isFinite(vdf?.nextHistory?.evcsNoDemandSinceMs),\n domain: 'input_datetime',\n service: 'set_datetime',\n data: {\n entity_id: 'input_datetime.victron_evcs_no_demand_since_at',\n datetime: formatParisStampFromMs(vdf.nextHistory.evcsNoDemandSinceMs)\n }\n },\n\n helper_evcs_no_demand_filter_eval_at: {\n execute: Number.isFinite(vdf?.nextHistory?.evcsNoDemandFilterEvalMs),\n domain: 'input_datetime',\n service: 'set_datetime',\n data: {\n entity_id: 'input_datetime.victron_evcs_no_demand_filter_eval_at',\n datetime: formatParisStampFromMs(vdf.nextHistory.evcsNoDemandFilterEvalMs)\n }\n },\n\n // =================================================\n // PHYSICAL EDF GRID TAKEOVER\n // =================================================\n edf_general_ok_on: {\n execute:\n (['grid_forced', 'grid_hc_allowed'].includes(context.sourceMode) ||\n night.nightBatteryGridOnly === true) &&\n !parsed.gridAvailable,\n domain: 'switch',\n service: 'turn_on',\n data: {\n entity_id: 'switch.edf_general_ok_switch'\n }\n },\n\n grid_power_setpoint: {\n execute: gridSetpointNeedsWrite,\n domain: 'number',\n service: 'set_value',\n data: {\n entity_id: 'number.gx_device_ac_power_setpoint',\n value: gridSetpointTargetW\n }\n },\n\n evcs_auto_start_off: {\n execute: autoStartAction === 'turn_off',\n domain: 'switch',\n service: 'turn_off',\n data: {\n entity_id: 'switch.victron_evcs_auto_start'\n }\n },\n\n evcs_charging: {\n execute: chargingAction !== 'none',\n domain: 'switch',\n service: decision.desiredCharging ? 'turn_on' : 'turn_off',\n data: {\n entity_id: 'switch.victron_evcs_charging'\n }\n },\n\n helper_permission: {\n execute: permissionAction !== 'none',\n domain: 'input_boolean',\n service: decision.desiredPermission ? 'turn_on' : 'turn_off',\n data: {\n entity_id: 'input_boolean.victron_evcs_charge_permission_now'\n }\n },\n\n helper_latch: {\n execute: latchAction !== 'none',\n domain: 'input_boolean',\n service: decision.desiredLatch ? 'turn_on' : 'turn_off',\n data: {\n entity_id: 'input_boolean.victron_evcs_control_latch'\n }\n },\n\n evcs_set_current: {\n execute: currentAction === 'set',\n domain: 'number',\n service: 'set_value',\n data: {\n entity_id: 'number.victron_evcs_set_current_command',\n value: round1(desiredCurrentA)\n }\n },\n\n logbook: {\n execute: signatureChanged,\n domain: 'logbook',\n service: 'log',\n data: {\n name: 'Victron EVCS Surplus',\n message: shortReason\n }\n },\n\n mobile_notification: {\n execute: notifyWanted,\n domain: cfg.mobileNotifyDomain,\n service: cfg.mobileNotifyService,\n data: {\n title: notifyTitle,\n message: notifyMessage || shortReason\n }\n }\n }\n }\n}];\n" }, "type": "n8n-nodes-base.code", "typeVersion": 2, @@ -165,7 +165,7 @@ }, { "parameters": { - "jsCode": "function minutesSince(ts, nowMs) {\n if (ts === null || nowMs === null) return null;\n return Math.max(0, (nowMs - ts) / 60000);\n}\n\nfunction secondsSince(ts, nowMs) {\n if (ts === null || nowMs === null) return null;\n return Math.max(0, (nowMs - ts) / 1000);\n}\n\nfunction isWithinWindow(nowMin, startMin, stopMin) {\n if (!Number.isFinite(startMin) || !Number.isFinite(stopMin)) return false;\n if (startMin === stopMin) return false;\n if (startMin < stopMin) return nowMin >= startMin && nowMin < stopMin;\n return nowMin >= startMin || nowMin < stopMin;\n}\n\n// Temps restant dans une fenetre, en minutes, ou null si on n'y est pas.\n//\n// ⚠️ IL VIT ICI, A COTE DE `isWithinWindow`, ET PAS DANS LE NOEUD QUI L'UTILISE.\n// Ce qui serait duplique ailleurs n'est pas l'analyse des heures — INPUT_PARSE\n// les a deja converties en minutes — c'est la LOGIQUE D'APPARTENANCE, et en\n// particulier le traitement du passage de minuit. Une seconde interpretation\n// divergerait le jour ou une borne bouge, et elles ont deja bouge une fois\n// (01:30 -> 01:24 le 09/07/2026).\nfunction minutesRemainingInWindow(nowMin, startMin, stopMin) {\n if (!isWithinWindow(nowMin, startMin, stopMin)) return null;\n if (startMin < stopMin) return stopMin - nowMin;\n // Fenetre a cheval sur minuit : avant minuit il reste la fin de la nuit EN\n // PLUS du reste de la journee. Atteignable par les valeurs de repli\n // d'INPUT_PARSE (23:00 -> 06:00) si les helpers d'horaire sont illisibles.\n return nowMin >= startMin ? stopMin + 1440 - nowMin : stopMin - nowMin;\n}\n\nfunction clamp(v, min, max) {\n return Math.min(Math.max(v, min), max);\n}\n\nconst prev = $input.first().json;\nconst {\n input,\n nowMs,\n now,\n cfg,\n parsed,\n params,\n history,\n batteryTune\n} = prev;\n\n// =====================================================\n// STRATEGY\n// =====================================================\nconst userStrategy = (params.userStrategyRaw ?? 'auto_solar').toString().trim().toLowerCase();\n\nconst isStopMode = userStrategy === 'stop';\nconst forceGridRequested = userStrategy === 'force_grid_now';\nconst forceChargeRequested = userStrategy === 'force_charge_now';\nconst isForceMode = forceGridRequested || forceChargeRequested;\n\n// =====================================================\n// LIFECYCLE / TIMING\n// =====================================================\nconst minutesSinceStart = minutesSince(history.lastStartMs, nowMs);\nconst minutesSinceStop = minutesSince(history.lastStopMs, nowMs);\nconst secondsSinceCurrentChange = secondsSince(history.lastCurrentChangeMs, nowMs);\nconst minutesSinceProbeStart = minutesSince(history.probeStartedMs, nowMs);\n\nconst minRunElapsed =\n minutesSinceStart === null ? true : minutesSinceStart >= params.minRunMinutes;\n\nconst minStopElapsed =\n minutesSinceStop === null ? true : minutesSinceStop >= params.minStopMinutes;\n\nconst currentCooldownBaseSec = parsed.evcsChargingSwitchOn\n ? batteryTune.global.stepDownCooldownSeconds\n : batteryTune.global.stepUpCooldownSeconds;\n\nconst currentCooldownElapsed =\n secondsSinceCurrentChange === null\n ? true\n : secondsSinceCurrentChange >= Math.max(\n params.currentChangeCooldownSeconds,\n currentCooldownBaseSec\n );\n\n// =====================================================\n// DEPARTURE PRIORITY\n// =====================================================\nconst minutesUntilDeparture =\n params.departureTimeMin === null\n ? null\n : (() => {\n const rawDiff = params.departureTimeMin - now.minNow;\n return rawDiff >= 0 ? rawDiff : (1440 - now.minNow + params.departureTimeMin);\n })();\n\nconst departurePriorityActive =\n parsed.departurePriorityEnabled &&\n Number.isFinite(minutesUntilDeparture) &&\n minutesUntilDeparture >= 0 &&\n minutesUntilDeparture <= cfg.departurePriorityWindowMinutes &&\n parsed.evcsVehicleConnected;\n\n// =====================================================\n// MANUAL CHARGE SEMANTICS\n// IMPORTANT:\n// force_charge_now = bypass fenêtres pour la charge,\n// MAIS PAS demande implicite de réseau.\n// =====================================================\nconst manualChargeBypassWindows = forceChargeRequested;\nconst manualChargeLocalRequested = forceChargeRequested && !forceGridRequested;\n\n// Compatibilité ascendante : on garde le champ dans context,\n// mais on le neutralise volontairement.\nconst forceChargeDepartureGridOverride = false;\n\n// =====================================================\n// SYSTEM DERIVED VALUES\n// =====================================================\nconst acVoltageRef =\n Number.isFinite(parsed.vebusAcOutVoltageV) && parsed.vebusAcOutVoltageV > 180\n ? parsed.vebusAcOutVoltageV\n : cfg.nominalAcVoltageV;\n\nconst batteryVoltageRef =\n Number.isFinite(parsed.vebusDcVoltageV) && parsed.vebusDcVoltageV > 0\n ? parsed.vebusDcVoltageV\n : 51.2;\n\nconst houseNonEvcsW = Math.max(0, parsed.houseAcPowerW - parsed.evcsAcPowerW);\nconst vebusNonEvcsW = Math.max(0, parsed.vebusAcOutPowerW - parsed.evcsAcPowerW);\n\nconst totalVebusSoftCeilingW = Math.max(6500, params.acLoadsMaxSafeW || 8500);\nconst totalVebusHardCeilingW = totalVebusSoftCeilingW + 400;\n\n// =====================================================\n// FENETRE D'ETABLISSEMENT DU RESEAU (31/08/2026)\n// =====================================================\n// Mesure du 31/08 : a la fermeture du contacteur, le reseau reprend d'un coup\n// TOUTE la charge de la maison et le solaire bascule vers la batterie. Batterie\n// preneuse, la production ne bouge pas ; batterie pleine, elle s'effondre (2663\n// -> 559 W) faute de destination. Dans les deux cas l'import decroit ensuite\n// jusqu'a la consigne en ~150 s (1985 -> 25 W, puis 2600 -> 52 W).\n//\n// Pendant cette phase, PV, import et courant batterie ne decrivent aucun regime\n// permanent : la boucle de 30 s y prend cinq decisions, et la moyenne PV 10 min\n// en garde la trace une dizaine de minutes de plus. Le systeme lit \"plus de\n// soleil\", en conclut qu'il a besoin du reseau, et confirme ainsi sa propre\n// premisse -- une boucle de rattrapage, comme les trois du 24/08.\n//\n// ASYMETRIE VOULUE : la fenetre ne s'ouvre qu'a l'ARRIVEE du reseau. A sa\n// disparition, les onduleurs reprennent instantanement toute la charge : les\n// gardes doivent agir plus vite, jamais moins vite.\nconst gridSettling =\n parsed.gridAvailable &&\n parsed.gridTransitionAgeSec < params.gridSettleSeconds;\n\nconst endDayGuardSoc = Math.max(params.endOfDaySocGuard, params.endOfDaySocTarget);\n\nconst energyNeededForEndDayGuardKwh = Math.max(\n 0,\n ((endDayGuardSoc - parsed.soc) / 100) * cfg.batteryUsableCapacityKwh\n);\n\nconst forecastBufferKwh =\n parsed.todayRemainingKwh -\n params.baseLoadRemainingKwh -\n params.flexibleSafetyMarginKwh -\n params.requiredEnergyKwh -\n energyNeededForEndDayGuardKwh;\n\nconst projectedEndSocAfterCharge = clamp(\n parsed.soc + (\n (\n parsed.todayRemainingKwh -\n params.baseLoadRemainingKwh -\n params.flexibleSafetyMarginKwh -\n params.requiredEnergyKwh\n ) / cfg.batteryUsableCapacityKwh\n ) * 100,\n 0,\n 100\n);\n\nconst currentReferenceA = clamp(\n Number.isFinite(parsed.evcsCommandCurrentA)\n ? parsed.evcsCommandCurrentA\n : Number.isFinite(parsed.evcsSetCurrentA)\n ? parsed.evcsSetCurrentA\n : params.targetCurrentAutoA,\n 0,\n 32\n);\n\nconst anyVebusOverload = parsed.vebusOverload || parsed.vebusL1Overload;\nconst anyVebusLowBattery = parsed.vebusLowBattery || parsed.vebusL1LowBattery;\n\nconst vebusAcOutSoftReached = parsed.vebusAcOutPowerW >= totalVebusSoftCeilingW;\nconst vebusAcOutHardReached = parsed.vebusAcOutPowerW >= totalVebusHardCeilingW;\n\nconst vebusDcVoltageSoftLow =\n Number.isFinite(parsed.vebusDcVoltageV) &&\n parsed.vebusDcVoltageV > 0 &&\n parsed.vebusDcVoltageV <= cfg.vebusDcVoltageSoftFloorV;\n\nconst vebusDcVoltageHardLow =\n Number.isFinite(parsed.vebusDcVoltageV) &&\n parsed.vebusDcVoltageV > 0 &&\n parsed.vebusDcVoltageV <= cfg.vebusDcVoltageHardFloorV;\n\nconst houseLoadHighVsHeadroom = houseNonEvcsW > params.houseAcHeadroomW;\n\n// =====================================================\n// WINDOWS / PROFILE\n// =====================================================\nconst inNightHcWindow = isWithinWindow(now.minNow, params.hcNightStartMin, params.hcNightStopMin);\nconst inMorningWindow = isWithinWindow(now.minNow, params.morningStartMin, params.morningStopMin);\nconst inMiddayWindow = isWithinWindow(now.minNow, params.hcMiddayStartMin, params.hcMiddayStopMin);\nconst inEveningWindow = isWithinWindow(now.minNow, params.eveningStartMin, params.eveningStopMin);\n\nconst profile =\n inNightHcWindow ? 'night_hc' :\n inMorningWindow ? 'morning_solar' :\n inMiddayWindow ? 'midday_hc_or_solar' :\n inEveningWindow ? 'evening_high_soc' :\n 'outside_window';\n\n// =====================================================\n// EVCS NIGHT PREVIEW ENGINE\n// =====================================================\nconst nightSessionActive =\n profile === 'night_hc' &&\n parsed.evcsVehicleConnected;\n\nconst nightSessionStartSocPct =\n nightSessionActive\n ? (\n Number.isFinite(history.nightSessionStartSocPct)\n ? history.nightSessionStartSocPct\n : parsed.soc\n )\n : null;\n\nconst nightSessionStartAt =\n nightSessionActive\n ? (history.nightSessionStartAt || '')\n : '';\n\nconst nightSessionStartMs =\n nightSessionActive\n ? (history.nightSessionStartMs ?? null)\n : null;\n\nconst nightSessionNeedsCapture =\n nightSessionActive &&\n (\n !Number.isFinite(history.nightSessionStartSocPct) ||\n !nightSessionStartAt ||\n nightSessionStartMs === null\n );\n\nconst nightSessionNeedsClear =\n !nightSessionActive &&\n (\n Number.isFinite(history.nightSessionStartSocPct) ||\n !!history.nightSessionStartAt\n );\n\nconst batteryHardFloorPct =\n params.evcsNightBatteryHardFloorPct;\n\n// P4 : la nuit avant minuit, today_remaining ~ 0 => utiliser la prévision du lendemain.\nconst nightForecastHour = now.hour;\nconst nightForecastKwh =\n (nightForecastHour >= 20 || nightForecastHour < 8)\n ? Math.max(parsed.todayRemainingKwh, parsed.tomorrowKwh || 0)\n : parsed.todayRemainingKwh;\n\nconst batteryMorningTargetRulePct =\n nightForecastKwh >= params.evcsNightStrongForecastKwh\n ? params.evcsNightTargetStrongForecastPct\n : nightForecastKwh >= params.evcsNightMediumForecastKwh\n ? params.evcsNightTargetMediumForecastPct\n : params.evcsNightTargetWeakForecastPct;\n\n// =====================================================\n// LE CONSEILLER NE FIXE PLUS LA CIBLE (retire le 13/09/2026)\n// =====================================================\n// Le premier cablage reel (09/08/2026, HANDOFF §20) faisait suivre a la cible\n// du matin `input_number.evcs_advisor_night_target_soc`, derriere un toggle,\n// un statut `ok`, une fraicheur de 120 min et un bornage.\n// Le 12/09 le conseiller est devenu OBSERVATEUR : il n'emet plus de cible, et\n// ce helper est fige depuis. Les quatre gardes continuaient pourtant de passer\n// toutes les quatre — la garde de fraicheur date le RUN, pas la VALEUR, et le\n// conseiller tourne toujours. Activer le toggle aurait donc applique un chiffre\n// perime sans qu'aucune garde ne s'y oppose. Un cablage qui ne peut plus que se\n// tromper se retire ; le scenario `advisor-cible-ignoree` le verifie.\nconst batteryMorningTargetBasePct = batteryMorningTargetRulePct;\n\n// =====================================================\n// CIBLE DU MATIN : UNE ENERGIE A COUVRIR, PAS UN PALIER (03/09/2026)\n// =====================================================\n// La regle historique classe la journee du lendemain en trois paliers\n// (>= 18 kWh -> 35 %, >= 10 -> 55 %, sinon 85 %) a partir de son TOTAL. Elle\n// est donc aveugle a l'heure d'arrivee de l'energie : le 03/09, demain\n// annoncait 33,9 kWh — « fort », donc 35 % — mais avec un matin creux\n// (0,03 kWh a 7 h, 0,77 a 8 h, 1,81 a 9 h) et un croisement a 9 h.\n//\n// C'est le meme defaut que le plancher du soir (cf. le bloc BILAN de\n// BATTERY_CLOUD_LOCKS), a l'autre bout de la nuit : un palier fige contre une\n// energie mesurable. Le bilan publie la seconde, instant par instant.\n//\n// RELEVE DE LA NUIT 02-03/09, qui a motive ce cablage :\n//\n// heure requis SOC reel cible fixe\n// 00:30 62,4 53 35\n// 03:00 50,4 45 35 <- EDF relache ici, 5 points sous la ligne\n// 04:30 41,7 37 35\n// 06:00 37,1 22 35 <- hysteresis, puis reseau PLEIN TARIF\n// de 07:24 a 10:33 (1,49 kWh)\n//\n// La batterie est restee sous la ligne toute la nuit et le bilan le savait des\n// 00:30. Le `required_soc` decroit proprement avec la nuit qui se consomme\n// (6,1 -> 0 kWh a couvrir) : c'est bien une cible instantanee, pas une\n// constante deguisee.\n//\n// SENS UNIQUE, comme pour le plancher du soir : le bilan ne peut que RELEVER\n// la cible, jamais l'abaisser. La direction corrigee est celle de l'echec\n// observe (cible trop basse -> batterie a plat avant le croisement -> reseau\n// au plein tarif le matin). L'abaisser — la regle dit 85 % l'hiver quand le\n// bilan dira parfois moins — ouvrirait un risque neuf que rien n'a encore\n// valide. Le principe unificateur : le bilan ne pousse JAMAIS dans le sens\n// « couvre moins bien la nuit ».\n//\n// PLAFOND 90 % : au-dela, la batterie cesse d'etre un puits et la production\n// du matin se fait evincer (cf. mesures du 31/08). Charger au reseau au-dela\n// de 90 echangerait un kWh bon marche cette nuit contre plusieurs kWh censures\n// demain. C'est deja la borne du chemin advisor : aucune constante nouvelle.\n//\n// PAS D'OSCILLATEUR : `required_soc` decroit de facon monotone pendant la nuit\n// (l'energie restant a couvrir ne fait que diminuer), et la passe unique de\n// recovery (`nightRecoveryDonePrev`, 27/08) reste en place.\n//\n// REPLI identique au plancher du soir : fraicheur, statut, valeur finie —\n// sinon regle historique. Kill-switch existant : `evcs_balance_enabled` OFF\n// fige `balance_last_run` et la cible retombe seule sur les paliers.\nconst BALANCE_TARGET_MAX_AGE_MIN = 30;\nconst balanceTargetAgeMin = minutesSince(params.balanceLastRunMs, nowMs);\nconst balanceMorningTargetApplied =\n params.balanceStatusOk &&\n Number.isFinite(params.balanceRequiredSocPct) &&\n balanceTargetAgeMin !== null &&\n balanceTargetAgeMin <= BALANCE_TARGET_MAX_AGE_MIN;\n\n// ⚠️ 01/10/2026 — DECISION DU PROPRIETAIRE : LE BILAN FIXE LA CIBLE, DANS LES\n// DEUX SENS. « La valeur de 35 % c'est idiot si le besoin est prevu 26,6 %, et\n// c'est aussi idiot si la prevision est de 60 % pour une journee nuageuse. »\n// Le sens unique ci-dessus (03/09) gardait le palier comme plancher : le\n// 01/10 a 06:00, cible 35 (palier « forte prevision ») pour un besoin du bilan\n// de 28,6 %. Le palier n'est plus qu'un REPLI quand le bilan est absent,\n// perime ou degrade. Bornes : le plancher dur en bas, 90 % en haut.\nconst batteryMorningTargetPct = balanceMorningTargetApplied\n ? Math.min(90, Math.max(batteryHardFloorPct, params.balanceRequiredSocPct))\n : batteryMorningTargetBasePct;\n\nconst maxBatteryDischargePoints =\n nightSessionActive && Number.isFinite(nightSessionStartSocPct)\n ? Math.max(0, nightSessionStartSocPct - batteryMorningTargetPct)\n : 0;\n\nconst gridTakeoverTriggerPct =\n Math.max(batteryHardFloorPct, batteryMorningTargetPct);\n\nconst batteryBelowHardFloor =\n parsed.soc <= batteryHardFloorPct;\n\nconst batteryBelowMorningTarget =\n parsed.soc < batteryMorningTargetPct;\n\nconst batteryAtOrBelowGridTakeoverTrigger =\n parsed.soc <= gridTakeoverTriggerPct;\n\nconst batteryKwhMissingToTarget =\n nightSessionActive\n ? Math.max(\n 0,\n ((batteryMorningTargetPct - parsed.soc) / 100) * cfg.batteryUsableCapacityKwh\n )\n : 0;\n\nconst batteryRecoveryMinutesNeeded =\n nightSessionActive && params.evcsNightBatteryRecoveryPowerKw > 0\n ? (batteryKwhMissingToTarget / params.evcsNightBatteryRecoveryPowerKw) * 60\n : 0;\n\n// 01/10/2026 : l'urgence avant depart exige qu'il MANQUE de l'energie. Sans\n// cette garde, « 0 min de recharge + 30 min de marge >= 30 min » la declenchait\n// a 06:00 chaque jour de depart, batterie au-dessus de sa cible (01/10 : SOC 37,\n// cible 35, EDF ferme 06:00-06:38 pour rien ; idem 21, 22, 25 et 27/09).\nconst batteryRecoveryUrgent =\n nightSessionActive &&\n batteryKwhMissingToTarget > 0 &&\n Number.isFinite(minutesUntilDeparture) &&\n minutesUntilDeparture >= 0 &&\n (\n batteryRecoveryMinutesNeeded + params.evcsNightDepartureSafetyMin\n >= minutesUntilDeparture\n );\n\n// P2 ANTI-CRENEAUX : continuité du takeover réseau nocturne.\n// Une fois le réseau HC engagé pendant une session nuit, on y reste\n// jusqu'à la fin de la session (fenêtre/débranchement) ou batterie quasi pleine.\n// Évite le cycle batterie->jeep->réseau->batterie (pertes + charge hachée).\nconst NIGHT_TAKEOVER_RELEASE_SOC_PCT = 90;\nconst nightTakeoverPrev =\n (input.night_takeover_prev ?? 'off').toString().trim().toLowerCase() === 'on';\n\n// P8 (07/08/2026) : \"charge terminée\" COLLANT via l'état surplus du cycle\n// précédent (helper input_text.victron_evcs_surplus_state relu par le snapshot).\n// Chaîne : VDF stoppe la charge (state vehicle_full_or_paused) → ce flag s'allume\n// → DECISION émet night_session_done_idle → le flag s'auto-entretient jusqu'au\n// débranchement (state vehicle_not_connected) ou à la fin de la fenêtre nuit.\nconst prevSurplusState = (input.prev_surplus_state ?? '').toString().trim().toLowerCase();\n\n// P11 (12/08/2026) : RESET du collant \"véhicule plein\" au CHANGEMENT DE\n// VÉHICULE. Incident 12/08 : JEEP chargée → night_recovery_done_idle collant ;\n// débranchement JEEP + branchement VW → l'état collant a survécu et bloquait\n// la VW (« charge terminée » = celle de la JEEP, pas de la VW).\n// Signal fiable : sensor.victron_evcs_charging_time repart de 0 à chaque\n// nouvelle connexion physique. Sous ~90 s → session neuve → on neutralise le\n// collant pour laisser le système RETESTER le véhicule fraîchement branché.\nconst P11_FRESH_SESSION_MAX_SEC = 90;\n// P11-bis (19/09/2026) : le charging_time de la borne repart de 0 sur un\n// debranchement FRANC, mais reste FIGE sur un swap trop rapide (14 s le 19/09 :\n// VW debranchee, JEEP branchee, borne gelee a l'etat VW). P11 restait alors\n// aveugle et le collant \"vehicule plein\" de la VW bloquait la JEEP. Le\n// last_changed du capteur de connexion, lui, a bien bouge sur ce swap : c'est\n// un SECOND signal de fraicheur, independant de la borne. Meme fenetre (90 s =\n// 3 cycles de 30 s, assez pour capter un (re)branchement entre deux cycles).\n// Cout d'un faux positif (glitch MQTT sub-seconde) : au pire un retest, que le\n// statut 3 / VDF re-latche aussitot si le vehicule est vraiment plein.\nconst sessionFresh =\n parsed.evcsVehicleConnected &&\n (\n params.resetBorneRequested || // echappatoire manuelle (bouton reset borne)\n (Number.isFinite(parsed.evcsChargingTimeSec) &&\n parsed.evcsChargingTimeSec <= P11_FRESH_SESSION_MAX_SEC) ||\n (Number.isFinite(parsed.evcsVehicleConnectedAgeSec) &&\n parsed.evcsVehicleConnectedAgeSec <= P11_FRESH_SESSION_MAX_SEC)\n );\n\n// Fix D (08/08/2026) : le statut natif EVCS \"charged\" (3) vaut aussi fin de\n// charge, sans attendre le VehicleDemandFilter (désarmé si la session a été\n// stoppée par une garde, ex battery_current_hard_stop → détection tardive\n// observée la nuit du 07-08/08).\n// Fix E (09/08/2026) : garde puissance ≤ 300 W pour ne jamais latcher \"fini\"\n// sur un glitch de statut pendant une vraie charge. NB : le statut retombe\n// (3→1/6) dès l'ouverture du relais → la persistance est assurée par l'état\n// collant écrit par DECISION pendant la session (voir Fix E DECISION).\n// P11 : sur une session neuve, on ignore aussi un éventuel statut 3 résiduel\n// (le temps que la borne renégocie avec le nouveau véhicule).\n// Fix H (07/09/2026) : un statut \"charged\" n'est pas cru sur parole.\n// La borne l'a annonce le 07/09 a 14:28:18 apres 2 min 42 s de charge et\n// 0,10 kWh livre, ce qui a latche `day_vehicle_full_idle` pour deux heures\n// alors que le vehicule pouvait charger (il a repris a 16:30 et tenu).\n//\n// Aucun signal ELECTRIQUE instantane ne separe une vraie fin de charge d'une\n// interruption : mesure a la seconde des deux coupures, elles sont\n// superposables (VW 06/09, vraie fin : 1534 -> 550 -> 82 -> 11 -> 0 W ;\n// JEEP 07/09, faux positif : 1626 -> 741 -> 100 -> 13 -> 0 W). Seule la\n// DUREE de la session les distingue, et le systeme porte deja la grandeur qui\n// dit ce qu'est une charge etablie : min_run_minutes (29 min en production).\n//\n// Deux formes de \"charged\" sont credibles, et une seule ne l'est pas :\n// energie de session nulle -> le vehicule etait plein en arrivant (P10)\n// session >= min_run_minutes -> charge menee a son terme\n// entre les deux -> charge INTERROMPUE : on ne latche pas\n// Convergence : une session interrompue redemarre a energie nulle, donc un\n// second \"charged\" immediat retombe dans le premier cas et latche.\nconst chargedSessionEnergyKwh = Math.max(0, parsed.evcsSessionEnergyKwh || 0);\nconst chargedSessionEstablished =\n Number.isFinite(parsed.evcsChargingTimeSec) &&\n parsed.evcsChargingTimeSec >= params.minRunMinutes * 60;\nconst chargedCredible =\n chargedSessionEnergyKwh <= 0 || chargedSessionEstablished;\n\nconst evcsStatusCharged =\n !sessionFresh &&\n Number.parseFloat(parsed.evcsStatusRaw) === 3 &&\n Math.max(0, parsed.evcsAcPowerW || 0) <= 300 &&\n chargedCredible;\n\nconst vehicleChargeCompleted =\n nightSessionActive &&\n !sessionFresh &&\n (\n prevSurplusState === 'vehicle_full_or_paused' ||\n prevSurplusState === 'night_session_done_idle' ||\n prevSurplusState === 'night_recovery_done_idle' ||\n prevSurplusState === 'day_vehicle_full_idle' ||\n evcsStatusCharged\n );\n\n// Fix A (08/08/2026) : recovery batterie post-charge = UNE SEULE passe.\n// Sans flag collant, SOC==cible est un point dégénéré (engage soc<=cible,\n// release soc>cible, l'ACM recharge pile à la cible) → rebond EDF ON/OFF\n// période ~10 min observé la nuit du 07-08/08. Une fois la cible atteinte,\n// l'état 'night_recovery_done_idle' (auto-entretenu via prev_surplus_state)\n// coupe le takeover. Ré-engagement uniquement sous cible − 2 pts (bande),\n// hard floor et fenêtre/débranchement restent prioritaires.\n// ⚠ 'night_recovery_done_idle' ne doit matcher AUCUN pattern no-demand de\n// l'hystérésis (full_or_paused / not_requesting_power / charge_complete /\n// no_demand) — même contrainte que 'night_session_done_idle'.\n// 27/08/2026 : la bande de 2 points fabriquait un oscillateur. Relevé de la\n// nuit du 26-27/08 (cible 35) : engagement à SOC 33, relâchement à 35, trois\n// cycles EDF de 52, 79 et 25 min, 2,5 kWh importés pour rien un matin de grand\n// soleil ; la nuit du 20-21/08 en comptait six. Deux points de SOC valent\n// ~0,34 kWh, soit une demi-heure de conso nocturne : ce n'est pas une\n// hystérésis, c'est la période d'une horloge — d'autant plus régulière que la\n// charge de nuit est parfaitement plate.\n// La recovery batterie post-charge est donc UNE SEULE passe par session de\n// nuit, ce qui était déjà l'intention affichée du Fix A (\"relâché\n// DÉFINITIVEMENT\"). Aucun seuil de ré-engagement ici : le plancher dur reste\n// un terme indépendant de nightGridTakeoverNeeded (voir plus bas), et\n// l'hystérésis réseau garde sa propre bande 20/30. Le vrai critère de\n// ré-engagement est un budget d'énergie restant à couvrir, pas un delta de\n// SOC : il appartient au bilan énergétique, pas à une constante de plus ici.\nconst nightRecoveryDonePrev = prevSurplusState === 'night_recovery_done_idle';\nconst nightRecoveryDone =\n vehicleChargeCompleted &&\n (parsed.soc >= batteryMorningTargetPct || nightRecoveryDonePrev);\n\n// =====================================================\n// P9 (09/08/2026) : CHARGE RÉSEAU AVANT DÉPART, HORS FENÊTRE HC.\n// Cas VW : départ 08:50 > fin HC 07:24 → fenêtre morte où le véhicule\n// sous-chargé attendait un solaire insuffisant (observé le 09/08 : advisor\n// force_grid_now, divergence n°10). Si le toggle est ON et que le départ du\n// véhicule SÉLECTIONNÉ est dans ≤ 120 min : réseau autorisé (grid_hc_allowed)\n// jusqu'à fin de charge. L'arrêt réutilise TOUTE la mécanique anti-rebond :\n// VDF → vehicle_full_or_paused → état collant 'departure_done_idle'\n// (réécrit chaque cycle, patterns hystérésis sûrs, coupure immédiate E2).\n// =====================================================\nconst P9_DEPARTURE_LEAD_MIN = 120;\n\nconst departureWindowActive =\n params.allowGridDepartureUrgent &&\n parsed.evcsVehicleConnected &&\n !inNightHcWindow &&\n Number.isFinite(minutesUntilDeparture) &&\n minutesUntilDeparture >= 0 &&\n minutesUntilDeparture <= P9_DEPARTURE_LEAD_MIN;\n\nconst departureDonePrev =\n prevSurplusState === 'departure_done_idle' ||\n (!inNightHcWindow && prevSurplusState === 'vehicle_full_or_paused');\n\nconst departureChargeDone =\n departureWindowActive &&\n !sessionFresh &&\n (departureDonePrev || evcsStatusCharged);\n\nconst departureUrgentActive =\n departureWindowActive &&\n !departureChargeDone;\n\n// =====================================================\n// P10 (09/08/2026) : MÉMOIRE \"VÉHICULE PLEIN\" EN JOURNÉE.\n// Observé le 09/08 09:49 : VW pleine branchée depuis 07:00, le surplus\n// solaire retentait une charge (statut \"Charged\", 0 W) toutes les ~15 min\n// toute la journée (usure relais, bruit). Les états collants P8/P9 étaient\n// scopés nuit/fenêtre départ. Ici : une fois le véhicule vu plein (statut 3\n// ou VDF), état collant 'day_vehicle_full_idle' jusqu'au DÉBRANCHEMENT.\n// - hérite des états \"done\" de la nuit (07:24 → pas de re-tentative) ;\n// - la nuit hérite en retour (vehicleChargeCompleted inclut day_*) ;\n// - échappatoires : force_charge_now / force_grid_now / débrancher-rebrancher.\n// =====================================================\nconst dayVehicleFullPrev =\n prevSurplusState === 'day_vehicle_full_idle' ||\n (\n !inNightHcWindow &&\n (\n prevSurplusState === 'vehicle_full_or_paused' ||\n prevSurplusState === 'night_recovery_done_idle' ||\n prevSurplusState === 'night_session_done_idle' ||\n prevSurplusState === 'departure_done_idle'\n )\n );\n\nconst dayVehicleFullIdle =\n parsed.evcsVehicleConnected &&\n !sessionFresh &&\n !inNightHcWindow &&\n !isStopMode &&\n !forceGridRequested &&\n !forceChargeRequested &&\n (dayVehicleFullPrev || evcsStatusCharged);\n\n// P8 : correction du fix 04/08 (logique inversée : \"|| vehicleChargeCompleted\"\n// GARDAIT le latch après la charge au lieu de le libérer).\nconst nightTakeoverLatched =\n nightSessionActive &&\n nightTakeoverPrev &&\n parsed.soc < NIGHT_TAKEOVER_RELEASE_SOC_PCT &&\n !vehicleChargeCompleted;\n\n// =====================================================\n// P12 — CHARGE NUIT SUR LA BATTERIE (15/09/2026)\n// =====================================================\n// Idee du proprietaire : plutot qu'acheter du reseau la nuit pour charger la\n// voiture, lui donner le solaire DEJA STOCKE, doucement, et laisser ainsi de la\n// place dans la batterie pour la production du lendemain. Payer du reseau la\n// nuit pour ecreter le solaire le lendemain, c'est perdre deux fois.\n//\n// Le mode `battery_assist_night` existe depuis toujours pour exactement ca, et\n// il est MORT depuis toujours : il exige `batteryCurrentA >= plancher de\n// decharge` (0 A), impossible la nuit puisque la maison tire sur la batterie\n// (§11 du HANDOFF, ~ -14 A). On ne cree donc pas un chemin, on rend viable\n// celui qui existe — en remplacant un test de COURANT impossible par un test\n// d'ENERGIE : rester au-dessus de ce qu'il faut pour tenir jusqu'au croisement.\n//\n// Le plancher n'est PAS une constante nouvelle : c'est `required_soc_prudent`,\n// que le bilan publie deja, en profil p80. La marge est mesuree, pas inventee.\nconst nightBatterySurplusFloorPct = Math.max(\n batteryHardFloorPct,\n Number.isFinite(params.balanceRequiredSocPrudentPct)\n ? params.balanceRequiredSocPrudentPct\n : Infinity,\n);\n\n// ⚠️ LE VERROU, ET IL N'EST PAS DECORATIF. Le plancher DESCEND pendant la nuit\n// (il reste moins de nuit a couvrir) et le SOC aussi. Mesure du 15/09 :\n//\n// voiture en charge : SOC -7,1 pt/h, plancher -3,7 pt/h -> le surplus s'epuise\n// voiture arretee : SOC -2,6 pt/h, plancher -3,7 pt/h -> le surplus REVIENT\n//\n// Sans verrou, la charge repartirait des qu'elle s'arrete : cinquieme\n// oscillateur du projet, par construction. UNE SEULE PASSE de surplus par\n// session de nuit, comme la recovery batterie depuis le 27/08.\n// BUDGET VOITURE DE LA NUIT (30/09/2026, decisions du proprietaire).\n// « Definir combien la voiture peut prendre sans utiliser EDF, et brider la\n// charge a cette valeur ou autoriser une charge complete avec EDF. »\n// Le bilan publie le budget ; il est FIGE au premier cycle de la session\n// (avec l'index du compteur de la borne) dans input_text\n// victron_evcs_night_budget = « budget|index », efface en fin de session.\n// L'arret se juge sur le COMPTEUR, qui ne fait que monter : condition\n// monotone, aucun oscillateur possible (meme logique que §40). Le plancher de\n// SOC prudent reste un second arret, jamais retire.\nconst nightBudgetPrev = (() => {\n const m = String(params.nightBudgetPrevRaw || '').match(/^\\s*([\\d.]+)\\|([\\d.]+)\\s*$/);\n return m ? { budgetKwh: Number(m[1]), refKwh: Number(m[2]) } : null;\n})();\nconst nightBudgetCaptureOk =\n nightSessionActive && !nightBudgetPrev &&\n balanceMorningTargetApplied &&\n Number.isFinite(params.balanceNightVehicleBudgetKwh) &&\n Number.isFinite(params.evcsTotalEnergyKwh);\nconst nightBudget = !nightSessionActive\n ? null\n : nightBudgetPrev || (nightBudgetCaptureOk\n ? { budgetKwh: Math.max(0, params.balanceNightVehicleBudgetKwh), refKwh: params.evcsTotalEnergyKwh }\n : null);\nconst nightBudgetToWrite = nightSessionActive\n ? (nightBudgetCaptureOk ? `${Math.round(nightBudget.budgetKwh * 100) / 100}|${nightBudget.refKwh}` : null)\n : (nightBudgetPrev ? '' : null);\nconst nightBudgetDeliveredKwh = nightBudget && Number.isFinite(params.evcsTotalEnergyKwh)\n ? Math.max(0, params.evcsTotalEnergyKwh - nightBudget.refKwh)\n : null;\nconst nightBudgetReached =\n !!nightBudget && nightBudgetDeliveredKwh !== null &&\n nightBudgetDeliveredKwh >= nightBudget.budgetKwh - 0.05;\nconst nightChargeComplete = params.nightChargeMode === 'complete';\n\n// Le verrou ne vaut QUE pendant la session de nuit (01/10/2026). Ecrit sous la\n// forme `prev || …`, il restait vrai pour toujours une fois pose ; invisible\n// tant que son ecriture n'atteignait pas HA (jamais relayee du 15/09 au 01/10).\n// Fin de session (debranchement ou fin de fenetre HC) = verrou leve.\nconst nightBatterySurplusExhausted =\n nightSessionActive && (\n params.nightBatteryDonePrev ||\n parsed.soc <= nightBatterySurplusFloorPct ||\n nightBudgetReached\n );\n\nconst nightBatterySurplusAvailable =\n nightSessionActive &&\n params.nightBatteryChargeEnabled &&\n balanceMorningTargetApplied &&\n Number.isFinite(params.balanceRequiredSocPrudentPct) &&\n !vehicleChargeCompleted &&\n // Le plancher est teste UNE SEULE FOIS, dans `nightBatterySurplusExhausted`.\n // Le retester ici serait redondant — et une redondance ne se teste pas : la\n // mutation du 15/09 l'a montre en ne cassant aucun scenario.\n !nightBatterySurplusExhausted;\n\n// La batterie d'abord, le reseau quand le surplus est epuise. C'est la soupape\n// decrite par le proprietaire — « quitte a fermer EDF entre 6h et 7h24 pour\n// completer » — et elle devient automatique : l'epuisement du surplus rend\n// `nightGridHcPreferred` vrai, donc P7 prend le relais.\n// « Sans EDF » (defaut) : la voiture ne prend JAMAIS le reseau la nuit ; une\n// fois le budget batterie atteint (ou sans budget exploitable), elle attend le\n// soleil. « Charge complete » : EDF en HC finit la charge, comme avant.\nconst nightGridHcPreferred =\n nightSessionActive &&\n params.allowGridHcNight &&\n nightChargeComplete &&\n !vehicleChargeCompleted &&\n !nightBatterySurplusAvailable;\n\nconst nightCarWaitsNoGrid =\n nightSessionActive &&\n !nightChargeComplete &&\n !vehicleChargeCompleted &&\n !nightBatterySurplusAvailable;\n\nconst batteryAssistAllowed =\n nightSessionActive &&\n !nightGridHcPreferred &&\n !nightCarWaitsNoGrid &&\n parsed.soc > batteryHardFloorPct &&\n parsed.soc > gridTakeoverTriggerPct &&\n !nightTakeoverLatched &&\n !batteryRecoveryUrgent;\n\n// =====================================================\n// SESSION RESEAU UNIQUE DE LA NUIT (03/09/2026)\n// =====================================================\n// §26 avait pose l'objectif — « une session reseau unique et dimensionnee » —\n// et designe le bon critere : « un budget d'energie restant a couvrir, il\n// appartient au bilan energetique ». Le bilan est cable depuis ce soir.\n//\n// CE QUI A ETE MESURE AVANT D'ECRIRE CECI, et qui a dicte la conception :\n// la conversion kWh -> points de SOC du bilan est une constante unique\n// (15 kWh). La jauge reelle vaut ~17 kWh au-dessus de 42 % (0,164 a\n// 0,171 kWh/point sur trois fenetres independantes) mais s'effondre en\n// dessous : 12,1 kWh entre 45 et 22 %, 8,2 kWh entre 33 et 20 %. Un point de\n// SOC vaut donc MOITIE MOINS dans la bande ou la nuit se termine, et\n// `required_soc` y est systematiquement trop BAS — c'est pourquoi la batterie\n// est restee sous la ligne toute la nuit du 02-03/09, en descendant 1,75 fois\n// plus vite qu'elle.\n//\n// CONSEQUENCE DE CONCEPTION : le relachement ne doit PAS etre un seuil de SOC.\n// Il porterait sur une grandeur fausse, et tout seuil que le SOC repasse\n// ensuite rouvre l'oscillateur eteint le 27/08.\n//\n// Donc : ENGAGEMENT sur le bilan (batterie sous la cible), RELACHEMENT a la\n// FIN DE LA FENETRE. L'oscillation devient structurellement impossible : la\n// condition de sortie est monotone, une fenetre ne se ferme qu'une fois. Le\n// bit d'engagement est porte par\n// input_boolean.victron_evcs_night_grid_session_committed — sans lui le\n// systeme reevaluerait a chaque cycle et retomberait dans une bande.\n//\n// CE QUE CA FAIT CONCRETEMENT : l'ACM recharge jusqu'a la cible puis tient la\n// batterie a 2 A (evcs_takeover_hold) ; la maison finit la nuit sur le reseau\n// en HEURES CREUSES au lieu de vider la reserve, et la batterie arrive a\n// l'aube pres de sa cible, donc avec de la place pour le solaire du matin —\n// au lieu d'etre a 20 % avec le reseau encore ouvert a 10:33 (03/09).\n//\n// PORTEE : uniquement quand le bilan est exploitable, et uniquement pendant\n// une session nuit (fenetre HC + vehicule branche). Sinon, comportement\n// historique inchange. Le bit se nettoie tout seul a la fin de la session.\nconst nightGridSessionCommittedPrev =\n (input.night_grid_session_committed_prev ?? 'off').toString().trim().toLowerCase() === 'on';\n\nconst nightGridSessionUncovered =\n balanceMorningTargetApplied && batteryBelowMorningTarget;\n\n// ⚠️ SANS VEHICULE AUSSI (12/09/2026).\n// Nuit du 11-12/09, voiture rendue la veille : le bilan a reclame entre 92 %\n// et 59 % de SOC a CHAQUE calcul de la nuit, `reseau_necessaire` vrai partout,\n// et rien ne s'est passe. SOC bloque a 20 % de 23:43 a l'aube, consigne reseau\n// figee a 700 W depuis 18:36 — soit exactement la maison. L'hysteresis avait\n// bien ferme le contacteur (SOC <= 20), mais fermer le contacteur ne suffit\n// pas : il faut DEMANDER la puissance (lecon du 01/09). Personne ne la\n// demandait, parce que la session exigeait un vehicule branche.\n//\n// Or le besoin n'a rien a voir avec la voiture : c'est la maison qui doit\n// traverser la nuit, et le bilan sait exactement ce qu'il lui faut. La\n// fenetre HC suffit. Le relachement reste la FIN DE FENETRE, donc monotone,\n// donc toujours immune a l'oscillation (§40).\n//\n// Sans cette correction, le cout se paye le matin : a 07:24 le SOC est encore\n// sous le seuil OFF de l'hysteresis (30), donc le contacteur reste ferme et\n// la maison passe au PLEIN TARIF jusqu'au croisement PV — le 1,49 kWh deja\n// consigne le 03/09.\n// ⚠️ LA FENETRE DE MIDI EST AUSSI UNE FENETRE D'HEURES CREUSES (18/09/2026).\n// L'engagement de session etait gate sur `night_hc` seul, parce que §40 traitait\n// la nuit. Consequence mesuree : la charge HC manuelle demandee par le\n// proprietaire ne pouvait RIEN faire a midi — le plancher relevait bien la cible\n// du bilan, mais aucune session ne s'engageait, donc le contacteur ne se fermait\n// pas et la consigne restait au repos. Le reglage aurait releve le plancher du\n// soir (donc bloque la voiture) sans jamais charger la batterie.\n//\n// ⚠️ ET LE CRITERE N'EST PAS LE MEME AUX DEUX FENETRES. La nuit, on compare le\n// SOC a `batteryMorningTargetPct`, qui contient le palier de prevision\n// (35/55/85). A midi ce palier n'a aucun sens : il vaut 35 par beau temps, donc\n// un SOC de 30 % en plein soleil engagerait une session reseau — c'est-a-dire la\n// substitution du solaire par le reseau qu'on a mis des semaines a eteindre.\n//\n// A midi le critere honnete est le SOC requis du bilan lui-meme, qui\n// s'auto-regule : le croisement PV etant passe, `energyToCoverKwh` vaut zero et\n// `required_soc` retombe au plancher dur. Releve du 17/09 a 13:51, plein soleil :\n// `required_soc` = 22,0 pour `energy_to_cover` = 0,0. Il ne remonte qu'un jour\n// sombre — ou quand le proprietaire pose un plancher manuel, ce qui est\n// exactement le cas d'usage.\n//\n// ET LA VOITURE (28/09/2026, strategie du proprietaire) : « JEEP branchee\n// engage le reseau en HC midi si l'analyse de production le justifie ». Le\n// bilan fait l'analyse (besoin saisi contre solaire disponible avant le depart,\n// batterie gardee pour tenir jusqu'a 01:24) et publie un verdict ; on le relaie,\n// sous les memes gardes de fraicheur et de statut que la cible.\nconst middayVehicleGridNeeded =\n balanceMorningTargetApplied &&\n params.balanceVehicleGridNeeded &&\n parsed.evcsVehicleConnected;\n\nconst middayGridSessionUncovered =\n balanceMorningTargetApplied &&\n (\n (Number.isFinite(params.balanceRequiredSocPct) &&\n parsed.soc < params.balanceRequiredSocPct) ||\n middayVehicleGridNeeded\n );\n\nconst gridSessionInHcWindow =\n profile === 'night_hc' || profile === 'midday_hc_or_solar';\n\nconst gridSessionUncovered =\n profile === 'night_hc' ? nightGridSessionUncovered : middayGridSessionUncovered;\n\nconst nightGridSessionCommitted =\n gridSessionInHcWindow &&\n (gridSessionUncovered || nightGridSessionCommittedPrev);\n\nconst nightGridTakeoverNeeded =\n nightGridSessionCommitted ||\n (\n nightSessionActive &&\n (\n nightGridSessionCommitted ||\n nightGridHcPreferred ||\n batteryBelowHardFloor ||\n (\n // Fix A : après fin de charge, le seul motif réseau restant est la\n // recovery batterie, et elle ne se ré-engage pas une fois done.\n vehicleChargeCompleted\n ? !nightRecoveryDone\n : (\n batteryAtOrBelowGridTakeoverTrigger ||\n batteryRecoveryUrgent ||\n nightTakeoverLatched\n )\n )\n )\n );\n\nconst nightPreferredSourceMode =\n !nightSessionActive\n ? 'blocked'\n : vehicleChargeCompleted\n ? 'blocked'\n // « Sans EDF » 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\n// ⚠️ 01/10/2026 — DECISION DU PROPRIETAIRE : LE BILAN FIXE LA CIBLE, DANS LES\n// DEUX SENS. « La valeur de 35 % c'est idiot si le besoin est prevu 26,6 %, et\n// c'est aussi idiot si la prevision est de 60 % pour une journee nuageuse. »\n// Le sens unique ci-dessus (03/09) gardait le palier comme plancher : le\n// 01/10 a 06:00, cible 35 (palier « forte prevision ») pour un besoin du bilan\n// de 28,6 %. Le palier n'est plus qu'un REPLI quand le bilan est absent,\n// perime ou degrade. Bornes : le plancher dur en bas, 90 % en haut.\nconst batteryMorningTargetPct = balanceMorningTargetApplied\n ? Math.min(90, Math.max(batteryHardFloorPct, params.balanceRequiredSocPct))\n : batteryMorningTargetBasePct;\n\nconst maxBatteryDischargePoints =\n nightSessionActive && Number.isFinite(nightSessionStartSocPct)\n ? Math.max(0, nightSessionStartSocPct - batteryMorningTargetPct)\n : 0;\n\nconst gridTakeoverTriggerPct =\n Math.max(batteryHardFloorPct, batteryMorningTargetPct);\n\nconst batteryBelowHardFloor =\n parsed.soc <= batteryHardFloorPct;\n\nconst batteryBelowMorningTarget =\n parsed.soc < batteryMorningTargetPct;\n\nconst batteryAtOrBelowGridTakeoverTrigger =\n parsed.soc <= gridTakeoverTriggerPct;\n\nconst batteryKwhMissingToTarget =\n nightSessionActive\n ? Math.max(\n 0,\n ((batteryMorningTargetPct - parsed.soc) / 100) * cfg.batteryUsableCapacityKwh\n )\n : 0;\n\nconst batteryRecoveryMinutesNeeded =\n nightSessionActive && params.evcsNightBatteryRecoveryPowerKw > 0\n ? (batteryKwhMissingToTarget / params.evcsNightBatteryRecoveryPowerKw) * 60\n : 0;\n\n// 01/10/2026 : l'urgence avant depart exige qu'il MANQUE de l'energie. Sans\n// cette garde, « 0 min de recharge + 30 min de marge >= 30 min » la declenchait\n// a 06:00 chaque jour de depart, batterie au-dessus de sa cible (01/10 : SOC 37,\n// cible 35, EDF ferme 06:00-06:38 pour rien ; idem 21, 22, 25 et 27/09).\nconst batteryRecoveryUrgent =\n nightSessionActive &&\n batteryKwhMissingToTarget > 0 &&\n Number.isFinite(minutesUntilDeparture) &&\n minutesUntilDeparture >= 0 &&\n (\n batteryRecoveryMinutesNeeded + params.evcsNightDepartureSafetyMin\n >= minutesUntilDeparture\n );\n\n// P2 ANTI-CRENEAUX : continuité du takeover réseau nocturne.\n// Une fois le réseau HC engagé pendant une session nuit, on y reste\n// jusqu'à la fin de la session (fenêtre/débranchement) ou batterie quasi pleine.\n// Évite le cycle batterie->jeep->réseau->batterie (pertes + charge hachée).\nconst NIGHT_TAKEOVER_RELEASE_SOC_PCT = 90;\nconst nightTakeoverPrev =\n (input.night_takeover_prev ?? 'off').toString().trim().toLowerCase() === 'on';\n\n// P8 (07/08/2026) : \"charge terminée\" COLLANT via l'état surplus du cycle\n// précédent (helper input_text.victron_evcs_surplus_state relu par le snapshot).\n// Chaîne : VDF stoppe la charge (state vehicle_full_or_paused) → ce flag s'allume\n// → DECISION émet night_session_done_idle → le flag s'auto-entretient jusqu'au\n// débranchement (state vehicle_not_connected) ou à la fin de la fenêtre nuit.\nconst prevSurplusState = (input.prev_surplus_state ?? '').toString().trim().toLowerCase();\n\n// P11 (12/08/2026) : RESET du collant \"véhicule plein\" au CHANGEMENT DE\n// VÉHICULE. Incident 12/08 : JEEP chargée → night_recovery_done_idle collant ;\n// débranchement JEEP + branchement VW → l'état collant a survécu et bloquait\n// la VW (« charge terminée » = celle de la JEEP, pas de la VW).\n// Signal fiable : sensor.victron_evcs_charging_time repart de 0 à chaque\n// nouvelle connexion physique. Sous ~90 s → session neuve → on neutralise le\n// collant pour laisser le système RETESTER le véhicule fraîchement branché.\nconst P11_FRESH_SESSION_MAX_SEC = 90;\n// P11-bis (19/09/2026) : le charging_time de la borne repart de 0 sur un\n// debranchement FRANC, mais reste FIGE sur un swap trop rapide (14 s le 19/09 :\n// VW debranchee, JEEP branchee, borne gelee a l'etat VW). P11 restait alors\n// aveugle et le collant \"vehicule plein\" de la VW bloquait la JEEP. Le\n// last_changed du capteur de connexion, lui, a bien bouge sur ce swap : c'est\n// un SECOND signal de fraicheur, independant de la borne. Meme fenetre (90 s =\n// 3 cycles de 30 s, assez pour capter un (re)branchement entre deux cycles).\n// Cout d'un faux positif (glitch MQTT sub-seconde) : au pire un retest, que le\n// statut 3 / VDF re-latche aussitot si le vehicule est vraiment plein.\nconst sessionFresh =\n parsed.evcsVehicleConnected &&\n (\n params.resetBorneRequested || // echappatoire manuelle (bouton reset borne)\n (Number.isFinite(parsed.evcsChargingTimeSec) &&\n parsed.evcsChargingTimeSec <= P11_FRESH_SESSION_MAX_SEC) ||\n (Number.isFinite(parsed.evcsVehicleConnectedAgeSec) &&\n parsed.evcsVehicleConnectedAgeSec <= P11_FRESH_SESSION_MAX_SEC)\n );\n\n// Fix D (08/08/2026) : le statut natif EVCS \"charged\" (3) vaut aussi fin de\n// charge, sans attendre le VehicleDemandFilter (désarmé si la session a été\n// stoppée par une garde, ex battery_current_hard_stop → détection tardive\n// observée la nuit du 07-08/08).\n// Fix E (09/08/2026) : garde puissance ≤ 300 W pour ne jamais latcher \"fini\"\n// sur un glitch de statut pendant une vraie charge. NB : le statut retombe\n// (3→1/6) dès l'ouverture du relais → la persistance est assurée par l'état\n// collant écrit par DECISION pendant la session (voir Fix E DECISION).\n// P11 : sur une session neuve, on ignore aussi un éventuel statut 3 résiduel\n// (le temps que la borne renégocie avec le nouveau véhicule).\n// Fix H (07/09/2026) : un statut \"charged\" n'est pas cru sur parole.\n// La borne l'a annonce le 07/09 a 14:28:18 apres 2 min 42 s de charge et\n// 0,10 kWh livre, ce qui a latche `day_vehicle_full_idle` pour deux heures\n// alors que le vehicule pouvait charger (il a repris a 16:30 et tenu).\n//\n// Aucun signal ELECTRIQUE instantane ne separe une vraie fin de charge d'une\n// interruption : mesure a la seconde des deux coupures, elles sont\n// superposables (VW 06/09, vraie fin : 1534 -> 550 -> 82 -> 11 -> 0 W ;\n// JEEP 07/09, faux positif : 1626 -> 741 -> 100 -> 13 -> 0 W). Seule la\n// DUREE de la session les distingue, et le systeme porte deja la grandeur qui\n// dit ce qu'est une charge etablie : min_run_minutes (29 min en production).\n//\n// Deux formes de \"charged\" sont credibles, et une seule ne l'est pas :\n// energie de session nulle -> le vehicule etait plein en arrivant (P10)\n// session >= min_run_minutes -> charge menee a son terme\n// entre les deux -> charge INTERROMPUE : on ne latche pas\n// Convergence : une session interrompue redemarre a energie nulle, donc un\n// second \"charged\" immediat retombe dans le premier cas et latche.\nconst chargedSessionEnergyKwh = Math.max(0, parsed.evcsSessionEnergyKwh || 0);\nconst chargedSessionEstablished =\n Number.isFinite(parsed.evcsChargingTimeSec) &&\n parsed.evcsChargingTimeSec >= params.minRunMinutes * 60;\nconst chargedCredible =\n chargedSessionEnergyKwh <= 0 || chargedSessionEstablished;\n\nconst evcsStatusCharged =\n !sessionFresh &&\n Number.parseFloat(parsed.evcsStatusRaw) === 3 &&\n Math.max(0, parsed.evcsAcPowerW || 0) <= 300 &&\n chargedCredible;\n\nconst vehicleChargeCompleted =\n nightSessionActive &&\n !sessionFresh &&\n (\n prevSurplusState === 'vehicle_full_or_paused' ||\n prevSurplusState === 'night_session_done_idle' ||\n prevSurplusState === 'night_recovery_done_idle' ||\n prevSurplusState === 'day_vehicle_full_idle' ||\n evcsStatusCharged\n );\n\n// Fix A (08/08/2026) : recovery batterie post-charge = UNE SEULE passe.\n// Sans flag collant, SOC==cible est un point dégénéré (engage soc<=cible,\n// release soc>cible, l'ACM recharge pile à la cible) → rebond EDF ON/OFF\n// période ~10 min observé la nuit du 07-08/08. Une fois la cible atteinte,\n// l'état 'night_recovery_done_idle' (auto-entretenu via prev_surplus_state)\n// coupe le takeover. Ré-engagement uniquement sous cible − 2 pts (bande),\n// hard floor et fenêtre/débranchement restent prioritaires.\n// ⚠ 'night_recovery_done_idle' ne doit matcher AUCUN pattern no-demand de\n// l'hystérésis (full_or_paused / not_requesting_power / charge_complete /\n// no_demand) — même contrainte que 'night_session_done_idle'.\n// 27/08/2026 : la bande de 2 points fabriquait un oscillateur. Relevé de la\n// nuit du 26-27/08 (cible 35) : engagement à SOC 33, relâchement à 35, trois\n// cycles EDF de 52, 79 et 25 min, 2,5 kWh importés pour rien un matin de grand\n// soleil ; la nuit du 20-21/08 en comptait six. Deux points de SOC valent\n// ~0,34 kWh, soit une demi-heure de conso nocturne : ce n'est pas une\n// hystérésis, c'est la période d'une horloge — d'autant plus régulière que la\n// charge de nuit est parfaitement plate.\n// La recovery batterie post-charge est donc UNE SEULE passe par session de\n// nuit, ce qui était déjà l'intention affichée du Fix A (\"relâché\n// DÉFINITIVEMENT\"). Aucun seuil de ré-engagement ici : le plancher dur reste\n// un terme indépendant de nightGridTakeoverNeeded (voir plus bas), et\n// l'hystérésis réseau garde sa propre bande 20/30. Le vrai critère de\n// ré-engagement est un budget d'énergie restant à couvrir, pas un delta de\n// SOC : il appartient au bilan énergétique, pas à une constante de plus ici.\nconst nightRecoveryDonePrev = prevSurplusState === 'night_recovery_done_idle';\nconst nightRecoveryDone =\n vehicleChargeCompleted &&\n (parsed.soc >= batteryMorningTargetPct || nightRecoveryDonePrev);\n\n// =====================================================\n// P9 (09/08/2026) : CHARGE RÉSEAU AVANT DÉPART, HORS FENÊTRE HC.\n// Cas VW : départ 08:50 > fin HC 07:24 → fenêtre morte où le véhicule\n// sous-chargé attendait un solaire insuffisant (observé le 09/08 : advisor\n// force_grid_now, divergence n°10). Si le toggle est ON et que le départ du\n// véhicule SÉLECTIONNÉ est dans ≤ 120 min : réseau autorisé (grid_hc_allowed)\n// jusqu'à fin de charge. L'arrêt réutilise TOUTE la mécanique anti-rebond :\n// VDF → vehicle_full_or_paused → état collant 'departure_done_idle'\n// (réécrit chaque cycle, patterns hystérésis sûrs, coupure immédiate E2).\n// =====================================================\nconst P9_DEPARTURE_LEAD_MIN = 120;\n\nconst departureWindowActive =\n params.allowGridDepartureUrgent &&\n parsed.evcsVehicleConnected &&\n !inNightHcWindow &&\n Number.isFinite(minutesUntilDeparture) &&\n minutesUntilDeparture >= 0 &&\n minutesUntilDeparture <= P9_DEPARTURE_LEAD_MIN;\n\nconst departureDonePrev =\n prevSurplusState === 'departure_done_idle' ||\n (!inNightHcWindow && prevSurplusState === 'vehicle_full_or_paused');\n\nconst departureChargeDone =\n departureWindowActive &&\n !sessionFresh &&\n (departureDonePrev || evcsStatusCharged);\n\nconst departureUrgentActive =\n departureWindowActive &&\n !departureChargeDone;\n\n// =====================================================\n// P10 (09/08/2026) : MÉMOIRE \"VÉHICULE PLEIN\" EN JOURNÉE.\n// Observé le 09/08 09:49 : VW pleine branchée depuis 07:00, le surplus\n// solaire retentait une charge (statut \"Charged\", 0 W) toutes les ~15 min\n// toute la journée (usure relais, bruit). Les états collants P8/P9 étaient\n// scopés nuit/fenêtre départ. Ici : une fois le véhicule vu plein (statut 3\n// ou VDF), état collant 'day_vehicle_full_idle' jusqu'au DÉBRANCHEMENT.\n// - hérite des états \"done\" de la nuit (07:24 → pas de re-tentative) ;\n// - la nuit hérite en retour (vehicleChargeCompleted inclut day_*) ;\n// - échappatoires : force_charge_now / force_grid_now / débrancher-rebrancher.\n// =====================================================\nconst dayVehicleFullPrev =\n prevSurplusState === 'day_vehicle_full_idle' ||\n (\n !inNightHcWindow &&\n (\n prevSurplusState === 'vehicle_full_or_paused' ||\n prevSurplusState === 'night_recovery_done_idle' ||\n prevSurplusState === 'night_session_done_idle' ||\n prevSurplusState === 'departure_done_idle'\n )\n );\n\nconst dayVehicleFullIdle =\n parsed.evcsVehicleConnected &&\n !sessionFresh &&\n !inNightHcWindow &&\n !isStopMode &&\n !forceGridRequested &&\n !forceChargeRequested &&\n (dayVehicleFullPrev || evcsStatusCharged);\n\n// P8 : correction du fix 04/08 (logique inversée : \"|| vehicleChargeCompleted\"\n// GARDAIT le latch après la charge au lieu de le libérer).\nconst nightTakeoverLatched =\n nightSessionActive &&\n nightTakeoverPrev &&\n parsed.soc < NIGHT_TAKEOVER_RELEASE_SOC_PCT &&\n !vehicleChargeCompleted;\n\n// =====================================================\n// P12 — CHARGE NUIT SUR LA BATTERIE (15/09/2026)\n// =====================================================\n// Idee du proprietaire : plutot qu'acheter du reseau la nuit pour charger la\n// voiture, lui donner le solaire DEJA STOCKE, doucement, et laisser ainsi de la\n// place dans la batterie pour la production du lendemain. Payer du reseau la\n// nuit pour ecreter le solaire le lendemain, c'est perdre deux fois.\n//\n// Le mode `battery_assist_night` existe depuis toujours pour exactement ca, et\n// il est MORT depuis toujours : il exige `batteryCurrentA >= plancher de\n// decharge` (0 A), impossible la nuit puisque la maison tire sur la batterie\n// (§11 du HANDOFF, ~ -14 A). On ne cree donc pas un chemin, on rend viable\n// celui qui existe — en remplacant un test de COURANT impossible par un test\n// d'ENERGIE : rester au-dessus de ce qu'il faut pour tenir jusqu'au croisement.\n//\n// Le plancher n'est PAS une constante nouvelle : c'est `required_soc_prudent`,\n// que le bilan publie deja, en profil p80. La marge est mesuree, pas inventee.\nconst nightBatterySurplusFloorPct = Math.max(\n batteryHardFloorPct,\n Number.isFinite(params.balanceRequiredSocPrudentPct)\n ? params.balanceRequiredSocPrudentPct\n : Infinity,\n);\n\n// ⚠️ LE VERROU, ET IL N'EST PAS DECORATIF. Le plancher DESCEND pendant la nuit\n// (il reste moins de nuit a couvrir) et le SOC aussi. Mesure du 15/09 :\n//\n// voiture en charge : SOC -7,1 pt/h, plancher -3,7 pt/h -> le surplus s'epuise\n// voiture arretee : SOC -2,6 pt/h, plancher -3,7 pt/h -> le surplus REVIENT\n//\n// Sans verrou, la charge repartirait des qu'elle s'arrete : cinquieme\n// oscillateur du projet, par construction. UNE SEULE PASSE de surplus par\n// session de nuit, comme la recovery batterie depuis le 27/08.\n// BUDGET VOITURE DE LA NUIT (30/09/2026, decisions du proprietaire).\n// « Definir combien la voiture peut prendre sans utiliser EDF, et brider la\n// charge a cette valeur ou autoriser une charge complete avec EDF. »\n// Le bilan publie le budget ; il est FIGE au premier cycle de la session\n// (avec l'index du compteur de la borne) dans input_text\n// victron_evcs_night_budget = « budget|index », efface en fin de session.\n// L'arret se juge sur le COMPTEUR, qui ne fait que monter : condition\n// monotone, aucun oscillateur possible (meme logique que §40). Le plancher de\n// SOC prudent reste un second arret, jamais retire.\nconst nightBudgetPrev = (() => {\n const m = String(params.nightBudgetPrevRaw || '').match(/^\\s*([\\d.]+)\\|([\\d.]+)\\s*$/);\n return m ? { budgetKwh: Number(m[1]), refKwh: Number(m[2]) } : null;\n})();\nconst nightBudgetCaptureOk =\n nightSessionActive && !nightBudgetPrev &&\n balanceMorningTargetApplied &&\n Number.isFinite(params.balanceNightVehicleBudgetKwh) &&\n Number.isFinite(params.evcsTotalEnergyKwh);\nconst nightBudget = !nightSessionActive\n ? null\n : nightBudgetPrev || (nightBudgetCaptureOk\n ? { budgetKwh: Math.max(0, params.balanceNightVehicleBudgetKwh), refKwh: params.evcsTotalEnergyKwh }\n : null);\nconst nightBudgetToWrite = nightSessionActive\n ? (nightBudgetCaptureOk ? `${Math.round(nightBudget.budgetKwh * 100) / 100}|${nightBudget.refKwh}` : null)\n : (nightBudgetPrev ? '' : null);\nconst nightBudgetDeliveredKwh = nightBudget && Number.isFinite(params.evcsTotalEnergyKwh)\n ? Math.max(0, params.evcsTotalEnergyKwh - nightBudget.refKwh)\n : null;\nconst nightBudgetReached =\n !!nightBudget && nightBudgetDeliveredKwh !== null &&\n nightBudgetDeliveredKwh >= nightBudget.budgetKwh - 0.05;\nconst nightChargeComplete = params.nightChargeMode === 'complete';\n\n// Le verrou ne vaut QUE pendant la session de nuit (01/10/2026). Ecrit sous la\n// forme `prev || …`, il restait vrai pour toujours une fois pose ; invisible\n// tant que son ecriture n'atteignait pas HA (jamais relayee du 15/09 au 01/10).\n// Fin de session (debranchement ou fin de fenetre HC) = verrou leve.\nconst nightBatterySurplusExhausted =\n nightSessionActive && (\n params.nightBatteryDonePrev ||\n parsed.soc <= nightBatterySurplusFloorPct ||\n nightBudgetReached\n );\n\nconst nightBatterySurplusAvailable =\n nightSessionActive &&\n params.nightBatteryChargeEnabled &&\n balanceMorningTargetApplied &&\n Number.isFinite(params.balanceRequiredSocPrudentPct) &&\n !vehicleChargeCompleted &&\n // Le plancher est teste UNE SEULE FOIS, dans `nightBatterySurplusExhausted`.\n // Le retester ici serait redondant — et une redondance ne se teste pas : la\n // mutation du 15/09 l'a montre en ne cassant aucun scenario.\n !nightBatterySurplusExhausted;\n\n// La batterie d'abord, le reseau quand le surplus est epuise. C'est la soupape\n// decrite par le proprietaire — « quitte a fermer EDF entre 6h et 7h24 pour\n// completer » — et elle devient automatique : l'epuisement du surplus rend\n// `nightGridHcPreferred` vrai, donc P7 prend le relais.\n// « Sans EDF » (defaut) : la voiture ne prend JAMAIS le reseau la nuit ; une\n// fois le budget batterie atteint (ou sans budget exploitable), elle attend le\n// soleil. « Charge complete » : EDF en HC finit la charge, comme avant.\nconst nightGridHcPreferred =\n nightSessionActive &&\n params.allowGridHcNight &&\n nightChargeComplete &&\n !vehicleChargeCompleted &&\n !nightBatterySurplusAvailable;\n\nconst nightCarWaitsNoGrid =\n nightSessionActive &&\n !nightChargeComplete &&\n !vehicleChargeCompleted &&\n !nightBatterySurplusAvailable;\n\nconst batteryAssistAllowed =\n nightSessionActive &&\n !nightGridHcPreferred &&\n !nightCarWaitsNoGrid &&\n parsed.soc > batteryHardFloorPct &&\n parsed.soc > gridTakeoverTriggerPct &&\n !nightTakeoverLatched &&\n !batteryRecoveryUrgent;\n\n// =====================================================\n// SESSION RESEAU UNIQUE DE LA NUIT (03/09/2026)\n// =====================================================\n// §26 avait pose l'objectif — « une session reseau unique et dimensionnee » —\n// et designe le bon critere : « un budget d'energie restant a couvrir, il\n// appartient au bilan energetique ». Le bilan est cable depuis ce soir.\n//\n// CE QUI A ETE MESURE AVANT D'ECRIRE CECI, et qui a dicte la conception :\n// la conversion kWh -> points de SOC du bilan est une constante unique\n// (15 kWh). La jauge reelle vaut ~17 kWh au-dessus de 42 % (0,164 a\n// 0,171 kWh/point sur trois fenetres independantes) mais s'effondre en\n// dessous : 12,1 kWh entre 45 et 22 %, 8,2 kWh entre 33 et 20 %. Un point de\n// SOC vaut donc MOITIE MOINS dans la bande ou la nuit se termine, et\n// `required_soc` y est systematiquement trop BAS — c'est pourquoi la batterie\n// est restee sous la ligne toute la nuit du 02-03/09, en descendant 1,75 fois\n// plus vite qu'elle.\n//\n// CONSEQUENCE DE CONCEPTION : le relachement ne doit PAS etre un seuil de SOC.\n// Il porterait sur une grandeur fausse, et tout seuil que le SOC repasse\n// ensuite rouvre l'oscillateur eteint le 27/08.\n//\n// Donc : ENGAGEMENT sur le bilan (batterie sous la cible), RELACHEMENT a la\n// FIN DE LA FENETRE. L'oscillation devient structurellement impossible : la\n// condition de sortie est monotone, une fenetre ne se ferme qu'une fois. Le\n// bit d'engagement est porte par\n// input_boolean.victron_evcs_night_grid_session_committed — sans lui le\n// systeme reevaluerait a chaque cycle et retomberait dans une bande.\n//\n// CE QUE CA FAIT CONCRETEMENT : l'ACM recharge jusqu'a la cible puis tient la\n// batterie a 2 A (evcs_takeover_hold) ; la maison finit la nuit sur le reseau\n// en HEURES CREUSES au lieu de vider la reserve, et la batterie arrive a\n// l'aube pres de sa cible, donc avec de la place pour le solaire du matin —\n// au lieu d'etre a 20 % avec le reseau encore ouvert a 10:33 (03/09).\n//\n// PORTEE : uniquement quand le bilan est exploitable, et uniquement pendant\n// une session nuit (fenetre HC + vehicule branche). Sinon, comportement\n// historique inchange. Le bit se nettoie tout seul a la fin de la session.\nconst nightGridSessionCommittedPrev =\n (input.night_grid_session_committed_prev ?? 'off').toString().trim().toLowerCase() === 'on';\n\nconst nightGridSessionUncovered =\n balanceMorningTargetApplied && batteryBelowMorningTarget;\n\n// ⚠️ SANS VEHICULE AUSSI (12/09/2026).\n// Nuit du 11-12/09, voiture rendue la veille : le bilan a reclame entre 92 %\n// et 59 % de SOC a CHAQUE calcul de la nuit, `reseau_necessaire` vrai partout,\n// et rien ne s'est passe. SOC bloque a 20 % de 23:43 a l'aube, consigne reseau\n// figee a 700 W depuis 18:36 — soit exactement la maison. L'hysteresis avait\n// bien ferme le contacteur (SOC <= 20), mais fermer le contacteur ne suffit\n// pas : il faut DEMANDER la puissance (lecon du 01/09). Personne ne la\n// demandait, parce que la session exigeait un vehicule branche.\n//\n// Or le besoin n'a rien a voir avec la voiture : c'est la maison qui doit\n// traverser la nuit, et le bilan sait exactement ce qu'il lui faut. La\n// fenetre HC suffit. Le relachement reste la FIN DE FENETRE, donc monotone,\n// donc toujours immune a l'oscillation (§40).\n//\n// Sans cette correction, le cout se paye le matin : a 07:24 le SOC est encore\n// sous le seuil OFF de l'hysteresis (30), donc le contacteur reste ferme et\n// la maison passe au PLEIN TARIF jusqu'au croisement PV — le 1,49 kWh deja\n// consigne le 03/09.\n// ⚠️ LA FENETRE DE MIDI EST AUSSI UNE FENETRE D'HEURES CREUSES (18/09/2026).\n// L'engagement de session etait gate sur `night_hc` seul, parce que §40 traitait\n// la nuit. Consequence mesuree : la charge HC manuelle demandee par le\n// proprietaire ne pouvait RIEN faire a midi — le plancher relevait bien la cible\n// du bilan, mais aucune session ne s'engageait, donc le contacteur ne se fermait\n// pas et la consigne restait au repos. Le reglage aurait releve le plancher du\n// soir (donc bloque la voiture) sans jamais charger la batterie.\n//\n// ⚠️ ET LE CRITERE N'EST PAS LE MEME AUX DEUX FENETRES. La nuit, on compare le\n// SOC a `batteryMorningTargetPct`, qui contient le palier de prevision\n// (35/55/85). A midi ce palier n'a aucun sens : il vaut 35 par beau temps, donc\n// un SOC de 30 % en plein soleil engagerait une session reseau — c'est-a-dire la\n// substitution du solaire par le reseau qu'on a mis des semaines a eteindre.\n//\n// A midi le critere honnete est le SOC requis du bilan lui-meme, qui\n// s'auto-regule : le croisement PV etant passe, `energyToCoverKwh` vaut zero et\n// `required_soc` retombe au plancher dur. Releve du 17/09 a 13:51, plein soleil :\n// `required_soc` = 22,0 pour `energy_to_cover` = 0,0. Il ne remonte qu'un jour\n// sombre — ou quand le proprietaire pose un plancher manuel, ce qui est\n// exactement le cas d'usage.\n//\n// ET LA VOITURE (28/09/2026, strategie du proprietaire) : « JEEP branchee\n// engage le reseau en HC midi si l'analyse de production le justifie ». Le\n// bilan fait l'analyse (besoin saisi contre solaire disponible avant le depart,\n// batterie gardee pour tenir jusqu'a 01:24) et publie un verdict ; on le relaie,\n// sous les memes gardes de fraicheur et de statut que la cible.\nconst middayVehicleGridNeeded =\n balanceMorningTargetApplied &&\n params.balanceVehicleGridNeeded &&\n parsed.evcsVehicleConnected;\n\nconst middayGridSessionUncovered =\n balanceMorningTargetApplied &&\n (\n (Number.isFinite(params.balanceRequiredSocPct) &&\n parsed.soc < params.balanceRequiredSocPct) ||\n middayVehicleGridNeeded\n );\n\nconst gridSessionInHcWindow =\n profile === 'night_hc' || profile === 'midday_hc_or_solar';\n\nconst gridSessionUncovered =\n profile === 'night_hc' ? nightGridSessionUncovered : middayGridSessionUncovered;\n\nconst nightGridSessionCommitted =\n gridSessionInHcWindow &&\n (gridSessionUncovered || nightGridSessionCommittedPrev);\n\nconst nightGridTakeoverNeeded =\n nightGridSessionCommitted ||\n (\n nightSessionActive &&\n (\n nightGridSessionCommitted ||\n nightGridHcPreferred ||\n batteryBelowHardFloor ||\n (\n // Fix A : après fin de charge, le seul motif réseau restant est la\n // recovery batterie, et elle ne se ré-engage pas une fois done.\n vehicleChargeCompleted\n ? !nightRecoveryDone\n : (\n batteryAtOrBelowGridTakeoverTrigger ||\n batteryRecoveryUrgent ||\n nightTakeoverLatched\n )\n )\n )\n );\n\nconst nightPreferredSourceMode =\n !nightSessionActive\n ? 'blocked'\n : vehicleChargeCompleted\n ? 'blocked'\n // « Sans EDF » : la voiture ne prend JAMAIS le reseau la nuit, MEME quand\n // EDF se ferme pour la batterie (plancher dur, cible du matin, session du\n // bilan, verrou). Decision du proprietaire le 04/10 (option A) : la nuit du\n // 03-04/10, EDF ferme a 02:57 pour la batterie, l'ancienne exception a\n // laisse la JEEP prendre 4,7 kWh au reseau jusqu'au plein. La protection\n // du materiel reste entiere : EDF se ferme quand meme (takeover), seule la\n // voiture attend le soleil.\n : nightCarWaitsNoGrid\n ? 'blocked'\n : batteryAssistAllowed\n ? 'battery_assist_night'\n : (\n batteryBelowHardFloor ||\n batteryBelowMorningTarget ||\n batteryRecoveryUrgent\n )\n ? 'grid_forced'\n : 'grid_hc_allowed';\n\n// « Sans EDF » ET EDF necessaire pour la BATTERIE (04/10/2026, option A du\n// proprietaire) : la voiture reste bloquee, mais la batterie a sa session\n// reseau. Sans ce drapeau, la fermeture d'EDF et la consigne de recharge, qui\n// suivent la source de la VOITURE, seraient perdues avec elle (golden\n// budget-sans-edf-plancher-dur-edf-protege, depart-matin-…-sous-la-cible).\nconst nightBatteryGridOnly =\n nightSessionActive &&\n nightCarWaitsNoGrid &&\n nightGridTakeoverNeeded;\n\nconst batteryRecoveryAllowedNow =\n nightSessionActive &&\n nightGridTakeoverNeeded &&\n (\n batteryBelowMorningTarget ||\n batteryRecoveryUrgent\n );\n\nconst batteryTargetReached =\n nightSessionActive &&\n parsed.soc >= batteryMorningTargetPct;\n\n// =====================================================\n// PROBES / SOLAR POTENTIAL\n// =====================================================\n// IMPORTANT:\n// On ne met plus effectivePvStartMinW à 0 en force_charge_now,\n// sinon la nuit 0W >= 0W ferait croire à un faux signal solaire.\nconst effectivePvStartMinW = params.pvStartMinW;\n\nconst morningProbeEligible =\n profile === 'morning_solar' &&\n params.probeEnabled &&\n parsed.pvPowerAvg10mW >= params.morningProbePvAvgMinW &&\n parsed.pvTrendW >= params.morningProbePvTrendMinW &&\n parsed.soc >= params.morningSocMin &&\n parsed.batteryCurrentA >= params.morningBatteryMaxDischargeA;\n\nconst middayProbeEligible =\n profile === 'midday_hc_or_solar' &&\n params.probeEnabled &&\n parsed.pvPowerAvg10mW >= params.middayProbePvAvgMinW &&\n parsed.soc >= params.middaySocMin &&\n parsed.batteryCurrentA >= params.middayBatteryMaxDischargeA;\n\nconst afternoonProbeEligible =\n profile === 'outside_window' &&\n params.probeEnabled &&\n parsed.soc >= params.afternoonProbeSocMin &&\n parsed.batteryCurrentA >= params.afternoonBatteryMaxDischargeA &&\n parsed.pvPowerAvg10mW >= params.afternoonProbePvAvgMinW;\n\nconst cloudDebtOkForPotential = params.cloudDebtPrevKwh <= params.cloudDebtMaxKwh;\nconst batteryOkForPotential = parsed.batteryCurrentA >= -Math.max(1, params.batteryNeutralBandA);\n\n// P3 PROVOCATION PV (installation sans injection) :\n// Batteries quasi pleines => le MPPT bride la production à la conso maison.\n// Les seuils PV observés, le forecast buffer et la cloud debt ne veulent\n// alors plus rien dire (la dette ne peut d'ailleurs plus se rembourser,\n// batterie pleine = courant nul). On autorise solar_potential sur la seule\n// santé batterie : SOC très haut + batterie non déchargée.\n// Les gardes aval (BATTERY_CLOUD_LOCKS / DECISION) restent actives.\nconst PV_PROVOCATION_SOC_MIN = 90;\nconst pvProvocationEligible =\n parsed.soc >= PV_PROVOCATION_SOC_MIN &&\n batteryOkForPotential;\n\nconst isHighBatteryForPotential =\n pvProvocationEligible ||\n (\n parsed.soc >= 85 &&\n forecastBufferKwh > 0 &&\n cloudDebtOkForPotential &&\n batteryOkForPotential\n );\n\nconst potentialAllowed = userStrategy !== 'auto_solar';\n\n// IMPORTANT:\n// On impose un vrai PV > 0 pour le test sur pvPowerW.\n// Ça évite qu'en force_charge_now la nuit, 0W soit pris pour un signal solaire.\nconst strongSolarSignal =\n (\n parsed.pvPowerW > 0 &&\n parsed.pvPowerW >= effectivePvStartMinW\n ) ||\n parsed.pvPowerAvg10mW >= params.pvHoldMinW ||\n (departurePriorityActive && parsed.pvPowerAvg10mW >= params.pvHoldMinW);\n\n// =====================================================\n// SOURCE MODE\n// SÉMANTIQUE CORRIGÉE:\n// - force_charge_now bypass les fenêtres\n// - mais ne devient jamais grid_forced à lui seul\n// - outside_window = pas de réseau programmé\n// - la charge locale peut rester autorisée\n// =====================================================\nlet sourceMode = 'blocked';\n\nif (isStopMode) {\n sourceMode = 'blocked';\n}\n\nelse if (forceGridRequested) {\n // Demande explicite de réseau.\n sourceMode = 'grid_forced';\n}\n\nelse if (forceChargeRequested) {\n // IMPORTANT:\n // force_charge_now = intention de charger maintenant,\n // sans exiger le réseau.\n\n if (strongSolarSignal) {\n sourceMode = 'solar_only';\n }\n\n else if (nightSessionActive && batteryAssistAllowed) {\n sourceMode = 'battery_assist_night';\n }\n\n else if (isHighBatteryForPotential && potentialAllowed) {\n sourceMode = 'solar_potential';\n }\n\n else if (profile === 'midday_hc_or_solar' && middayProbeEligible) {\n sourceMode = 'hybrid_probe';\n }\n\n else if (profile === 'morning_solar' && morningProbeEligible) {\n sourceMode = 'hybrid_probe';\n }\n\n else {\n // Fallback manuel local :\n // on n'impose pas le grid, on laisse l'étage aval décider\n // selon les garde-fous physiques / batterie.\n sourceMode = 'solar_potential';\n }\n}\n\nelse if (profile === 'night_hc') {\n if (nightSessionActive) {\n sourceMode = nightPreferredSourceMode;\n } else {\n sourceMode = 'blocked';\n }\n}\n\nelse if (profile === 'midday_hc_or_solar' || profile === 'morning_solar') {\n // Session de midi engagee et contacteur ferme : la voiture charge sur le\n // reseau avec la batterie (« batterie et voiture ensemble », 28/09), meme au\n // soleil. La consigne garde son credit solaire (HA_SERVICE_CALLS_OUTPUT) :\n // elle ne preleve au reseau que ce que les chargeurs ne donnent pas.\n if (profile === 'midday_hc_or_solar' && nightGridSessionCommitted && parsed.gridAvailable) {\n sourceMode = 'grid_hc_allowed';\n }\n\n else if (strongSolarSignal) {\n sourceMode = 'solar_only';\n }\n\n else if (isHighBatteryForPotential && potentialAllowed) {\n sourceMode = 'solar_potential';\n }\n\n else if (profile === 'midday_hc_or_solar' && middayProbeEligible) {\n sourceMode = 'hybrid_probe';\n }\n\n else if (profile === 'morning_solar' && morningProbeEligible) {\n sourceMode = 'hybrid_probe';\n }\n\n else if (profile === 'midday_hc_or_solar' && params.allowGridHcMidday && parsed.gridAvailable) {\n sourceMode = 'grid_hc_allowed';\n }\n\n else {\n sourceMode = 'blocked';\n }\n}\n\nelse if (profile === 'evening_high_soc') {\n if (strongSolarSignal) {\n sourceMode = 'solar_only';\n }\n\n else if (isHighBatteryForPotential && potentialAllowed) {\n sourceMode = 'solar_potential';\n }\n\n else {\n sourceMode = 'blocked';\n }\n}\n\nelse if (profile === 'outside_window') {\n if (strongSolarSignal) {\n sourceMode = 'solar_only';\n }\n\n else if (isHighBatteryForPotential && potentialAllowed) {\n sourceMode = 'solar_potential';\n }\n\n else if (afternoonProbeEligible) {\n sourceMode = 'hybrid_probe';\n }\n\n else {\n // IMPORTANT:\n // hors fenêtre = pas de réseau programmé.\n // Donc on ne bascule plus vers grid_forced juste parce que le réseau existe.\n sourceMode = 'blocked';\n }\n}\n\n// P9 : le réseau avant départ prend le pas sur les modes spéculatifs du jour\n// (blocked / solar_potential / hybrid_probe), mais JAMAIS sur le solaire réel\n// (solar_only), ni sur stop / force_* (déjà traités en tête de cascade).\nif (\n departureUrgentActive &&\n !isStopMode &&\n !forceGridRequested &&\n !forceChargeRequested &&\n profile !== 'night_hc' &&\n sourceMode !== 'solar_only'\n) {\n sourceMode = 'grid_hc_allowed';\n}\n\n// P9 : véhicule plein pendant la fenêtre départ → aucune source.\nif (departureChargeDone && profile !== 'night_hc' && !isStopMode && !forceGridRequested && !forceChargeRequested) {\n sourceMode = 'blocked';\n}\n\n// P10 : véhicule plein en journée (mémoire collante) → aucune source,\n// y compris solaire (charger un véhicule plein ne sert à rien).\nif (dayVehicleFullIdle && profile !== 'night_hc') {\n sourceMode = 'blocked';\n}\n\n// =====================================================\n// PLAFOND DE COURANT EVCS — calculé APRÈS sourceMode (31/08/2026)\n// =====================================================\n// P5 : plafond majoré (input_number.evcs_plafond_courant_edf_on, jusqu'à 28 A\n// en BOOST) quand le réseau alimente réellement AC-IN ; sinon plafond câblé\n// prudent, celui que les deux MultiPlus peuvent porter SEULS.\n//\n// SÉQUENCE D'OUVERTURE (31/08/2026) — pourquoi ce bloc a été déplacé ici.\n// Le plafond majoré n'est légitime que tant que le réseau est là. Or c'est\n// l'hystérésis (cycle 1 min) qui ouvre le contacteur, et le contrôleur EVCS ne\n// l'apprenait qu'après coup : pendant ce temps les deux onduleurs portaient\n// seuls la voiture au courant majoré plus la maison — le mécanisme du W-105.\n//\n// Correctif sans poignée de main entre les deux automates : le plafond majoré\n// exige désormais que l'EVCS DEMANDE lui-même le réseau. Sa demande tombe au\n// cycle où la fenêtre se ferme (30 s), alors que l'hystérésis ne peut ouvrir\n// qu'ensuite (override disparu ET min_on écoulé, cycle 1 min). L'EVCS baisse\n// donc AVANT, mécaniquement, sans rien coordonner.\n// Le calcul doit venir après sourceMode ; rien entre les deux ne l'utilisait.\nconst evcsGridCeilingAllowed =\n parsed.acInAvailable &&\n (\n sourceMode === 'grid_forced' ||\n sourceMode === 'grid_hc_allowed' ||\n forceGridRequested\n );\n\nconst evcsDedicatedCapBaseW = Math.max(0, cfg.evcsDedicatedMaxW - cfg.systemReserveW);\nconst safeEvcsByDedicatedCapW = evcsGridCeilingAllowed\n ? Math.max(0, params.gridCurrentMaxA * acVoltageRef)\n : evcsDedicatedCapBaseW;\nconst safeEvcsByVebusW = Math.max(0, totalVebusSoftCeilingW - vebusNonEvcsW - cfg.systemReserveW);\nconst safeEvcsCombinedW = Math.max(0, Math.min(safeEvcsByDedicatedCapW, safeEvcsByVebusW));\n\nconst systemCapCurrentA = Math.max(0, Math.floor(safeEvcsCombinedW / acVoltageRef));\nconst physicalCapA = clamp(\n systemCapCurrentA,\n 0,\n evcsGridCeilingAllowed\n ? Math.max(params.currentMaxAutoA, params.gridCurrentMaxA)\n : params.currentMaxAutoA\n);\nconst canRunAtMinCurrent = physicalCapA >= params.currentMinA;\n\nconst operatingMode = profile;\n\n// =====================================================\n// OUTPUT\n// =====================================================\nreturn [{\n json: {\n ...prev,\n\n strategy: {\n userStrategy,\n isStopMode,\n isForceMode,\n forceGridRequested,\n forceChargeRequested,\n },\n\n lifecycle: {\n minutesSinceStart,\n minutesSinceStop,\n secondsSinceCurrentChange,\n minutesSinceProbeStart,\n minRunElapsed,\n minStopElapsed,\n currentCooldownElapsed,\n minutesUntilDeparture,\n },\n\n windows: {\n inNightHcWindow,\n inMorningWindow,\n inMiddayWindow,\n inEveningWindow,\n // Temps restant dans la fenetre d'HEURES CREUSES courante (nuit ou midi),\n // null hors de toute fenetre creuse. Consomme par le dimensionnement de\n // la consigne reseau : recharger la batterie sur le reseau n'a de sens\n // economique que la-dedans, donc `null` y vaut « pas de part batterie »\n // sans test supplementaire.\n minutesRemainingInHcWindow:\n minutesRemainingInWindow(now.minNow, params.hcNightStartMin, params.hcNightStopMin)\n ?? minutesRemainingInWindow(now.minNow, params.hcMiddayStartMin, params.hcMiddayStopMin),\n },\n\n derived: {\n acVoltageRef,\n batteryVoltageRef,\n houseNonEvcsW,\n vebusNonEvcsW,\n totalVebusSoftCeilingW,\n totalVebusHardCeilingW,\n safeEvcsByDedicatedCapW,\n safeEvcsByVebusW,\n safeEvcsCombinedW,\n systemCapCurrentA,\n physicalCapA,\n canRunAtMinCurrent,\n evcsGridCeilingAllowed,\n gridSettling,\n gridTransitionAgeSec: parsed.gridTransitionAgeSec,\n endDayGuardSoc,\n energyNeededForEndDayGuardKwh,\n forecastBufferKwh,\n projectedEndSocAfterCharge,\n currentReferenceA,\n anyVebusOverload,\n anyVebusLowBattery,\n vebusAcOutSoftReached,\n vebusAcOutHardReached,\n vebusDcVoltageSoftLow,\n vebusDcVoltageHardLow,\n houseLoadHighVsHeadroom,\n },\n\n context: {\n departurePriorityActive,\n forceChargeDepartureGridOverride,\n manualChargeBypassWindows,\n manualChargeLocalRequested,\n morningProbeEligible,\n middayProbeEligible,\n afternoonProbeEligible,\n pvProvocationEligible,\n isHighBatteryForPotential,\n strongSolarSignal,\n effectivePvStartMinW,\n profile,\n operatingMode,\n sourceMode,\n },\n\n // R1 : source de vérité UNIQUE du moteur nuit.\n // BATTERY_CLOUD_LOCKS consomme cet objet, il ne recalcule plus rien.\n nightEvcsPreview: {\n nightSessionActive,\n nightSessionNeedsCapture,\n nightSessionNeedsClear,\n nightSessionStartSocPct,\n nightSessionStartAt,\n nightSessionStartMs,\n\n batteryHardFloorPct,\n batteryMorningTargetPct,\n batteryMorningTargetBasePct,\n balanceMorningTargetApplied,\n nightBatterySurplusFloorPct,\n nightBatterySurplusAvailable,\n nightBatterySurplusExhausted,\n nightBudgetKwh: nightBudget ? nightBudget.budgetKwh : null,\n nightBudgetDeliveredKwh,\n nightBudgetReached,\n nightBudgetToWrite,\n nightChargeMode: params.nightChargeMode,\n nightCarWaitsNoGrid,\n nightBatteryGridOnly,\n nightGridSessionCommitted,\n middayVehicleGridNeeded,\n nightGridSessionUncovered,\n balanceTargetAgeMin:\n balanceTargetAgeMin === null ? null : Math.round(balanceTargetAgeMin * 10) / 10,\n batteryMorningTargetRulePct,\n maxBatteryDischargePoints,\n gridTakeoverTriggerPct,\n\n batteryBelowHardFloor,\n batteryBelowMorningTarget,\n batteryAtOrBelowGridTakeoverTrigger,\n\n batteryKwhMissingToTarget,\n batteryRecoveryMinutesNeeded,\n batteryRecoveryUrgent,\n batteryRecoveryAllowedNow,\n batteryTargetReached,\n\n batteryAssistAllowed,\n nightGridTakeoverNeeded,\n nightTakeoverLatched,\n nightGridHcPreferred,\n vehicleChargeCompleted,\n nightRecoveryDone,\n nightPreferredSourceMode,\n\n // P9\n departureUrgentActive,\n departureChargeDone,\n\n // P10\n dayVehicleFullIdle,\n\n // P11\n sessionFresh,\n chargedSessionEnergyKwh,\n chargedSessionEstablished,\n chargedCredible,\n }\n }\n}];\n" }, "type": "n8n-nodes-base.code", "typeVersion": 2,