diff --git a/BATTERY/BATTERY_CLOUD_LOCKS.js b/BATTERY/BATTERY_CLOUD_LOCKS.js new file mode 100644 index 0000000..f8a2a03 --- /dev/null +++ b/BATTERY/BATTERY_CLOUD_LOCKS.js @@ -0,0 +1,873 @@ +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 + ); + +// ===================================================== +// 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); + +// ===================================================== +// 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; + +// ===================================================== +// START / HOLD CONDITIONS +// ===================================================== +const solarStartOk = + (context.sourceMode === 'solar_only' || context.sourceMode === 'solar_potential') && + commonSafetyStartOk && + parsed.soc >= profileSocStartFloor && + parsed.batteryCurrentA >= profileBatteryDischargeFloorA && + ( + context.sourceMode === 'solar_potential' || + parsed.pvPowerW >= context.effectivePvStartMinW + ); + +const solarHoldOk = + (context.sourceMode === 'solar_only' || context.sourceMode === 'solar_potential') && + commonSafetyHoldOk && + parsed.soc >= profileSocHoldFloor && + parsed.batteryCurrentA >= profileBatteryDischargeFloorA && + ( + parsed.pvPowerW >= params.pvHoldMinW || + cloudToleranceOk + ); + +const gridStartOk = + isGridSource && + commonSafetyStartOk && + parsed.gridAvailable && + parsed.batteryCurrentA >= gridBatterySoftDynamicA; + +const gridHoldOk = + isGridSource && + commonSafetyHoldOk && + parsed.gridAvailable && + parsed.batteryCurrentA >= gridBatteryHardDynamicA; + +const batteryAssistNightStartOk = + context.sourceMode === 'battery_assist_night' && + commonSafetyStartOk && + nightSessionActive && + batteryAssistAllowed && + parsed.soc > batteryHardFloorPct && + parsed.soc > gridTakeoverTriggerPct && + parsed.batteryCurrentA >= profileBatteryDischargeFloorA; + +const batteryAssistNightHoldOk = + context.sourceMode === 'battery_assist_night' && + commonSafetyHoldOk && + nightSessionActive && + batteryAssistAllowed && + parsed.soc > batteryHardFloorPct && + parsed.soc > gridTakeoverTriggerPct && + parsed.batteryCurrentA >= batteryDischargeHardDynamicA; + +// ===================================================== +// PROBE LOGIC — STRICTEMENT LIMITÉE AU MODE PROBE +// ===================================================== +const probeModeActive = context.sourceMode === 'hybrid_probe'; + +const probeStartOk = + probeModeActive && + commonSafetyStartOk && + (context.morningProbeEligible || context.middayProbeEligible); + +const probeHoldGuardOk = + probeModeActive && + commonSafetyHoldOk && + parsed.batteryCurrentA >= profileBatteryDischargeFloorA; + +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) + ); + +const probeFailedNow = + probeModeActive && + probeElapsedReached && + !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; + +const solarShouldStepUp = + 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, + + gridBatterySoftDynamicA, + gridBatteryHardDynamicA, + gridForceChargeRelaxSoftA, + gridForceChargeRelaxHardA, + + houseSpikeDetected, + solarPotentialContext, + }, + + 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, + maxBatteryDischargePoints, + gridTakeoverTriggerPct, + + batteryBelowHardFloor, + batteryBelowMorningTarget, + batteryAtOrBelowGridTakeoverTrigger, + + batteryKwhMissingToTarget, + batteryRecoveryMinutesNeeded, + batteryRecoveryUrgent, + batteryRecoveryAllowedNow, + batteryTargetReached, + + batteryAssistAllowed, + nightGridTakeoverNeeded, + nightPreferredSourceMode, + }, + + safeguards: { + isGridSource, + commonSafetyStartOk, + commonSafetyHoldOk, + profileSocStartFloor, + profileSocHoldFloor, + profileBatteryDischargeFloorA, + batteryCurrentHardExceeded, + mustStopHard, + gridBatteryProtectionStop, + }, + + conditions: { + solarStartOk, + solarHoldOk, + gridStartOk, + gridHoldOk, + + batteryAssistNightStartOk, + batteryAssistNightHoldOk, + + probeStartOk, + probeHoldGuardOk, + probeWindowActive, + probeElapsedReached, + probeSuccessNow, + probeFailedNow, + + solarShouldStepUp, + gridShouldStepUp, + batteryAssistNightShouldStepUp, + + solarShouldStepDownBattery, + solarShouldStepDownCloudDebt, + gridShouldStepDownBattery, + batteryAssistNightShouldStepDown, + + shouldStepDownVebusPower, + shouldStepDownVebusVoltage, + shouldStepDownOverload, + shouldStopOverloadAtMin, + } + } +}]; + diff --git a/BATTERY/BATTERY_CLOUD_LOCKSP1.js b/BATTERY/BATTERY_CLOUD_LOCKSP1.js new file mode 100644 index 0000000..e24b8ae --- /dev/null +++ b/BATTERY/BATTERY_CLOUD_LOCKSP1.js @@ -0,0 +1,235 @@ +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; diff --git a/BATTERY/BATTERY_CLOUD_LOCKSP2.js b/BATTERY/BATTERY_CLOUD_LOCKSP2.js new file mode 100644 index 0000000..d0e6d61 --- /dev/null +++ b/BATTERY/BATTERY_CLOUD_LOCKSP2.js @@ -0,0 +1,233 @@ +// ===================================================== +// 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); diff --git a/BATTERY/BATTERY_CLOUD_LOCKSP3.js b/BATTERY/BATTERY_CLOUD_LOCKSP3.js new file mode 100644 index 0000000..f04ecc9 --- /dev/null +++ b/BATTERY/BATTERY_CLOUD_LOCKSP3.js @@ -0,0 +1,404 @@ + +// ===================================================== +// 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; + +// ===================================================== +// START / HOLD CONDITIONS +// ===================================================== +const solarStartOk = + (context.sourceMode === 'solar_only' || context.sourceMode === 'solar_potential') && + commonSafetyStartOk && + parsed.soc >= profileSocStartFloor && + parsed.batteryCurrentA >= profileBatteryDischargeFloorA && + ( + context.sourceMode === 'solar_potential' || + parsed.pvPowerW >= context.effectivePvStartMinW + ); + +const solarHoldOk = + (context.sourceMode === 'solar_only' || context.sourceMode === 'solar_potential') && + commonSafetyHoldOk && + parsed.soc >= profileSocHoldFloor && + parsed.batteryCurrentA >= profileBatteryDischargeFloorA && + ( + parsed.pvPowerW >= params.pvHoldMinW || + cloudToleranceOk + ); + +const gridStartOk = + isGridSource && + commonSafetyStartOk && + parsed.gridAvailable && + parsed.batteryCurrentA >= gridBatterySoftDynamicA; + +const gridHoldOk = + isGridSource && + commonSafetyHoldOk && + parsed.gridAvailable && + parsed.batteryCurrentA >= gridBatteryHardDynamicA; + +const batteryAssistNightStartOk = + context.sourceMode === 'battery_assist_night' && + commonSafetyStartOk && + nightSessionActive && + batteryAssistAllowed && + parsed.soc > batteryHardFloorPct && + parsed.soc > gridTakeoverTriggerPct && + parsed.batteryCurrentA >= profileBatteryDischargeFloorA; + +const batteryAssistNightHoldOk = + context.sourceMode === 'battery_assist_night' && + commonSafetyHoldOk && + nightSessionActive && + batteryAssistAllowed && + parsed.soc > batteryHardFloorPct && + parsed.soc > gridTakeoverTriggerPct && + parsed.batteryCurrentA >= batteryDischargeHardDynamicA; + +// ===================================================== +// PROBE LOGIC — STRICTEMENT LIMITÉE AU MODE PROBE +// ===================================================== +const probeModeActive = context.sourceMode === 'hybrid_probe'; + +const probeStartOk = + probeModeActive && + commonSafetyStartOk && + (context.morningProbeEligible || context.middayProbeEligible); + +const probeHoldGuardOk = + probeModeActive && + commonSafetyHoldOk && + parsed.batteryCurrentA >= profileBatteryDischargeFloorA; + +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) + ); + +const probeFailedNow = + probeModeActive && + probeElapsedReached && + !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; + +const solarShouldStepUp = + 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, + + gridBatterySoftDynamicA, + gridBatteryHardDynamicA, + gridForceChargeRelaxSoftA, + gridForceChargeRelaxHardA, + + houseSpikeDetected, + solarPotentialContext, + }, + + 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, + maxBatteryDischargePoints, + gridTakeoverTriggerPct, + + batteryBelowHardFloor, + batteryBelowMorningTarget, + batteryAtOrBelowGridTakeoverTrigger, + + batteryKwhMissingToTarget, + batteryRecoveryMinutesNeeded, + batteryRecoveryUrgent, + batteryRecoveryAllowedNow, + batteryTargetReached, + + batteryAssistAllowed, + nightGridTakeoverNeeded, + nightPreferredSourceMode, + }, + + safeguards: { + isGridSource, + commonSafetyStartOk, + commonSafetyHoldOk, + profileSocStartFloor, + profileSocHoldFloor, + profileBatteryDischargeFloorA, + batteryCurrentHardExceeded, + mustStopHard, + gridBatteryProtectionStop, + }, + + conditions: { + solarStartOk, + solarHoldOk, + gridStartOk, + gridHoldOk, + + batteryAssistNightStartOk, + batteryAssistNightHoldOk, + + probeStartOk, + probeHoldGuardOk, + probeWindowActive, + probeElapsedReached, + probeSuccessNow, + probeFailedNow, + + solarShouldStepUp, + gridShouldStepUp, + batteryAssistNightShouldStepUp, + + solarShouldStepDownBattery, + solarShouldStepDownCloudDebt, + gridShouldStepDownBattery, + batteryAssistNightShouldStepDown, + + shouldStepDownVebusPower, + shouldStepDownVebusVoltage, + shouldStepDownOverload, + shouldStopOverloadAtMin, + } + } +}]; + diff --git a/BATTERY/battery_cloud_locks_new_profil_sans_injection.js b/BATTERY/battery_cloud_locks_new_profil_sans_injection.js new file mode 100644 index 0000000..d50cd62 --- /dev/null +++ b/BATTERY/battery_cloud_locks_new_profil_sans_injection.js @@ -0,0 +1,951 @@ +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 +// ===================================================== +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); + +// ===================================================== +// 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; + +// ===================================================== +// 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 && + cloudDebtNextKwh <= batteryAssistSolarDebtStartMaxKwh && + !cloudDebtActive; + +const batteryAssistSolarHoldOk = + batteryAssistSolarPolicyEligible && + commonSafetyHoldOk && + !isGridSource && + parsed.soc >= Math.max(profileSocHoldFloor, batteryAssistSolarHoldSocMin) && + parsed.batteryCurrentA >= profileBatteryDischargeFloorA && + cloudToleranceOk; + +// ===================================================== +// START / HOLD CONDITIONS +// ===================================================== +const rawSolarStartOk = + (context.sourceMode === 'solar_only' || context.sourceMode === 'solar_potential') && + commonSafetyStartOk && + parsed.soc >= profileSocStartFloor && + parsed.batteryCurrentA >= profileBatteryDischargeFloorA && + ( + context.sourceMode === 'solar_potential' || + parsed.pvPowerW >= context.effectivePvStartMinW + ); + +const solarStartOk = + rawSolarStartOk || batteryAssistSolarStartOk; + +const rawSolarHoldOk = + (context.sourceMode === 'solar_only' || context.sourceMode === 'solar_potential') && + commonSafetyHoldOk && + parsed.soc >= profileSocHoldFloor && + parsed.batteryCurrentA >= profileBatteryDischargeFloorA && + ( + parsed.pvPowerW >= params.pvHoldMinW || + cloudToleranceOk + ); + +const solarHoldOk = + rawSolarHoldOk || batteryAssistSolarHoldOk; + +const gridStartOk = + isGridSource && + commonSafetyStartOk && + parsed.gridAvailable && + parsed.batteryCurrentA >= gridBatterySoftDynamicA; + +const gridHoldOk = + isGridSource && + commonSafetyHoldOk && + parsed.gridAvailable && + parsed.batteryCurrentA >= gridBatteryHardDynamicA; + +const batteryAssistNightStartOk = + context.sourceMode === 'battery_assist_night' && + commonSafetyStartOk && + nightSessionActive && + batteryAssistAllowed && + parsed.soc > batteryHardFloorPct && + parsed.soc > gridTakeoverTriggerPct && + parsed.batteryCurrentA >= profileBatteryDischargeFloorA; + +const batteryAssistNightHoldOk = + context.sourceMode === 'battery_assist_night' && + commonSafetyHoldOk && + nightSessionActive && + batteryAssistAllowed && + parsed.soc > batteryHardFloorPct && + parsed.soc > gridTakeoverTriggerPct && + parsed.batteryCurrentA >= batteryDischargeHardDynamicA; + +// ===================================================== +// PROBE LOGIC — STRICTEMENT LIMITÉE AU MODE PROBE +// ===================================================== +const probeModeActive = context.sourceMode === 'hybrid_probe'; + +const probeStartOk = + probeModeActive && + commonSafetyStartOk && + (context.morningProbeEligible || context.middayProbeEligible); + +const probeHoldGuardOk = + probeModeActive && + commonSafetyHoldOk && + parsed.batteryCurrentA >= profileBatteryDischargeFloorA; + +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) + ); + +const probeFailedNow = + probeModeActive && + probeElapsedReached && + !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; + +const solarShouldStepUp = + 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, + + 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, + maxBatteryDischargePoints, + gridTakeoverTriggerPct, + + batteryBelowHardFloor, + batteryBelowMorningTarget, + batteryAtOrBelowGridTakeoverTrigger, + + batteryKwhMissingToTarget, + batteryRecoveryMinutesNeeded, + batteryRecoveryUrgent, + batteryRecoveryAllowedNow, + batteryTargetReached, + + batteryAssistAllowed, + nightGridTakeoverNeeded, + nightPreferredSourceMode, + }, + + safeguards: { + isGridSource, + commonSafetyStartOk, + commonSafetyHoldOk, + profileSocStartFloor, + profileSocHoldFloor, + profileBatteryDischargeFloorA, + batteryCurrentHardExceeded, + mustStopHard, + gridBatteryProtectionStop, + }, + + conditions: { + rawSolarStartOk, + rawSolarHoldOk, + batteryAssistSolarPolicyEligible, + batteryAssistSolarStartOk, + batteryAssistSolarHoldOk, + + solarStartOk, + solarHoldOk, + gridStartOk, + gridHoldOk, + + batteryAssistNightStartOk, + batteryAssistNightHoldOk, + + probeStartOk, + probeHoldGuardOk, + probeWindowActive, + probeElapsedReached, + probeSuccessNow, + probeFailedNow, + + solarShouldStepUp, + gridShouldStepUp, + batteryAssistNightShouldStepUp, + + solarShouldStepDownBattery, + solarShouldStepDownCloudDebt, + gridShouldStepDownBattery, + batteryAssistNightShouldStepDown, + + shouldStepDownVebusPower, + shouldStepDownVebusVoltage, + shouldStepDownOverload, + shouldStopOverloadAtMin, + } + } +}]; +