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 );