diff --git a/DECISION/DECISION_ENGINEV2.js b/DECISION/DECISION_ENGINEV2.js new file mode 100644 index 0000000..4d00d2e --- /dev/null +++ b/DECISION/DECISION_ENGINEV2.js @@ -0,0 +1,1277 @@ +function clamp(v, min, max) { + return Math.min(Math.max(v, min), max); +} + +function round1(v) { + return Math.round(v * 10) / 10; +} + +function round3(v) { + return Math.round(v * 1000) / 1000; +} + +function to2(n) { + return String(n).padStart(2, '0'); +} + +function finiteOr(v, fallback) { + return Number.isFinite(v) ? v : fallback; +} + +function sigmoid(x) { + return 1 / (1 + Math.exp(-x)); +} + +function alphaFromTau(dtSec, tauSec) { + const dt = Math.max(1, finiteOr(dtSec, 60)); + const tau = Math.max(1, finiteOr(tauSec, 60)); + return Math.min(1, Math.max(0.001, 1 - Math.exp(-dt / tau))); +} + +function ema(prev, sample, alpha) { + if (!Number.isFinite(sample)) return Number.isFinite(prev) ? prev : 0; + if (!Number.isFinite(prev)) return sample; + return prev + alpha * (sample - prev); +} + +function formatParisStampFromMs(ms) { + 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); + return `${parts.year}-${parts.month}-${parts.day} ${to2(hour)}:${parts.minute}:${parts.second}`; +} + +const prev = $input.first().json; + +const { + now, + nowMs, + parsed, + params, + history, + strategy, + lifecycle, + context, + derived, + locks, + cloud, + modes, + safeguards, + conditions, + batteryTune, + nightEvcs, +} = prev; + +// ===================================================== +// DECISION INIT +// ===================================================== +let desiredCharging = false; +let desiredPermission = false; +let desiredLatch = false; +let desiredCurrentA = params.currentMinA; + +let state = 'idle'; +let currentDecision = 'hold'; +let dynamicOk = false; +let mustStop = false; + +let reason = []; + +let setCloudDebtStartedAt = false; +let setProbeStartedAt = false; + +// EVCS night session memory +let setNightSessionStart = false; +let clearNightSessionStart = false; + +let notifyWanted = false; +let notifyTitle = 'Victron EVCS Surplus'; +let notifyMessage = ''; + +let nextBlockReason = 'none'; +let nextBlockedUntilStamp = now.stamp; + +// ===================================================== +// HELPERS +// ===================================================== +function setOff({ + stateName, + decisionName = 'stop', + reasonText, + dynamic = false, + mustStopFlag = false, + blockReason = 'none', + blockedUntilStamp = now.stamp, +}) { + desiredCharging = false; + desiredPermission = false; + desiredLatch = false; + desiredCurrentA = params.currentMinA; + + state = stateName; + currentDecision = decisionName; + dynamicOk = dynamic; + mustStop = mustStopFlag; + nextBlockReason = blockReason; + nextBlockedUntilStamp = blockedUntilStamp; + + if (reasonText) reason.push(reasonText); +} + +function setOn({ + stateName, + decisionName = 'hold', + currentA = params.currentMinA, + reasonText, + dynamic = true, + mustStopFlag = false, +}) { + desiredCharging = true; + desiredPermission = true; + desiredLatch = true; + desiredCurrentA = clamp( + currentA, + params.currentMinA, + Math.max(params.currentMinA, derived.physicalCapA || params.currentMinA) + ); + + state = stateName; + currentDecision = decisionName; + dynamicOk = dynamic; + mustStop = mustStopFlag; + + if (reasonText) reason.push(reasonText); +} + +// ===================================================== +// SESSION MEMORY SIDE EFFECTS +// ===================================================== +if (nightEvcs?.nightSessionNeedsCapture) { + setNightSessionStart = true; +} + +if (nightEvcs?.nightSessionNeedsClear) { + clearNightSessionStart = true; +} + +// ===================================================== +// MANUAL LOCAL CHARGE SEMANTICS +// IMPORTANT: +// force_charge_now = bypass fenêtres pour la charge locale, +// sans imposer le réseau. +// ===================================================== +const manualChargeBypassWindows = !!context.manualChargeBypassWindows; +const manualChargeLocalRequested = !!context.manualChargeLocalRequested; + +const manualLocalChargeAllowed = + !!strategy.forceChargeRequested && + manualChargeBypassWindows && + manualChargeLocalRequested && + !safeguards.mustStopHard && + !!derived.canRunAtMinCurrent; + +// ===================================================== +// EVCS NUMERIC NO-DEMAND FILTER +// Objectif: +// détecter "voiture branchée + session armée + commande claire +// mais plus de demande réelle" sans seuils fixes bruts. +// IMPORTANT: +// on n'utilise PAS le PV comme critère. +// ===================================================== +const prevDeliveryRatioEma = finiteOr(history.evcsDeliveryRatioEma, NaN); +const prevDeliveryNoiseEma = finiteOr(history.evcsDeliveryNoiseEma, NaN); +const prevNoDemandScoreEma = finiteOr(history.evcsNoDemandScoreEma, NaN); +const prevNoDemandSemanticActive = !!history.evcsNoDemandSemanticActive; +const prevNoDemandSinceMs = finiteOr(history.evcsNoDemandSinceMs, NaN); +const prevNoDemandFilterEvalMs = finiteOr(history.evcsNoDemandFilterEvalMs, NaN); + +const noDemandDtSec = + Number.isFinite(prevNoDemandFilterEvalMs) && Number.isFinite(nowMs) + ? clamp((nowMs - prevNoDemandFilterEvalMs) / 1000, 1, 600) + : 60; + +// Constantes dynamiques du filtre = temps de réponse du filtre, +// pas des seuils métiers bruts. +const deliveryTauSec = Math.max(15, finiteOr(params.evcsDeliveryTauSec, 45)); +const noiseTauSec = Math.max(deliveryTauSec, finiteOr(params.evcsDeliveryNoiseTauSec, 180)); +const scoreTauSec = Math.max(20, finiteOr(params.evcsNoDemandScoreTauSec, 150)); +const armTauSec = Math.max(20, finiteOr(params.evcsNoDemandArmTauSec, 180)); + +const alphaDelivery = alphaFromTau(noDemandDtSec, deliveryTauSec); +const alphaNoise = alphaFromTau(noDemandDtSec, noiseTauSec); +const alphaScore = alphaFromTau(noDemandDtSec, scoreTauSec); + +const acVoltageRef = finiteOr(derived.acVoltageRef, 230); + +const commandCurrentA = clamp( + finiteOr( + derived.currentReferenceA, + finiteOr( + parsed.evcsCommandCurrentA, + finiteOr(parsed.evcsSetCurrentA, finiteOr(params.currentMinA, 6)) + ) + ), + 0, + 32 +); + +const expectedPowerW = Math.max(1, commandCurrentA * acVoltageRef); + +const realCurrentA = Math.abs( + finiteOr( + parsed.evcsCurrentRealA, + acVoltageRef > 180 + ? finiteOr(parsed.evcsAcPowerW, 0) / acVoltageRef + : 0 + ) +); + +const realPowerW = Math.max(0, finiteOr(parsed.evcsAcPowerW, 0)); + +const currentDeliveryRatioRaw = clamp( + realCurrentA / Math.max(commandCurrentA, 0.1), + 0, + 1.25 +); + +const powerDeliveryRatioRaw = clamp( + realPowerW / expectedPowerW, + 0, + 1.25 +); + +const deliveryRatioRaw = clamp( + Math.max(currentDeliveryRatioRaw, powerDeliveryRatioRaw), + 0, + 1 +); + +const deliveryRatioEma = ema(prevDeliveryRatioEma, deliveryRatioRaw, alphaDelivery); + +const deliveryResidualAbs = Math.abs(deliveryRatioRaw - deliveryRatioEma); +const deliveryNoiseEma = ema(prevDeliveryNoiseEma, deliveryResidualAbs, alphaNoise); + +const adaptiveQuietBand = clamp( + 0.02 + 3.0 * deliveryNoiseEma, + 0.02, + 0.35 +); + +const noDemandEvidenceRaw = clamp( + (adaptiveQuietBand - deliveryRatioEma) / adaptiveQuietBand, + 0, + 1 +); + +const sessionElapsedSec = Math.max( + 0, + finiteOr(lifecycle.minutesSinceStart, 0) * 60 +); + +const sessionArmFactor = 1 - Math.exp(-sessionElapsedSec / armTauSec); + +const currentMinRefA = Math.max(1, finiteOr(params.currentMinA, 6)); +const commandStrength = commandCurrentA / currentMinRefA; + +const commandFactor = clamp( + sigmoid((commandStrength - 1.0) * 2.2), + 0, + 1 +); + +// Source effectivement livrable. +// IMPORTANT: +// on évite de conclure "voiture pleine" si en réalité la source n'est +// pas disponible (ex: attente grid takeover). +const manualLocalNoDemandEligible = + manualLocalChargeAllowed && + ( + context.sourceMode === 'solar_only' || + context.sourceMode === 'solar_potential' || + context.sourceMode === 'blocked' + ); + +const sourceDeliverableForNoDemand = + manualLocalNoDemandEligible + ? true + : (context.sourceMode === 'grid_hc_allowed' || context.sourceMode === 'grid_forced') + ? !!parsed.gridAvailable + : (context.sourceMode === 'solar_only' || context.sourceMode === 'solar_potential') + ? !!conditions.solarHoldOk || !!conditions.solarStartOk + : context.sourceMode === 'hybrid_probe' + ? !!conditions.probeHoldGuardOk || !!conditions.probeSuccessNow || !!conditions.probeStartOk + : context.sourceMode === 'battery_assist_night' + ? !!conditions.batteryAssistNightHoldOk || !!conditions.batteryAssistNightStartOk + : false; + +const evcsSessionArmedForNoDemand = + !!cloud.sessionObservedActive && + !!parsed.evcsVehicleConnected && + !!parsed.evcsChargingSwitchOn && + !!parsed.chargePermissionNow && + !!parsed.controlLatch && + commandCurrentA > 0 && + sourceDeliverableForNoDemand; + +const noDemandScoreRaw = evcsSessionArmedForNoDemand + ? clamp( + noDemandEvidenceRaw * sessionArmFactor * commandFactor, + 0, + 1 + ) + : 0; + +const noDemandScoreEma = ema(prevNoDemandScoreEma, noDemandScoreRaw, alphaScore); + +const adaptiveDecisionCenter = clamp( + 0.72 - 0.20 * deliveryNoiseEma, + 0.55, + 0.82 +); + +const adaptiveDecisionBand = clamp( + 0.16 + 0.80 * deliveryNoiseEma, + 0.12, + 0.34 +); + +const noDemandScoreOn = clamp( + adaptiveDecisionCenter + adaptiveDecisionBand / 2, + 0.60, + 0.95 +); + +const noDemandScoreOff = clamp( + adaptiveDecisionCenter - adaptiveDecisionBand / 2, + 0.15, + noDemandScoreOn - 0.05 +); + +let evcsVehicleNoDemandSemantic = prevNoDemandSemanticActive; + +if (!evcsSessionArmedForNoDemand) { + evcsVehicleNoDemandSemantic = false; +} else if (prevNoDemandSemanticActive) { + evcsVehicleNoDemandSemantic = noDemandScoreEma >= noDemandScoreOff; +} else { + evcsVehicleNoDemandSemantic = noDemandScoreEma >= noDemandScoreOn; +} + +const evcsNoDemandSinceMs = + evcsVehicleNoDemandSemantic + ? ( + prevNoDemandSemanticActive && Number.isFinite(prevNoDemandSinceMs) + ? prevNoDemandSinceMs + : nowMs + ) + : null; + +const evcsNoDemandSinceMinutes = + Number.isFinite(evcsNoDemandSinceMs) && Number.isFinite(nowMs) + ? Math.max(0, (nowMs - evcsNoDemandSinceMs) / 60000) + : null; + +const vehicleDemandFilter = { + dtSec: round3(noDemandDtSec), + + commandCurrentA: round3(commandCurrentA), + realCurrentA: round3(realCurrentA), + expectedPowerW: round3(expectedPowerW), + realPowerW: round3(realPowerW), + + currentDeliveryRatioRaw: round3(currentDeliveryRatioRaw), + powerDeliveryRatioRaw: round3(powerDeliveryRatioRaw), + deliveryRatioRaw: round3(deliveryRatioRaw), + deliveryRatioEma: round3(deliveryRatioEma), + deliveryNoiseEma: round3(deliveryNoiseEma), + + adaptiveQuietBand: round3(adaptiveQuietBand), + noDemandEvidenceRaw: round3(noDemandEvidenceRaw), + + sessionElapsedSec: round3(sessionElapsedSec), + sessionArmFactor: round3(sessionArmFactor), + commandFactor: round3(commandFactor), + sourceDeliverableForNoDemand, + evcsSessionArmedForNoDemand, + + noDemandScoreRaw: round3(noDemandScoreRaw), + noDemandScoreEma: round3(noDemandScoreEma), + + noDemandScoreOn: round3(noDemandScoreOn), + noDemandScoreOff: round3(noDemandScoreOff), + + evcsVehicleNoDemandSemantic, + evcsNoDemandSinceMs: Number.isFinite(evcsNoDemandSinceMs) ? evcsNoDemandSinceMs : null, + evcsNoDemandSinceMinutes: Number.isFinite(evcsNoDemandSinceMinutes) + ? round3(evcsNoDemandSinceMinutes) + : null, + + nextHistory: { + evcsDeliveryRatioEma: round3(deliveryRatioEma), + evcsDeliveryNoiseEma: round3(deliveryNoiseEma), + evcsNoDemandScoreEma: round3(noDemandScoreEma), + evcsNoDemandSemanticActive: evcsVehicleNoDemandSemantic, + evcsNoDemandSinceMs: Number.isFinite(evcsNoDemandSinceMs) ? evcsNoDemandSinceMs : null, + evcsNoDemandFilterEvalMs: nowMs + } +}; + +// ===================================================== +// DECISION TREE +// ===================================================== +if (!parsed.controllerEnabled) { + desiredCharging = parsed.evcsChargingSwitchOn; + desiredPermission = parsed.chargePermissionNow; + desiredLatch = parsed.controlLatch; + desiredCurrentA = clamp( + derived.currentReferenceA || params.currentMinA, + params.currentMinA, + params.currentMaxAutoA + ); + + state = 'controller_disabled'; + currentDecision = 'hold'; + dynamicOk = false; + mustStop = false; + reason.push('Contrôleur désactivé'); +} + +else if (strategy.isStopMode) { + setOff({ + stateName: 'stopped_user', + decisionName: 'stop_stop_mode', + reasonText: 'Arrêt manuel via Stratégie STOP', + dynamic: false, + mustStopFlag: false, + blockReason: 'manual_stop', + blockedUntilStamp: now.stamp, + }); +} + +else if (parsed.evcsModeIsAuto) { + setOff({ + stateName: 'mode_auto_conflict', + decisionName: 'stop_auto_mode_conflict', + reasonText: 'Borne en mode Auto interne', + dynamic: false, + mustStopFlag: true, + blockReason: 'mode_auto_conflict', + blockedUntilStamp: now.stamp, + }); +} + +else if (!parsed.evcsDeviceConnected || !parsed.evcsVehicleConnected) { + setOff({ + stateName: 'vehicle_not_connected', + decisionName: 'stop_not_connected', + reasonText: 'Véhicule ou borne non connecté', + dynamic: false, + mustStopFlag: false, + blockReason: 'vehicle_not_connected', + blockedUntilStamp: now.stamp, + }); +} + +else if (safeguards.mustStopHard) { + setOff({ + stateName: + derived.anyVebusLowBattery ? 'vebus_low_battery_hard_stop' : + derived.vebusDcVoltageHardLow ? 'vebus_dc_voltage_hard_stop' : + derived.vebusAcOutHardReached ? 'vebus_ac_hard_stop' : + safeguards.batteryCurrentHardExceeded ? 'battery_current_hard_stop' : + 'hard_stop', + decisionName: 'stop_hard', + reasonText: 'Hard stop de protection', + dynamic: false, + mustStopFlag: true, + blockReason: + safeguards.batteryCurrentHardExceeded ? 'battery_current_hard_stop' : + derived.vebusDcVoltageHardLow ? 'vebus_dc_voltage_stop' : + derived.anyVebusLowBattery ? 'vebus_low_battery_stop' : + derived.vebusAcOutHardReached ? 'vebus_ac_hard_stop' : + 'battery_protection_stop', + blockedUntilStamp: formatParisStampFromMs(nowMs + 15 * 60000), + }); +} + +// ===================================================== +// ACTIVE SESSION BRANCH +// ===================================================== +else if (cloud.sessionObservedActive) { + if (cloud.enteringCloudDebt) { + setCloudDebtStartedAt = true; + } + + if (conditions.shouldStopOverloadAtMin) { + setOff({ + stateName: 'vebus_overload_stop_min_current', + decisionName: 'stop', + reasonText: 'Arrêt: surcharge convertisseur persistante au courant mini', + dynamic: false, + mustStopFlag: true, + blockReason: 'vebus_overload_persistent', + blockedUntilStamp: formatParisStampFromMs( + nowMs + batteryTune.global.overloadStopAfterSeconds * 1000 + ), + }); + } + + else if (safeguards.gridBatteryProtectionStop) { + setOff({ + stateName: 'stop_grid_battery_guard', + decisionName: 'stop', + reasonText: `Arrêt charge réseau: batterie trop sollicitée (${round1(parsed.batteryCurrentA)}A)`, + dynamic: false, + mustStopFlag: true, + blockReason: 'grid_battery_guard', + blockedUntilStamp: formatParisStampFromMs(nowMs + 20 * 60000), + }); + } + + else if (conditions.probeFailedNow) { + setOff({ + stateName: 'probe_failed_lock', + decisionName: 'stop', + reasonText: 'Mode sonde échoué', + dynamic: false, + mustStopFlag: true, + blockReason: 'probe_failed', + blockedUntilStamp: formatParisStampFromMs(nowMs + params.probeRetryMinutes * 60000), + }); + } + + else if (derived.physicalCapA < params.currentMinA) { + if (lifecycle.minRunElapsed) { + setOff({ + stateName: 'stop_power_cap', + decisionName: 'stop_power_cap', + reasonText: 'Capacité système insuffisante', + dynamic: false, + mustStopFlag: true, + blockReason: 'system_capacity_stop', + blockedUntilStamp: formatParisStampFromMs(nowMs + 10 * 60000), + }); + } else { + setOn({ + stateName: 'hold_min_run_power_cap', + decisionName: 'hold', + currentA: params.currentMinA, + reasonText: "Maintien jusqu'au temps mini ON", + dynamic: false, + mustStopFlag: false, + }); + } + } + + else if (conditions.probeWindowActive && !conditions.probeSuccessNow) { + if (!conditions.probeHoldGuardOk) { + setOff({ + stateName: 'probe_stop_guard', + decisionName: 'stop', + reasonText: 'Sonde interrompue: garde-fous non respectés', + dynamic: false, + mustStopFlag: true, + blockReason: 'probe_failed', + blockedUntilStamp: formatParisStampFromMs(nowMs + params.probeRetryMinutes * 60000), + }); + } else { + setOn({ + stateName: 'probe_hold', + decisionName: 'hold', + currentA: Math.min(params.probeCurrentA, derived.physicalCapA), + reasonText: `Mode sonde actif à ${round1(Math.min(params.probeCurrentA, derived.physicalCapA))}A`, + dynamic: true, + mustStopFlag: false, + }); + } + } + + else { + const activeSourceMode = + conditions.probeSuccessNow + ? 'solar_only' + : context.sourceMode; + + const holdConditionsOk = + (activeSourceMode === 'grid_hc_allowed' || activeSourceMode === 'grid_forced') + ? conditions.gridHoldOk + : (activeSourceMode === 'solar_only' || activeSourceMode === 'solar_potential') + ? conditions.solarHoldOk + : activeSourceMode === 'hybrid_probe' + ? conditions.probeHoldGuardOk + : activeSourceMode === 'battery_assist_night' + ? conditions.batteryAssistNightHoldOk + : false; + + const manualLocalHoldOverride = + manualLocalChargeAllowed && + ( + activeSourceMode === 'solar_only' || + activeSourceMode === 'solar_potential' || + activeSourceMode === 'blocked' + ); + + const effectiveHoldConditionsOk = + holdConditionsOk || manualLocalHoldOverride; + + // ================================================= + // VEHICLE FULL / PAUSED - NUMERIC FILTER DECISION + // IMPORTANT: + // - uniquement en session active + // - uniquement si la source est réellement livrable + // - sans utiliser le PV comme critère + // ================================================= + if ( + vehicleDemandFilter.evcsVehicleNoDemandSemantic && + vehicleDemandFilter.sourceDeliverableForNoDemand + ) { + setOff({ + stateName: 'vehicle_full_or_paused', + decisionName: 'stop_vehicle_full_or_paused', + reasonText: 'Véhicule branché mais ne demande plus réellement de puissance', + dynamic: false, + mustStopFlag: false, + blockReason: 'none', + blockedUntilStamp: now.stamp, + }); + + reason.push(`DeliveryRatioEma=${vehicleDemandFilter.deliveryRatioEma}`); + reason.push(`NoiseEma=${vehicleDemandFilter.deliveryNoiseEma}`); + reason.push(`NoDemandScore=${vehicleDemandFilter.noDemandScoreEma}`); + reason.push(`ScoreOn=${vehicleDemandFilter.noDemandScoreOn}`); + reason.push(`ScoreOff=${vehicleDemandFilter.noDemandScoreOff}`); + + if (vehicleDemandFilter.evcsNoDemandSinceMinutes !== null) { + reason.push(`NoDemand depuis ${round1(vehicleDemandFilter.evcsNoDemandSinceMinutes)} min`); + } + } + + else if (manualLocalHoldOverride && activeSourceMode === 'blocked') { + setOn({ + stateName: 'charging_hold_manual_local', + decisionName: 'hold', + currentA: Math.min(derived.currentReferenceA, derived.physicalCapA), + reasonText: 'Maintien manuel local hors fenêtre sans réseau', + dynamic: true, + mustStopFlag: false, + }); + } + + else if (activeSourceMode === 'blocked') { + if (lifecycle.minRunElapsed) { + setOff({ + stateName: 'stop_source_lost', + decisionName: 'stop', + reasonText: 'Source autorisée perdue', + dynamic: false, + mustStopFlag: true, + blockReason: context.profile === 'outside_window' ? 'outside_window' : 'source_unavailable', + blockedUntilStamp: + context.profile === 'outside_window' && Number.isFinite(locks.nextAllowedWindowStartMs) + ? formatParisStampFromMs(locks.nextAllowedWindowStartMs) + : formatParisStampFromMs(nowMs + params.minStopMinutes * 60000), + }); + } else { + setOn({ + stateName: 'hold_min_run_source_lost', + decisionName: 'hold', + currentA: Math.min(derived.currentReferenceA, derived.physicalCapA), + reasonText: "Source perdue mais maintien jusqu'au mini ON", + dynamic: false, + mustStopFlag: false, + }); + } + } + + else if ( + (activeSourceMode === 'grid_hc_allowed' || activeSourceMode === 'grid_forced') && + !parsed.gridAvailable + ) { + if (lifecycle.minRunElapsed) { + setOff({ + stateName: + context.profile === 'night_hc' + ? 'night_wait_grid_takeover_runtime' + : 'wait_grid_source_runtime', + decisionName: 'hold', + reasonText: + context.profile === 'night_hc' + ? 'Relais réseau demandé pendant la session nuit EVCS' + : 'Source réseau demandée mais pas encore disponible', + dynamic: false, + mustStopFlag: false, + blockReason: + context.profile === 'night_hc' ? 'grid_hc_unavailable' : 'grid_unavailable', + blockedUntilStamp: formatParisStampFromMs(nowMs + 10 * 60000), + }); + } else { + setOn({ + stateName: 'hold_min_run_wait_grid_takeover', + decisionName: 'hold', + currentA: Math.min(derived.currentReferenceA, derived.physicalCapA), + reasonText: "Maintien jusqu'au mini ON en attente du réseau", + dynamic: false, + mustStopFlag: false, + }); + } + } + + else if (!effectiveHoldConditionsOk) { + if (lifecycle.minRunElapsed) { + setOff({ + stateName: 'stop_conditions_lost', + decisionName: 'stop', + reasonText: 'Conditions de maintien perdues', + dynamic: false, + mustStopFlag: true, + blockReason: 'hold_conditions_lost', + blockedUntilStamp: formatParisStampFromMs(nowMs + 10 * 60000), + }); + } else { + setOn({ + stateName: 'hold_min_run', + decisionName: 'hold', + currentA: Math.min(derived.currentReferenceA, derived.physicalCapA), + reasonText: "Maintien jusqu'au temps mini ON", + dynamic: true, + mustStopFlag: false, + }); + } + } + + else if ((derived.currentReferenceA > derived.physicalCapA) && lifecycle.currentCooldownElapsed) { + setOn({ + stateName: 'charging_step_down_system_cap', + decisionName: 'step_down', + currentA: Math.max( + params.currentMinA, + Math.min( + derived.currentReferenceA - Math.max(params.currentStepDownA, 2), + derived.physicalCapA + ) + ), + reasonText: 'Réduction pour respecter la capacité système', + dynamic: true, + mustStopFlag: false, + }); + } + + else if (conditions.shouldStepDownOverload) { + setOn({ + stateName: 'charging_step_down_overload', + decisionName: 'step_down_fast', + currentA: Math.max( + params.currentMinA, + Math.min( + derived.currentReferenceA - Math.max(batteryTune.global.overloadStepDownA, params.currentStepDownA), + derived.physicalCapA + ) + ), + reasonText: 'Baisse rapide: surcharge convertisseur', + dynamic: true, + mustStopFlag: false, + }); + } + + else if (conditions.shouldStepDownVebusPower) { + setOn({ + stateName: 'charging_step_down_vebus_power', + decisionName: 'step_down_fast', + currentA: Math.max( + params.currentMinA, + Math.min( + derived.currentReferenceA - Math.max(params.currentStepDownA + 1, 3), + derived.physicalCapA + ) + ), + reasonText: 'Baisse rapide: puissance VE.Bus élevée', + dynamic: true, + mustStopFlag: false, + }); + } + + else if (conditions.shouldStepDownVebusVoltage) { + setOn({ + stateName: 'charging_step_down_dc_voltage', + decisionName: 'step_down_fast', + currentA: Math.max( + params.currentMinA, + Math.min( + derived.currentReferenceA - Math.max(params.currentStepDownA + 1, 3), + derived.physicalCapA + ) + ), + reasonText: 'Baisse rapide: tension DC basse', + dynamic: true, + mustStopFlag: false, + }); + } + + else if ( + activeSourceMode === 'battery_assist_night' && + conditions.batteryAssistNightShouldStepDown + ) { + setOn({ + stateName: 'charging_step_down_battery_assist_night', + decisionName: 'step_down', + currentA: Math.max( + params.currentMinA, + Math.min( + derived.currentReferenceA - params.currentStepDownA, + derived.physicalCapA + ) + ), + reasonText: + nightEvcs?.batteryRecoveryUrgent + ? 'Réduction: approche départ, préparation takeover réseau' + : 'Réduction: assistance batterie nuit devient trop coûteuse', + dynamic: true, + mustStopFlag: false, + }); + } + + else if ( + (activeSourceMode === 'grid_hc_allowed' || activeSourceMode === 'grid_forced') && + conditions.gridShouldStepDownBattery + ) { + setOn({ + stateName: + activeSourceMode === 'grid_forced' + ? 'charging_step_down_grid_forced_guard' + : 'charging_step_down_grid_guard', + decisionName: 'step_down', + currentA: Math.max( + params.currentMinA, + Math.min( + derived.currentReferenceA - params.currentStepDownA, + derived.physicalCapA + ) + ), + reasonText: + activeSourceMode === 'grid_forced' + ? 'Réduction réseau forcé: la batterie commence à participer' + : 'Réduction réseau: la batterie commence à participer', + dynamic: true, + mustStopFlag: false, + }); + } + + else if ( + (activeSourceMode === 'solar_only' || activeSourceMode === 'solar_potential') && + conditions.solarShouldStepDownCloudDebt + ) { + setOn({ + stateName: 'charging_step_down_cloud_debt', + decisionName: 'step_down', + currentA: Math.max( + params.currentMinA, + Math.min( + derived.currentReferenceA - Math.max(params.currentStepDownA, 2), + derived.physicalCapA + ) + ), + reasonText: 'Réduction: dette nuage trop élevée', + dynamic: true, + mustStopFlag: false, + }); + } + + else if ( + (activeSourceMode === 'solar_only' || activeSourceMode === 'solar_potential') && + conditions.solarShouldStepDownBattery + ) { + setOn({ + stateName: 'charging_step_down_battery', + decisionName: 'step_down', + currentA: Math.max( + params.currentMinA, + Math.min( + derived.currentReferenceA - params.currentStepDownA, + derived.physicalCapA + ) + ), + reasonText: 'Réduction: batterie trop sollicitée', + dynamic: true, + mustStopFlag: false, + }); + } + + else if ( + (activeSourceMode === 'grid_hc_allowed' || activeSourceMode === 'grid_forced') && + conditions.gridShouldStepUp + ) { + setOn({ + stateName: + activeSourceMode === 'grid_forced' + ? ( + nightEvcs?.batteryRecoveryUrgent + ? 'night_departure_deadline_grid_forced' + : 'charging_step_up_grid_forced' + ) + : 'charging_step_up_grid_hc', + decisionName: 'step_up', + currentA: clamp( + derived.currentReferenceA + params.currentStepUpA, + params.currentMinA, + derived.physicalCapA + ), + reasonText: + activeSourceMode === 'grid_forced' + ? ( + nightEvcs?.batteryRecoveryUrgent + ? 'Hausse prudente: priorité départ, réseau forcé' + : 'Hausse prudente du courant en charge réseau forcée' + ) + : 'Hausse prudente du courant en HC réseau', + dynamic: true, + mustStopFlag: false, + }); + } + + else if ( + activeSourceMode === 'battery_assist_night' && + conditions.batteryAssistNightShouldStepUp + ) { + setOn({ + stateName: 'charging_step_up_battery_assist_night', + decisionName: 'step_up', + currentA: clamp( + derived.currentReferenceA + params.currentStepUpA, + params.currentMinA, + derived.physicalCapA + ), + reasonText: 'Hausse prudente du courant en assistance batterie nuit', + dynamic: true, + mustStopFlag: false, + }); + } + + else if ( + (activeSourceMode === 'solar_only' || activeSourceMode === 'solar_potential') && + conditions.solarShouldStepUp + ) { + setOn({ + stateName: + conditions.probeSuccessNow + ? 'probe_success_step_up' + : (modes.batteryPriorityMode + ? 'charging_step_up_battery' + : 'charging_step_up_forecast'), + decisionName: 'step_up', + currentA: clamp( + derived.currentReferenceA + params.currentStepUpA, + params.currentMinA, + derived.physicalCapA + ), + reasonText: 'Hausse prudente du courant', + dynamic: true, + mustStopFlag: false, + }); + } + + else { + setOn({ + stateName: + manualLocalHoldOverride ? 'charging_hold_manual_local' : + conditions.probeSuccessNow ? 'probe_success_hold' : + activeSourceMode === 'battery_assist_night' ? 'charging_hold_battery_assist_night' : + (activeSourceMode === 'grid_hc_allowed' || activeSourceMode === 'grid_forced') ? 'charging_hold_grid' : + cloud.cloudDebtActive ? 'charging_hold_cloud_tolerance' : + 'charging_hold', + decisionName: 'hold', + currentA: Math.min(derived.currentReferenceA, derived.physicalCapA), + reasonText: + manualLocalHoldOverride + ? 'Maintien manuel local du courant' + : activeSourceMode === 'battery_assist_night' + ? 'Maintien du courant en assistance batterie nuit' + : 'Maintien du courant', + dynamic: true, + mustStopFlag: false, + }); + } + } +} + +// ===================================================== +// START BRANCH +// ===================================================== +else { + const startConditionsOk = + (context.sourceMode === 'grid_hc_allowed' || context.sourceMode === 'grid_forced') + ? conditions.gridStartOk + : (context.sourceMode === 'solar_only' || context.sourceMode === 'solar_potential') + ? conditions.solarStartOk + : context.sourceMode === 'hybrid_probe' + ? conditions.probeStartOk + : context.sourceMode === 'battery_assist_night' + ? conditions.batteryAssistNightStartOk + : false; + + const manualLocalStartOverride = + manualLocalChargeAllowed && + ( + context.sourceMode === 'solar_only' || + context.sourceMode === 'solar_potential' || + context.sourceMode === 'blocked' + ); + + if (!derived.canRunAtMinCurrent) { + setOff({ + stateName: 'blocked_by_capacity_min_current', + decisionName: 'hold', + reasonText: 'Capacité insuffisante pour courant mini', + dynamic: false, + mustStopFlag: false, + blockReason: 'system_capacity_stop', + blockedUntilStamp: formatParisStampFromMs(nowMs + 10 * 60000), + }); + } + + else if (manualLocalStartOverride) { + setOn({ + stateName: 'start_manual_local_min_current', + decisionName: 'start', + currentA: params.currentMinA, + reasonText: + context.profile === 'outside_window' + ? 'Démarrage manuel local hors fenêtre sans réseau' + : 'Démarrage manuel local sans réseau', + dynamic: true, + mustStopFlag: false, + }); + + nextBlockReason = 'none'; + nextBlockedUntilStamp = now.stamp; + setProbeStartedAt = false; + } + + else if (context.sourceMode === 'blocked') { + setOff({ + stateName: + context.profile === 'outside_window' + ? 'blocked_outside_window' + : (context.profile === 'night_hc' + ? 'night_no_grid_hc_lock' + : 'idle_wait_source'), + decisionName: 'hold', + reasonText: + context.profile === 'outside_window' + ? 'Hors fenêtre préférée sans source suffisante' + : context.profile === 'night_hc' + ? 'Nuit sans autorisation HC réseau' + : 'Source non autorisée ou insuffisante', + dynamic: false, + mustStopFlag: false, + blockReason: + context.profile === 'outside_window' + ? 'outside_window' + : context.profile === 'night_hc' + ? 'grid_hc_unavailable' + : 'source_unavailable', + blockedUntilStamp: + Number.isFinite(locks.nextAllowedWindowStartMs) + ? formatParisStampFromMs(locks.nextAllowedWindowStartMs) + : formatParisStampFromMs(nowMs + 15 * 60000), + }); + } + + else if ( + (context.sourceMode === 'grid_hc_allowed' || context.sourceMode === 'grid_forced') && + !parsed.gridAvailable + ) { + setOff({ + stateName: + context.profile === 'night_hc' + ? 'night_wait_grid_takeover' + : 'wait_grid_source', + decisionName: 'hold', + reasonText: + context.profile === 'night_hc' + ? ( + nightEvcs?.batteryRecoveryUrgent + ? 'Réseau demandé: urgence recharge batterie avant départ' + : 'Prise de relais réseau demandée pour session nuit EVCS' + ) + : 'Source réseau demandée mais pas encore disponible', + dynamic: false, + mustStopFlag: false, + blockReason: + context.profile === 'night_hc' + ? 'grid_hc_unavailable' + : 'grid_unavailable', + blockedUntilStamp: formatParisStampFromMs(nowMs + 10 * 60000), + }); + } + + else if (locks.startBlockedByReason) { + setOff({ + stateName: 'wait_reason_lock', + decisionName: 'hold', + reasonText: `Blocage anti-cliquetis: ${history.lastBlockReason}`, + dynamic: false, + mustStopFlag: false, + blockReason: history.lastBlockReason, + blockedUntilStamp: history.blockedUntilAt || now.stamp, + }); + } + + else if (!startConditionsOk) { + setOff({ + stateName: 'idle_wait_conditions', + decisionName: 'hold', + reasonText: + context.sourceMode === 'battery_assist_night' + ? 'Conditions de démarrage nuit batterie non réunies' + : 'Conditions de démarrage non réunies', + dynamic: false, + mustStopFlag: false, + blockReason: 'none', + blockedUntilStamp: now.stamp, + }); + } + + else if (!lifecycle.minStopElapsed) { + setOff({ + stateName: 'wait_min_stop', + decisionName: 'hold', + reasonText: 'Temps mini OFF non écoulé', + dynamic: false, + mustStopFlag: false, + blockReason: 'none', + blockedUntilStamp: now.stamp, + }); + } + + else { + const startCurrentA = + context.sourceMode === 'hybrid_probe' + ? clamp(params.probeCurrentA, params.currentMinA, derived.physicalCapA) + : params.currentMinA; + + setOn({ + stateName: + context.sourceMode === 'grid_forced' ? ( + nightEvcs?.batteryRecoveryUrgent + ? 'night_departure_deadline_grid_forced' + : 'start_grid_forced_min_current' + ) : + context.sourceMode === 'grid_hc_allowed' ? 'start_grid_hc_min_current' : + context.sourceMode === 'battery_assist_night' ? 'start_battery_assist_night_min_current' : + context.sourceMode === 'hybrid_probe' ? 'probe_start_6a' : + context.sourceMode === 'solar_potential' ? 'start_forecast_solar_potential' : + modes.batteryFullHarvestMode ? 'start_min_current_battery_full_harvest' : + modes.forecastCommitMode ? 'start_min_current_forecast_commit' : + 'start_min_current', + decisionName: 'start', + currentA: startCurrentA, + reasonText: + context.sourceMode === 'grid_forced' + ? ( + nightEvcs?.batteryRecoveryUrgent + ? 'Démarrage réseau forcé: priorité départ / recharge batterie' + : 'Démarrage en charge réseau forcée' + ) + : context.sourceMode === 'grid_hc_allowed' + ? 'Démarrage en fenêtre HC réseau' + : context.sourceMode === 'battery_assist_night' + ? 'Démarrage nuit en assistance batterie contrôlée' + : context.sourceMode === 'hybrid_probe' + ? 'Démarrage en mode sonde' + : context.sourceMode === 'solar_potential' + ? 'Démarrage par potentiel solaire confirmé' + : modes.batteryFullHarvestMode + ? 'Démarrage harvest solaire' + : modes.forecastCommitMode + ? 'Démarrage forecast commit' + : 'Démarrage prudent à courant mini', + dynamic: true, + mustStopFlag: false, + }); + + nextBlockReason = 'none'; + nextBlockedUntilStamp = now.stamp; + setProbeStartedAt = context.sourceMode === 'hybrid_probe'; + } +} + +// ===================================================== +// CONTEXT REASONS +// ===================================================== +if (nightEvcs?.nightSessionActive) { + reason.push( + `Nuit EVCS: target=${round1(nightEvcs.batteryMorningTargetPct)}% | trigger réseau=${round1(nightEvcs.gridTakeoverTriggerPct)}%` + ); + + if (Number.isFinite(nightEvcs.nightSessionStartSocPct)) { + reason.push( + `SOC départ session nuit=${round1(nightEvcs.nightSessionStartSocPct)}% | décharge autorisée=${round1(nightEvcs.maxBatteryDischargePoints)} pts` + ); + } + + if (nightEvcs.batteryRecoveryUrgent) { + reason.push("Urgence départ: il faut anticiper la reprise réseau"); + } +} + +if (manualLocalChargeAllowed) { + reason.push(`ManualLocalCharge=on`); + reason.push(`ManualLocalProfile=${context.profile}`); + reason.push(`ManualLocalSource=${context.sourceMode}`); +} + +reason.push(`VehicleDemandFilter delivery=${vehicleDemandFilter.deliveryRatioEma}`); +reason.push(`VehicleDemandFilter noise=${vehicleDemandFilter.deliveryNoiseEma}`); +reason.push(`VehicleDemandFilter score=${vehicleDemandFilter.noDemandScoreEma}`); +reason.push(`VehicleDemandFilter noDemand=${vehicleDemandFilter.evcsVehicleNoDemandSemantic ? 'on' : 'off'}`); + +// ===================================================== +// OUTPUT +// ===================================================== +return [{ + json: { + ...prev, + vehicleDemandFilter, + decision: { + desiredCharging, + desiredPermission, + desiredLatch, + desiredCurrentA, + + state, + currentDecision, + dynamicOk, + mustStop, + reason, + + nextBlockReason, + nextBlockedUntilStamp, + + setCloudDebtStartedAt, + setProbeStartedAt, + + setNightSessionStart, + clearNightSessionStart, + + notifyWanted, + notifyTitle, + notifyMessage, + } + } +}]; + diff --git a/DECISION/decision_engine.js b/DECISION/decision_engine.js new file mode 100644 index 0000000..9b6eff1 --- /dev/null +++ b/DECISION/decision_engine.js @@ -0,0 +1,1194 @@ +function clamp(v, min, max) { + return Math.min(Math.max(v, min), max); +} + +function round1(v) { + return Math.round(v * 10) / 10; +} + +function round3(v) { + return Math.round(v * 1000) / 1000; +} + +function to2(n) { + return String(n).padStart(2, '0'); +} + +function finiteOr(v, fallback) { + return Number.isFinite(v) ? v : fallback; +} + +function sigmoid(x) { + return 1 / (1 + Math.exp(-x)); +} + +function alphaFromTau(dtSec, tauSec) { + const dt = Math.max(1, finiteOr(dtSec, 60)); + const tau = Math.max(1, finiteOr(tauSec, 60)); + return Math.min(1, Math.max(0.001, 1 - Math.exp(-dt / tau))); +} + +function ema(prev, sample, alpha) { + if (!Number.isFinite(sample)) return Number.isFinite(prev) ? prev : 0; + if (!Number.isFinite(prev)) return sample; + return prev + alpha * (sample - prev); +} + +function formatParisStampFromMs(ms) { + 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); + return `${parts.year}-${parts.month}-${parts.day} ${to2(hour)}:${parts.minute}:${parts.second}`; +} + +const prev = $input.first().json; + +const { + now, + nowMs, + parsed, + params, + history, + strategy, + lifecycle, + context, + derived, + locks, + cloud, + modes, + safeguards, + conditions, + batteryTune, + nightEvcs, +} = prev; + +// ===================================================== +// DECISION INIT +// ===================================================== +let desiredCharging = false; +let desiredPermission = false; +let desiredLatch = false; +let desiredCurrentA = params.currentMinA; + +let state = 'idle'; +let currentDecision = 'hold'; +let dynamicOk = false; +let mustStop = false; + +let reason = []; + +let setCloudDebtStartedAt = false; +let setProbeStartedAt = false; + +// EVCS night session memory +let setNightSessionStart = false; +let clearNightSessionStart = false; + +let notifyWanted = false; +let notifyTitle = 'Victron EVCS Surplus'; +let notifyMessage = ''; + +let nextBlockReason = 'none'; +let nextBlockedUntilStamp = now.stamp; + +// ===================================================== +// HELPERS +// ===================================================== +function setOff({ + stateName, + decisionName = 'stop', + reasonText, + dynamic = false, + mustStopFlag = false, + blockReason = 'none', + blockedUntilStamp = now.stamp, +}) { + desiredCharging = false; + desiredPermission = false; + desiredLatch = false; + desiredCurrentA = params.currentMinA; + + state = stateName; + currentDecision = decisionName; + dynamicOk = dynamic; + mustStop = mustStopFlag; + nextBlockReason = blockReason; + nextBlockedUntilStamp = blockedUntilStamp; + + if (reasonText) reason.push(reasonText); +} + +function setOn({ + stateName, + decisionName = 'hold', + currentA = params.currentMinA, + reasonText, + dynamic = true, + mustStopFlag = false, +}) { + desiredCharging = true; + desiredPermission = true; + desiredLatch = true; + desiredCurrentA = clamp( + currentA, + params.currentMinA, + Math.max(params.currentMinA, derived.physicalCapA || params.currentMinA) + ); + + state = stateName; + currentDecision = decisionName; + dynamicOk = dynamic; + mustStop = mustStopFlag; + + if (reasonText) reason.push(reasonText); +} + +// ===================================================== +// SESSION MEMORY SIDE EFFECTS +// ===================================================== +if (nightEvcs?.nightSessionNeedsCapture) { + setNightSessionStart = true; +} + +if (nightEvcs?.nightSessionNeedsClear) { + clearNightSessionStart = true; +} + +// ===================================================== +// EVCS NUMERIC NO-DEMAND FILTER +// Objectif: +// détecter "voiture branchée + session armée + commande claire +// mais plus de demande réelle" sans seuils fixes bruts. +// IMPORTANT: +// on n'utilise PAS le PV comme critère. +// ===================================================== +const prevDeliveryRatioEma = finiteOr(history.evcsDeliveryRatioEma, NaN); +const prevDeliveryNoiseEma = finiteOr(history.evcsDeliveryNoiseEma, NaN); +const prevNoDemandScoreEma = finiteOr(history.evcsNoDemandScoreEma, NaN); +const prevNoDemandSemanticActive = !!history.evcsNoDemandSemanticActive; +const prevNoDemandSinceMs = finiteOr(history.evcsNoDemandSinceMs, NaN); +const prevNoDemandFilterEvalMs = finiteOr(history.evcsNoDemandFilterEvalMs, NaN); + +const noDemandDtSec = + Number.isFinite(prevNoDemandFilterEvalMs) && Number.isFinite(nowMs) + ? clamp((nowMs - prevNoDemandFilterEvalMs) / 1000, 1, 600) + : 60; + +// Constantes dynamiques du filtre = temps de réponse du filtre, +// pas des seuils métiers bruts. +const deliveryTauSec = Math.max(15, finiteOr(params.evcsDeliveryTauSec, 45)); +const noiseTauSec = Math.max(deliveryTauSec, finiteOr(params.evcsDeliveryNoiseTauSec, 180)); +const scoreTauSec = Math.max(20, finiteOr(params.evcsNoDemandScoreTauSec, 150)); +const armTauSec = Math.max(20, finiteOr(params.evcsNoDemandArmTauSec, 180)); + +const alphaDelivery = alphaFromTau(noDemandDtSec, deliveryTauSec); +const alphaNoise = alphaFromTau(noDemandDtSec, noiseTauSec); +const alphaScore = alphaFromTau(noDemandDtSec, scoreTauSec); + +const acVoltageRef = finiteOr(derived.acVoltageRef, 230); + +const commandCurrentA = clamp( + finiteOr( + derived.currentReferenceA, + finiteOr( + parsed.evcsCommandCurrentA, + finiteOr(parsed.evcsSetCurrentA, finiteOr(params.currentMinA, 6)) + ) + ), + 0, + 32 +); + +const expectedPowerW = Math.max(1, commandCurrentA * acVoltageRef); + +const realCurrentA = Math.abs( + finiteOr( + parsed.evcsCurrentRealA, + acVoltageRef > 180 + ? finiteOr(parsed.evcsAcPowerW, 0) / acVoltageRef + : 0 + ) +); + +const realPowerW = Math.max(0, finiteOr(parsed.evcsAcPowerW, 0)); + +const currentDeliveryRatioRaw = clamp( + realCurrentA / Math.max(commandCurrentA, 0.1), + 0, + 1.25 +); + +const powerDeliveryRatioRaw = clamp( + realPowerW / expectedPowerW, + 0, + 1.25 +); + +const deliveryRatioRaw = clamp( + Math.max(currentDeliveryRatioRaw, powerDeliveryRatioRaw), + 0, + 1 +); + +const deliveryRatioEma = ema(prevDeliveryRatioEma, deliveryRatioRaw, alphaDelivery); + +const deliveryResidualAbs = Math.abs(deliveryRatioRaw - deliveryRatioEma); +const deliveryNoiseEma = ema(prevDeliveryNoiseEma, deliveryResidualAbs, alphaNoise); + +const adaptiveQuietBand = clamp( + 0.02 + 3.0 * deliveryNoiseEma, + 0.02, + 0.35 +); + +const noDemandEvidenceRaw = clamp( + (adaptiveQuietBand - deliveryRatioEma) / adaptiveQuietBand, + 0, + 1 +); + +const sessionElapsedSec = Math.max( + 0, + finiteOr(lifecycle.minutesSinceStart, 0) * 60 +); + +const sessionArmFactor = 1 - Math.exp(-sessionElapsedSec / armTauSec); + +const currentMinRefA = Math.max(1, finiteOr(params.currentMinA, 6)); +const commandStrength = commandCurrentA / currentMinRefA; + +const commandFactor = clamp( + sigmoid((commandStrength - 1.0) * 2.2), + 0, + 1 +); + +// Source effectivement livrable. +// IMPORTANT: +// on évite de conclure "voiture pleine" si en réalité la source n'est +// pas disponible (ex: attente grid takeover). +const sourceDeliverableForNoDemand = + (context.sourceMode === 'grid_hc_allowed' || context.sourceMode === 'grid_forced') + ? !!parsed.gridAvailable + : (context.sourceMode === 'solar_only' || context.sourceMode === 'solar_potential') + ? !!conditions.solarHoldOk || !!conditions.solarStartOk + : context.sourceMode === 'hybrid_probe' + ? !!conditions.probeHoldGuardOk || !!conditions.probeSuccessNow || !!conditions.probeStartOk + : context.sourceMode === 'battery_assist_night' + ? !!conditions.batteryAssistNightHoldOk || !!conditions.batteryAssistNightStartOk + : false; + +const evcsSessionArmedForNoDemand = + !!cloud.sessionObservedActive && + !!parsed.evcsVehicleConnected && + !!parsed.evcsChargingSwitchOn && + !!parsed.chargePermissionNow && + !!parsed.controlLatch && + commandCurrentA > 0 && + sourceDeliverableForNoDemand; + +const noDemandScoreRaw = evcsSessionArmedForNoDemand + ? clamp( + noDemandEvidenceRaw * sessionArmFactor * commandFactor, + 0, + 1 + ) + : 0; + +const noDemandScoreEma = ema(prevNoDemandScoreEma, noDemandScoreRaw, alphaScore); + +const adaptiveDecisionCenter = clamp( + 0.72 - 0.20 * deliveryNoiseEma, + 0.55, + 0.82 +); + +const adaptiveDecisionBand = clamp( + 0.16 + 0.80 * deliveryNoiseEma, + 0.12, + 0.34 +); + +const noDemandScoreOn = clamp( + adaptiveDecisionCenter + adaptiveDecisionBand / 2, + 0.60, + 0.95 +); + +const noDemandScoreOff = clamp( + adaptiveDecisionCenter - adaptiveDecisionBand / 2, + 0.15, + noDemandScoreOn - 0.05 +); + +let evcsVehicleNoDemandSemantic = prevNoDemandSemanticActive; + +if (!evcsSessionArmedForNoDemand) { + evcsVehicleNoDemandSemantic = false; +} else if (prevNoDemandSemanticActive) { + evcsVehicleNoDemandSemantic = noDemandScoreEma >= noDemandScoreOff; +} else { + evcsVehicleNoDemandSemantic = noDemandScoreEma >= noDemandScoreOn; +} + +const evcsNoDemandSinceMs = + evcsVehicleNoDemandSemantic + ? ( + prevNoDemandSemanticActive && Number.isFinite(prevNoDemandSinceMs) + ? prevNoDemandSinceMs + : nowMs + ) + : null; + +const evcsNoDemandSinceMinutes = + Number.isFinite(evcsNoDemandSinceMs) && Number.isFinite(nowMs) + ? Math.max(0, (nowMs - evcsNoDemandSinceMs) / 60000) + : null; + +const vehicleDemandFilter = { + dtSec: round3(noDemandDtSec), + + commandCurrentA: round3(commandCurrentA), + realCurrentA: round3(realCurrentA), + expectedPowerW: round3(expectedPowerW), + realPowerW: round3(realPowerW), + + currentDeliveryRatioRaw: round3(currentDeliveryRatioRaw), + powerDeliveryRatioRaw: round3(powerDeliveryRatioRaw), + deliveryRatioRaw: round3(deliveryRatioRaw), + deliveryRatioEma: round3(deliveryRatioEma), + deliveryNoiseEma: round3(deliveryNoiseEma), + + adaptiveQuietBand: round3(adaptiveQuietBand), + noDemandEvidenceRaw: round3(noDemandEvidenceRaw), + + sessionElapsedSec: round3(sessionElapsedSec), + sessionArmFactor: round3(sessionArmFactor), + commandFactor: round3(commandFactor), + sourceDeliverableForNoDemand, + evcsSessionArmedForNoDemand, + + noDemandScoreRaw: round3(noDemandScoreRaw), + noDemandScoreEma: round3(noDemandScoreEma), + + noDemandScoreOn: round3(noDemandScoreOn), + noDemandScoreOff: round3(noDemandScoreOff), + + evcsVehicleNoDemandSemantic, + evcsNoDemandSinceMs: Number.isFinite(evcsNoDemandSinceMs) ? evcsNoDemandSinceMs : null, + evcsNoDemandSinceMinutes: Number.isFinite(evcsNoDemandSinceMinutes) + ? round3(evcsNoDemandSinceMinutes) + : null, + + nextHistory: { + evcsDeliveryRatioEma: round3(deliveryRatioEma), + evcsDeliveryNoiseEma: round3(deliveryNoiseEma), + evcsNoDemandScoreEma: round3(noDemandScoreEma), + evcsNoDemandSemanticActive: evcsVehicleNoDemandSemantic, + evcsNoDemandSinceMs: Number.isFinite(evcsNoDemandSinceMs) ? evcsNoDemandSinceMs : null, + evcsNoDemandFilterEvalMs: nowMs + } +}; + +// ===================================================== +// DECISION TREE +// ===================================================== +if (!parsed.controllerEnabled) { + desiredCharging = parsed.evcsChargingSwitchOn; + desiredPermission = parsed.chargePermissionNow; + desiredLatch = parsed.controlLatch; + desiredCurrentA = clamp( + derived.currentReferenceA || params.currentMinA, + params.currentMinA, + params.currentMaxAutoA + ); + + state = 'controller_disabled'; + currentDecision = 'hold'; + dynamicOk = false; + mustStop = false; + reason.push('Contrôleur désactivé'); +} + +else if (strategy.isStopMode) { + setOff({ + stateName: 'stopped_user', + decisionName: 'stop_stop_mode', + reasonText: 'Arrêt manuel via Stratégie STOP', + dynamic: false, + mustStopFlag: false, + blockReason: 'manual_stop', + blockedUntilStamp: now.stamp, + }); +} + +else if (parsed.evcsModeIsAuto) { + setOff({ + stateName: 'mode_auto_conflict', + decisionName: 'stop_auto_mode_conflict', + reasonText: 'Borne en mode Auto interne', + dynamic: false, + mustStopFlag: true, + blockReason: 'mode_auto_conflict', + blockedUntilStamp: now.stamp, + }); +} + +else if (!parsed.evcsDeviceConnected || !parsed.evcsVehicleConnected) { + setOff({ + stateName: 'vehicle_not_connected', + decisionName: 'stop_not_connected', + reasonText: 'Véhicule ou borne non connecté', + dynamic: false, + mustStopFlag: false, + blockReason: 'vehicle_not_connected', + blockedUntilStamp: now.stamp, + }); +} + +else if (safeguards.mustStopHard) { + setOff({ + stateName: + derived.anyVebusLowBattery ? 'vebus_low_battery_hard_stop' : + derived.vebusDcVoltageHardLow ? 'vebus_dc_voltage_hard_stop' : + derived.vebusAcOutHardReached ? 'vebus_ac_hard_stop' : + safeguards.batteryCurrentHardExceeded ? 'battery_current_hard_stop' : + 'hard_stop', + decisionName: 'stop_hard', + reasonText: 'Hard stop de protection', + dynamic: false, + mustStopFlag: true, + blockReason: + safeguards.batteryCurrentHardExceeded ? 'battery_current_hard_stop' : + derived.vebusDcVoltageHardLow ? 'vebus_dc_voltage_stop' : + derived.anyVebusLowBattery ? 'vebus_low_battery_stop' : + derived.vebusAcOutHardReached ? 'vebus_ac_hard_stop' : + 'battery_protection_stop', + blockedUntilStamp: formatParisStampFromMs(nowMs + 15 * 60000), + }); +} + +// ===================================================== +// ACTIVE SESSION BRANCH +// ===================================================== +else if (cloud.sessionObservedActive) { + if (cloud.enteringCloudDebt) { + setCloudDebtStartedAt = true; + } + + if (conditions.shouldStopOverloadAtMin) { + setOff({ + stateName: 'vebus_overload_stop_min_current', + decisionName: 'stop', + reasonText: 'Arrêt: surcharge convertisseur persistante au courant mini', + dynamic: false, + mustStopFlag: true, + blockReason: 'vebus_overload_persistent', + blockedUntilStamp: formatParisStampFromMs( + nowMs + batteryTune.global.overloadStopAfterSeconds * 1000 + ), + }); + } + + else if (safeguards.gridBatteryProtectionStop) { + setOff({ + stateName: 'stop_grid_battery_guard', + decisionName: 'stop', + reasonText: `Arrêt charge réseau: batterie trop sollicitée (${round1(parsed.batteryCurrentA)}A)`, + dynamic: false, + mustStopFlag: true, + blockReason: 'grid_battery_guard', + blockedUntilStamp: formatParisStampFromMs(nowMs + 20 * 60000), + }); + } + + else if (conditions.probeFailedNow) { + setOff({ + stateName: 'probe_failed_lock', + decisionName: 'stop', + reasonText: 'Mode sonde échoué', + dynamic: false, + mustStopFlag: true, + blockReason: 'probe_failed', + blockedUntilStamp: formatParisStampFromMs(nowMs + params.probeRetryMinutes * 60000), + }); + } + + else if (derived.physicalCapA < params.currentMinA) { + if (lifecycle.minRunElapsed) { + setOff({ + stateName: 'stop_power_cap', + decisionName: 'stop_power_cap', + reasonText: 'Capacité système insuffisante', + dynamic: false, + mustStopFlag: true, + blockReason: 'system_capacity_stop', + blockedUntilStamp: formatParisStampFromMs(nowMs + 10 * 60000), + }); + } else { + setOn({ + stateName: 'hold_min_run_power_cap', + decisionName: 'hold', + currentA: params.currentMinA, + reasonText: "Maintien jusqu'au temps mini ON", + dynamic: false, + mustStopFlag: false, + }); + } + } + + else if (conditions.probeWindowActive && !conditions.probeSuccessNow) { + if (!conditions.probeHoldGuardOk) { + setOff({ + stateName: 'probe_stop_guard', + decisionName: 'stop', + reasonText: 'Sonde interrompue: garde-fous non respectés', + dynamic: false, + mustStopFlag: true, + blockReason: 'probe_failed', + blockedUntilStamp: formatParisStampFromMs(nowMs + params.probeRetryMinutes * 60000), + }); + } else { + setOn({ + stateName: 'probe_hold', + decisionName: 'hold', + currentA: Math.min(params.probeCurrentA, derived.physicalCapA), + reasonText: `Mode sonde actif à ${round1(Math.min(params.probeCurrentA, derived.physicalCapA))}A`, + dynamic: true, + mustStopFlag: false, + }); + } + } + + else { + const activeSourceMode = + conditions.probeSuccessNow + ? 'solar_only' + : context.sourceMode; + + const holdConditionsOk = + (activeSourceMode === 'grid_hc_allowed' || activeSourceMode === 'grid_forced') + ? conditions.gridHoldOk + : (activeSourceMode === 'solar_only' || activeSourceMode === 'solar_potential') + ? conditions.solarHoldOk + : activeSourceMode === 'hybrid_probe' + ? conditions.probeHoldGuardOk + : activeSourceMode === 'battery_assist_night' + ? conditions.batteryAssistNightHoldOk + : false; + + // ================================================= + // VEHICLE FULL / PAUSED - NUMERIC FILTER DECISION + // IMPORTANT: + // - uniquement en session active + // - uniquement si la source est réellement livrable + // - sans utiliser le PV comme critère + // ================================================= + if ( + vehicleDemandFilter.evcsVehicleNoDemandSemantic && + vehicleDemandFilter.sourceDeliverableForNoDemand + ) { + setOff({ + stateName: 'vehicle_full_or_paused', + decisionName: 'stop_vehicle_full_or_paused', + reasonText: 'Véhicule branché mais ne demande plus réellement de puissance', + dynamic: false, + mustStopFlag: false, + blockReason: 'none', + blockedUntilStamp: now.stamp, + }); + + reason.push(`DeliveryRatioEma=${vehicleDemandFilter.deliveryRatioEma}`); + reason.push(`NoiseEma=${vehicleDemandFilter.deliveryNoiseEma}`); + reason.push(`NoDemandScore=${vehicleDemandFilter.noDemandScoreEma}`); + reason.push(`ScoreOn=${vehicleDemandFilter.noDemandScoreOn}`); + reason.push(`ScoreOff=${vehicleDemandFilter.noDemandScoreOff}`); + + if (vehicleDemandFilter.evcsNoDemandSinceMinutes !== null) { + reason.push(`NoDemand depuis ${round1(vehicleDemandFilter.evcsNoDemandSinceMinutes)} min`); + } + } + + else if (activeSourceMode === 'blocked') { + if (lifecycle.minRunElapsed) { + setOff({ + stateName: 'stop_source_lost', + decisionName: 'stop', + reasonText: 'Source autorisée perdue', + dynamic: false, + mustStopFlag: true, + blockReason: context.profile === 'outside_window' ? 'outside_window' : 'source_unavailable', + blockedUntilStamp: + context.profile === 'outside_window' && Number.isFinite(locks.nextAllowedWindowStartMs) + ? formatParisStampFromMs(locks.nextAllowedWindowStartMs) + : formatParisStampFromMs(nowMs + params.minStopMinutes * 60000), + }); + } else { + setOn({ + stateName: 'hold_min_run_source_lost', + decisionName: 'hold', + currentA: Math.min(derived.currentReferenceA, derived.physicalCapA), + reasonText: "Source perdue mais maintien jusqu'au mini ON", + dynamic: false, + mustStopFlag: false, + }); + } + } + + else if ( + (activeSourceMode === 'grid_hc_allowed' || activeSourceMode === 'grid_forced') && + !parsed.gridAvailable + ) { + if (lifecycle.minRunElapsed) { + setOff({ + stateName: + context.profile === 'night_hc' + ? 'night_wait_grid_takeover_runtime' + : 'wait_grid_source_runtime', + decisionName: 'hold', + reasonText: + context.profile === 'night_hc' + ? 'Relais réseau demandé pendant la session nuit EVCS' + : 'Source réseau demandée mais pas encore disponible', + dynamic: false, + mustStopFlag: false, + blockReason: + context.profile === 'night_hc' ? 'grid_hc_unavailable' : 'grid_unavailable', + blockedUntilStamp: formatParisStampFromMs(nowMs + 10 * 60000), + }); + } else { + setOn({ + stateName: 'hold_min_run_wait_grid_takeover', + decisionName: 'hold', + currentA: Math.min(derived.currentReferenceA, derived.physicalCapA), + reasonText: "Maintien jusqu'au mini ON en attente du réseau", + dynamic: false, + mustStopFlag: false, + }); + } + } + + else if (!holdConditionsOk) { + if (lifecycle.minRunElapsed) { + setOff({ + stateName: 'stop_conditions_lost', + decisionName: 'stop', + reasonText: 'Conditions de maintien perdues', + dynamic: false, + mustStopFlag: true, + blockReason: 'hold_conditions_lost', + blockedUntilStamp: formatParisStampFromMs(nowMs + 10 * 60000), + }); + } else { + setOn({ + stateName: 'hold_min_run', + decisionName: 'hold', + currentA: Math.min(derived.currentReferenceA, derived.physicalCapA), + reasonText: "Maintien jusqu'au temps mini ON", + dynamic: true, + mustStopFlag: false, + }); + } + } + + else if ((derived.currentReferenceA > derived.physicalCapA) && lifecycle.currentCooldownElapsed) { + setOn({ + stateName: 'charging_step_down_system_cap', + decisionName: 'step_down', + currentA: Math.max( + params.currentMinA, + Math.min( + derived.currentReferenceA - Math.max(params.currentStepDownA, 2), + derived.physicalCapA + ) + ), + reasonText: 'Réduction pour respecter la capacité système', + dynamic: true, + mustStopFlag: false, + }); + } + + else if (conditions.shouldStepDownOverload) { + setOn({ + stateName: 'charging_step_down_overload', + decisionName: 'step_down_fast', + currentA: Math.max( + params.currentMinA, + Math.min( + derived.currentReferenceA - Math.max(batteryTune.global.overloadStepDownA, params.currentStepDownA), + derived.physicalCapA + ) + ), + reasonText: 'Baisse rapide: surcharge convertisseur', + dynamic: true, + mustStopFlag: false, + }); + } + + else if (conditions.shouldStepDownVebusPower) { + setOn({ + stateName: 'charging_step_down_vebus_power', + decisionName: 'step_down_fast', + currentA: Math.max( + params.currentMinA, + Math.min( + derived.currentReferenceA - Math.max(params.currentStepDownA + 1, 3), + derived.physicalCapA + ) + ), + reasonText: 'Baisse rapide: puissance VE.Bus élevée', + dynamic: true, + mustStopFlag: false, + }); + } + + else if (conditions.shouldStepDownVebusVoltage) { + setOn({ + stateName: 'charging_step_down_dc_voltage', + decisionName: 'step_down_fast', + currentA: Math.max( + params.currentMinA, + Math.min( + derived.currentReferenceA - Math.max(params.currentStepDownA + 1, 3), + derived.physicalCapA + ) + ), + reasonText: 'Baisse rapide: tension DC basse', + dynamic: true, + mustStopFlag: false, + }); + } + + else if ( + activeSourceMode === 'battery_assist_night' && + conditions.batteryAssistNightShouldStepDown + ) { + setOn({ + stateName: 'charging_step_down_battery_assist_night', + decisionName: 'step_down', + currentA: Math.max( + params.currentMinA, + Math.min( + derived.currentReferenceA - params.currentStepDownA, + derived.physicalCapA + ) + ), + reasonText: + nightEvcs?.batteryRecoveryUrgent + ? 'Réduction: approche départ, préparation takeover réseau' + : 'Réduction: assistance batterie nuit devient trop coûteuse', + dynamic: true, + mustStopFlag: false, + }); + } + + else if ( + (activeSourceMode === 'grid_hc_allowed' || activeSourceMode === 'grid_forced') && + conditions.gridShouldStepDownBattery + ) { + setOn({ + stateName: + activeSourceMode === 'grid_forced' + ? 'charging_step_down_grid_forced_guard' + : 'charging_step_down_grid_guard', + decisionName: 'step_down', + currentA: Math.max( + params.currentMinA, + Math.min( + derived.currentReferenceA - params.currentStepDownA, + derived.physicalCapA + ) + ), + reasonText: + activeSourceMode === 'grid_forced' + ? 'Réduction réseau forcé: la batterie commence à participer' + : 'Réduction réseau: la batterie commence à participer', + dynamic: true, + mustStopFlag: false, + }); + } + + else if ( + (activeSourceMode === 'solar_only' || activeSourceMode === 'solar_potential') && + conditions.solarShouldStepDownCloudDebt + ) { + setOn({ + stateName: 'charging_step_down_cloud_debt', + decisionName: 'step_down', + currentA: Math.max( + params.currentMinA, + Math.min( + derived.currentReferenceA - Math.max(params.currentStepDownA, 2), + derived.physicalCapA + ) + ), + reasonText: 'Réduction: dette nuage trop élevée', + dynamic: true, + mustStopFlag: false, + }); + } + + else if ( + (activeSourceMode === 'solar_only' || activeSourceMode === 'solar_potential') && + conditions.solarShouldStepDownBattery + ) { + setOn({ + stateName: 'charging_step_down_battery', + decisionName: 'step_down', + currentA: Math.max( + params.currentMinA, + Math.min( + derived.currentReferenceA - params.currentStepDownA, + derived.physicalCapA + ) + ), + reasonText: 'Réduction: batterie trop sollicitée', + dynamic: true, + mustStopFlag: false, + }); + } + + else if ( + (activeSourceMode === 'grid_hc_allowed' || activeSourceMode === 'grid_forced') && + conditions.gridShouldStepUp + ) { + setOn({ + stateName: + activeSourceMode === 'grid_forced' + ? ( + nightEvcs?.batteryRecoveryUrgent + ? 'night_departure_deadline_grid_forced' + : 'charging_step_up_grid_forced' + ) + : 'charging_step_up_grid_hc', + decisionName: 'step_up', + currentA: clamp( + derived.currentReferenceA + params.currentStepUpA, + params.currentMinA, + derived.physicalCapA + ), + reasonText: + activeSourceMode === 'grid_forced' + ? ( + nightEvcs?.batteryRecoveryUrgent + ? 'Hausse prudente: priorité départ, réseau forcé' + : 'Hausse prudente du courant en charge réseau forcée' + ) + : 'Hausse prudente du courant en HC réseau', + dynamic: true, + mustStopFlag: false, + }); + } + + else if ( + activeSourceMode === 'battery_assist_night' && + conditions.batteryAssistNightShouldStepUp + ) { + setOn({ + stateName: 'charging_step_up_battery_assist_night', + decisionName: 'step_up', + currentA: clamp( + derived.currentReferenceA + params.currentStepUpA, + params.currentMinA, + derived.physicalCapA + ), + reasonText: 'Hausse prudente du courant en assistance batterie nuit', + dynamic: true, + mustStopFlag: false, + }); + } + + else if ( + (activeSourceMode === 'solar_only' || activeSourceMode === 'solar_potential') && + conditions.solarShouldStepUp + ) { + setOn({ + stateName: + conditions.probeSuccessNow + ? 'probe_success_step_up' + : (modes.batteryPriorityMode + ? 'charging_step_up_battery' + : 'charging_step_up_forecast'), + decisionName: 'step_up', + currentA: clamp( + derived.currentReferenceA + params.currentStepUpA, + params.currentMinA, + derived.physicalCapA + ), + reasonText: 'Hausse prudente du courant', + dynamic: true, + mustStopFlag: false, + }); + } + + else { + setOn({ + stateName: + conditions.probeSuccessNow ? 'probe_success_hold' : + activeSourceMode === 'battery_assist_night' ? 'charging_hold_battery_assist_night' : + (activeSourceMode === 'grid_hc_allowed' || activeSourceMode === 'grid_forced') ? 'charging_hold_grid' : + cloud.cloudDebtActive ? 'charging_hold_cloud_tolerance' : + 'charging_hold', + decisionName: 'hold', + currentA: Math.min(derived.currentReferenceA, derived.physicalCapA), + reasonText: + activeSourceMode === 'battery_assist_night' + ? 'Maintien du courant en assistance batterie nuit' + : 'Maintien du courant', + dynamic: true, + mustStopFlag: false, + }); + } + } +} + +// ===================================================== +// START BRANCH +// ===================================================== +else { + const startConditionsOk = + (context.sourceMode === 'grid_hc_allowed' || context.sourceMode === 'grid_forced') + ? conditions.gridStartOk + : (context.sourceMode === 'solar_only' || context.sourceMode === 'solar_potential') + ? conditions.solarStartOk + : context.sourceMode === 'hybrid_probe' + ? conditions.probeStartOk + : context.sourceMode === 'battery_assist_night' + ? conditions.batteryAssistNightStartOk + : false; + + if (!derived.canRunAtMinCurrent) { + setOff({ + stateName: 'blocked_by_capacity_min_current', + decisionName: 'hold', + reasonText: 'Capacité insuffisante pour courant mini', + dynamic: false, + mustStopFlag: false, + blockReason: 'system_capacity_stop', + blockedUntilStamp: formatParisStampFromMs(nowMs + 10 * 60000), + }); + } + + else if (context.sourceMode === 'blocked') { + setOff({ + stateName: + context.profile === 'outside_window' + ? 'blocked_outside_window' + : (context.profile === 'night_hc' + ? 'night_no_grid_hc_lock' + : 'idle_wait_source'), + decisionName: 'hold', + reasonText: + context.profile === 'outside_window' + ? 'Hors fenêtre préférée sans source suffisante' + : context.profile === 'night_hc' + ? 'Nuit sans autorisation HC réseau' + : 'Source non autorisée ou insuffisante', + dynamic: false, + mustStopFlag: false, + blockReason: + context.profile === 'outside_window' + ? 'outside_window' + : context.profile === 'night_hc' + ? 'grid_hc_unavailable' + : 'source_unavailable', + blockedUntilStamp: + Number.isFinite(locks.nextAllowedWindowStartMs) + ? formatParisStampFromMs(locks.nextAllowedWindowStartMs) + : formatParisStampFromMs(nowMs + 15 * 60000), + }); + } + + else if ( + (context.sourceMode === 'grid_hc_allowed' || context.sourceMode === 'grid_forced') && + !parsed.gridAvailable + ) { + setOff({ + stateName: + context.profile === 'night_hc' + ? 'night_wait_grid_takeover' + : 'wait_grid_source', + decisionName: 'hold', + reasonText: + context.profile === 'night_hc' + ? ( + nightEvcs?.batteryRecoveryUrgent + ? 'Réseau demandé: urgence recharge batterie avant départ' + : 'Prise de relais réseau demandée pour session nuit EVCS' + ) + : 'Source réseau demandée mais pas encore disponible', + dynamic: false, + mustStopFlag: false, + blockReason: + context.profile === 'night_hc' + ? 'grid_hc_unavailable' + : 'grid_unavailable', + blockedUntilStamp: formatParisStampFromMs(nowMs + 10 * 60000), + }); + } + + else if (locks.startBlockedByReason) { + setOff({ + stateName: 'wait_reason_lock', + decisionName: 'hold', + reasonText: `Blocage anti-cliquetis: ${history.lastBlockReason}`, + dynamic: false, + mustStopFlag: false, + blockReason: history.lastBlockReason, + blockedUntilStamp: history.blockedUntilAt || now.stamp, + }); + } + + else if (!startConditionsOk) { + setOff({ + stateName: 'idle_wait_conditions', + decisionName: 'hold', + reasonText: + context.sourceMode === 'battery_assist_night' + ? 'Conditions de démarrage nuit batterie non réunies' + : 'Conditions de démarrage non réunies', + dynamic: false, + mustStopFlag: false, + blockReason: 'none', + blockedUntilStamp: now.stamp, + }); + } + + else if (!lifecycle.minStopElapsed) { + setOff({ + stateName: 'wait_min_stop', + decisionName: 'hold', + reasonText: 'Temps mini OFF non écoulé', + dynamic: false, + mustStopFlag: false, + blockReason: 'none', + blockedUntilStamp: now.stamp, + }); + } + + else { + const startCurrentA = + context.sourceMode === 'hybrid_probe' + ? clamp(params.probeCurrentA, params.currentMinA, derived.physicalCapA) + : params.currentMinA; + + setOn({ + stateName: + context.sourceMode === 'grid_forced' ? ( + nightEvcs?.batteryRecoveryUrgent + ? 'night_departure_deadline_grid_forced' + : 'start_grid_forced_min_current' + ) : + context.sourceMode === 'grid_hc_allowed' ? 'start_grid_hc_min_current' : + context.sourceMode === 'battery_assist_night' ? 'start_battery_assist_night_min_current' : + context.sourceMode === 'hybrid_probe' ? 'probe_start_6a' : + context.sourceMode === 'solar_potential' ? 'start_forecast_solar_potential' : + modes.batteryFullHarvestMode ? 'start_min_current_battery_full_harvest' : + modes.forecastCommitMode ? 'start_min_current_forecast_commit' : + 'start_min_current', + decisionName: 'start', + currentA: startCurrentA, + reasonText: + context.sourceMode === 'grid_forced' + ? ( + nightEvcs?.batteryRecoveryUrgent + ? 'Démarrage réseau forcé: priorité départ / recharge batterie' + : 'Démarrage en charge réseau forcée' + ) + : context.sourceMode === 'grid_hc_allowed' + ? 'Démarrage en fenêtre HC réseau' + : context.sourceMode === 'battery_assist_night' + ? 'Démarrage nuit en assistance batterie contrôlée' + : context.sourceMode === 'hybrid_probe' + ? 'Démarrage en mode sonde' + : context.sourceMode === 'solar_potential' + ? 'Démarrage par potentiel solaire confirmé' + : modes.batteryFullHarvestMode + ? 'Démarrage harvest solaire' + : modes.forecastCommitMode + ? 'Démarrage forecast commit' + : 'Démarrage prudent à courant mini', + dynamic: true, + mustStopFlag: false, + }); + + nextBlockReason = 'none'; + nextBlockedUntilStamp = now.stamp; + setProbeStartedAt = context.sourceMode === 'hybrid_probe'; + } +} + +// ===================================================== +// CONTEXT REASONS +// ===================================================== +if (nightEvcs?.nightSessionActive) { + reason.push( + `Nuit EVCS: target=${round1(nightEvcs.batteryMorningTargetPct)}% | trigger réseau=${round1(nightEvcs.gridTakeoverTriggerPct)}%` + ); + + if (Number.isFinite(nightEvcs.nightSessionStartSocPct)) { + reason.push( + `SOC départ session nuit=${round1(nightEvcs.nightSessionStartSocPct)}% | décharge autorisée=${round1(nightEvcs.maxBatteryDischargePoints)} pts` + ); + } + + if (nightEvcs.batteryRecoveryUrgent) { + reason.push("Urgence départ: il faut anticiper la reprise réseau"); + } +} + +reason.push(`VehicleDemandFilter delivery=${vehicleDemandFilter.deliveryRatioEma}`); +reason.push(`VehicleDemandFilter noise=${vehicleDemandFilter.deliveryNoiseEma}`); +reason.push(`VehicleDemandFilter score=${vehicleDemandFilter.noDemandScoreEma}`); +reason.push(`VehicleDemandFilter noDemand=${vehicleDemandFilter.evcsVehicleNoDemandSemantic ? 'on' : 'off'}`); + +// ===================================================== +// OUTPUT +// ===================================================== +return [{ + json: { + ...prev, + vehicleDemandFilter, + decision: { + desiredCharging, + desiredPermission, + desiredLatch, + desiredCurrentA, + + state, + currentDecision, + dynamicOk, + mustStop, + reason, + + nextBlockReason, + nextBlockedUntilStamp, + + setCloudDebtStartedAt, + setProbeStartedAt, + + setNightSessionStart, + clearNightSessionStart, + + notifyWanted, + notifyTitle, + notifyMessage, + } + } +}]; + diff --git a/DECISION/decision_engineP1.js b/DECISION/decision_engineP1.js new file mode 100644 index 0000000..159d703 --- /dev/null +++ b/DECISION/decision_engineP1.js @@ -0,0 +1,279 @@ +function clamp(v, min, max) { + return Math.min(Math.max(v, min), max); +} + +function round1(v) { + return Math.round(v * 10) / 10; +} + +function round3(v) { + return Math.round(v * 1000) / 1000; +} + +function to2(n) { + return String(n).padStart(2, '0'); +} + +function finiteOr(v, fallback) { + return Number.isFinite(v) ? v : fallback; +} + +function sigmoid(x) { + return 1 / (1 + Math.exp(-x)); +} + +function alphaFromTau(dtSec, tauSec) { + const dt = Math.max(1, finiteOr(dtSec, 60)); + const tau = Math.max(1, finiteOr(tauSec, 60)); + return Math.min(1, Math.max(0.001, 1 - Math.exp(-dt / tau))); +} + +function ema(prev, sample, alpha) { + if (!Number.isFinite(sample)) return Number.isFinite(prev) ? prev : 0; + if (!Number.isFinite(prev)) return sample; + return prev + alpha * (sample - prev); +} + +function formatParisStampFromMs(ms) { + 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); + return `${parts.year}-${parts.month}-${parts.day} ${to2(hour)}:${parts.minute}:${parts.second}`; +} + +const prev = $input.first().json; + +const { + now, + nowMs, + parsed, + params, + history, + strategy, + lifecycle, + context, + derived, + locks, + cloud, + modes, + safeguards, + conditions, + batteryTune, + nightEvcs, +} = prev; + +// ===================================================== +// DECISION INIT +// ===================================================== +let desiredCharging = false; +let desiredPermission = false; +let desiredLatch = false; +let desiredCurrentA = params.currentMinA; + +let state = 'idle'; +let currentDecision = 'hold'; +let dynamicOk = false; +let mustStop = false; + +let reason = []; + +let setCloudDebtStartedAt = false; +let setProbeStartedAt = false; + +// EVCS night session memory +let setNightSessionStart = false; +let clearNightSessionStart = false; + +let notifyWanted = false; +let notifyTitle = 'Victron EVCS Surplus'; +let notifyMessage = ''; + +let nextBlockReason = 'none'; +let nextBlockedUntilStamp = now.stamp; + +// ===================================================== +// HELPERS +// ===================================================== +function setOff({ + stateName, + decisionName = 'stop', + reasonText, + dynamic = false, + mustStopFlag = false, + blockReason = 'none', + blockedUntilStamp = now.stamp, +}) { + desiredCharging = false; + desiredPermission = false; + desiredLatch = false; + desiredCurrentA = params.currentMinA; + + state = stateName; + currentDecision = decisionName; + dynamicOk = dynamic; + mustStop = mustStopFlag; + nextBlockReason = blockReason; + nextBlockedUntilStamp = blockedUntilStamp; + + if (reasonText) reason.push(reasonText); +} + +function setOn({ + stateName, + decisionName = 'hold', + currentA = params.currentMinA, + reasonText, + dynamic = true, + mustStopFlag = false, +}) { + desiredCharging = true; + desiredPermission = true; + desiredLatch = true; + desiredCurrentA = clamp( + currentA, + params.currentMinA, + Math.max(params.currentMinA, derived.physicalCapA || params.currentMinA) + ); + + state = stateName; + currentDecision = decisionName; + dynamicOk = dynamic; + mustStop = mustStopFlag; + + if (reasonText) reason.push(reasonText); +} + +// ===================================================== +// SESSION MEMORY SIDE EFFECTS +// ===================================================== +if (nightEvcs?.nightSessionNeedsCapture) { + setNightSessionStart = true; +} + +if (nightEvcs?.nightSessionNeedsClear) { + clearNightSessionStart = true; +} + +// ===================================================== +// EVCS NUMERIC NO-DEMAND FILTER +// Objectif: +// détecter "voiture branchée + session armée + commande claire +// mais plus de demande réelle" sans seuils fixes bruts. +// IMPORTANT: +// on n'utilise PAS le PV comme critère. +// ===================================================== +const prevDeliveryRatioEma = finiteOr(history.evcsDeliveryRatioEma, NaN); +const prevDeliveryNoiseEma = finiteOr(history.evcsDeliveryNoiseEma, NaN); +const prevNoDemandScoreEma = finiteOr(history.evcsNoDemandScoreEma, NaN); +const prevNoDemandSemanticActive = !!history.evcsNoDemandSemanticActive; +const prevNoDemandSinceMs = finiteOr(history.evcsNoDemandSinceMs, NaN); +const prevNoDemandFilterEvalMs = finiteOr(history.evcsNoDemandFilterEvalMs, NaN); + +const noDemandDtSec = + Number.isFinite(prevNoDemandFilterEvalMs) && Number.isFinite(nowMs) + ? clamp((nowMs - prevNoDemandFilterEvalMs) / 1000, 1, 600) + : 60; + +// Constantes dynamiques du filtre = temps de réponse du filtre, +// pas des seuils métiers bruts. +const deliveryTauSec = Math.max(15, finiteOr(params.evcsDeliveryTauSec, 45)); +const noiseTauSec = Math.max(deliveryTauSec, finiteOr(params.evcsDeliveryNoiseTauSec, 180)); +const scoreTauSec = Math.max(20, finiteOr(params.evcsNoDemandScoreTauSec, 150)); +const armTauSec = Math.max(20, finiteOr(params.evcsNoDemandArmTauSec, 180)); + +const alphaDelivery = alphaFromTau(noDemandDtSec, deliveryTauSec); +const alphaNoise = alphaFromTau(noDemandDtSec, noiseTauSec); +const alphaScore = alphaFromTau(noDemandDtSec, scoreTauSec); + +const acVoltageRef = finiteOr(derived.acVoltageRef, 230); + +const commandCurrentA = clamp( + finiteOr( + derived.currentReferenceA, + finiteOr( + parsed.evcsCommandCurrentA, + finiteOr(parsed.evcsSetCurrentA, finiteOr(params.currentMinA, 6)) + ) + ), + 0, + 32 +); + +const expectedPowerW = Math.max(1, commandCurrentA * acVoltageRef); + +const realCurrentA = Math.abs( + finiteOr( + parsed.evcsCurrentRealA, + acVoltageRef > 180 + ? finiteOr(parsed.evcsAcPowerW, 0) / acVoltageRef + : 0 + ) +); + +const realPowerW = Math.max(0, finiteOr(parsed.evcsAcPowerW, 0)); + +const currentDeliveryRatioRaw = clamp( + realCurrentA / Math.max(commandCurrentA, 0.1), + 0, + 1.25 +); + +const powerDeliveryRatioRaw = clamp( + realPowerW / expectedPowerW, + 0, + 1.25 +); + +const deliveryRatioRaw = clamp( + Math.max(currentDeliveryRatioRaw, powerDeliveryRatioRaw), + 0, + 1 +); + +const deliveryRatioEma = ema(prevDeliveryRatioEma, deliveryRatioRaw, alphaDelivery); + +const deliveryResidualAbs = Math.abs(deliveryRatioRaw - deliveryRatioEma); +const deliveryNoiseEma = ema(prevDeliveryNoiseEma, deliveryResidualAbs, alphaNoise); + +const adaptiveQuietBand = clamp( + 0.02 + 3.0 * deliveryNoiseEma, + 0.02, + 0.35 +); + +const noDemandEvidenceRaw = clamp( + (adaptiveQuietBand - deliveryRatioEma) / adaptiveQuietBand, + 0, + 1 +); + +const sessionElapsedSec = Math.max( + 0, + finiteOr(lifecycle.minutesSinceStart, 0) * 60 +); + +const sessionArmFactor = 1 - Math.exp(-sessionElapsedSec / armTauSec); + +const currentMinRefA = Math.max(1, finiteOr(params.currentMinA, 6)); +const commandStrength = commandCurrentA / currentMinRefA; + +const commandFactor = clamp( + sigmoid((commandStrength - 1.0) * 2.2), + 0, + 1 +); diff --git a/DECISION/decision_engineP2.js b/DECISION/decision_engineP2.js new file mode 100644 index 0000000..d799ba4 --- /dev/null +++ b/DECISION/decision_engineP2.js @@ -0,0 +1,313 @@ +// Source effectivement livrable. +// IMPORTANT: +// on évite de conclure "voiture pleine" si en réalité la source n'est +// pas disponible (ex: attente grid takeover). +const sourceDeliverableForNoDemand = + (context.sourceMode === 'grid_hc_allowed' || context.sourceMode === 'grid_forced') + ? !!parsed.gridAvailable + : (context.sourceMode === 'solar_only' || context.sourceMode === 'solar_potential') + ? !!conditions.solarHoldOk || !!conditions.solarStartOk + : context.sourceMode === 'hybrid_probe' + ? !!conditions.probeHoldGuardOk || !!conditions.probeSuccessNow || !!conditions.probeStartOk + : context.sourceMode === 'battery_assist_night' + ? !!conditions.batteryAssistNightHoldOk || !!conditions.batteryAssistNightStartOk + : false; + +const evcsSessionArmedForNoDemand = + !!cloud.sessionObservedActive && + !!parsed.evcsVehicleConnected && + !!parsed.evcsChargingSwitchOn && + !!parsed.chargePermissionNow && + !!parsed.controlLatch && + commandCurrentA > 0 && + sourceDeliverableForNoDemand; + +const noDemandScoreRaw = evcsSessionArmedForNoDemand + ? clamp( + noDemandEvidenceRaw * sessionArmFactor * commandFactor, + 0, + 1 + ) + : 0; + +const noDemandScoreEma = ema(prevNoDemandScoreEma, noDemandScoreRaw, alphaScore); + +const adaptiveDecisionCenter = clamp( + 0.72 - 0.20 * deliveryNoiseEma, + 0.55, + 0.82 +); + +const adaptiveDecisionBand = clamp( + 0.16 + 0.80 * deliveryNoiseEma, + 0.12, + 0.34 +); + +const noDemandScoreOn = clamp( + adaptiveDecisionCenter + adaptiveDecisionBand / 2, + 0.60, + 0.95 +); + +const noDemandScoreOff = clamp( + adaptiveDecisionCenter - adaptiveDecisionBand / 2, + 0.15, + noDemandScoreOn - 0.05 +); + +let evcsVehicleNoDemandSemantic = prevNoDemandSemanticActive; + +if (!evcsSessionArmedForNoDemand) { + evcsVehicleNoDemandSemantic = false; +} else if (prevNoDemandSemanticActive) { + evcsVehicleNoDemandSemantic = noDemandScoreEma >= noDemandScoreOff; +} else { + evcsVehicleNoDemandSemantic = noDemandScoreEma >= noDemandScoreOn; +} + +const evcsNoDemandSinceMs = + evcsVehicleNoDemandSemantic + ? ( + prevNoDemandSemanticActive && Number.isFinite(prevNoDemandSinceMs) + ? prevNoDemandSinceMs + : nowMs + ) + : null; + +const evcsNoDemandSinceMinutes = + Number.isFinite(evcsNoDemandSinceMs) && Number.isFinite(nowMs) + ? Math.max(0, (nowMs - evcsNoDemandSinceMs) / 60000) + : null; + +const vehicleDemandFilter = { + dtSec: round3(noDemandDtSec), + + commandCurrentA: round3(commandCurrentA), + realCurrentA: round3(realCurrentA), + expectedPowerW: round3(expectedPowerW), + realPowerW: round3(realPowerW), + + currentDeliveryRatioRaw: round3(currentDeliveryRatioRaw), + powerDeliveryRatioRaw: round3(powerDeliveryRatioRaw), + deliveryRatioRaw: round3(deliveryRatioRaw), + deliveryRatioEma: round3(deliveryRatioEma), + deliveryNoiseEma: round3(deliveryNoiseEma), + + adaptiveQuietBand: round3(adaptiveQuietBand), + noDemandEvidenceRaw: round3(noDemandEvidenceRaw), + + sessionElapsedSec: round3(sessionElapsedSec), + sessionArmFactor: round3(sessionArmFactor), + commandFactor: round3(commandFactor), + sourceDeliverableForNoDemand, + evcsSessionArmedForNoDemand, + + noDemandScoreRaw: round3(noDemandScoreRaw), + noDemandScoreEma: round3(noDemandScoreEma), + + noDemandScoreOn: round3(noDemandScoreOn), + noDemandScoreOff: round3(noDemandScoreOff), + + evcsVehicleNoDemandSemantic, + evcsNoDemandSinceMs: Number.isFinite(evcsNoDemandSinceMs) ? evcsNoDemandSinceMs : null, + evcsNoDemandSinceMinutes: Number.isFinite(evcsNoDemandSinceMinutes) + ? round3(evcsNoDemandSinceMinutes) + : null, + + nextHistory: { + evcsDeliveryRatioEma: round3(deliveryRatioEma), + evcsDeliveryNoiseEma: round3(deliveryNoiseEma), + evcsNoDemandScoreEma: round3(noDemandScoreEma), + evcsNoDemandSemanticActive: evcsVehicleNoDemandSemantic, + evcsNoDemandSinceMs: Number.isFinite(evcsNoDemandSinceMs) ? evcsNoDemandSinceMs : null, + evcsNoDemandFilterEvalMs: nowMs + } +}; + +// ===================================================== +// DECISION TREE +// ===================================================== +if (!parsed.controllerEnabled) { + desiredCharging = parsed.evcsChargingSwitchOn; + desiredPermission = parsed.chargePermissionNow; + desiredLatch = parsed.controlLatch; + desiredCurrentA = clamp( + derived.currentReferenceA || params.currentMinA, + params.currentMinA, + params.currentMaxAutoA + ); + + state = 'controller_disabled'; + currentDecision = 'hold'; + dynamicOk = false; + mustStop = false; + reason.push('Contrôleur désactivé'); +} + +else if (strategy.isStopMode) { + setOff({ + stateName: 'stopped_user', + decisionName: 'stop_stop_mode', + reasonText: 'Arrêt manuel via Stratégie STOP', + dynamic: false, + mustStopFlag: false, + blockReason: 'manual_stop', + blockedUntilStamp: now.stamp, + }); +} + +else if (parsed.evcsModeIsAuto) { + setOff({ + stateName: 'mode_auto_conflict', + decisionName: 'stop_auto_mode_conflict', + reasonText: 'Borne en mode Auto interne', + dynamic: false, + mustStopFlag: true, + blockReason: 'mode_auto_conflict', + blockedUntilStamp: now.stamp, + }); +} + +else if (!parsed.evcsDeviceConnected || !parsed.evcsVehicleConnected) { + setOff({ + stateName: 'vehicle_not_connected', + decisionName: 'stop_not_connected', + reasonText: 'Véhicule ou borne non connecté', + dynamic: false, + mustStopFlag: false, + blockReason: 'vehicle_not_connected', + blockedUntilStamp: now.stamp, + }); +} + +else if (safeguards.mustStopHard) { + setOff({ + stateName: + derived.anyVebusLowBattery ? 'vebus_low_battery_hard_stop' : + derived.vebusDcVoltageHardLow ? 'vebus_dc_voltage_hard_stop' : + derived.vebusAcOutHardReached ? 'vebus_ac_hard_stop' : + safeguards.batteryCurrentHardExceeded ? 'battery_current_hard_stop' : + 'hard_stop', + decisionName: 'stop_hard', + reasonText: 'Hard stop de protection', + dynamic: false, + mustStopFlag: true, + blockReason: + safeguards.batteryCurrentHardExceeded ? 'battery_current_hard_stop' : + derived.vebusDcVoltageHardLow ? 'vebus_dc_voltage_stop' : + derived.anyVebusLowBattery ? 'vebus_low_battery_stop' : + derived.vebusAcOutHardReached ? 'vebus_ac_hard_stop' : + 'battery_protection_stop', + blockedUntilStamp: formatParisStampFromMs(nowMs + 15 * 60000), + }); +} + +// ===================================================== +// ACTIVE SESSION BRANCH +// ===================================================== +else if (cloud.sessionObservedActive) { + if (cloud.enteringCloudDebt) { + setCloudDebtStartedAt = true; + } + + if (conditions.shouldStopOverloadAtMin) { + setOff({ + stateName: 'vebus_overload_stop_min_current', + decisionName: 'stop', + reasonText: 'Arrêt: surcharge convertisseur persistante au courant mini', + dynamic: false, + mustStopFlag: true, + blockReason: 'vebus_overload_persistent', + blockedUntilStamp: formatParisStampFromMs( + nowMs + batteryTune.global.overloadStopAfterSeconds * 1000 + ), + }); + } + + else if (safeguards.gridBatteryProtectionStop) { + setOff({ + stateName: 'stop_grid_battery_guard', + decisionName: 'stop', + reasonText: `Arrêt charge réseau: batterie trop sollicitée (${round1(parsed.batteryCurrentA)}A)`, + dynamic: false, + mustStopFlag: true, + blockReason: 'grid_battery_guard', + blockedUntilStamp: formatParisStampFromMs(nowMs + 20 * 60000), + }); + } + + else if (conditions.probeFailedNow) { + setOff({ + stateName: 'probe_failed_lock', + decisionName: 'stop', + reasonText: 'Mode sonde échoué', + dynamic: false, + mustStopFlag: true, + blockReason: 'probe_failed', + blockedUntilStamp: formatParisStampFromMs(nowMs + params.probeRetryMinutes * 60000), + }); + } + + else if (derived.physicalCapA < params.currentMinA) { + if (lifecycle.minRunElapsed) { + setOff({ + stateName: 'stop_power_cap', + decisionName: 'stop_power_cap', + reasonText: 'Capacité système insuffisante', + dynamic: false, + mustStopFlag: true, + blockReason: 'system_capacity_stop', + blockedUntilStamp: formatParisStampFromMs(nowMs + 10 * 60000), + }); + } else { + setOn({ + stateName: 'hold_min_run_power_cap', + decisionName: 'hold', + currentA: params.currentMinA, + reasonText: "Maintien jusqu'au temps mini ON", + dynamic: false, + mustStopFlag: false, + }); + } + } + + else if (conditions.probeWindowActive && !conditions.probeSuccessNow) { + if (!conditions.probeHoldGuardOk) { + setOff({ + stateName: 'probe_stop_guard', + decisionName: 'stop', + reasonText: 'Sonde interrompue: garde-fous non respectés', + dynamic: false, + mustStopFlag: true, + blockReason: 'probe_failed', + blockedUntilStamp: formatParisStampFromMs(nowMs + params.probeRetryMinutes * 60000), + }); + } else { + setOn({ + stateName: 'probe_hold', + decisionName: 'hold', + currentA: Math.min(params.probeCurrentA, derived.physicalCapA), + reasonText: `Mode sonde actif à ${round1(Math.min(params.probeCurrentA, derived.physicalCapA))}A`, + dynamic: true, + mustStopFlag: false, + }); + } + } + + else { + const activeSourceMode = + conditions.probeSuccessNow + ? 'solar_only' + : context.sourceMode; + + const holdConditionsOk = + (activeSourceMode === 'grid_hc_allowed' || activeSourceMode === 'grid_forced') + ? conditions.gridHoldOk + : (activeSourceMode === 'solar_only' || activeSourceMode === 'solar_potential') + ? conditions.solarHoldOk + : activeSourceMode === 'hybrid_probe' + ? conditions.probeHoldGuardOk + : activeSourceMode === 'battery_assist_night' + ? conditions.batteryAssistNightHoldOk + : false; diff --git a/DECISION/decision_engineP3.js b/DECISION/decision_engineP3.js new file mode 100644 index 0000000..a7bf69f --- /dev/null +++ b/DECISION/decision_engineP3.js @@ -0,0 +1,364 @@ + // ================================================= + // VEHICLE FULL / PAUSED - NUMERIC FILTER DECISION + // IMPORTANT: + // - uniquement en session active + // - uniquement si la source est réellement livrable + // - sans utiliser le PV comme critère + // ================================================= + if ( + vehicleDemandFilter.evcsVehicleNoDemandSemantic && + vehicleDemandFilter.sourceDeliverableForNoDemand + ) { + setOff({ + stateName: 'vehicle_full_or_paused', + decisionName: 'stop_vehicle_full_or_paused', + reasonText: 'Véhicule branché mais ne demande plus réellement de puissance', + dynamic: false, + mustStopFlag: false, + blockReason: 'none', + blockedUntilStamp: now.stamp, + }); + + reason.push(`DeliveryRatioEma=${vehicleDemandFilter.deliveryRatioEma}`); + reason.push(`NoiseEma=${vehicleDemandFilter.deliveryNoiseEma}`); + reason.push(`NoDemandScore=${vehicleDemandFilter.noDemandScoreEma}`); + reason.push(`ScoreOn=${vehicleDemandFilter.noDemandScoreOn}`); + reason.push(`ScoreOff=${vehicleDemandFilter.noDemandScoreOff}`); + + if (vehicleDemandFilter.evcsNoDemandSinceMinutes !== null) { + reason.push(`NoDemand depuis ${round1(vehicleDemandFilter.evcsNoDemandSinceMinutes)} min`); + } + } + + else if (activeSourceMode === 'blocked') { + if (lifecycle.minRunElapsed) { + setOff({ + stateName: 'stop_source_lost', + decisionName: 'stop', + reasonText: 'Source autorisée perdue', + dynamic: false, + mustStopFlag: true, + blockReason: context.profile === 'outside_window' ? 'outside_window' : 'source_unavailable', + blockedUntilStamp: + context.profile === 'outside_window' && Number.isFinite(locks.nextAllowedWindowStartMs) + ? formatParisStampFromMs(locks.nextAllowedWindowStartMs) + : formatParisStampFromMs(nowMs + params.minStopMinutes * 60000), + }); + } else { + setOn({ + stateName: 'hold_min_run_source_lost', + decisionName: 'hold', + currentA: Math.min(derived.currentReferenceA, derived.physicalCapA), + reasonText: "Source perdue mais maintien jusqu'au mini ON", + dynamic: false, + mustStopFlag: false, + }); + } + } + + else if ( + (activeSourceMode === 'grid_hc_allowed' || activeSourceMode === 'grid_forced') && + !parsed.gridAvailable + ) { + if (lifecycle.minRunElapsed) { + setOff({ + stateName: + context.profile === 'night_hc' + ? 'night_wait_grid_takeover_runtime' + : 'wait_grid_source_runtime', + decisionName: 'hold', + reasonText: + context.profile === 'night_hc' + ? 'Relais réseau demandé pendant la session nuit EVCS' + : 'Source réseau demandée mais pas encore disponible', + dynamic: false, + mustStopFlag: false, + blockReason: + context.profile === 'night_hc' ? 'grid_hc_unavailable' : 'grid_unavailable', + blockedUntilStamp: formatParisStampFromMs(nowMs + 10 * 60000), + }); + } else { + setOn({ + stateName: 'hold_min_run_wait_grid_takeover', + decisionName: 'hold', + currentA: Math.min(derived.currentReferenceA, derived.physicalCapA), + reasonText: "Maintien jusqu'au mini ON en attente du réseau", + dynamic: false, + mustStopFlag: false, + }); + } + } + + else if (!holdConditionsOk) { + if (lifecycle.minRunElapsed) { + setOff({ + stateName: 'stop_conditions_lost', + decisionName: 'stop', + reasonText: 'Conditions de maintien perdues', + dynamic: false, + mustStopFlag: true, + blockReason: 'hold_conditions_lost', + blockedUntilStamp: formatParisStampFromMs(nowMs + 10 * 60000), + }); + } else { + setOn({ + stateName: 'hold_min_run', + decisionName: 'hold', + currentA: Math.min(derived.currentReferenceA, derived.physicalCapA), + reasonText: "Maintien jusqu'au temps mini ON", + dynamic: true, + mustStopFlag: false, + }); + } + } + + else if ((derived.currentReferenceA > derived.physicalCapA) && lifecycle.currentCooldownElapsed) { + setOn({ + stateName: 'charging_step_down_system_cap', + decisionName: 'step_down', + currentA: Math.max( + params.currentMinA, + Math.min( + derived.currentReferenceA - Math.max(params.currentStepDownA, 2), + derived.physicalCapA + ) + ), + reasonText: 'Réduction pour respecter la capacité système', + dynamic: true, + mustStopFlag: false, + }); + } + + else if (conditions.shouldStepDownOverload) { + setOn({ + stateName: 'charging_step_down_overload', + decisionName: 'step_down_fast', + currentA: Math.max( + params.currentMinA, + Math.min( + derived.currentReferenceA - Math.max(batteryTune.global.overloadStepDownA, params.currentStepDownA), + derived.physicalCapA + ) + ), + reasonText: 'Baisse rapide: surcharge convertisseur', + dynamic: true, + mustStopFlag: false, + }); + } + + else if (conditions.shouldStepDownVebusPower) { + setOn({ + stateName: 'charging_step_down_vebus_power', + decisionName: 'step_down_fast', + currentA: Math.max( + params.currentMinA, + Math.min( + derived.currentReferenceA - Math.max(params.currentStepDownA + 1, 3), + derived.physicalCapA + ) + ), + reasonText: 'Baisse rapide: puissance VE.Bus élevée', + dynamic: true, + mustStopFlag: false, + }); + } + + else if (conditions.shouldStepDownVebusVoltage) { + setOn({ + stateName: 'charging_step_down_dc_voltage', + decisionName: 'step_down_fast', + currentA: Math.max( + params.currentMinA, + Math.min( + derived.currentReferenceA - Math.max(params.currentStepDownA + 1, 3), + derived.physicalCapA + ) + ), + reasonText: 'Baisse rapide: tension DC basse', + dynamic: true, + mustStopFlag: false, + }); + } + + else if ( + activeSourceMode === 'battery_assist_night' && + conditions.batteryAssistNightShouldStepDown + ) { + setOn({ + stateName: 'charging_step_down_battery_assist_night', + decisionName: 'step_down', + currentA: Math.max( + params.currentMinA, + Math.min( + derived.currentReferenceA - params.currentStepDownA, + derived.physicalCapA + ) + ), + reasonText: + nightEvcs?.batteryRecoveryUrgent + ? 'Réduction: approche départ, préparation takeover réseau' + : 'Réduction: assistance batterie nuit devient trop coûteuse', + dynamic: true, + mustStopFlag: false, + }); + } + + else if ( + (activeSourceMode === 'grid_hc_allowed' || activeSourceMode === 'grid_forced') && + conditions.gridShouldStepDownBattery + ) { + setOn({ + stateName: + activeSourceMode === 'grid_forced' + ? 'charging_step_down_grid_forced_guard' + : 'charging_step_down_grid_guard', + decisionName: 'step_down', + currentA: Math.max( + params.currentMinA, + Math.min( + derived.currentReferenceA - params.currentStepDownA, + derived.physicalCapA + ) + ), + reasonText: + activeSourceMode === 'grid_forced' + ? 'Réduction réseau forcé: la batterie commence à participer' + : 'Réduction réseau: la batterie commence à participer', + dynamic: true, + mustStopFlag: false, + }); + } + + else if ( + (activeSourceMode === 'solar_only' || activeSourceMode === 'solar_potential') && + conditions.solarShouldStepDownCloudDebt + ) { + setOn({ + stateName: 'charging_step_down_cloud_debt', + decisionName: 'step_down', + currentA: Math.max( + params.currentMinA, + Math.min( + derived.currentReferenceA - Math.max(params.currentStepDownA, 2), + derived.physicalCapA + ) + ), + reasonText: 'Réduction: dette nuage trop élevée', + dynamic: true, + mustStopFlag: false, + }); + } + + else if ( + (activeSourceMode === 'solar_only' || activeSourceMode === 'solar_potential') && + conditions.solarShouldStepDownBattery + ) { + setOn({ + stateName: 'charging_step_down_battery', + decisionName: 'step_down', + currentA: Math.max( + params.currentMinA, + Math.min( + derived.currentReferenceA - params.currentStepDownA, + derived.physicalCapA + ) + ), + reasonText: 'Réduction: batterie trop sollicitée', + dynamic: true, + mustStopFlag: false, + }); + } + + else if ( + (activeSourceMode === 'grid_hc_allowed' || activeSourceMode === 'grid_forced') && + conditions.gridShouldStepUp + ) { + setOn({ + stateName: + activeSourceMode === 'grid_forced' + ? ( + nightEvcs?.batteryRecoveryUrgent + ? 'night_departure_deadline_grid_forced' + : 'charging_step_up_grid_forced' + ) + : 'charging_step_up_grid_hc', + decisionName: 'step_up', + currentA: clamp( + derived.currentReferenceA + params.currentStepUpA, + params.currentMinA, + derived.physicalCapA + ), + reasonText: + activeSourceMode === 'grid_forced' + ? ( + nightEvcs?.batteryRecoveryUrgent + ? 'Hausse prudente: priorité départ, réseau forcé' + : 'Hausse prudente du courant en charge réseau forcée' + ) + : 'Hausse prudente du courant en HC réseau', + dynamic: true, + mustStopFlag: false, + }); + } + + else if ( + activeSourceMode === 'battery_assist_night' && + conditions.batteryAssistNightShouldStepUp + ) { + setOn({ + stateName: 'charging_step_up_battery_assist_night', + decisionName: 'step_up', + currentA: clamp( + derived.currentReferenceA + params.currentStepUpA, + params.currentMinA, + derived.physicalCapA + ), + reasonText: 'Hausse prudente du courant en assistance batterie nuit', + dynamic: true, + mustStopFlag: false, + }); + } + + else if ( + (activeSourceMode === 'solar_only' || activeSourceMode === 'solar_potential') && + conditions.solarShouldStepUp + ) { + setOn({ + stateName: + conditions.probeSuccessNow + ? 'probe_success_step_up' + : (modes.batteryPriorityMode + ? 'charging_step_up_battery' + : 'charging_step_up_forecast'), + decisionName: 'step_up', + currentA: clamp( + derived.currentReferenceA + params.currentStepUpA, + params.currentMinA, + derived.physicalCapA + ), + reasonText: 'Hausse prudente du courant', + dynamic: true, + mustStopFlag: false, + }); + } + + else { + setOn({ + stateName: + conditions.probeSuccessNow ? 'probe_success_hold' : + activeSourceMode === 'battery_assist_night' ? 'charging_hold_battery_assist_night' : + (activeSourceMode === 'grid_hc_allowed' || activeSourceMode === 'grid_forced') ? 'charging_hold_grid' : + cloud.cloudDebtActive ? 'charging_hold_cloud_tolerance' : + 'charging_hold', + decisionName: 'hold', + currentA: Math.min(derived.currentReferenceA, derived.physicalCapA), + reasonText: + activeSourceMode === 'battery_assist_night' + ? 'Maintien du courant en assistance batterie nuit' + : 'Maintien du courant', + dynamic: true, + mustStopFlag: false, + }); + } + } +} diff --git a/DECISION/decision_engineP4.js b/DECISION/decision_engineP4.js new file mode 100644 index 0000000..0e9a4fa --- /dev/null +++ b/DECISION/decision_engineP4.js @@ -0,0 +1,235 @@ +// ===================================================== +// START BRANCH +// ===================================================== +else { + const startConditionsOk = + (context.sourceMode === 'grid_hc_allowed' || context.sourceMode === 'grid_forced') + ? conditions.gridStartOk + : (context.sourceMode === 'solar_only' || context.sourceMode === 'solar_potential') + ? conditions.solarStartOk + : context.sourceMode === 'hybrid_probe' + ? conditions.probeStartOk + : context.sourceMode === 'battery_assist_night' + ? conditions.batteryAssistNightStartOk + : false; + + if (!derived.canRunAtMinCurrent) { + setOff({ + stateName: 'blocked_by_capacity_min_current', + decisionName: 'hold', + reasonText: 'Capacité insuffisante pour courant mini', + dynamic: false, + mustStopFlag: false, + blockReason: 'system_capacity_stop', + blockedUntilStamp: formatParisStampFromMs(nowMs + 10 * 60000), + }); + } + + else if (context.sourceMode === 'blocked') { + setOff({ + stateName: + context.profile === 'outside_window' + ? 'blocked_outside_window' + : (context.profile === 'night_hc' + ? 'night_no_grid_hc_lock' + : 'idle_wait_source'), + decisionName: 'hold', + reasonText: + context.profile === 'outside_window' + ? 'Hors fenêtre préférée sans source suffisante' + : context.profile === 'night_hc' + ? 'Nuit sans autorisation HC réseau' + : 'Source non autorisée ou insuffisante', + dynamic: false, + mustStopFlag: false, + blockReason: + context.profile === 'outside_window' + ? 'outside_window' + : context.profile === 'night_hc' + ? 'grid_hc_unavailable' + : 'source_unavailable', + blockedUntilStamp: + Number.isFinite(locks.nextAllowedWindowStartMs) + ? formatParisStampFromMs(locks.nextAllowedWindowStartMs) + : formatParisStampFromMs(nowMs + 15 * 60000), + }); + } + + else if ( + (context.sourceMode === 'grid_hc_allowed' || context.sourceMode === 'grid_forced') && + !parsed.gridAvailable + ) { + setOff({ + stateName: + context.profile === 'night_hc' + ? 'night_wait_grid_takeover' + : 'wait_grid_source', + decisionName: 'hold', + reasonText: + context.profile === 'night_hc' + ? ( + nightEvcs?.batteryRecoveryUrgent + ? 'Réseau demandé: urgence recharge batterie avant départ' + : 'Prise de relais réseau demandée pour session nuit EVCS' + ) + : 'Source réseau demandée mais pas encore disponible', + dynamic: false, + mustStopFlag: false, + blockReason: + context.profile === 'night_hc' + ? 'grid_hc_unavailable' + : 'grid_unavailable', + blockedUntilStamp: formatParisStampFromMs(nowMs + 10 * 60000), + }); + } + + else if (locks.startBlockedByReason) { + setOff({ + stateName: 'wait_reason_lock', + decisionName: 'hold', + reasonText: `Blocage anti-cliquetis: ${history.lastBlockReason}`, + dynamic: false, + mustStopFlag: false, + blockReason: history.lastBlockReason, + blockedUntilStamp: history.blockedUntilAt || now.stamp, + }); + } + + else if (!startConditionsOk) { + setOff({ + stateName: 'idle_wait_conditions', + decisionName: 'hold', + reasonText: + context.sourceMode === 'battery_assist_night' + ? 'Conditions de démarrage nuit batterie non réunies' + : 'Conditions de démarrage non réunies', + dynamic: false, + mustStopFlag: false, + blockReason: 'none', + blockedUntilStamp: now.stamp, + }); + } + + else if (!lifecycle.minStopElapsed) { + setOff({ + stateName: 'wait_min_stop', + decisionName: 'hold', + reasonText: 'Temps mini OFF non écoulé', + dynamic: false, + mustStopFlag: false, + blockReason: 'none', + blockedUntilStamp: now.stamp, + }); + } + + else { + const startCurrentA = + context.sourceMode === 'hybrid_probe' + ? clamp(params.probeCurrentA, params.currentMinA, derived.physicalCapA) + : params.currentMinA; + + setOn({ + stateName: + context.sourceMode === 'grid_forced' ? ( + nightEvcs?.batteryRecoveryUrgent + ? 'night_departure_deadline_grid_forced' + : 'start_grid_forced_min_current' + ) : + context.sourceMode === 'grid_hc_allowed' ? 'start_grid_hc_min_current' : + context.sourceMode === 'battery_assist_night' ? 'start_battery_assist_night_min_current' : + context.sourceMode === 'hybrid_probe' ? 'probe_start_6a' : + context.sourceMode === 'solar_potential' ? 'start_forecast_solar_potential' : + modes.batteryFullHarvestMode ? 'start_min_current_battery_full_harvest' : + modes.forecastCommitMode ? 'start_min_current_forecast_commit' : + 'start_min_current', + decisionName: 'start', + currentA: startCurrentA, + reasonText: + context.sourceMode === 'grid_forced' + ? ( + nightEvcs?.batteryRecoveryUrgent + ? 'Démarrage réseau forcé: priorité départ / recharge batterie' + : 'Démarrage en charge réseau forcée' + ) + : context.sourceMode === 'grid_hc_allowed' + ? 'Démarrage en fenêtre HC réseau' + : context.sourceMode === 'battery_assist_night' + ? 'Démarrage nuit en assistance batterie contrôlée' + : context.sourceMode === 'hybrid_probe' + ? 'Démarrage en mode sonde' + : context.sourceMode === 'solar_potential' + ? 'Démarrage par potentiel solaire confirmé' + : modes.batteryFullHarvestMode + ? 'Démarrage harvest solaire' + : modes.forecastCommitMode + ? 'Démarrage forecast commit' + : 'Démarrage prudent à courant mini', + dynamic: true, + mustStopFlag: false, + }); + + nextBlockReason = 'none'; + nextBlockedUntilStamp = now.stamp; + setProbeStartedAt = context.sourceMode === 'hybrid_probe'; + } +} + +// ===================================================== +// CONTEXT REASONS +// ===================================================== +if (nightEvcs?.nightSessionActive) { + reason.push( + `Nuit EVCS: target=${round1(nightEvcs.batteryMorningTargetPct)}% | trigger réseau=${round1(nightEvcs.gridTakeoverTriggerPct)}%` + ); + + if (Number.isFinite(nightEvcs.nightSessionStartSocPct)) { + reason.push( + `SOC départ session nuit=${round1(nightEvcs.nightSessionStartSocPct)}% | décharge autorisée=${round1(nightEvcs.maxBatteryDischargePoints)} pts` + ); + } + + if (nightEvcs.batteryRecoveryUrgent) { + reason.push("Urgence départ: il faut anticiper la reprise réseau"); + } +} + +reason.push(`VehicleDemandFilter delivery=${vehicleDemandFilter.deliveryRatioEma}`); +reason.push(`VehicleDemandFilter noise=${vehicleDemandFilter.deliveryNoiseEma}`); +reason.push(`VehicleDemandFilter score=${vehicleDemandFilter.noDemandScoreEma}`); +reason.push(`VehicleDemandFilter noDemand=${vehicleDemandFilter.evcsVehicleNoDemandSemantic ? 'on' : 'off'}`); + +// ===================================================== +// OUTPUT +// ===================================================== +return [{ + json: { + ...prev, + vehicleDemandFilter, + decision: { + desiredCharging, + desiredPermission, + desiredLatch, + desiredCurrentA, + + state, + currentDecision, + dynamicOk, + mustStop, + reason, + + nextBlockReason, + nextBlockedUntilStamp, + + setCloudDebtStartedAt, + setProbeStartedAt, + + setNightSessionStart, + clearNightSessionStart, + + notifyWanted, + notifyTitle, + notifyMessage, + } + } +}]; + diff --git a/EVCS b/EVCS index 874aca1..5e6798b 160000 --- a/EVCS +++ b/EVCS @@ -1 +1 @@ -Subproject commit 874aca1afa4f55158e5c3253e17a92c6dc58c836 +Subproject commit 5e6798b7023202ad4667db100fc2a2c478e1a0cb diff --git a/WINDOWS/WINDOW_SOURCEV2.js b/WINDOWS/WINDOW_SOURCEV2.js new file mode 100644 index 0000000..4b5d0ae --- /dev/null +++ b/WINDOWS/WINDOW_SOURCEV2.js @@ -0,0 +1,574 @@ +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; + +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 +// 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 { + // 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, + 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, + } + } +}]; + diff --git a/WINDOWS/manage_windows.js b/WINDOWS/manage_windows.js new file mode 100644 index 0000000..9ba797c --- /dev/null +++ b/WINDOWS/manage_windows.js @@ -0,0 +1,579 @@ +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, + } + } +}]; + diff --git a/WINDOWS/manage_windowsP1.js b/WINDOWS/manage_windowsP1.js new file mode 100644 index 0000000..9abd6f7 --- /dev/null +++ b/WINDOWS/manage_windowsP1.js @@ -0,0 +1,304 @@ +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'; diff --git a/WINDOWS/manage_windowsP2.js b/WINDOWS/manage_windowsP2.js new file mode 100644 index 0000000..6c4a7be --- /dev/null +++ b/WINDOWS/manage_windowsP2.js @@ -0,0 +1,274 @@ +// ===================================================== +// 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, + } + } +}]; +