Files
EVCS/DECISION/decision_engine.js

1195 lines
36 KiB
JavaScript

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