// ===================================================== // 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 ); // ===================================================== // 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' : 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 );