// ===================================================== // 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 // ===================================================== const nightSessionActive = context.profile === 'night_hc' && parsed.evcsVehicleConnected; const nightSessionStartSocPct = nightSessionActive ? ( Number.isFinite(history.nightSessionStartSocPct) ? history.nightSessionStartSocPct : parsed.soc ) : null; const nightSessionStartAt = nightSessionActive ? (history.nightSessionStartAt || '') : ''; const nightSessionStartMs = nightSessionActive ? (history.nightSessionStartMs ?? null) : null; const nightSessionNeedsCapture = nightSessionActive && ( !Number.isFinite(history.nightSessionStartSocPct) || !nightSessionStartAt || nightSessionStartMs === null ); 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 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(lifecycle.minutesUntilDeparture) && lifecycle.minutesUntilDeparture >= 0 && ( batteryRecoveryMinutesNeeded + params.evcsNightDepartureSafetyMin >= lifecycle.minutesUntilDeparture ); const batteryBelowHardFloor = parsed.soc <= batteryHardFloorPct; const batteryBelowMorningTarget = parsed.soc < batteryMorningTargetPct; const batteryAtOrBelowGridTakeoverTrigger = parsed.soc <= gridTakeoverTriggerPct; const batteryAssistAllowed = nightSessionActive && !batteryBelowHardFloor && !batteryRecoveryUrgent && parsed.soc > gridTakeoverTriggerPct; const nightGridTakeoverNeeded = nightSessionActive && ( batteryBelowHardFloor || batteryAtOrBelowGridTakeoverTrigger || batteryRecoveryUrgent ); const nightPreferredSourceMode = !nightSessionActive ? 'blocked' : batteryAssistAllowed ? 'battery_assist_night' : ( batteryBelowHardFloor || batteryBelowMorningTarget || batteryRecoveryUrgent ) ? 'grid_forced' : 'grid_hc_allowed'; const batteryRecoveryAllowedNow = nightSessionActive && nightGridTakeoverNeeded && ( batteryBelowMorningTarget || batteryRecoveryUrgent ); const batteryTargetReached = nightSessionActive && parsed.soc >= batteryMorningTargetPct; // ===================================================== // PROFILE-BASED THRESHOLDS // ===================================================== const profileSocStartFloor = context.profile === 'morning_solar' ? params.morningSocMin : context.profile === 'midday_hc_or_solar' ? params.middaySocMin : context.profile === 'evening_high_soc' ? params.eveningSocStartMin : params.socStartMin; 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; 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);