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, } } }];