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; // ===================================================== // MANUAL CHARGE SEMANTICS // IMPORTANT: // force_charge_now = bypass fenêtres pour la charge, // MAIS PAS demande implicite de réseau. // ===================================================== const manualChargeBypassWindows = forceChargeRequested; const manualChargeLocalRequested = forceChargeRequested && !forceGridRequested; // Compatibilité ascendante : on garde le champ dans context, // mais on le neutralise volontairement. const forceChargeDepartureGridOverride = false; // ===================================================== // 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; // P5 : plafond EVCS configurable quand le réseau alimente réellement AC-IN // (input_number.evcs_plafond_courant_edf_on). Hors réseau : plafond câblé prudent. const evcsDedicatedCapBaseW = Math.max(0, cfg.evcsDedicatedMaxW - cfg.systemReserveW); const safeEvcsByDedicatedCapW = parsed.acInAvailable ? Math.max(0, params.gridCurrentMaxA * acVoltageRef) : evcsDedicatedCapBaseW; 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, parsed.acInAvailable ? Math.max(params.currentMaxAutoA, params.gridCurrentMaxA) : 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 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; // P4 : la nuit avant minuit, today_remaining ~ 0 => utiliser la prévision du lendemain. const nightForecastHour = now.hour; const nightForecastKwh = (nightForecastHour >= 20 || nightForecastHour < 8) ? Math.max(parsed.todayRemainingKwh, parsed.tomorrowKwh || 0) : parsed.todayRemainingKwh; const batteryMorningTargetPct = nightForecastKwh >= params.evcsNightStrongForecastKwh ? params.evcsNightTargetStrongForecastPct : nightForecastKwh >= 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 ); // P2 ANTI-CRENEAUX : continuité du takeover réseau nocturne. // Une fois le réseau HC engagé pendant une session nuit, on y reste // jusqu'à la fin de la session (fenêtre/débranchement) ou batterie quasi pleine. // Évite le cycle batterie->jeep->réseau->batterie (pertes + charge hachée). const NIGHT_TAKEOVER_RELEASE_SOC_PCT = 90; const nightTakeoverPrev = (input.night_takeover_prev ?? 'off').toString().trim().toLowerCase() === 'on'; // P8 (07/08/2026) : "charge terminée" COLLANT via l'état surplus du cycle // précédent (helper input_text.victron_evcs_surplus_state relu par le snapshot). // Chaîne : VDF stoppe la charge (state vehicle_full_or_paused) → ce flag s'allume // → DECISION émet night_session_done_idle → le flag s'auto-entretient jusqu'au // débranchement (state vehicle_not_connected) ou à la fin de la fenêtre nuit. const prevSurplusState = (input.prev_surplus_state ?? '').toString().trim().toLowerCase(); // Fix D (08/08/2026) : le statut natif EVCS "charged" (3) vaut aussi fin de // charge, sans attendre le VehicleDemandFilter (désarmé si la session a été // stoppée par une garde, ex battery_current_hard_stop → détection tardive // observée la nuit du 07-08/08). const evcsStatusCharged = Number.parseFloat(parsed.evcsStatusRaw) === 3; const vehicleChargeCompleted = nightSessionActive && ( prevSurplusState === 'vehicle_full_or_paused' || prevSurplusState === 'night_session_done_idle' || prevSurplusState === 'night_recovery_done_idle' || evcsStatusCharged ); // Fix A (08/08/2026) : recovery batterie post-charge = UNE SEULE passe. // Sans flag collant, SOC==cible est un point dégénéré (engage soc<=cible, // release soc>cible, l'ACM recharge pile à la cible) → rebond EDF ON/OFF // période ~10 min observé la nuit du 07-08/08. Une fois la cible atteinte, // l'état 'night_recovery_done_idle' (auto-entretenu via prev_surplus_state) // coupe le takeover. Ré-engagement uniquement sous cible − 2 pts (bande), // hard floor et fenêtre/débranchement restent prioritaires. // ⚠ 'night_recovery_done_idle' ne doit matcher AUCUN pattern no-demand de // l'hystérésis (full_or_paused / not_requesting_power / charge_complete / // no_demand) — même contrainte que 'night_session_done_idle'. const NIGHT_RECOVERY_REARM_BAND_PCT = 2; const nightRecoveryDonePrev = prevSurplusState === 'night_recovery_done_idle'; const nightRecoveryDone = vehicleChargeCompleted && ( parsed.soc >= batteryMorningTargetPct || ( nightRecoveryDonePrev && parsed.soc > batteryMorningTargetPct - NIGHT_RECOVERY_REARM_BAND_PCT ) ); // P8 : correction du fix 04/08 (logique inversée : "|| vehicleChargeCompleted" // GARDAIT le latch après la charge au lieu de le libérer). const nightTakeoverLatched = nightSessionActive && nightTakeoverPrev && parsed.soc < NIGHT_TAKEOVER_RELEASE_SOC_PCT && !vehicleChargeCompleted; const nightGridHcPreferred = nightSessionActive && params.allowGridHcNight && !vehicleChargeCompleted; const batteryAssistAllowed = nightSessionActive && !nightGridHcPreferred && parsed.soc > batteryHardFloorPct && parsed.soc > gridTakeoverTriggerPct && !nightTakeoverLatched && !batteryRecoveryUrgent; const nightGridTakeoverNeeded = nightSessionActive && ( nightGridHcPreferred || batteryBelowHardFloor || ( // Fix A : après fin de charge, le seul motif réseau restant est la // recovery batterie, et elle ne se ré-engage pas une fois done. vehicleChargeCompleted ? !nightRecoveryDone : ( batteryAtOrBelowGridTakeoverTrigger || batteryRecoveryUrgent || nightTakeoverLatched ) ) ); const nightPreferredSourceMode = !nightSessionActive ? 'blocked' : vehicleChargeCompleted ? '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; // ===================================================== // 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 afternoonProbeEligible = profile === 'outside_window' && params.probeEnabled && parsed.soc >= params.afternoonProbeSocMin && parsed.batteryCurrentA >= params.afternoonBatteryMaxDischargeA && parsed.pvPowerAvg10mW >= params.afternoonProbePvAvgMinW; const cloudDebtOkForPotential = params.cloudDebtPrevKwh <= params.cloudDebtMaxKwh; const batteryOkForPotential = parsed.batteryCurrentA >= -Math.max(1, params.batteryNeutralBandA); // P3 PROVOCATION PV (installation sans injection) : // Batteries quasi pleines => le MPPT bride la production à la conso maison. // Les seuils PV observés, le forecast buffer et la cloud debt ne veulent // alors plus rien dire (la dette ne peut d'ailleurs plus se rembourser, // batterie pleine = courant nul). On autorise solar_potential sur la seule // santé batterie : SOC très haut + batterie non déchargée. // Les gardes aval (BATTERY_CLOUD_LOCKS / DECISION) restent actives. const PV_PROVOCATION_SOC_MIN = 90; const pvProvocationEligible = parsed.soc >= PV_PROVOCATION_SOC_MIN && batteryOkForPotential; const isHighBatteryForPotential = pvProvocationEligible || ( 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 // SÉMANTIQUE CORRIGÉE: // - force_charge_now bypass les fenêtres // - mais ne devient jamais grid_forced à lui seul // - outside_window = pas de réseau programmé // - la charge locale peut rester autorisée // ===================================================== let sourceMode = 'blocked'; if (isStopMode) { sourceMode = 'blocked'; } else if (forceGridRequested) { // Demande explicite de réseau. sourceMode = 'grid_forced'; } else if (forceChargeRequested) { // IMPORTANT: // force_charge_now = intention de charger maintenant, // sans exiger le réseau. if (strongSolarSignal) { sourceMode = 'solar_only'; } else if (nightSessionActive && batteryAssistAllowed) { sourceMode = 'battery_assist_night'; } 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 { // Fallback manuel local : // on n'impose pas le grid, on laisse l'étage aval décider // selon les garde-fous physiques / batterie. sourceMode = 'solar_potential'; } } 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 (afternoonProbeEligible) { sourceMode = 'hybrid_probe'; } else { // IMPORTANT: // hors fenêtre = pas de réseau programmé. // Donc on ne bascule plus vers grid_forced juste parce que le réseau existe. 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, manualChargeBypassWindows, manualChargeLocalRequested, morningProbeEligible, middayProbeEligible, afternoonProbeEligible, pvProvocationEligible, isHighBatteryForPotential, strongSolarSignal, effectivePvStartMinW, profile, operatingMode, sourceMode, }, // R1 : source de vérité UNIQUE du moteur nuit. // BATTERY_CLOUD_LOCKS consomme cet objet, il ne recalcule plus rien. nightEvcsPreview: { nightSessionActive, nightSessionNeedsCapture, nightSessionNeedsClear, nightSessionStartSocPct, nightSessionStartAt, nightSessionStartMs, batteryHardFloorPct, batteryMorningTargetPct, maxBatteryDischargePoints, gridTakeoverTriggerPct, batteryBelowHardFloor, batteryBelowMorningTarget, batteryAtOrBelowGridTakeoverTrigger, batteryKwhMissingToTarget, batteryRecoveryMinutesNeeded, batteryRecoveryUrgent, batteryRecoveryAllowedNow, batteryTargetReached, batteryAssistAllowed, nightGridTakeoverNeeded, nightTakeoverLatched, nightGridHcPreferred, vehicleChargeCompleted, nightRecoveryDone, nightPreferredSourceMode, } } }];