function minutesSince(ts, nowMs) { if (ts === null || nowMs === null) return null; return Math.max(0, (nowMs - ts) / 60000); } function secondsSince(ts, nowMs) { if (ts === null || nowMs === null) return null; return Math.max(0, (nowMs - ts) / 1000); } function isWithinWindow(nowMin, startMin, stopMin) { if (!Number.isFinite(startMin) || !Number.isFinite(stopMin)) return false; if (startMin === stopMin) return false; if (startMin < stopMin) return nowMin >= startMin && nowMin < stopMin; return nowMin >= startMin || nowMin < stopMin; } function clamp(v, min, max) { return Math.min(Math.max(v, min), max); } const prev = $input.first().json; const { input, nowMs, now, cfg, parsed, params, history, batteryTune } = prev; // ===================================================== // STRATEGY // ===================================================== const userStrategy = (params.userStrategyRaw ?? 'auto_solar').toString().trim().toLowerCase(); const isStopMode = userStrategy === 'stop'; const forceGridRequested = userStrategy === 'force_grid_now'; const forceChargeRequested = userStrategy === 'force_charge_now'; const isForceMode = forceGridRequested || forceChargeRequested; // ===================================================== // LIFECYCLE / TIMING // ===================================================== const minutesSinceStart = minutesSince(history.lastStartMs, nowMs); const minutesSinceStop = minutesSince(history.lastStopMs, nowMs); const secondsSinceCurrentChange = secondsSince(history.lastCurrentChangeMs, nowMs); const minutesSinceProbeStart = minutesSince(history.probeStartedMs, nowMs); const minRunElapsed = minutesSinceStart === null ? true : minutesSinceStart >= params.minRunMinutes; const minStopElapsed = minutesSinceStop === null ? true : minutesSinceStop >= params.minStopMinutes; const currentCooldownBaseSec = parsed.evcsChargingSwitchOn ? batteryTune.global.stepDownCooldownSeconds : batteryTune.global.stepUpCooldownSeconds; const currentCooldownElapsed = secondsSinceCurrentChange === null ? true : secondsSinceCurrentChange >= Math.max( params.currentChangeCooldownSeconds, currentCooldownBaseSec ); // ===================================================== // DEPARTURE PRIORITY // ===================================================== const minutesUntilDeparture = params.departureTimeMin === null ? null : (() => { const rawDiff = params.departureTimeMin - now.minNow; return rawDiff >= 0 ? rawDiff : (1440 - now.minNow + params.departureTimeMin); })(); const departurePriorityActive = parsed.departurePriorityEnabled && Number.isFinite(minutesUntilDeparture) && minutesUntilDeparture >= 0 && minutesUntilDeparture <= cfg.departurePriorityWindowMinutes && parsed.evcsVehicleConnected; // ===================================================== // EVCS PRIORITY OVERRIDE FOR FORCE CHARGE // force_charge_now + voiture branchée + départ défini = grid_forced // ===================================================== const forceChargeDepartureGridOverride = forceChargeRequested && parsed.evcsVehicleConnected && Number.isFinite(minutesUntilDeparture) && minutesUntilDeparture >= 0; // ===================================================== // SYSTEM DERIVED VALUES // ===================================================== const acVoltageRef = Number.isFinite(parsed.vebusAcOutVoltageV) && parsed.vebusAcOutVoltageV > 180 ? parsed.vebusAcOutVoltageV : cfg.nominalAcVoltageV; const batteryVoltageRef = Number.isFinite(parsed.vebusDcVoltageV) && parsed.vebusDcVoltageV > 0 ? parsed.vebusDcVoltageV : 51.2; const houseNonEvcsW = Math.max(0, parsed.houseAcPowerW - parsed.evcsAcPowerW); const vebusNonEvcsW = Math.max(0, parsed.vebusAcOutPowerW - parsed.evcsAcPowerW); const totalVebusSoftCeilingW = Math.max(6500, params.acLoadsMaxSafeW || 8500); const totalVebusHardCeilingW = totalVebusSoftCeilingW + 400; const safeEvcsByDedicatedCapW = Math.max(0, cfg.evcsDedicatedMaxW - cfg.systemReserveW); const safeEvcsByVebusW = Math.max(0, totalVebusSoftCeilingW - vebusNonEvcsW - cfg.systemReserveW); const safeEvcsCombinedW = Math.max(0, Math.min(safeEvcsByDedicatedCapW, safeEvcsByVebusW)); const systemCapCurrentA = Math.max(0, Math.floor(safeEvcsCombinedW / acVoltageRef)); const physicalCapA = clamp(systemCapCurrentA, 0, params.currentMaxAutoA); const canRunAtMinCurrent = physicalCapA >= params.currentMinA; const endDayGuardSoc = Math.max(params.endOfDaySocGuard, params.endOfDaySocTarget); const energyNeededForEndDayGuardKwh = Math.max( 0, ((endDayGuardSoc - parsed.soc) / 100) * cfg.batteryUsableCapacityKwh ); const forecastBufferKwh = parsed.todayRemainingKwh - params.baseLoadRemainingKwh - params.flexibleSafetyMarginKwh - params.requiredEnergyKwh - energyNeededForEndDayGuardKwh; const projectedEndSocAfterCharge = clamp( parsed.soc + ( ( parsed.todayRemainingKwh - params.baseLoadRemainingKwh - params.flexibleSafetyMarginKwh - params.requiredEnergyKwh ) / cfg.batteryUsableCapacityKwh ) * 100, 0, 100 ); const currentReferenceA = clamp( Number.isFinite(parsed.evcsCommandCurrentA) ? parsed.evcsCommandCurrentA : Number.isFinite(parsed.evcsSetCurrentA) ? parsed.evcsSetCurrentA : params.targetCurrentAutoA, 0, 32 ); const anyVebusOverload = parsed.vebusOverload || parsed.vebusL1Overload; const anyVebusLowBattery = parsed.vebusLowBattery || parsed.vebusL1LowBattery; const vebusAcOutSoftReached = parsed.vebusAcOutPowerW >= totalVebusSoftCeilingW; const vebusAcOutHardReached = parsed.vebusAcOutPowerW >= totalVebusHardCeilingW; const vebusDcVoltageSoftLow = Number.isFinite(parsed.vebusDcVoltageV) && parsed.vebusDcVoltageV > 0 && parsed.vebusDcVoltageV <= cfg.vebusDcVoltageSoftFloorV; const vebusDcVoltageHardLow = Number.isFinite(parsed.vebusDcVoltageV) && parsed.vebusDcVoltageV > 0 && parsed.vebusDcVoltageV <= cfg.vebusDcVoltageHardFloorV; const houseLoadHighVsHeadroom = houseNonEvcsW > params.houseAcHeadroomW; // ===================================================== // WINDOWS / PROFILE // ===================================================== const inNightHcWindow = isWithinWindow(now.minNow, params.hcNightStartMin, params.hcNightStopMin); const inMorningWindow = isWithinWindow(now.minNow, params.morningStartMin, params.morningStopMin); const inMiddayWindow = isWithinWindow(now.minNow, params.hcMiddayStartMin, params.hcMiddayStopMin); const inEveningWindow = isWithinWindow(now.minNow, params.eveningStartMin, params.eveningStopMin); const profile = inNightHcWindow ? 'night_hc' : inMorningWindow ? 'morning_solar' : inMiddayWindow ? 'midday_hc_or_solar' : inEveningWindow ? 'evening_high_soc' : 'outside_window'; // ===================================================== // EVCS NIGHT PREVIEW ENGINE // ===================================================== const nightSessionActive = profile === 'night_hc' && parsed.evcsVehicleConnected; const nightSessionStartSocPct = nightSessionActive ? ( Number.isFinite(history.nightSessionStartSocPct) ? history.nightSessionStartSocPct : parsed.soc ) : null; const nightSessionStartAt = nightSessionActive ? (history.nightSessionStartAt || '') : ''; const nightSessionNeedsCapture = nightSessionActive && ( !Number.isFinite(history.nightSessionStartSocPct) || !history.nightSessionStartAt ); const nightSessionNeedsClear = !nightSessionActive && ( Number.isFinite(history.nightSessionStartSocPct) || !!history.nightSessionStartAt ); const batteryHardFloorPct = params.evcsNightBatteryHardFloorPct; const batteryMorningTargetPct = parsed.todayRemainingKwh >= params.evcsNightStrongForecastKwh ? params.evcsNightTargetStrongForecastPct : parsed.todayRemainingKwh >= params.evcsNightMediumForecastKwh ? params.evcsNightTargetMediumForecastPct : params.evcsNightTargetWeakForecastPct; const maxBatteryDischargePoints = nightSessionActive && Number.isFinite(nightSessionStartSocPct) ? Math.max(0, nightSessionStartSocPct - batteryMorningTargetPct) : 0; const gridTakeoverTriggerPct = Math.max(batteryHardFloorPct, batteryMorningTargetPct); const batteryBelowHardFloor = parsed.soc <= batteryHardFloorPct; const batteryBelowMorningTarget = parsed.soc < batteryMorningTargetPct; const batteryAtOrBelowGridTakeoverTrigger = parsed.soc <= gridTakeoverTriggerPct; const batteryKwhMissingToTarget = nightSessionActive ? Math.max( 0, ((batteryMorningTargetPct - parsed.soc) / 100) * cfg.batteryUsableCapacityKwh ) : 0; const batteryRecoveryMinutesNeeded = nightSessionActive && params.evcsNightBatteryRecoveryPowerKw > 0 ? (batteryKwhMissingToTarget / params.evcsNightBatteryRecoveryPowerKw) * 60 : 0; const batteryRecoveryUrgent = nightSessionActive && Number.isFinite(minutesUntilDeparture) && minutesUntilDeparture >= 0 && ( batteryRecoveryMinutesNeeded + params.evcsNightDepartureSafetyMin >= minutesUntilDeparture ); const batteryAssistAllowed = nightSessionActive && parsed.soc > batteryHardFloorPct && parsed.soc > gridTakeoverTriggerPct && !batteryRecoveryUrgent; const nightGridTakeoverNeeded = nightSessionActive && ( batteryBelowHardFloor || batteryAtOrBelowGridTakeoverTrigger || batteryRecoveryUrgent ); const nightPreferredSourceMode = !nightSessionActive ? 'blocked' : batteryAssistAllowed ? 'battery_assist_night' : ( batteryBelowHardFloor || batteryBelowMorningTarget || batteryRecoveryUrgent ) ? 'grid_forced' : 'grid_hc_allowed'; // ===================================================== // PROBES / SOLAR POTENTIAL // ===================================================== // IMPORTANT: // On ne met plus effectivePvStartMinW à 0 en force_charge_now, // sinon la nuit 0W >= 0W ferait croire à un faux signal solaire. const effectivePvStartMinW = params.pvStartMinW; const morningProbeEligible = profile === 'morning_solar' && params.probeEnabled && parsed.pvPowerAvg10mW >= params.morningProbePvAvgMinW && parsed.pvTrendW >= params.morningProbePvTrendMinW && parsed.soc >= params.morningSocMin && parsed.batteryCurrentA >= params.morningBatteryMaxDischargeA; const middayProbeEligible = profile === 'midday_hc_or_solar' && params.probeEnabled && parsed.pvPowerAvg10mW >= params.middayProbePvAvgMinW && parsed.soc >= params.middaySocMin && parsed.batteryCurrentA >= params.middayBatteryMaxDischargeA; const cloudDebtOkForPotential = params.cloudDebtPrevKwh <= params.cloudDebtMaxKwh; const batteryOkForPotential = parsed.batteryCurrentA >= -Math.max(1, params.batteryNeutralBandA); const isHighBatteryForPotential = parsed.soc >= 85 && forecastBufferKwh > 0 && cloudDebtOkForPotential && batteryOkForPotential; const potentialAllowed = userStrategy !== 'auto_solar'; // IMPORTANT: // On impose un vrai PV > 0 pour le test sur pvPowerW. // Ça évite qu'en force_charge_now la nuit, 0W soit pris pour un signal solaire. const strongSolarSignal = ( parsed.pvPowerW > 0 && parsed.pvPowerW >= effectivePvStartMinW ) || parsed.pvPowerAvg10mW >= params.pvHoldMinW || (departurePriorityActive && parsed.pvPowerAvg10mW >= params.pvHoldMinW); // ===================================================== // SOURCE MODE // ===================================================== let sourceMode = 'blocked'; if (isStopMode) { sourceMode = 'blocked'; } else if (forceGridRequested) { // Demande explicite de réseau, même si le réseau n'est pas encore présent. // Cela permet aux étages suivants / sorties HA de le réclamer. sourceMode = 'grid_forced'; } else if (forceChargeRequested) { // PATCH EVCS PRIORITAIRE : // force_charge_now + voiture branchée + départ défini => grid_forced if (forceChargeDepartureGridOverride) { sourceMode = 'grid_forced'; } else if (nightSessionActive) { sourceMode = nightPreferredSourceMode; } else if (strongSolarSignal) { sourceMode = 'solar_only'; } else if (isHighBatteryForPotential && potentialAllowed) { sourceMode = 'solar_potential'; } else if (profile === 'midday_hc_or_solar' && parsed.gridAvailable) { sourceMode = params.allowGridHcMidday ? 'grid_hc_allowed' : 'grid_forced'; } else if (parsed.gridAvailable) { sourceMode = 'grid_forced'; } else if (profile === 'midday_hc_or_solar' && middayProbeEligible) { sourceMode = 'hybrid_probe'; } else if (profile === 'morning_solar' && morningProbeEligible) { sourceMode = 'hybrid_probe'; } else { // Si force_charge_now est demandé mais qu'aucune autre source locale ne permet // un démarrage fiable, on garde quand même l'intention EVCS prioritaire. sourceMode = 'grid_forced'; } } else if (profile === 'night_hc') { if (nightSessionActive) { sourceMode = nightPreferredSourceMode; } else { sourceMode = 'blocked'; } } else if (profile === 'midday_hc_or_solar' || profile === 'morning_solar') { if (strongSolarSignal) { sourceMode = 'solar_only'; } else if (isHighBatteryForPotential && potentialAllowed) { sourceMode = 'solar_potential'; } else if (profile === 'midday_hc_or_solar' && middayProbeEligible) { sourceMode = 'hybrid_probe'; } else if (profile === 'morning_solar' && morningProbeEligible) { sourceMode = 'hybrid_probe'; } else if (profile === 'midday_hc_or_solar' && params.allowGridHcMidday && parsed.gridAvailable) { sourceMode = 'grid_hc_allowed'; } else { sourceMode = 'blocked'; } } else if (profile === 'evening_high_soc') { if (strongSolarSignal) { sourceMode = 'solar_only'; } else if (isHighBatteryForPotential && potentialAllowed) { sourceMode = 'solar_potential'; } else { sourceMode = 'blocked'; } } else if (profile === 'outside_window') { if (strongSolarSignal) { sourceMode = 'solar_only'; } else if (isHighBatteryForPotential && potentialAllowed) { sourceMode = 'solar_potential'; } else if (forceChargeRequested) { sourceMode = 'grid_forced'; } else if (parsed.gridAvailable) { sourceMode = 'grid_forced'; } else { sourceMode = 'blocked'; } } const operatingMode = profile; // ===================================================== // OUTPUT // ===================================================== return [{ json: { ...prev, strategy: { userStrategy, isStopMode, isForceMode, forceGridRequested, forceChargeRequested, }, lifecycle: { minutesSinceStart, minutesSinceStop, secondsSinceCurrentChange, minutesSinceProbeStart, minRunElapsed, minStopElapsed, currentCooldownElapsed, minutesUntilDeparture, }, windows: { inNightHcWindow, inMorningWindow, inMiddayWindow, inEveningWindow, }, derived: { acVoltageRef, batteryVoltageRef, houseNonEvcsW, vebusNonEvcsW, totalVebusSoftCeilingW, totalVebusHardCeilingW, safeEvcsByDedicatedCapW, safeEvcsByVebusW, safeEvcsCombinedW, systemCapCurrentA, physicalCapA, canRunAtMinCurrent, endDayGuardSoc, energyNeededForEndDayGuardKwh, forecastBufferKwh, projectedEndSocAfterCharge, currentReferenceA, anyVebusOverload, anyVebusLowBattery, vebusAcOutSoftReached, vebusAcOutHardReached, vebusDcVoltageSoftLow, vebusDcVoltageHardLow, houseLoadHighVsHeadroom, }, context: { departurePriorityActive, forceChargeDepartureGridOverride, morningProbeEligible, middayProbeEligible, isHighBatteryForPotential, strongSolarSignal, effectivePvStartMinW, profile, operatingMode, sourceMode, }, nightEvcsPreview: { nightSessionActive, nightSessionNeedsCapture, nightSessionNeedsClear, nightSessionStartSocPct, nightSessionStartAt, batteryHardFloorPct, batteryMorningTargetPct, maxBatteryDischargePoints, gridTakeoverTriggerPct, batteryBelowHardFloor, batteryBelowMorningTarget, batteryAtOrBelowGridTakeoverTrigger, batteryKwhMissingToTarget, batteryRecoveryMinutesNeeded, batteryRecoveryUrgent, batteryAssistAllowed, nightGridTakeoverNeeded, nightPreferredSourceMode, } } }];