function minutesSince(ts, nowMs) { if (ts === null || nowMs === null) return null; return Math.max(0, (nowMs - ts) / 60000); } function secondsSince(ts, nowMs) { if (ts === null || nowMs === null) return null; return Math.max(0, (nowMs - ts) / 1000); } function isWithinWindow(nowMin, startMin, stopMin) { if (!Number.isFinite(startMin) || !Number.isFinite(stopMin)) return false; if (startMin === stopMin) return false; if (startMin < stopMin) return nowMin >= startMin && nowMin < stopMin; return nowMin >= startMin || nowMin < stopMin; } function clamp(v, min, max) { return Math.min(Math.max(v, min), max); } const prev = $input.first().json; const { input, nowMs, now, cfg, parsed, params, history, batteryTune } = prev; // ===================================================== // STRATEGY // ===================================================== const userStrategy = (params.userStrategyRaw ?? 'auto_solar').toString().trim().toLowerCase(); const isStopMode = userStrategy === 'stop'; const forceGridRequested = userStrategy === 'force_grid_now'; const forceChargeRequested = userStrategy === 'force_charge_now'; const isForceMode = forceGridRequested || forceChargeRequested; // ===================================================== // LIFECYCLE / TIMING // ===================================================== const minutesSinceStart = minutesSince(history.lastStartMs, nowMs); const minutesSinceStop = minutesSince(history.lastStopMs, nowMs); const secondsSinceCurrentChange = secondsSince(history.lastCurrentChangeMs, nowMs); const minutesSinceProbeStart = minutesSince(history.probeStartedMs, nowMs); const minRunElapsed = minutesSinceStart === null ? true : minutesSinceStart >= params.minRunMinutes; const minStopElapsed = minutesSinceStop === null ? true : minutesSinceStop >= params.minStopMinutes; const currentCooldownBaseSec = parsed.evcsChargingSwitchOn ? batteryTune.global.stepDownCooldownSeconds : batteryTune.global.stepUpCooldownSeconds; const currentCooldownElapsed = secondsSinceCurrentChange === null ? true : secondsSinceCurrentChange >= Math.max( params.currentChangeCooldownSeconds, currentCooldownBaseSec ); // ===================================================== // DEPARTURE PRIORITY // ===================================================== const minutesUntilDeparture = params.departureTimeMin === null ? null : (() => { const rawDiff = params.departureTimeMin - now.minNow; return rawDiff >= 0 ? rawDiff : (1440 - now.minNow + params.departureTimeMin); })(); const departurePriorityActive = parsed.departurePriorityEnabled && Number.isFinite(minutesUntilDeparture) && minutesUntilDeparture >= 0 && minutesUntilDeparture <= cfg.departurePriorityWindowMinutes && parsed.evcsVehicleConnected; // ===================================================== // EVCS PRIORITY OVERRIDE FOR FORCE CHARGE // force_charge_now + voiture branchée + départ défini = grid_forced // ===================================================== const forceChargeDepartureGridOverride = forceChargeRequested && parsed.evcsVehicleConnected && Number.isFinite(minutesUntilDeparture) && minutesUntilDeparture >= 0; // ===================================================== // SYSTEM DERIVED VALUES // ===================================================== const acVoltageRef = Number.isFinite(parsed.vebusAcOutVoltageV) && parsed.vebusAcOutVoltageV > 180 ? parsed.vebusAcOutVoltageV : cfg.nominalAcVoltageV; const batteryVoltageRef = Number.isFinite(parsed.vebusDcVoltageV) && parsed.vebusDcVoltageV > 0 ? parsed.vebusDcVoltageV : 51.2; const houseNonEvcsW = Math.max(0, parsed.houseAcPowerW - parsed.evcsAcPowerW); const vebusNonEvcsW = Math.max(0, parsed.vebusAcOutPowerW - parsed.evcsAcPowerW); const totalVebusSoftCeilingW = Math.max(6500, params.acLoadsMaxSafeW || 8500); const totalVebusHardCeilingW = totalVebusSoftCeilingW + 400; const safeEvcsByDedicatedCapW = Math.max(0, cfg.evcsDedicatedMaxW - cfg.systemReserveW); const safeEvcsByVebusW = Math.max(0, totalVebusSoftCeilingW - vebusNonEvcsW - cfg.systemReserveW); const safeEvcsCombinedW = Math.max(0, Math.min(safeEvcsByDedicatedCapW, safeEvcsByVebusW)); const systemCapCurrentA = Math.max(0, Math.floor(safeEvcsCombinedW / acVoltageRef)); const physicalCapA = clamp(systemCapCurrentA, 0, params.currentMaxAutoA); const canRunAtMinCurrent = physicalCapA >= params.currentMinA; const endDayGuardSoc = Math.max(params.endOfDaySocGuard, params.endOfDaySocTarget); const energyNeededForEndDayGuardKwh = Math.max( 0, ((endDayGuardSoc - parsed.soc) / 100) * cfg.batteryUsableCapacityKwh ); const forecastBufferKwh = parsed.todayRemainingKwh - params.baseLoadRemainingKwh - params.flexibleSafetyMarginKwh - params.requiredEnergyKwh - energyNeededForEndDayGuardKwh; const projectedEndSocAfterCharge = clamp( parsed.soc + ( ( parsed.todayRemainingKwh - params.baseLoadRemainingKwh - params.flexibleSafetyMarginKwh - params.requiredEnergyKwh ) / cfg.batteryUsableCapacityKwh ) * 100, 0, 100 ); const currentReferenceA = clamp( Number.isFinite(parsed.evcsCommandCurrentA) ? parsed.evcsCommandCurrentA : Number.isFinite(parsed.evcsSetCurrentA) ? parsed.evcsSetCurrentA : params.targetCurrentAutoA, 0, 32 ); const anyVebusOverload = parsed.vebusOverload || parsed.vebusL1Overload; const anyVebusLowBattery = parsed.vebusLowBattery || parsed.vebusL1LowBattery; const vebusAcOutSoftReached = parsed.vebusAcOutPowerW >= totalVebusSoftCeilingW; const vebusAcOutHardReached = parsed.vebusAcOutPowerW >= totalVebusHardCeilingW; const vebusDcVoltageSoftLow = Number.isFinite(parsed.vebusDcVoltageV) && parsed.vebusDcVoltageV > 0 && parsed.vebusDcVoltageV <= cfg.vebusDcVoltageSoftFloorV; const vebusDcVoltageHardLow = Number.isFinite(parsed.vebusDcVoltageV) && parsed.vebusDcVoltageV > 0 && parsed.vebusDcVoltageV <= cfg.vebusDcVoltageHardFloorV; const houseLoadHighVsHeadroom = houseNonEvcsW > params.houseAcHeadroomW; // ===================================================== // WINDOWS / PROFILE // ===================================================== const inNightHcWindow = isWithinWindow(now.minNow, params.hcNightStartMin, params.hcNightStopMin); const inMorningWindow = isWithinWindow(now.minNow, params.morningStartMin, params.morningStopMin); const inMiddayWindow = isWithinWindow(now.minNow, params.hcMiddayStartMin, params.hcMiddayStopMin); const inEveningWindow = isWithinWindow(now.minNow, params.eveningStartMin, params.eveningStopMin); const profile = inNightHcWindow ? 'night_hc' : inMorningWindow ? 'morning_solar' : inMiddayWindow ? 'midday_hc_or_solar' : inEveningWindow ? 'evening_high_soc' : 'outside_window'; // ===================================================== // EVCS NIGHT PREVIEW ENGINE // ===================================================== const nightSessionActive = profile === 'night_hc' && parsed.evcsVehicleConnected; const nightSessionStartSocPct = nightSessionActive ? ( Number.isFinite(history.nightSessionStartSocPct) ? history.nightSessionStartSocPct : parsed.soc ) : null; const nightSessionStartAt = nightSessionActive ? (history.nightSessionStartAt || '') : ''; const nightSessionNeedsCapture = nightSessionActive && ( !Number.isFinite(history.nightSessionStartSocPct) || !history.nightSessionStartAt ); const nightSessionNeedsClear = !nightSessionActive && ( Number.isFinite(history.nightSessionStartSocPct) || !!history.nightSessionStartAt ); const batteryHardFloorPct = params.evcsNightBatteryHardFloorPct; const batteryMorningTargetPct = parsed.todayRemainingKwh >= params.evcsNightStrongForecastKwh ? params.evcsNightTargetStrongForecastPct : parsed.todayRemainingKwh >= params.evcsNightMediumForecastKwh ? params.evcsNightTargetMediumForecastPct : params.evcsNightTargetWeakForecastPct; const maxBatteryDischargePoints = nightSessionActive && Number.isFinite(nightSessionStartSocPct) ? Math.max(0, nightSessionStartSocPct - batteryMorningTargetPct) : 0; const gridTakeoverTriggerPct = Math.max(batteryHardFloorPct, batteryMorningTargetPct); const batteryBelowHardFloor = parsed.soc <= batteryHardFloorPct; const batteryBelowMorningTarget = parsed.soc < batteryMorningTargetPct; const batteryAtOrBelowGridTakeoverTrigger = parsed.soc <= gridTakeoverTriggerPct; const batteryKwhMissingToTarget = nightSessionActive ? Math.max( 0, ((batteryMorningTargetPct - parsed.soc) / 100) * cfg.batteryUsableCapacityKwh ) : 0; const batteryRecoveryMinutesNeeded = nightSessionActive && params.evcsNightBatteryRecoveryPowerKw > 0 ? (batteryKwhMissingToTarget / params.evcsNightBatteryRecoveryPowerKw) * 60 : 0; const batteryRecoveryUrgent = nightSessionActive && Number.isFinite(minutesUntilDeparture) && minutesUntilDeparture >= 0 && ( batteryRecoveryMinutesNeeded + params.evcsNightDepartureSafetyMin >= minutesUntilDeparture ); const batteryAssistAllowed = nightSessionActive && parsed.soc > batteryHardFloorPct && parsed.soc > gridTakeoverTriggerPct && !batteryRecoveryUrgent; const nightGridTakeoverNeeded = nightSessionActive && ( batteryBelowHardFloor || batteryAtOrBelowGridTakeoverTrigger || batteryRecoveryUrgent ); const nightPreferredSourceMode = !nightSessionActive ? 'blocked' : batteryAssistAllowed ? 'battery_assist_night' : ( batteryBelowHardFloor || batteryBelowMorningTarget || batteryRecoveryUrgent ) ? 'grid_forced' : 'grid_hc_allowed';