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