function clamp(v, min, max) { return Math.min(Math.max(v, min), max); } function to2(n) { return String(n).padStart(2, '0'); } function getParisParts(ms = Date.now()) { const fmt = new Intl.DateTimeFormat('fr-FR', { timeZone: 'Europe/Paris', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false, }); const parts = Object.fromEntries( fmt.formatToParts(new Date(ms)) .filter(p => p.type !== 'literal') .map(p => [p.type, p.value]) ); const hour = Number(parts.hour) === 24 ? 0 : Number(parts.hour); const date = `${parts.year}-${parts.month}-${parts.day}`; return { date, hour, minute: Number(parts.minute), second: Number(parts.second), }; } function getTzOffsetMs(utcTs, timeZone = 'Europe/Paris') { const dtf = new Intl.DateTimeFormat('en-US', { timeZone, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false, }); const parts = Object.fromEntries( dtf.formatToParts(new Date(utcTs)) .filter(p => p.type !== 'literal') .map(p => [p.type, p.value]) ); const asIfUtc = Date.UTC( Number(parts.year), Number(parts.month) - 1, Number(parts.day), Number(parts.hour) === 24 ? 0 : Number(parts.hour), Number(parts.minute), Number(parts.second) ); const d = new Date(utcTs); const actualUtc = Date.UTC( d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate(), d.getUTCHours(), d.getUTCMinutes(), d.getUTCSeconds() ); return asIfUtc - actualUtc; } function parseParisStampToMs(s) { const text = (s ?? '').toString().trim(); const m = text.match(/^(\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2}):(\d{2})$/); if (!m) return null; const y = Number(m[1]); const mo = Number(m[2]); const d = Number(m[3]); const h = Number(m[4]); const mi = Number(m[5]); const se = Number(m[6]); const guessUtc = Date.UTC(y, mo - 1, d, h, mi, se); const off1 = getTzOffsetMs(guessUtc, 'Europe/Paris'); let ts = guessUtc - off1; const off2 = getTzOffsetMs(ts, 'Europe/Paris'); if (off2 !== off1) ts = guessUtc - off2; return ts; } function hhmmssFromMinutes(minOfDay) { const mm = ((minOfDay % 1440) + 1440) % 1440; const hh = Math.floor(mm / 60); const mi = mm % 60; return `${to2(hh)}:${to2(mi)}:00`; } function nextDailyOccurrenceMs(startMin, refMs) { if (!Number.isFinite(startMin)) return null; const today = getParisParts(refMs).date; const todayStamp = `${today} ${hhmmssFromMinutes(startMin)}`; const todayMs = parseParisStampToMs(todayStamp); if (todayMs !== null && todayMs > refMs + 1000) return todayMs; const tomorrow = getParisParts(refMs + 36 * 3600 * 1000).date; const tomorrowStamp = `${tomorrow} ${hhmmssFromMinutes(startMin)}`; return parseParisStampToMs(tomorrowStamp); } const prev = $input.first().json; const { nowMs, cfg, parsed, params, history, batteryTune, strategy, derived, context, lifecycle, } = prev; // ===================================================== // SESSION / SOURCE // ===================================================== const sessionObservedActive = parsed.evcsChargingSwitchOn || parsed.controlLatch || parsed.chargePermissionNow; const isGridSource = context.sourceMode === 'grid_hc_allowed' || context.sourceMode === 'grid_forced'; const isLocalSolarSource = context.sourceMode === 'solar_only' || context.sourceMode === 'solar_potential'; // ===================================================== // SOC BAND / BATTERY DYNAMIC POLICY // ===================================================== const socBand = parsed.soc > 90 ? 'gt90' : parsed.soc > 80 ? '80_90' : parsed.soc > 60 ? '60_80' : parsed.soc > 40 ? '40_60' : parsed.soc > 25 ? '25_40' : 'lt25'; const band = batteryTune.bands[socBand]; const dischargeModeFactor = strategy.forceChargeRequested ? 1.2 : strategy.forceGridRequested ? 0.7 : strategy.userStrategy === 'auto_solar' ? 0.8 : 1.0; const chargeReserveModeFactor = strategy.forceChargeRequested ? 0.8 : strategy.forceGridRequested ? 1.1 : strategy.userStrategy === 'auto_solar' ? 1.1 : 1.0; const toleranceModeFactor = strategy.forceChargeRequested ? 1.25 : strategy.forceGridRequested ? 0.8 : strategy.userStrategy === 'auto_solar' ? 0.8 : 1.0; const batteryDischargeSoftDynamicA = band.dischargeSoftA * dischargeModeFactor; const batteryDischargeHardDynamicA = band.dischargeHardA * dischargeModeFactor; const batteryChargeReserveDynamicA = Math.max(0, band.chargeReserveA * chargeReserveModeFactor); const batterySoftToleranceMinutes = band.softToleranceMin * toleranceModeFactor; const batteryHardToleranceSeconds = band.hardToleranceSec; const batteryRecoveryMinutes = band.recoveryMin; // ===================================================== // CONTEXT HELPERS // ===================================================== const houseSpikeThresholdW = 1500; const houseSpikeDetected = (parsed.houseAcPowerW - parsed.houseAcPowerPrevW) >= houseSpikeThresholdW; const solarPotentialContext = context.sourceMode === 'solar_potential' || ( parsed.soc >= 80 && derived.forecastBufferKwh > 0 && parsed.pvPowerW < context.effectivePvStartMinW && derived.houseNonEvcsW < 1200 ); // ===================================================== // OUTSIDE-WINDOW LOCAL BYPASS // IMPORTANT: // en auto_solar_plus ou force_charge_now, un contexte local solaire // ne doit plus rester bloqué juste parce qu'on est hors fenêtre. // ===================================================== const outsideWindowLocalBypass = context.profile === 'outside_window' && !strategy.forceGridRequested && ( strategy.forceChargeRequested || strategy.userStrategy === 'auto_solar_plus' ) && isLocalSolarSource; // ===================================================== // REASON-BASED LOCKS // ===================================================== const nextWindowCandidates = [ nextDailyOccurrenceMs(params.morningStartMin, nowMs), nextDailyOccurrenceMs(params.hcMiddayStartMin, nowMs), nextDailyOccurrenceMs(params.eveningStartMin, nowMs), nextDailyOccurrenceMs(params.hcNightStartMin, nowMs), ].filter(v => Number.isFinite(v)); const nextAllowedWindowStartMs = nextWindowCandidates.length > 0 ? Math.min(...nextWindowCandidates) : null; const blockActive = history.blockedUntilMs !== null && history.blockedUntilMs > nowMs + 1000; const blockStillRelevant = history.lastBlockReason === 'outside_window' ? (context.profile === 'outside_window' && !outsideWindowLocalBypass) : history.lastBlockReason === 'probe_failed' ? context.sourceMode === 'hybrid_probe' : history.lastBlockReason === 'battery_protection_stop' ? ( parsed.batteryCurrentA < -params.batteryNeutralBandA || derived.anyVebusLowBattery || derived.vebusDcVoltageSoftLow ) : history.lastBlockReason === 'grid_battery_guard' ? isGridSource : history.lastBlockReason === 'vebus_overload_persistent' ? derived.anyVebusOverload : false; const startBlockedByReason = blockActive && blockStillRelevant; // ===================================================== // CLOUD DEBT // ===================================================== const batteryNetPowerW = parsed.batteryCurrentA * derived.batteryVoltageRef; let cloudDebtNextKwh = params.cloudDebtPrevKwh; if (sessionObservedActive) { if (batteryNetPowerW < 0 && !isGridSource) { cloudDebtNextKwh += (Math.abs(batteryNetPowerW) * cfg.sampleSeconds) / 3600000; } else if (batteryNetPowerW > 0) { cloudDebtNextKwh -= (batteryNetPowerW * cfg.sampleSeconds) / 3600000; } } else if (batteryNetPowerW > 0) { cloudDebtNextKwh -= (batteryNetPowerW * cfg.sampleSeconds) / 3600000; } cloudDebtNextKwh = clamp(cloudDebtNextKwh, 0, params.cloudDebtMaxKwh * 4); const cloudDebtMeaningful = cloudDebtNextKwh > cfg.cloudDebtMeaningfulThresholdKwh; const cloudEventDischargingNow = sessionObservedActive && !isGridSource && parsed.batteryCurrentA < -Math.max(1, params.batteryNeutralBandA) && cloudDebtMeaningful; const continuingCloudEvent = cloudEventDischargingNow && history.cloudDebtStartedMsPrev !== null && params.cloudDebtPrevKwh > cfg.cloudDebtRecoveryThresholdKwh; const enteringCloudDebt = cloudEventDischargingNow && !continuingCloudEvent; const cloudDebtActive = cloudEventDischargingNow; const cloudDebtStartedMs = cloudEventDischargingNow ? (continuingCloudEvent ? history.cloudDebtStartedMsPrev : nowMs) : null; const cloudDebtDurationMinutes = cloudDebtActive && cloudDebtStartedMs !== null ? Math.max(0, (nowMs - cloudDebtStartedMs) / 60000) : 0; const effectiveCloudToleranceMinutes = params.cloudToleranceMinutes + (solarPotentialContext ? batteryTune.global.solarPotentialGraceMinutes : 0) + (houseSpikeDetected ? batteryTune.global.houseSpikeGraceMinutes : 0); const cloudToleranceOk = cloudDebtNextKwh <= params.cloudDebtMaxKwh && ( !cloudDebtActive || cloudDebtDurationMinutes <= effectiveCloudToleranceMinutes ); // ===================================================== // LEGACY / FORECAST MODES // ===================================================== const batteryFullHarvestMode = parsed.forecastModeEnabled && context.profile !== 'night_hc' && context.profile !== 'outside_window' && parsed.soc >= params.batteryFullSoc && parsed.todayRemainingKwh >= params.batteryFullForecastMinKwh && derived.projectedEndSocAfterCharge >= (derived.endDayGuardSoc - 2); const forecastCommitMode = parsed.forecastModeEnabled && context.profile !== 'night_hc' && context.profile !== 'outside_window' && parsed.soc >= params.forecastCommitSocFloor && parsed.todayRemainingKwh >= params.forecastCommitMinKwh && derived.projectedEndSocAfterCharge >= derived.endDayGuardSoc; const batteryPriorityMode = !batteryFullHarvestMode && !forecastCommitMode; // ===================================================== // EVCS NIGHT ENGINE — R1 (07/08/2026) // Le moteur nuit est calculé UNE SEULE FOIS dans WINDOWS_SOURCEV2 // (payload nightEvcsPreview). Ce node CONSOMME, il ne recalcule plus. // TOUTE évolution de la logique nuit se fait dans WINDOWS_SOURCEV2. // Fallback sûr si preview absent : session inactive / 'blocked'. // ===================================================== const nightPrev = prev.nightEvcsPreview || {}; const nightSessionActive = !!nightPrev.nightSessionActive; const nightSessionNeedsCapture = !!nightPrev.nightSessionNeedsCapture; const nightSessionNeedsClear = !!nightPrev.nightSessionNeedsClear; const nightSessionStartSocPct = Number.isFinite(nightPrev.nightSessionStartSocPct) ? nightPrev.nightSessionStartSocPct : null; const nightSessionStartAt = nightPrev.nightSessionStartAt || ''; const nightSessionStartMs = Number.isFinite(nightPrev.nightSessionStartMs) ? nightPrev.nightSessionStartMs : null; const batteryHardFloorPct = Number.isFinite(nightPrev.batteryHardFloorPct) ? nightPrev.batteryHardFloorPct : params.evcsNightBatteryHardFloorPct; const batteryMorningTargetPct = Number.isFinite(nightPrev.batteryMorningTargetPct) ? nightPrev.batteryMorningTargetPct : params.evcsNightTargetWeakForecastPct; const balanceMorningTargetApplied = !!nightPrev.balanceMorningTargetApplied; const nightGridSessionCommitted = !!nightPrev.nightGridSessionCommitted; const maxBatteryDischargePoints = Number.isFinite(nightPrev.maxBatteryDischargePoints) ? nightPrev.maxBatteryDischargePoints : 0; const gridTakeoverTriggerPct = Number.isFinite(nightPrev.gridTakeoverTriggerPct) ? nightPrev.gridTakeoverTriggerPct : Math.max(batteryHardFloorPct, batteryMorningTargetPct); const batteryBelowHardFloor = !!nightPrev.batteryBelowHardFloor; const batteryBelowMorningTarget = !!nightPrev.batteryBelowMorningTarget; const batteryAtOrBelowGridTakeoverTrigger = !!nightPrev.batteryAtOrBelowGridTakeoverTrigger; const batteryKwhMissingToTarget = Number.isFinite(nightPrev.batteryKwhMissingToTarget) ? nightPrev.batteryKwhMissingToTarget : 0; const batteryRecoveryMinutesNeeded = Number.isFinite(nightPrev.batteryRecoveryMinutesNeeded) ? nightPrev.batteryRecoveryMinutesNeeded : 0; const batteryRecoveryUrgent = !!nightPrev.batteryRecoveryUrgent; const batteryRecoveryAllowedNow = !!nightPrev.batteryRecoveryAllowedNow; const batteryTargetReached = !!nightPrev.batteryTargetReached; const batteryAssistAllowed = !!nightPrev.batteryAssistAllowed; const nightGridTakeoverNeeded = !!nightPrev.nightGridTakeoverNeeded; const nightTakeoverLatched = !!nightPrev.nightTakeoverLatched; const vehicleChargeCompleted = !!nightPrev.vehicleChargeCompleted; const nightRecoveryDone = !!nightPrev.nightRecoveryDone; const departureUrgentActive = !!nightPrev.departureUrgentActive; const departureChargeDone = !!nightPrev.departureChargeDone; const dayVehicleFullIdle = !!nightPrev.dayVehicleFullIdle; const nightPreferredSourceMode = nightPrev.nightPreferredSourceMode || 'blocked'; // ===================================================== // PROFILE-BASED THRESHOLDS // ===================================================== // ===================================================== // PLANCHER DU SOIR : UN NIVEAU FIXE, REMPLACE PAR L'ENERGIE A COUVRIR (03/09/2026) // ===================================================== // `evening_soc_start_min` = 85 % est une tentative de dire « garde de quoi // passer la nuit ». C'est un NIVEAU, pris a un instant, et il ne sait rien de // la nuit qui vient : ni sa duree, ni la consommation a couvrir, ni l'heure a // laquelle le solaire prendra le relais. // // Le bilan energetique calcule exactement cela et le publie — // `evcs_balance_required_soc`, deduit de l'energie a couvrir jusqu'au // croisement production/consommation. Personne ne le lisait : `grep // evcs_balance` sur les deux workflows de decision renvoyait 0. // // Le 03/09 a 19:44, les deux nombres coexistaient dans le meme systeme : // plancher du soir 85 % (SOC 94, donc charge autorisee) contre SOC requis // 99,7 % pour tenir jusqu'au croisement de 9 h. La charge du soir a fait // passer le manque de la nuit de 0,86 a 2,21 kWh. // // SENS UNIQUE, ET C'EST DELIBERE : le bilan ne peut que RENFORCER le plancher, // jamais l'abaisser (Math.max). L'assouplir — le bilan dira souvent moins de // 85 % par forte prevision d'ete — autoriserait des charges qui n'ont pas lieu // aujourd'hui : c'est une decision distincte, avec ses propres risques, qui // doit etre validee pour elle-meme et non glissee dans ce correctif. // Corollaire : le max rend la garde robuste sans borne ni ecretage — une // valeur nulle, negative ou aberrante ne peut pas relacher le plancher. // // PORTEE : plancher de DEMARRAGE. Le plancher de maintien reste // min(profileSocHoldFloor, ...) donc inchange a 80 % : relever le plancher de // demarrage ne coupe aucune charge en cours. // // L'abondance P6 continue de primer : PV moyen >= 4 kW ET batterie qui monte a // >= +20 A est une PREUVE MESUREE que la ressource depasse ce que le systeme // absorbe — le cas ou la voiture est effectivement gratuite. // // REPLI : lecture absente, statut non 'ok', ou bilan plus vieux que six de ses // cycles => regle historique inchangee. Le kill-switch existe deja et n'a pas // besoin d'etre double : `input_boolean.evcs_balance_enabled` a OFF arrete les // ecritures du bilan, `balance_last_run` se fige, et le plancher retombe seul // sur 85 % en une demi-heure. const BALANCE_MAX_AGE_MIN = 30; const balanceAgeMin = params.balanceLastRunMs !== null && params.balanceLastRunMs !== undefined ? (nowMs - params.balanceLastRunMs) / 60000 : null; const balanceFresh = balanceAgeMin !== null && balanceAgeMin >= -5 && balanceAgeMin <= BALANCE_MAX_AGE_MIN; const balanceEveningFloorUsable = balanceFresh && params.balanceStatusOk && Number.isFinite(params.balanceRequiredSocPct); const eveningSocStartFloorPct = balanceEveningFloorUsable ? Math.max(params.eveningSocStartMin, params.balanceRequiredSocPct) : params.eveningSocStartMin; const eveningFloorFromBalance = balanceEveningFloorUsable && eveningSocStartFloorPct > params.eveningSocStartMin; const profileSocStartFloor = context.profile === 'morning_solar' ? params.morningSocMin : context.profile === 'midday_hc_or_solar' ? params.middaySocMin : context.profile === 'evening_high_soc' ? eveningSocStartFloorPct : params.socStartMin; // P6 ABONDANCE : PV moyen élevé + batterie en charge franche => le plancher SOC // de démarrage perd son sens (la batterie continue de monter même avec l'EVCS à 6 A). // Jours nuageux ou batterie peu chargée par le PV : rien ne change. const PV_ABUNDANCE_AVG_MIN_W = 4000; const ABUNDANCE_BATTERY_CHARGE_MIN_A = 20; const ABUNDANCE_SOC_FLOOR_PCT = 40; // 24/08/2026 — L'abondance se PROUVE au démarrage (batterie qui monte franchement, // >= +20 A, avant qu'on touche à quoi que ce soit) mais elle se MAINTIENT tant que // le PV reste fort ET que la batterie n'est pas devenue fournisseuse. Sans cette // distinction, la charge du véhicule — qui détourne précisément le surplus — fait // tomber le courant batterie sous +20 A et détruit sa propre condition // d'autorisation : système bistable, oscillation à la période de min_run. const solarAbundanceStart = parsed.pvPowerAvg10mW >= PV_ABUNDANCE_AVG_MIN_W && parsed.batteryCurrentA >= ABUNDANCE_BATTERY_CHARGE_MIN_A; const solarAbundanceHold = parsed.pvPowerAvg10mW >= PV_ABUNDANCE_AVG_MIN_W && parsed.batteryCurrentA >= 0; const solarAbundance = solarAbundanceStart; // conservé pour la traçabilité/debug const abundanceSocFloor = (abundant) => abundant ? Math.min(profileSocStartFloor, ABUNDANCE_SOC_FLOOR_PCT) : profileSocStartFloor; const effectiveSocStartFloor = abundanceSocFloor(solarAbundanceStart); const profileSocHoldFloor = context.profile === 'morning_solar' ? Math.max(params.socStopMin, params.morningSocMin - 10) : context.profile === 'midday_hc_or_solar' ? Math.max(params.socStopMin, params.middaySocMin - 15) : context.profile === 'evening_high_soc' ? params.eveningSocStopMin : params.socStopMin; // INVARIANT (24/08/2026) : on ne doit JAMAIS avoir le droit de DÉMARRER une charge // qu'on n'a pas le droit de MAINTENIR. Le plancher de maintien doit donc rester // <= au plancher de démarrage effectif. // Incident du 24/08 midi : bascule de profil vers midday_hc_or_solar à 12:24 avec // SOC 58 % => plancher de démarrage 40 % (abondance P6) mais plancher de maintien // max(29, 75-15) = 60 % => démarrage autorisé, maintien refusé, cycle limite de // 3 min (min_run) sur toute la bande [40 %, 60 %], et ~2,6 kW de PV écrêtés à // chaque creux faute de preneur (ESS sans injection). const effectiveSocHoldFloor = Math.min( profileSocHoldFloor, abundanceSocFloor(solarAbundanceHold) ); const profileSpecificDischargeFloorA = context.profile === 'morning_solar' ? params.morningBatteryMaxDischargeA : context.profile === 'midday_hc_or_solar' ? params.middayBatteryMaxDischargeA : context.profile === 'evening_high_soc' ? params.eveningBatteryMaxDischargeA : params.batteryDischargeSoftA; const profileBatteryDischargeFloorA = Math.max(profileSpecificDischargeFloorA, batteryDischargeSoftDynamicA); // ===================================================== // DEPASSEMENT SOUPLE DU PLANCHER DE DECHARGE (01/09/2026) // ===================================================== // La table des bandes SOC (INPUT_PARSE) declare depuis toujours DEUX regimes : // un courant SOUPLE tolere pendant des MINUTES (softToleranceMin : 12 min a // SOC > 90, 1 min sous 25) et un courant DUR tolere quelques SECONDES. Seuls // les seuils de courant etaient lus ; la dimension temporelle etait calculee, // exportee dans le debug, et branchee sur AUCUNE decision. Le controleur // comparait donc un echantillon instantane, toutes les 30 s, a un plancher. // // Incident du 01/09 (14:26 et 14:56, SOC 94, batterie en float) : le courant // batterie oscillait entre +1,8 et -2,6 A d'un echantillon a l'autre, soit le // bruit de regulation d'une batterie qui ne fait rien. Le test devenait un // tirage a pile ou face et la charge du vehicule s'arretait dessus. Chaque // arret coutait ~0,45 kWh de production refusee : batterie pleine, le vehicule // etait le SEUL preneur, donc la PV s'effondrait de 6,6 a 2,5 kW en 30 s, et // la reprise mettait 21 min a remonter de 6 A a 20 A. 1 a 6 arrets par jour // sur la semaine du 26 au 31/08. // // Le correctif est PUREMENT TEMPOREL : aucun seuil n'est deplace, aucune // constante n'est ajoutee. On applique la table telle qu'elle est ecrite : // un depassement souple doit DURER pour compter. Comme le bruit change de // signe en quelques secondes, le compteur se remet a zero de lui-meme ; une // vraie decharge, elle, reste negative a chaque echantillon et finit par // depasser la tolerance de sa bande. // // CE QUI N'EST PAS TOUCHE : le plancher de DEMARRAGE reste instantane (ne pas // lancer une charge sur une batterie qui se decharge deja, c'est la frontiere // "delestage et non charge prioritaire"), et le garde DUR reste instantane — // hardToleranceSec n'est volontairement PAS branche, cela ne pourrait que // ralentir une securite, et c'est le risque qui prime. const batterySoftBreachNow = sessionObservedActive && parsed.batteryCurrentA < profileBatteryDischargeFloorA; const batterySoftBreachStartedMs = batterySoftBreachNow ? (history.batterySoftBreachMsPrev !== null ? history.batterySoftBreachMsPrev : nowMs) : null; const batterySoftBreachMinutes = batterySoftBreachStartedMs !== null ? Math.max(0, (nowMs - batterySoftBreachStartedMs) / 60000) : 0; // Vrai tant que le depassement n'a pas dure plus que la tolerance de sa bande. // Une tolerance a 0 minute redonne exactement le comportement instantane. const batterySoftDischargeHoldOk = !batterySoftBreachNow || batterySoftBreachMinutes <= batterySoftToleranceMinutes; // ===================================================== // GRID RELAX IN force_charge_now // ===================================================== // Assouplissement uniquement pour la garde réseau, // sans toucher aux sécurités dures globales. const gridForceChargeRelaxSoftA = strategy.forceChargeRequested && isGridSource ? ( parsed.soc >= 90 ? 28 : parsed.soc >= 80 ? 16 : parsed.soc >= 60 ? 8 : parsed.soc >= 40 ? 4 : 0 ) : 0; const gridForceChargeRelaxHardA = strategy.forceChargeRequested && isGridSource ? ( parsed.soc >= 90 ? 39 : parsed.soc >= 80 ? 29 : parsed.soc >= 60 ? 18 : parsed.soc >= 40 ? 8 : 0 ) : 0; const gridBatterySoftDynamicABase = Math.max(params.gridBatterySoftA, batteryDischargeSoftDynamicA); const gridBatteryHardDynamicABase = Math.max(params.gridBatteryHardA, batteryDischargeHardDynamicA); let gridBatterySoftDynamicA = gridBatterySoftDynamicABase - gridForceChargeRelaxSoftA; let gridBatteryHardDynamicA = gridBatteryHardDynamicABase - gridForceChargeRelaxHardA; // Garantit que le hard reste plus permissif que le soft d'au moins 3 A if (gridBatteryHardDynamicA > (gridBatterySoftDynamicA - 3)) { gridBatteryHardDynamicA = gridBatterySoftDynamicA - 3; } // ===================================================== // BASE SAFETY // ===================================================== const commonSafetyStartOk = derived.canRunAtMinCurrent && !derived.anyVebusOverload && !derived.anyVebusLowBattery && !derived.vebusDcVoltageSoftLow && !derived.vebusAcOutSoftReached && parsed.evcsDeviceConnected && parsed.evcsVehicleConnected && !parsed.evcsModeIsAuto; const commonSafetyHoldOk = derived.canRunAtMinCurrent && !derived.anyVebusLowBattery && !derived.vebusDcVoltageHardLow && parsed.evcsDeviceConnected && parsed.evcsVehicleConnected && !parsed.evcsModeIsAuto; // ===================================================== // EXCEDENT SOLAIRE — LA GRANDEUR QUI DECIDE D'UN DEMARRAGE (03/09/2026) // ===================================================== // Incident du 03/09 19:44 : PV 758 W, maison 1202 W, batterie deja a -12 A, // nuit qui tombe. Le controleur a lance 1,6 kW de charge parce que // pv_start_min_w (700 W) est une PUISSANCE PV ABSOLUE : "758 >= 700, donc il y // a du soleil". L'excedent, lui, valait -444 W. En 33 minutes la voiture a pris // 0,897 kWh et la batterie en a rendu 1,479 kWh, la veille d'une nuit que le // bilan energetique annoncait deja deficitaire (SOC requis 99,7 %). // // La grandeur juste n'est pas la production, c'est ce qui RESTE une fois la // maison servie. Sans injection et reseau absent, elle vaut la puissance // batterie au signe pres : // // excedent = PV - maison(hors EVCS) ~= puissance batterie // // C'est aussi ce qui separe les deux situations que la mesure PV seule confond // (PV_mesuree = min(ressource, conso + absorption batterie)) : // // - CENSURE : batterie pleine, MPPT bride faute de puits. La production se // cale sur la maison, l'excedent vaut ~0, la batterie ne bouge pas. Il y a // de la ressource REFUSEE : la provoquer avec la voiture est legitime, et // c'est toute la raison d'etre de P3 / de l'assistance batterie. // - DEFICIT reel : soir, nuages. L'excedent est NEGATIF, la batterie porte // deja la maison. Charger la voiture, c'est la vider. // // Zero n'est donc pas un seuil de plus : c'est la frontiere physique entre les // deux, et elle etait invisible tant qu'on regardait la production seule. // Aucune constante n'est introduite ; le chemin "soleil reel" reutilise // pv_start_min_w tel quel, applique a la bonne grandeur. // // PORTEE : DEMARRAGE uniquement. Les conditions de MAINTIEN sont inchangees — // une charge etablie a deja son plancher de decharge et sa tolerance temporelle // (01/09), et couper une charge en cours sur une mesure d'excedent bruitee // recreerait l'oscillateur qu'on vient d'eteindre. const solarSurplusW = parsed.pvPowerW - derived.houseNonEvcsW; // LA PROVOCATION DOIT ETRE JUSTIFIEE PAR LES CHARGEURS EUX-MEMES (03/09/2026) // ===================================================================== // Un excedent proche de zero est AMBIGU : c'est la signature de la censure // (production bridee, calee sur la maison) ET celle de l'equilibre de fin de // journee (production reelle qui rejoint la consommation). Le releve du 03/09 // contient les deux a 51 minutes d'ecart, avec le meme excedent : // // 18:50 PV 769 W, maison 755 W, excedent +14 W -> 7 trackers LIMITES // 19:41 PV 817 W, maison 816 W, excedent +1 W -> 7 trackers MPPT_ACTIVE // // Le premier cache de la ressource refusee, le second n'a plus rien a donner. // Aucun seuil de puissance ne les separe. Les chargeurs, eux, le disent : // 'mppt_active' = je suis a mon point de puissance maximale, je donne tout ; // 'voltage_current_limited' / 'off' = je me retiens faute de preneur. // // Signal DIRECT (le materiel lui-meme, pas un proxy), INSTANTANE (30 s, la // cadence de la boucle) et, sur l'incident du 03/09, il a bascule a 19:20 — // 24 minutes avant le drapeau `pv_censored` du bilan energetique, qui tourne // toutes les 5 min et dont l'un des motifs est justement le proxy SOC >= 90 // qu'on cherchait a remplacer. // // ⚠️ DEMARRAGE UNIQUEMENT, et c'est structurel, pas une precaution : // gager le sourceMode ou le MAINTIEN sur ce signal ferait que la charge, en // donnant un preneur au MPPT, detruirait sa propre condition d'autorisation — // exactement le bistable de l'abondance P6 corrige le 24/08. La garde n'est // lue que quand la voiture est a l'arret, instant ou l'etat des trackers // decrit la situation SANS elle. Verifie le 01/09 a 14:22-14:34 : les trackers // restaient limites PENDANT une charge de 3,4 kW sous 6,9 kW de PV — a midi la // ressource depasse ce que la voiture peut prendre, le signal ne s'auto-annule // donc pas non plus. // // Aucune lecture exploitable (integration muette) => on ne sait pas => on // retombe sur le comportement anterieur. On ne devine jamais un etat. const mpptReadable = parsed.mpptReadableCount > 0; const mpptAllAtMaxPower = mpptReadable && parsed.mpptHoldingBackCount === 0; const solarProvocationJustified = !mpptAllAtMaxPower; // Chemin PROVOCATION (batterie haute, ou sourceMode solar_potential) : on // n'exige pas d'excedent mesurable — il est censure par construction — mais on // exige que la batterie ne porte pas deja la maison. const solarSurplusProvocationOk = solarSurplusW >= 0; // Chemin SOLEIL REEL : l'excedent doit atteindre le seuil de demarrage. const solarSurplusStartOk = context.sourceMode === 'solar_potential' ? (solarSurplusProvocationOk && solarProvocationJustified) : solarSurplusW >= context.effectivePvStartMinW; // ===================================================== // BATTERY-ASSISTED SOLAR START/HOLD // IMPORTANT: // Installation sans injection: // le PV visible peut rester bas tant que l'EVCS ne démarre pas. // On autorise donc un démarrage/maintien local si la batterie est haute // et que la dette nuage reste faible. // ===================================================== const batteryAssistSolarPolicyEligible = !strategy.forceGridRequested && ( strategy.forceChargeRequested || strategy.userStrategy === 'auto_solar_plus' ) && isLocalSolarSource; const batteryAssistSolarStartSocMin = 90; const batteryAssistSolarHoldSocMin = 85; const batteryAssistSolarDebtStartMaxKwh = Math.max( cfg.cloudDebtRecoveryThresholdKwh, Math.min(params.cloudDebtMaxKwh, 0.05) ); const batteryAssistSolarStartOk = batteryAssistSolarPolicyEligible && commonSafetyStartOk && !isGridSource && parsed.soc >= Math.max(profileSocStartFloor, batteryAssistSolarStartSocMin) && parsed.soc > batteryAssistSolarHoldSocMin && parsed.batteryCurrentA >= profileBatteryDischargeFloorA && solarSurplusProvocationOk && solarProvocationJustified && cloudDebtNextKwh <= batteryAssistSolarDebtStartMaxKwh && !cloudDebtActive; const batteryAssistSolarHoldOk = batteryAssistSolarPolicyEligible && commonSafetyHoldOk && !isGridSource && parsed.soc >= Math.max(profileSocHoldFloor, batteryAssistSolarHoldSocMin) && batterySoftDischargeHoldOk && cloudToleranceOk; // ===================================================== // START / HOLD CONDITIONS // ===================================================== // FENETRE D'ETABLISSEMENT (31/08/2026) — cf. WINDOWS_SOURCEV2 pour la mesure. // Pendant les ~150 s qui suivent l'arrivee du reseau, PV et courant batterie ne // mesurent rien d'exploitable. On NE DEMARRE PAS sur ces valeurs, et on ne // laisse pas une charge en cours s'arreter a cause d'elles. Les gardes de // securite (commonSafety*, planchers SOC, surcharge, pas de tampon) restent // evaluees normalement : c'est le risque qui prime, jamais le confort. const gridSettling = derived.gridSettling === true; const rawSolarStartOk = (context.sourceMode === 'solar_only' || context.sourceMode === 'solar_potential') && !gridSettling && commonSafetyStartOk && parsed.soc >= effectiveSocStartFloor && parsed.batteryCurrentA >= profileBatteryDischargeFloorA && solarSurplusStartOk; const solarStartOk = rawSolarStartOk || batteryAssistSolarStartOk; const rawSolarHoldOk = (context.sourceMode === 'solar_only' || context.sourceMode === 'solar_potential') && commonSafetyHoldOk && parsed.soc >= effectiveSocHoldFloor && ( gridSettling || batterySoftDischargeHoldOk ) && ( gridSettling || parsed.pvPowerW >= params.pvHoldMinW || cloudToleranceOk ); const solarHoldOk = rawSolarHoldOk || batteryAssistSolarHoldOk; // FIX V2.5: Garde SOC - pas de grid pour EVCS si SOC > 35% sauf force_grid_now // P9 (09/08/2026) : la fenêtre départ imminent autorise aussi le réseau. const gridAllowedBySoc = strategy.forceGridRequested || context.profile === 'night_hc' || departureUrgentActive || parsed.soc <= 35; const gridStartOk = gridAllowedBySoc && isGridSource && commonSafetyStartOk && parsed.gridAvailable && parsed.batteryCurrentA >= gridBatterySoftDynamicA; const gridHoldOk = gridAllowedBySoc && isGridSource && commonSafetyHoldOk && parsed.gridAvailable && parsed.batteryCurrentA >= gridBatteryHardDynamicA; // P12 (15/09/2026) — le mode `battery_assist_night` etait MORT depuis toujours. // Il exige `batteryCurrentA >= plancher de decharge du profil` (0 A), condition // impossible la nuit puisque la maison tire sur la batterie (§11, ~ -14 A). Il // n'a donc jamais demarre une seule fois. // // Quand P12 est actif, on remplace ce test de COURANT par un test d'ENERGIE : // rester au-dessus de ce qu'il faut pour tenir jusqu'au croisement PV. Le // plancher vient du bilan (`required_soc_prudent`, profil p80), il est calcule // dans WINDOWS_SOURCEV2 et transmis ici — ce node CONSOMME, il ne recalcule pas. // // ⚠️ LES GARDES DE COURANT NE SONT PAS SUPPRIMEES POUR AUTANT : // - `commonSafetyStartOk` / `commonSafetyHoldOk` restent evalues normalement ; // - le MAINTIEN garde `batteryDischargeHardDynamicA`, l'arret dur, qui n'est // jamais contourne ; // - la garde SOUPLE et sa tolerance temporelle (§35) continuent de s'appliquer // par `batterySoftDischargeHoldOk` en aval, et c'est elle qui arretera la // charge quand la bande de SOC deviendra trop basse pour le courant tire. // Ce qu'on retire, c'est uniquement le plancher de PROFIL, qui interdisait de // demarrer quoi que ce soit des que la batterie n'etait pas a l'equilibre. const nightBatterySurplusAvailable = !!nightPrev.nightBatterySurplusAvailable; const nightBatterySurplusFloorPct = Number.isFinite(nightPrev.nightBatterySurplusFloorPct) ? nightPrev.nightBatterySurplusFloorPct : Infinity; const batteryAssistNightStartOk = context.sourceMode === 'battery_assist_night' && commonSafetyStartOk && nightSessionActive && batteryAssistAllowed && parsed.soc > batteryHardFloorPct && ( nightBatterySurplusAvailable ? parsed.soc > nightBatterySurplusFloorPct : ( parsed.soc > gridTakeoverTriggerPct && parsed.batteryCurrentA >= profileBatteryDischargeFloorA ) ); const batteryAssistNightHoldOk = context.sourceMode === 'battery_assist_night' && commonSafetyHoldOk && nightSessionActive && batteryAssistAllowed && parsed.soc > batteryHardFloorPct && parsed.batteryCurrentA >= batteryDischargeHardDynamicA && ( nightBatterySurplusAvailable ? parsed.soc > nightBatterySurplusFloorPct : parsed.soc > gridTakeoverTriggerPct ); // ===================================================== // PROBE LOGIC — STRICTEMENT LIMITÉE AU MODE PROBE // ===================================================== const probeModeActive = context.sourceMode === 'hybrid_probe'; const probeStartOk = probeModeActive && commonSafetyStartOk && (context.morningProbeEligible || context.middayProbeEligible || context.afternoonProbeEligible); const probeHoldGuardOk = probeModeActive && commonSafetyHoldOk && batterySoftDischargeHoldOk; const probeWindowActive = probeModeActive && sessionObservedActive && history.probeStartedMs !== null && lifecycle.minutesSinceProbeStart !== null && lifecycle.minutesSinceProbeStart < params.probeMinutes; const probeElapsedReached = probeModeActive && sessionObservedActive && history.probeStartedMs !== null && lifecycle.minutesSinceProbeStart !== null && lifecycle.minutesSinceProbeStart >= params.probeMinutes; const probeSuccessNow = probeModeActive && sessionObservedActive && history.probeStartedMs !== null && ( parsed.pvPowerW >= params.pvHoldMinW && parsed.batteryCurrentA >= Math.min(0, batteryDischargeSoftDynamicA) ); // Une sonde ne peut pas etre declaree ratee sur une mesure prise pendant // l'etablissement : c'est le reseau qui masque la production, pas la sonde qui // echoue. On differe le verdict, on ne l'invente pas. const probeFailedNow = probeModeActive && probeElapsedReached && !gridSettling && !probeSuccessNow; // ===================================================== // STEP-UP LOGIC // ===================================================== const stepUpByBatteryReserve = batteryPriorityMode && parsed.batteryCurrentA >= batteryChargeReserveDynamicA; const stepUpByForecastMode = !batteryPriorityMode && ( parsed.pvPowerW >= params.pvHoldMinW || parsed.pvPowerAvg10mW >= params.pvHoldMinW ) && derived.forecastBufferKwh >= -1 && derived.projectedEndSocAfterCharge >= (derived.endDayGuardSoc - 5) && cloudToleranceOk; // Pas de montee en courant sur des mesures d'etablissement. La descente, elle, // n'est pas bridee : c'est la direction sure. const solarShouldStepUp = !gridSettling && lifecycle.currentCooldownElapsed && derived.currentReferenceA < derived.physicalCapA && derived.physicalCapA >= params.currentMinA && !derived.vebusAcOutSoftReached && !derived.vebusDcVoltageSoftLow && !derived.houseLoadHighVsHeadroom && ( stepUpByBatteryReserve || stepUpByForecastMode || parsed.pvPowerW >= (context.effectivePvStartMinW + 400) ); const gridShouldStepUp = lifecycle.currentCooldownElapsed && derived.currentReferenceA < derived.physicalCapA && derived.physicalCapA >= params.currentMinA && !derived.vebusAcOutSoftReached && !derived.vebusDcVoltageSoftLow && parsed.batteryCurrentA >= gridBatterySoftDynamicA && !derived.houseLoadHighVsHeadroom; const batteryAssistNightShouldStepUp = context.sourceMode === 'battery_assist_night' && lifecycle.currentCooldownElapsed && derived.currentReferenceA < derived.physicalCapA && derived.physicalCapA >= params.currentMinA && !derived.vebusAcOutSoftReached && !derived.vebusDcVoltageSoftLow && !derived.houseLoadHighVsHeadroom && parsed.batteryCurrentA >= batteryChargeReserveDynamicA && batteryAssistAllowed; // ===================================================== // STEP-DOWN LOGIC // ===================================================== const solarShouldStepDownBattery = parsed.batteryCurrentA <= batteryDischargeSoftDynamicA && lifecycle.currentCooldownElapsed && derived.currentReferenceA > params.currentMinA; const solarShouldStepDownCloudDebt = !cloudToleranceOk && lifecycle.currentCooldownElapsed && derived.currentReferenceA > params.currentMinA; const gridShouldStepDownBattery = parsed.batteryCurrentA < gridBatterySoftDynamicA && lifecycle.currentCooldownElapsed && derived.currentReferenceA > params.currentMinA; const batteryAssistNightShouldStepDown = context.sourceMode === 'battery_assist_night' && lifecycle.currentCooldownElapsed && derived.currentReferenceA > params.currentMinA && ( parsed.batteryCurrentA <= batteryDischargeSoftDynamicA || parsed.soc <= gridTakeoverTriggerPct || batteryRecoveryUrgent ); const shouldStepDownVebusPower = derived.vebusAcOutSoftReached && lifecycle.currentCooldownElapsed && derived.currentReferenceA > params.currentMinA; const shouldStepDownVebusVoltage = derived.vebusDcVoltageSoftLow && lifecycle.currentCooldownElapsed && derived.currentReferenceA > params.currentMinA; const shouldStepDownOverload = derived.anyVebusOverload && lifecycle.currentCooldownElapsed && derived.currentReferenceA > params.currentMinA; const shouldStopOverloadAtMin = derived.anyVebusOverload && derived.currentReferenceA <= params.currentMinA && sessionObservedActive; // ===================================================== // HARD STOPS / PROTECTIONS // ===================================================== const batteryCurrentHardExceeded = parsed.batteryCurrentA <= batteryDischargeHardDynamicA; // overload volontairement exclu du hard stop immédiat const mustStopHard = derived.anyVebusLowBattery || derived.vebusDcVoltageHardLow || derived.vebusAcOutHardReached || batteryCurrentHardExceeded; const gridBatteryProtectionStop = sessionObservedActive && isGridSource && parsed.batteryCurrentA < gridBatteryHardDynamicA; // ===================================================== // OUTPUT // ===================================================== return [{ json: { ...prev, batteryPolicy: { socBand, batteryDischargeSoftDynamicA, batteryDischargeHardDynamicA, batteryChargeReserveDynamicA, batterySoftToleranceMinutes, batteryHardToleranceSeconds, batteryRecoveryMinutes, // Depassement souple en cours (01/09/2026). startedMs = null quand il // n'y a pas de depassement : c'est ce que HA_SERVICE_CALLS_OUTPUT lit // pour ecrire ou effacer le helper de persistance. batterySoftBreachNow, batterySoftBreachStartedMs, batterySoftBreachMinutes, batterySoftDischargeHoldOk, gridBatterySoftDynamicA, gridBatteryHardDynamicA, gridForceChargeRelaxSoftA, gridForceChargeRelaxHardA, batteryAssistSolarStartSocMin, batteryAssistSolarHoldSocMin, batteryAssistSolarDebtStartMaxKwh, houseSpikeDetected, solarPotentialContext, outsideWindowLocalBypass, }, locks: { nextAllowedWindowStartMs, blockActive, blockStillRelevant, startBlockedByReason, }, cloud: { sessionObservedActive, batteryNetPowerW, cloudDebtNextKwh, cloudDebtMeaningful, cloudEventDischargingNow, continuingCloudEvent, enteringCloudDebt, cloudDebtActive, cloudDebtStartedMs, cloudDebtDurationMinutes, effectiveCloudToleranceMinutes, cloudToleranceOk, }, modes: { batteryFullHarvestMode, forecastCommitMode, batteryPriorityMode, }, nightEvcs: { nightSessionActive, nightSessionNeedsCapture, nightSessionNeedsClear, nightSessionStartSocPct, nightSessionStartAt, nightSessionStartMs, batteryHardFloorPct, batteryMorningTargetPct, balanceMorningTargetApplied, nightGridSessionCommitted, // P12 — passe-plat du verrou anti-oscillateur vers la sortie. nightBatterySurplusExhausted: !!nightPrev.nightBatterySurplusExhausted, nightBatterySurplusAvailable: !!nightPrev.nightBatterySurplusAvailable, maxBatteryDischargePoints, gridTakeoverTriggerPct, batteryBelowHardFloor, batteryBelowMorningTarget, batteryAtOrBelowGridTakeoverTrigger, batteryKwhMissingToTarget, batteryRecoveryMinutesNeeded, batteryRecoveryUrgent, batteryRecoveryAllowedNow, batteryTargetReached, batteryAssistAllowed, nightGridTakeoverNeeded, nightTakeoverLatched, vehicleChargeCompleted, nightRecoveryDone, departureUrgentActive, departureChargeDone, dayVehicleFullIdle, nightPreferredSourceMode, }, safeguards: { isGridSource, commonSafetyStartOk, commonSafetyHoldOk, profileSocStartFloor, effectiveSocStartFloor, solarAbundance, solarAbundanceHold, profileSocHoldFloor, effectiveSocHoldFloor, profileBatteryDischargeFloorA, // Excedent solaire (03/09/2026) : la grandeur qui autorise un demarrage. // Plancher du soir issu du bilan energetique (03/09/2026). eveningSocStartFloorPct, eveningFloorFromBalance, balanceRequiredSocPct: params.balanceRequiredSocPct, balanceEnergyToCoverKwh: params.balanceEnergyToCoverKwh, balanceAgeMin: balanceAgeMin === null ? null : Math.round(balanceAgeMin * 10) / 10, balanceEveningFloorUsable, solarSurplusW, solarSurplusProvocationOk, solarSurplusStartOk, // Etat declare par les chargeurs solaires (03/09/2026). mpptReadableCount: parsed.mpptReadableCount, mpptHoldingBackCount: parsed.mpptHoldingBackCount, mpptAllAtMaxPower, solarProvocationJustified, batteryCurrentHardExceeded, mustStopHard, gridBatteryProtectionStop, }, conditions: { rawSolarStartOk, rawSolarHoldOk, batteryAssistSolarPolicyEligible, batteryAssistSolarStartOk, batteryAssistSolarHoldOk, solarStartOk, solarHoldOk, gridSettling, gridTransitionAgeSec: parsed.gridTransitionAgeSec, gridStartOk, gridHoldOk, batteryAssistNightStartOk, nightBatterySurplusAvailable, nightBatterySurplusFloorPct, batteryAssistNightHoldOk, probeStartOk, probeHoldGuardOk, probeWindowActive, probeElapsedReached, probeSuccessNow, probeFailedNow, solarShouldStepUp, gridShouldStepUp, batteryAssistNightShouldStepUp, solarShouldStepDownBattery, solarShouldStepDownCloudDebt, gridShouldStepDownBattery, batteryAssistNightShouldStepDown, shouldStepDownVebusPower, shouldStepDownVebusVoltage, shouldStepDownOverload, shouldStopOverloadAtMin, } } }];