Compare commits

..

2 Commits

Author SHA1 Message Date
1776d2776f correction logique commande priorisation 2026-05-01 18:34:35 +02:00
26b27f5faa correction sur la logique 2026-05-01 17:39:46 +02:00
15 changed files with 6642 additions and 0 deletions

File diff suppressed because it is too large Load Diff

1194
DECISION/decision_engine.js Normal file

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@@ -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,
});
}
}
}

View File

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

1
EVCS Submodule

Submodule EVCS added at 5e6798b702

625
HYSTERESIS/COMPUTE.js Normal file
View File

@@ -0,0 +1,625 @@
function num(v, fallback = NaN) {
const n = Number.parseFloat(v);
return Number.isFinite(n) ? n : fallback;
}
function low(v) {
return (v ?? '').toString().trim().toLowerCase();
}
function parseMaybeJson(v) {
if (typeof v !== 'string') return v;
try {
return JSON.parse(v);
} catch {
return v;
}
}
function extractSnapshot(input) {
if (typeof input === 'string') return parseMaybeJson(input);
if (input?.data && typeof input.data === 'string') return parseMaybeJson(input.data);
if (input?.body && typeof input.body === 'string') return parseMaybeJson(input.body);
if (input?.data && typeof input.data === 'object') return input.data;
if (input?.body && typeof input.body === 'object') return input.body;
return input;
}
function round1(v) {
return Math.round(v * 10) / 10;
}
function clamp(v, min, max) {
return Math.min(Math.max(v, min), max);
}
function pickDefined(...values) {
for (const v of values) {
if (v === undefined || v === null) continue;
if (typeof v === 'string' && v.trim() === '') continue;
return v;
}
return undefined;
}
function isOnLike(v) {
const s = low(v);
return s === 'on' || s === 'true' || s === '1' || s === 'yes';
}
function getParisNowParts() {
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())
.filter(p => p.type !== 'literal')
.map(p => [p.type, p.value])
);
const date = `${parts.year}-${parts.month}-${parts.day}`;
const stamp = `${date} ${parts.hour}:${parts.minute}:${parts.second}`;
return {
year: Number(parts.year),
month: Number(parts.month),
day: Number(parts.day),
hour: Number(parts.hour),
minute: Number(parts.minute),
second: Number(parts.second),
date,
stamp,
};
}
function parseLocalStampToMs(s) {
const text = (s ?? '').toString().trim();
const m = text.match(/^(\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2}):(\d{2})$/);
if (!m) return null;
const y = Number(m[1]);
const mo = Number(m[2]);
const d = Number(m[3]);
const h = Number(m[4]);
const mi = Number(m[5]);
const se = Number(m[6]);
if (![y, mo, d, h, mi, se].every(Number.isFinite)) return null;
return new Date(y, mo - 1, d, h, mi, se).getTime();
}
const cfg = {
mobileNotifyDomain: 'notify',
mobileNotifyService: 'mobile_app_iphone_16_de_tt',
};
const raw = $input.first().json;
const input = extractSnapshot(raw);
const now = getParisNowParts();
const nowMs = parseLocalStampToMs(now.stamp);
// ======================================================
// INPUTS PRINCIPAUX
// ======================================================
const soc = clamp(num(input.soc_percent, 0), 0, 100);
const gridIsOn = low(input.grid_state || 'off') === 'on';
const pvPowerW = Math.max(0, num(input.pv_power_w, 0));
const houseAcPowerW = Math.max(0, num(input.house_ac_power_w, 0));
const gridPowerW = num(input.grid_power_w, 0);
const evcsAcPowerW = Math.max(0, num(input.evcs_ac_power_w, 0));
const evcsCharging = low(input.evcs_charging || 'off') === 'on';
const evcsStatusRaw = num(input.evcs_status_raw, NaN);
const hysteresisEnabled = low(input.hysteresis_enabled || 'off') === 'on';
const notificationsEnabled = low(input.notifications_enabled || 'off') === 'on';
const latchIsOn = low(input.grid_latch || 'off') === 'on';
const gridSocOn = clamp(num(input.grid_soc_on, 22), 0, 100);
const gridSocOff = clamp(num(input.grid_soc_off, 35), 0, 100);
const gridMinOnMinutes = Math.max(0, num(input.grid_min_on_minutes, 45));
const gridCooldownMinutes = Math.max(0, num(input.grid_cooldown_minutes, 15));
const acLoadsMaxSafeW = Math.max(0, num(input.ac_loads_max_safe_w, 7800));
const lastOnAt = (input.last_on_at ?? '').toString().trim();
const lastOffAt = (input.last_off_at ?? '').toString().trim();
const lastSignature = (input.last_signature ?? '').toString().trim();
const lastOnMs = parseLocalStampToMs(lastOnAt);
const lastOffMs = parseLocalStampToMs(lastOffAt);
const minutesSinceLastOn =
lastOnMs !== null && nowMs !== null ? Math.max(0, (nowMs - lastOnMs) / 60000) : null;
const minutesSinceLastOff =
lastOffMs !== null && nowMs !== null ? Math.max(0, (nowMs - lastOffMs) / 60000) : null;
const minOnElapsed =
minutesSinceLastOn === null ? true : minutesSinceLastOn >= gridMinOnMinutes;
const cooldownElapsed =
minutesSinceLastOff === null ? true : minutesSinceLastOff >= gridCooldownMinutes;
const socBand =
soc <= gridSocOn ? 'low' :
soc < gridSocOff ? 'mid' :
'high';
const evcsSeemsActive = evcsCharging || evcsAcPowerW > 300;
const acOverSafeLimit = houseAcPowerW >= acLoadsMaxSafeW;
// ======================================================
// EVCS OVERRIDE INPUTS
// ======================================================
const evcsPriorityActive = isOnLike(
pickDefined(
input.evcs_priority_active,
input.victron_evcs_priority_active,
input['input_boolean.victron_evcs_priority_active']
)
);
const evcsNightGridTakeoverNeeded = isOnLike(
pickDefined(
input.night_grid_takeover_needed,
input.victron_evcs_night_grid_takeover_needed,
input['input_boolean.victron_evcs_night_grid_takeover_needed']
)
);
const evcsSourceModeRaw = (
pickDefined(
input.evcs_source_mode,
input.victron_evcs_source_mode,
input['input_text.victron_evcs_source_mode']
) ?? ''
).toString().trim();
const evcsSourceMode = low(evcsSourceModeRaw);
const userStrategyRaw = (
pickDefined(
input.user_strategy,
input.victron_evcs_user_strategy,
input['input_select.victron_evcs_user_strategy']
) ?? ''
).toString().trim();
const userStrategy = low(userStrategyRaw);
// ======================================================
// EVCS SEMANTIC STATE
// Ici on ne détecte PAS la voiture pleine par seuils.
// On consomme seulement l'état sémantique déjà produit
// par le workflow EVCS.
// ======================================================
const evcsSurplusStateRaw = (
pickDefined(
input.evcs_surplus_state,
input.victron_evcs_surplus_state,
input['input_text.victron_evcs_surplus_state']
) ?? ''
).toString().trim();
const evcsSurplusState = low(evcsSurplusStateRaw);
const evcsVehicleNoDemandSemantic =
evcsSurplusState === 'vehicle_full_or_paused' ||
evcsSurplusState === 'vehicle_not_requesting_power' ||
evcsSurplusState === 'vehicle_full' ||
evcsSurplusState === 'charge_complete' ||
evcsSurplusState.includes('full_or_paused') ||
evcsSurplusState.includes('not_requesting_power') ||
evcsSurplusState.includes('charge_complete') ||
evcsSurplusState.includes('no_demand');
// ======================================================
// OVERRIDE EVCS VERS GRID
// Si EVCS dit déjà "plus de demande", on relâche l'override.
// ======================================================
const evcsGridOverride =
!evcsVehicleNoDemandSemantic &&
(
userStrategy === 'force_grid_now' ||
userStrategy === 'force_charge_now' ||
evcsPriorityActive ||
evcsNightGridTakeoverNeeded ||
evcsSourceMode === 'réseau forcé' ||
evcsSourceMode === 'réseau hc autorisé' ||
evcsSourceMode === 'grid_forced' ||
evcsSourceMode === 'grid_hc_allowed'
);
// ======================================================
// DÉCISION
// ======================================================
let state = 'observe_only';
let reason = [];
let desiredGrid = gridIsOn ? 'on' : 'off';
let desiredLatch = latchIsOn ? 'on' : 'off';
// ======================================================
// CORE HYSTERESIS LOGIC
// ======================================================
if (evcsGridOverride) {
desiredGrid = 'on';
desiredLatch = 'on';
if (!gridIsOn) {
state = 'turn_on_evcs_override';
reason.push('Override EVCS actif : activation EDF forcée');
} else if (!latchIsOn) {
state = 'adopt_existing_grid_on_evcs_override';
reason.push('Override EVCS actif : EDF déjà ON, adoption par latch');
} else {
state = 'hold_on_evcs_override';
reason.push('Override EVCS actif : maintien EDF ON');
}
if (userStrategy === 'force_grid_now') {
reason.push('Cause override : force_grid_now');
} else if (userStrategy === 'force_charge_now') {
reason.push('Cause override : force_charge_now');
} else if (evcsPriorityActive) {
reason.push('Cause override : evcs_priority_active');
} else if (evcsNightGridTakeoverNeeded) {
reason.push('Cause override : night_grid_takeover_needed');
} else {
reason.push(`Cause override : source_mode=${evcsSourceModeRaw || 'n/a'}`);
}
} else if (!hysteresisEnabled) {
state = 'hysteresis_disabled';
desiredGrid = gridIsOn ? 'on' : 'off';
desiredLatch = latchIsOn ? 'on' : 'off';
reason.push('Automatisme hystérésis réseau désactivé');
} else if (soc <= gridSocOn) {
desiredGrid = 'on';
desiredLatch = 'on';
if (!cooldownElapsed && !gridIsOn) {
state = 'turn_on_low_soc_cooldown_bypassed';
reason.push(`SOC ${round1(soc)}% <= seuil ON ${gridSocOn}% : activation EDF malgré cooldown`);
} else if (!gridIsOn) {
state = 'turn_on_low_soc';
reason.push(`SOC ${round1(soc)}% <= seuil ON ${gridSocOn}% : activation EDF`);
} else if (!latchIsOn) {
state = 'adopt_existing_grid_on_low_soc';
reason.push(`SOC ${round1(soc)}% <= seuil ON ${gridSocOn}% : EDF déjà ON, prise de contrôle par latch`);
} else {
state = 'hold_on_low_soc';
reason.push(`SOC encore bas ${round1(soc)}% : maintien EDF ON`);
}
} else if (gridIsOn && !latchIsOn) {
if (soc < gridSocOff) {
desiredGrid = 'on';
desiredLatch = 'on';
state = 'adopt_existing_grid_on';
reason.push(`EDF déjà ON avec latch OFF et SOC ${round1(soc)}% < seuil OFF ${gridSocOff}% : adoption de l'état ON`);
} else {
desiredGrid = 'off';
desiredLatch = 'off';
state = 'turn_off_stray_grid_on';
reason.push(`EDF ON avec latch OFF et SOC ${round1(soc)}% >= seuil OFF ${gridSocOff}% : coupure de reprise de contrôle`);
}
} else if (latchIsOn) {
if (!minOnElapsed) {
desiredGrid = 'on';
desiredLatch = 'on';
state = 'hold_on_min_runtime';
reason.push(`EDF maintenu ON : durée mini ${gridMinOnMinutes} min non atteinte (${round1(minutesSinceLastOn || 0)} min)`);
} else if (soc < gridSocOff) {
desiredGrid = 'on';
desiredLatch = 'on';
state = 'hold_on_until_soc_off';
reason.push(`EDF maintenu ON : SOC ${round1(soc)}% < seuil OFF ${gridSocOff}%`);
} else {
desiredGrid = 'off';
desiredLatch = 'off';
state = 'turn_off_recovered';
reason.push(`SOC ${round1(soc)}% >= seuil OFF ${gridSocOff}% : coupure EDF`);
}
} else {
desiredGrid = 'off';
desiredLatch = 'off';
state = 'observe_only';
reason.push('Aucune action : hors zone d’hystérésis');
}
// ======================================================
// COMPARE DESIRED VS CURRENT
// ======================================================
const gridAction =
desiredGrid === (gridIsOn ? 'on' : 'off')
? 'none'
: desiredGrid === 'on'
? 'turn_on'
: 'turn_off';
const latchAction =
desiredLatch === (latchIsOn ? 'on' : 'off')
? 'none'
: desiredLatch === 'on'
? 'turn_on'
: 'turn_off';
// ======================================================
// TIMESTAMPS
// ======================================================
const setLastOnAt =
gridAction === 'turn_on' ||
state === 'adopt_existing_grid_on' ||
state === 'adopt_existing_grid_on_low_soc' ||
state === 'adopt_existing_grid_on_evcs_override';
const setLastOffAt =
gridAction === 'turn_off';
// ======================================================
// CONTEXT / ALERTS
// ======================================================
reason.push(`Grid actuel ${gridIsOn ? 'ON' : 'OFF'}`);
reason.push(`Latch actuel ${latchIsOn ? 'ON' : 'OFF'}`);
reason.push(`SOC ${round1(soc)}% | seuils ${gridSocOn}%/${gridSocOff}%`);
reason.push(`PV ${Math.round(pvPowerW)}W`);
reason.push(`Charge AC maison ${Math.round(houseAcPowerW)}W`);
reason.push(`Puissance réseau ${Math.round(gridPowerW)}W`);
reason.push(`EVCS ${evcsSeemsActive ? 'actif' : 'inactif'} | ${Math.round(evcsAcPowerW)}W | status_raw=${Number.isFinite(evcsStatusRaw) ? evcsStatusRaw : 'n/a'}`);
if (minutesSinceLastOn !== null) {
reason.push(`Dernier ON il y a ${round1(minutesSinceLastOn)} min`);
}
if (minutesSinceLastOff !== null) {
reason.push(`Dernier OFF il y a ${round1(minutesSinceLastOff)} min`);
}
if (acOverSafeLimit) {
reason.push(`⚠️ Charge AC maison ${Math.round(houseAcPowerW)}W >= limite sûre ${Math.round(acLoadsMaxSafeW)}W`);
}
reason.push(`EVCS override ${evcsGridOverride ? 'ON' : 'OFF'}`);
reason.push(`EVCS priority ${evcsPriorityActive ? 'ON' : 'OFF'}`);
reason.push(`EVCS night takeover ${evcsNightGridTakeoverNeeded ? 'ON' : 'OFF'}`);
reason.push(`EVCS source ${evcsSourceModeRaw || 'n/a'}`);
reason.push(`EVCS strategy ${userStrategyRaw || 'n/a'}`);
reason.push(`EVCS surplus state ${evcsSurplusStateRaw || 'n/a'}`);
reason.push(`EVCS no-demand semantic ${evcsVehicleNoDemandSemantic ? 'ON' : 'OFF'}`);
// ======================================================
// NOTIFICATIONS
// ======================================================
let notifyTitle = 'Victron Grid Hysteresis';
let notifyMessage = '';
let notifyWanted = false;
if (
state === 'turn_on_evcs_override' ||
state === 'adopt_existing_grid_on_evcs_override' ||
state === 'hold_on_evcs_override'
) {
notifyTitle = '🚗 EDF forcé par EVCS';
notifyMessage = `Priorité EVCS active. EDF maintenu ON pour la recharge véhicule. Cause: ${userStrategyRaw || evcsSourceModeRaw || 'override EVCS'}.`;
notifyWanted = true;
} else if (state === 'turn_on_low_soc' || state === 'turn_on_low_soc_cooldown_bypassed') {
notifyTitle = '⚡ EDF activé sur SOC bas';
notifyMessage = `SOC ${round1(soc)}% <= ${gridSocOn}%. EDF activé et maintenu jusqu'à au moins ${gridSocOff}% (min ${gridMinOnMinutes} min).`;
notifyWanted = true;
} else if (state === 'adopt_existing_grid_on_low_soc' || state === 'adopt_existing_grid_on') {
notifyTitle = '🔁 EDF ON repris par hystérésis';
notifyMessage = `EDF était déjà ON. Le workflow reprend la main et le maintient jusqu'à ${gridSocOff}% (min ${gridMinOnMinutes} min).`;
notifyWanted = true;
} else if (state === 'turn_off_recovered') {
notifyTitle = '✅ EDF coupé après recharge';
notifyMessage = `SOC ${round1(soc)}% >= ${gridSocOff}%. Retour hors réseau autorisé.`;
notifyWanted = true;
} else if (state === 'turn_off_stray_grid_on') {
notifyTitle = '✅ EDF coupé (reprise de contrôle)';
notifyMessage = `EDF était ON sans latch. SOC ${round1(soc)}% >= ${gridSocOff}%, coupure réseau appliquée.`;
notifyWanted = true;
} else if (acOverSafeLimit && !gridIsOn) {
notifyTitle = '🚨 Charge AC élevée hors réseau';
notifyMessage = `Charge AC maison ${Math.round(houseAcPowerW)}W au-dessus de la limite sûre ${Math.round(acLoadsMaxSafeW)}W alors que EDF est OFF.`;
notifyWanted = true;
} else if (acOverSafeLimit && gridIsOn) {
notifyTitle = '⚠️ Charge AC élevée';
notifyMessage = `Charge AC maison ${Math.round(houseAcPowerW)}W proche/au-dessus de la limite sûre ${Math.round(acLoadsMaxSafeW)}W.`;
notifyWanted = true;
}
const shortReason = reason.join(' | ').slice(0, 255);
const signature = [
now.date,
state,
`dg=${desiredGrid}`,
`dl=${desiredLatch}`,
`sb=${socBand}`,
`ac=${acOverSafeLimit ? '1' : '0'}`,
`ov=${evcsGridOverride ? '1' : '0'}`,
`ep=${evcsPriorityActive ? '1' : '0'}`,
`es=${evcsSurplusState || 'na'}`
].join('|');
const signatureChanged = signature !== lastSignature;
// ======================================================
// OUTPUT
// ======================================================
const result = {
timestamp_paris: now.stamp,
inputs: {
soc_percent: soc,
grid_state: gridIsOn ? 'on' : 'off',
pv_power_w: pvPowerW,
house_ac_power_w: houseAcPowerW,
grid_power_w: gridPowerW,
evcs_ac_power_w: evcsAcPowerW,
evcs_charging: evcsCharging,
evcs_status_raw: Number.isFinite(evcsStatusRaw) ? evcsStatusRaw : null,
hysteresis_enabled: hysteresisEnabled,
notifications_enabled: notificationsEnabled,
grid_latch: latchIsOn,
grid_soc_on: gridSocOn,
grid_soc_off: gridSocOff,
grid_min_on_minutes: gridMinOnMinutes,
grid_cooldown_minutes: gridCooldownMinutes,
ac_loads_max_safe_w: acLoadsMaxSafeW,
last_on_at: lastOnAt || null,
last_off_at: lastOffAt || null,
last_signature: lastSignature || null,
evcs_priority_active: evcsPriorityActive,
night_grid_takeover_needed: evcsNightGridTakeoverNeeded,
evcs_source_mode: evcsSourceModeRaw || null,
user_strategy: userStrategyRaw || null,
evcs_surplus_state: evcsSurplusStateRaw || null
},
computed: {
desired_grid: desiredGrid,
desired_latch: desiredLatch,
minutes_since_last_on: minutesSinceLastOn !== null ? round1(minutesSinceLastOn) : null,
minutes_since_last_off: minutesSinceLastOff !== null ? round1(minutesSinceLastOff) : null,
min_on_elapsed: minOnElapsed,
cooldown_elapsed: cooldownElapsed,
ac_over_safe_limit: acOverSafeLimit,
evcs_seems_active: evcsSeemsActive,
evcs_grid_override: evcsGridOverride,
evcs_priority_active: evcsPriorityActive,
evcs_night_grid_takeover_needed: evcsNightGridTakeoverNeeded,
evcs_source_mode: evcsSourceModeRaw || null,
evcs_user_strategy: userStrategyRaw || null,
evcs_surplus_state: evcsSurplusStateRaw || null,
evcs_vehicle_no_demand_semantic: evcsVehicleNoDemandSemantic
},
decision: {
state,
grid_action: gridAction,
latch_action: latchAction,
set_last_on_at: setLastOnAt,
set_last_off_at: setLastOffAt,
should_notify: notificationsEnabled && signatureChanged && notifyWanted,
signature,
signature_changed: signatureChanged,
reason,
short_reason: shortReason
},
ha_service_calls: {
helper_state: {
domain: 'input_text',
service: 'set_value',
data: {
entity_id: 'input_text.victron_grid_control_state',
value: state
}
},
helper_reason: {
domain: 'input_text',
service: 'set_value',
data: {
entity_id: 'input_text.victron_grid_control_reason',
value: shortReason || 'Aucune raison'
}
},
helper_signature: {
domain: 'input_text',
service: 'set_value',
data: {
entity_id: 'input_text.victron_grid_control_last_signature',
value: signature
}
},
helper_latch: {
domain: 'input_boolean',
service: desiredLatch === 'on' ? 'turn_on' : 'turn_off',
data: {
entity_id: 'input_boolean.victron_grid_hysteresis_latch'
}
},
helper_last_on_at: {
domain: 'input_datetime',
service: 'set_datetime',
data: {
entity_id: 'input_datetime.victron_grid_last_on_at',
datetime: now.stamp
}
},
helper_last_off_at: {
domain: 'input_datetime',
service: 'set_datetime',
data: {
entity_id: 'input_datetime.victron_grid_last_off_at',
datetime: now.stamp
}
},
grid: {
domain: 'switch',
service: desiredGrid === 'on' ? 'turn_on' : 'turn_off',
data: {
entity_id: 'switch.edf_general_ok_switch'
}
},
logbook: {
domain: 'logbook',
service: 'log',
data: {
name: 'Victron Grid Hysteresis',
message: `${state} | Grid=${desiredGrid} | Latch=${desiredLatch} | SOC=${round1(soc)}% | AC=${Math.round(houseAcPowerW)}W | ${shortReason}`,
entity_id: 'switch.edf_general_ok_switch'
}
},
mobile_notification: {
domain: cfg.mobileNotifyDomain,
service: cfg.mobileNotifyService,
data: {
title: notifyTitle,
message: notifyMessage,
data: {
push: {
'interruption-level': 'time-sensitive',
sound: { name: 'default' }
},
ttl: 0,
priority: 'high'
}
}
}
}
};
return [{ json: result }];

198
HYSTERESIS/COMPUTEP1.js Normal file
View File

@@ -0,0 +1,198 @@
function num(v, fallback = NaN) {
const n = Number.parseFloat(v);
return Number.isFinite(n) ? n : fallback;
}
function low(v) {
return (v ?? '').toString().trim().toLowerCase();
}
function parseMaybeJson(v) {
if (typeof v !== 'string') return v;
try {
return JSON.parse(v);
} catch {
return v;
}
}
function extractSnapshot(input) {
if (typeof input === 'string') return parseMaybeJson(input);
if (input?.data && typeof input.data === 'string') return parseMaybeJson(input.data);
if (input?.body && typeof input.body === 'string') return parseMaybeJson(input.body);
if (input?.data && typeof input.data === 'object') return input.data;
if (input?.body && typeof input.body === 'object') return input.body;
return input;
}
function round1(v) {
return Math.round(v * 10) / 10;
}
function clamp(v, min, max) {
return Math.min(Math.max(v, min), max);
}
function pickDefined(...values) {
for (const v of values) {
if (v === undefined || v === null) continue;
if (typeof v === 'string' && v.trim() === '') continue;
return v;
}
return undefined;
}
function isOnLike(v) {
const s = low(v);
return s === 'on' || s === 'true' || s === '1' || s === 'yes';
}
function getParisNowParts() {
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())
.filter(p => p.type !== 'literal')
.map(p => [p.type, p.value])
);
const date = `${parts.year}-${parts.month}-${parts.day}`;
const stamp = `${date} ${parts.hour}:${parts.minute}:${parts.second}`;
return {
year: Number(parts.year),
month: Number(parts.month),
day: Number(parts.day),
hour: Number(parts.hour),
minute: Number(parts.minute),
second: Number(parts.second),
date,
stamp,
};
}
function parseLocalStampToMs(s) {
const text = (s ?? '').toString().trim();
const m = text.match(/^(\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2}):(\d{2})$/);
if (!m) return null;
const y = Number(m[1]);
const mo = Number(m[2]);
const d = Number(m[3]);
const h = Number(m[4]);
const mi = Number(m[5]);
const se = Number(m[6]);
if (![y, mo, d, h, mi, se].every(Number.isFinite)) return null;
return new Date(y, mo - 1, d, h, mi, se).getTime();
}
const cfg = {
mobileNotifyDomain: 'notify',
mobileNotifyService: 'mobile_app_iphone_16_de_tt',
};
const raw = $input.first().json;
const input = extractSnapshot(raw);
const now = getParisNowParts();
const nowMs = parseLocalStampToMs(now.stamp);
// ======================================================
// INPUTS PRINCIPAUX
// ======================================================
const soc = clamp(num(input.soc_percent, 0), 0, 100);
const gridIsOn = low(input.grid_state || 'off') === 'on';
const pvPowerW = Math.max(0, num(input.pv_power_w, 0));
const houseAcPowerW = Math.max(0, num(input.house_ac_power_w, 0));
const gridPowerW = num(input.grid_power_w, 0);
const evcsAcPowerW = Math.max(0, num(input.evcs_ac_power_w, 0));
const evcsCharging = low(input.evcs_charging || 'off') === 'on';
const evcsStatusRaw = num(input.evcs_status_raw, NaN);
const hysteresisEnabled = low(input.hysteresis_enabled || 'off') === 'on';
const notificationsEnabled = low(input.notifications_enabled || 'off') === 'on';
const latchIsOn = low(input.grid_latch || 'off') === 'on';
const gridSocOn = clamp(num(input.grid_soc_on, 22), 0, 100);
const gridSocOff = clamp(num(input.grid_soc_off, 35), 0, 100);
const gridMinOnMinutes = Math.max(0, num(input.grid_min_on_minutes, 45));
const gridCooldownMinutes = Math.max(0, num(input.grid_cooldown_minutes, 15));
const acLoadsMaxSafeW = Math.max(0, num(input.ac_loads_max_safe_w, 7800));
const lastOnAt = (input.last_on_at ?? '').toString().trim();
const lastOffAt = (input.last_off_at ?? '').toString().trim();
const lastSignature = (input.last_signature ?? '').toString().trim();
const lastOnMs = parseLocalStampToMs(lastOnAt);
const lastOffMs = parseLocalStampToMs(lastOffAt);
const minutesSinceLastOn =
lastOnMs !== null && nowMs !== null ? Math.max(0, (nowMs - lastOnMs) / 60000) : null;
const minutesSinceLastOff =
lastOffMs !== null && nowMs !== null ? Math.max(0, (nowMs - lastOffMs) / 60000) : null;
const minOnElapsed =
minutesSinceLastOn === null ? true : minutesSinceLastOn >= gridMinOnMinutes;
const cooldownElapsed =
minutesSinceLastOff === null ? true : minutesSinceLastOff >= gridCooldownMinutes;
const socBand =
soc <= gridSocOn ? 'low' :
soc < gridSocOff ? 'mid' :
'high';
const evcsSeemsActive = evcsCharging || evcsAcPowerW > 300;
const acOverSafeLimit = houseAcPowerW >= acLoadsMaxSafeW;
// ======================================================
// EVCS OVERRIDE INPUTS
// ======================================================
const evcsPriorityActive = isOnLike(
pickDefined(
input.evcs_priority_active,
input.victron_evcs_priority_active,
input['input_boolean.victron_evcs_priority_active']
)
);
const evcsNightGridTakeoverNeeded = isOnLike(
pickDefined(
input.night_grid_takeover_needed,
input.victron_evcs_night_grid_takeover_needed,
input['input_boolean.victron_evcs_night_grid_takeover_needed']
)
);
const evcsSourceModeRaw = (
pickDefined(
input.evcs_source_mode,
input.victron_evcs_source_mode,
input['input_text.victron_evcs_source_mode']
) ?? ''
).toString().trim();
const evcsSourceMode = low(evcsSourceModeRaw);
const userStrategyRaw = (
pickDefined(
input.user_strategy,
input.victron_evcs_user_strategy,
input['input_select.victron_evcs_user_strategy']
) ?? ''
).toString().trim();
const userStrategy = low(userStrategyRaw);

201
HYSTERESIS/COMPUTEP2.js Normal file
View File

@@ -0,0 +1,201 @@
// ======================================================
// EVCS SEMANTIC STATE
// Ici on ne détecte PAS la voiture pleine par seuils.
// On consomme seulement l'état sémantique déjà produit
// par le workflow EVCS.
// ======================================================
const evcsSurplusStateRaw = (
pickDefined(
input.evcs_surplus_state,
input.victron_evcs_surplus_state,
input['input_text.victron_evcs_surplus_state']
) ?? ''
).toString().trim();
const evcsSurplusState = low(evcsSurplusStateRaw);
const evcsVehicleNoDemandSemantic =
evcsSurplusState === 'vehicle_full_or_paused' ||
evcsSurplusState === 'vehicle_not_requesting_power' ||
evcsSurplusState === 'vehicle_full' ||
evcsSurplusState === 'charge_complete' ||
evcsSurplusState.includes('full_or_paused') ||
evcsSurplusState.includes('not_requesting_power') ||
evcsSurplusState.includes('charge_complete') ||
evcsSurplusState.includes('no_demand');
// ======================================================
// OVERRIDE EVCS VERS GRID
// Si EVCS dit déjà "plus de demande", on relâche l'override.
// ======================================================
const evcsGridOverride =
!evcsVehicleNoDemandSemantic &&
(
userStrategy === 'force_grid_now' ||
userStrategy === 'force_charge_now' ||
evcsPriorityActive ||
evcsNightGridTakeoverNeeded ||
evcsSourceMode === 'réseau forcé' ||
evcsSourceMode === 'réseau hc autorisé' ||
evcsSourceMode === 'grid_forced' ||
evcsSourceMode === 'grid_hc_allowed'
);
// ======================================================
// DÉCISION
// ======================================================
let state = 'observe_only';
let reason = [];
let desiredGrid = gridIsOn ? 'on' : 'off';
let desiredLatch = latchIsOn ? 'on' : 'off';
// ======================================================
// CORE HYSTERESIS LOGIC
// ======================================================
if (evcsGridOverride) {
desiredGrid = 'on';
desiredLatch = 'on';
if (!gridIsOn) {
state = 'turn_on_evcs_override';
reason.push('Override EVCS actif : activation EDF forcée');
} else if (!latchIsOn) {
state = 'adopt_existing_grid_on_evcs_override';
reason.push('Override EVCS actif : EDF déjà ON, adoption par latch');
} else {
state = 'hold_on_evcs_override';
reason.push('Override EVCS actif : maintien EDF ON');
}
if (userStrategy === 'force_grid_now') {
reason.push('Cause override : force_grid_now');
} else if (userStrategy === 'force_charge_now') {
reason.push('Cause override : force_charge_now');
} else if (evcsPriorityActive) {
reason.push('Cause override : evcs_priority_active');
} else if (evcsNightGridTakeoverNeeded) {
reason.push('Cause override : night_grid_takeover_needed');
} else {
reason.push(`Cause override : source_mode=${evcsSourceModeRaw || 'n/a'}`);
}
} else if (!hysteresisEnabled) {
state = 'hysteresis_disabled';
desiredGrid = gridIsOn ? 'on' : 'off';
desiredLatch = latchIsOn ? 'on' : 'off';
reason.push('Automatisme hystérésis réseau désactivé');
} else if (soc <= gridSocOn) {
desiredGrid = 'on';
desiredLatch = 'on';
if (!cooldownElapsed && !gridIsOn) {
state = 'turn_on_low_soc_cooldown_bypassed';
reason.push(`SOC ${round1(soc)}% <= seuil ON ${gridSocOn}% : activation EDF malgré cooldown`);
} else if (!gridIsOn) {
state = 'turn_on_low_soc';
reason.push(`SOC ${round1(soc)}% <= seuil ON ${gridSocOn}% : activation EDF`);
} else if (!latchIsOn) {
state = 'adopt_existing_grid_on_low_soc';
reason.push(`SOC ${round1(soc)}% <= seuil ON ${gridSocOn}% : EDF déjà ON, prise de contrôle par latch`);
} else {
state = 'hold_on_low_soc';
reason.push(`SOC encore bas ${round1(soc)}% : maintien EDF ON`);
}
} else if (gridIsOn && !latchIsOn) {
if (soc < gridSocOff) {
desiredGrid = 'on';
desiredLatch = 'on';
state = 'adopt_existing_grid_on';
reason.push(`EDF déjà ON avec latch OFF et SOC ${round1(soc)}% < seuil OFF ${gridSocOff}% : adoption de l'état ON`);
} else {
desiredGrid = 'off';
desiredLatch = 'off';
state = 'turn_off_stray_grid_on';
reason.push(`EDF ON avec latch OFF et SOC ${round1(soc)}% >= seuil OFF ${gridSocOff}% : coupure de reprise de contrôle`);
}
} else if (latchIsOn) {
if (!minOnElapsed) {
desiredGrid = 'on';
desiredLatch = 'on';
state = 'hold_on_min_runtime';
reason.push(`EDF maintenu ON : durée mini ${gridMinOnMinutes} min non atteinte (${round1(minutesSinceLastOn || 0)} min)`);
} else if (soc < gridSocOff) {
desiredGrid = 'on';
desiredLatch = 'on';
state = 'hold_on_until_soc_off';
reason.push(`EDF maintenu ON : SOC ${round1(soc)}% < seuil OFF ${gridSocOff}%`);
} else {
desiredGrid = 'off';
desiredLatch = 'off';
state = 'turn_off_recovered';
reason.push(`SOC ${round1(soc)}% >= seuil OFF ${gridSocOff}% : coupure EDF`);
}
} else {
desiredGrid = 'off';
desiredLatch = 'off';
state = 'observe_only';
reason.push('Aucune action : hors zone d’hystérésis');
}
// ======================================================
// COMPARE DESIRED VS CURRENT
// ======================================================
const gridAction =
desiredGrid === (gridIsOn ? 'on' : 'off')
? 'none'
: desiredGrid === 'on'
? 'turn_on'
: 'turn_off';
const latchAction =
desiredLatch === (latchIsOn ? 'on' : 'off')
? 'none'
: desiredLatch === 'on'
? 'turn_on'
: 'turn_off';
// ======================================================
// TIMESTAMPS
// ======================================================
const setLastOnAt =
gridAction === 'turn_on' ||
state === 'adopt_existing_grid_on' ||
state === 'adopt_existing_grid_on_low_soc' ||
state === 'adopt_existing_grid_on_evcs_override';
const setLastOffAt =
gridAction === 'turn_off';
// ======================================================
// CONTEXT / ALERTS
// ======================================================
reason.push(`Grid actuel ${gridIsOn ? 'ON' : 'OFF'}`);
reason.push(`Latch actuel ${latchIsOn ? 'ON' : 'OFF'}`);
reason.push(`SOC ${round1(soc)}% | seuils ${gridSocOn}%/${gridSocOff}%`);
reason.push(`PV ${Math.round(pvPowerW)}W`);
reason.push(`Charge AC maison ${Math.round(houseAcPowerW)}W`);
reason.push(`Puissance réseau ${Math.round(gridPowerW)}W`);
reason.push(`EVCS ${evcsSeemsActive ? 'actif' : 'inactif'} | ${Math.round(evcsAcPowerW)}W | status_raw=${Number.isFinite(evcsStatusRaw) ? evcsStatusRaw : 'n/a'}`);
if (minutesSinceLastOn !== null) {
reason.push(`Dernier ON il y a ${round1(minutesSinceLastOn)} min`);
}
if (minutesSinceLastOff !== null) {
reason.push(`Dernier OFF il y a ${round1(minutesSinceLastOff)} min`);
}
if (acOverSafeLimit) {
reason.push(`⚠️ Charge AC maison ${Math.round(houseAcPowerW)}W >= limite sûre ${Math.round(acLoadsMaxSafeW)}W`);
}
reason.push(`EVCS override ${evcsGridOverride ? 'ON' : 'OFF'}`);
reason.push(`EVCS priority ${evcsPriorityActive ? 'ON' : 'OFF'}`);
reason.push(`EVCS night takeover ${evcsNightGridTakeoverNeeded ? 'ON' : 'OFF'}`);
reason.push(`EVCS source ${evcsSourceModeRaw || 'n/a'}`);
reason.push(`EVCS strategy ${userStrategyRaw || 'n/a'}`);
reason.push(`EVCS surplus state ${evcsSurplusStateRaw || 'n/a'}`);
reason.push(`EVCS no-demand semantic ${evcsVehicleNoDemandSemantic ? 'ON' : 'OFF'}`);

224
HYSTERESIS/COMPUTEP3.js Normal file
View File

@@ -0,0 +1,224 @@
// ======================================================
// NOTIFICATIONS
// ======================================================
let notifyTitle = 'Victron Grid Hysteresis';
let notifyMessage = '';
let notifyWanted = false;
if (
state === 'turn_on_evcs_override' ||
state === 'adopt_existing_grid_on_evcs_override' ||
state === 'hold_on_evcs_override'
) {
notifyTitle = '🚗 EDF forcé par EVCS';
notifyMessage = `Priorité EVCS active. EDF maintenu ON pour la recharge véhicule. Cause: ${userStrategyRaw || evcsSourceModeRaw || 'override EVCS'}.`;
notifyWanted = true;
} else if (state === 'turn_on_low_soc' || state === 'turn_on_low_soc_cooldown_bypassed') {
notifyTitle = '⚡ EDF activé sur SOC bas';
notifyMessage = `SOC ${round1(soc)}% <= ${gridSocOn}%. EDF activé et maintenu jusqu'à au moins ${gridSocOff}% (min ${gridMinOnMinutes} min).`;
notifyWanted = true;
} else if (state === 'adopt_existing_grid_on_low_soc' || state === 'adopt_existing_grid_on') {
notifyTitle = '🔁 EDF ON repris par hystérésis';
notifyMessage = `EDF était déjà ON. Le workflow reprend la main et le maintient jusqu'à ${gridSocOff}% (min ${gridMinOnMinutes} min).`;
notifyWanted = true;
} else if (state === 'turn_off_recovered') {
notifyTitle = '✅ EDF coupé après recharge';
notifyMessage = `SOC ${round1(soc)}% >= ${gridSocOff}%. Retour hors réseau autorisé.`;
notifyWanted = true;
} else if (state === 'turn_off_stray_grid_on') {
notifyTitle = '✅ EDF coupé (reprise de contrôle)';
notifyMessage = `EDF était ON sans latch. SOC ${round1(soc)}% >= ${gridSocOff}%, coupure réseau appliquée.`;
notifyWanted = true;
} else if (acOverSafeLimit && !gridIsOn) {
notifyTitle = '🚨 Charge AC élevée hors réseau';
notifyMessage = `Charge AC maison ${Math.round(houseAcPowerW)}W au-dessus de la limite sûre ${Math.round(acLoadsMaxSafeW)}W alors que EDF est OFF.`;
notifyWanted = true;
} else if (acOverSafeLimit && gridIsOn) {
notifyTitle = '⚠️ Charge AC élevée';
notifyMessage = `Charge AC maison ${Math.round(houseAcPowerW)}W proche/au-dessus de la limite sûre ${Math.round(acLoadsMaxSafeW)}W.`;
notifyWanted = true;
}
const shortReason = reason.join(' | ').slice(0, 255);
const signature = [
now.date,
state,
`dg=${desiredGrid}`,
`dl=${desiredLatch}`,
`sb=${socBand}`,
`ac=${acOverSafeLimit ? '1' : '0'}`,
`ov=${evcsGridOverride ? '1' : '0'}`,
`ep=${evcsPriorityActive ? '1' : '0'}`,
`es=${evcsSurplusState || 'na'}`
].join('|');
const signatureChanged = signature !== lastSignature;
// ======================================================
// OUTPUT
// ======================================================
const result = {
timestamp_paris: now.stamp,
inputs: {
soc_percent: soc,
grid_state: gridIsOn ? 'on' : 'off',
pv_power_w: pvPowerW,
house_ac_power_w: houseAcPowerW,
grid_power_w: gridPowerW,
evcs_ac_power_w: evcsAcPowerW,
evcs_charging: evcsCharging,
evcs_status_raw: Number.isFinite(evcsStatusRaw) ? evcsStatusRaw : null,
hysteresis_enabled: hysteresisEnabled,
notifications_enabled: notificationsEnabled,
grid_latch: latchIsOn,
grid_soc_on: gridSocOn,
grid_soc_off: gridSocOff,
grid_min_on_minutes: gridMinOnMinutes,
grid_cooldown_minutes: gridCooldownMinutes,
ac_loads_max_safe_w: acLoadsMaxSafeW,
last_on_at: lastOnAt || null,
last_off_at: lastOffAt || null,
last_signature: lastSignature || null,
evcs_priority_active: evcsPriorityActive,
night_grid_takeover_needed: evcsNightGridTakeoverNeeded,
evcs_source_mode: evcsSourceModeRaw || null,
user_strategy: userStrategyRaw || null,
evcs_surplus_state: evcsSurplusStateRaw || null
},
computed: {
desired_grid: desiredGrid,
desired_latch: desiredLatch,
minutes_since_last_on: minutesSinceLastOn !== null ? round1(minutesSinceLastOn) : null,
minutes_since_last_off: minutesSinceLastOff !== null ? round1(minutesSinceLastOff) : null,
min_on_elapsed: minOnElapsed,
cooldown_elapsed: cooldownElapsed,
ac_over_safe_limit: acOverSafeLimit,
evcs_seems_active: evcsSeemsActive,
evcs_grid_override: evcsGridOverride,
evcs_priority_active: evcsPriorityActive,
evcs_night_grid_takeover_needed: evcsNightGridTakeoverNeeded,
evcs_source_mode: evcsSourceModeRaw || null,
evcs_user_strategy: userStrategyRaw || null,
evcs_surplus_state: evcsSurplusStateRaw || null,
evcs_vehicle_no_demand_semantic: evcsVehicleNoDemandSemantic
},
decision: {
state,
grid_action: gridAction,
latch_action: latchAction,
set_last_on_at: setLastOnAt,
set_last_off_at: setLastOffAt,
should_notify: notificationsEnabled && signatureChanged && notifyWanted,
signature,
signature_changed: signatureChanged,
reason,
short_reason: shortReason
},
ha_service_calls: {
helper_state: {
domain: 'input_text',
service: 'set_value',
data: {
entity_id: 'input_text.victron_grid_control_state',
value: state
}
},
helper_reason: {
domain: 'input_text',
service: 'set_value',
data: {
entity_id: 'input_text.victron_grid_control_reason',
value: shortReason || 'Aucune raison'
}
},
helper_signature: {
domain: 'input_text',
service: 'set_value',
data: {
entity_id: 'input_text.victron_grid_control_last_signature',
value: signature
}
},
helper_latch: {
domain: 'input_boolean',
service: desiredLatch === 'on' ? 'turn_on' : 'turn_off',
data: {
entity_id: 'input_boolean.victron_grid_hysteresis_latch'
}
},
helper_last_on_at: {
domain: 'input_datetime',
service: 'set_datetime',
data: {
entity_id: 'input_datetime.victron_grid_last_on_at',
datetime: now.stamp
}
},
helper_last_off_at: {
domain: 'input_datetime',
service: 'set_datetime',
data: {
entity_id: 'input_datetime.victron_grid_last_off_at',
datetime: now.stamp
}
},
grid: {
domain: 'switch',
service: desiredGrid === 'on' ? 'turn_on' : 'turn_off',
data: {
entity_id: 'switch.edf_general_ok_switch'
}
},
logbook: {
domain: 'logbook',
service: 'log',
data: {
name: 'Victron Grid Hysteresis',
message: `${state} | Grid=${desiredGrid} | Latch=${desiredLatch} | SOC=${round1(soc)}% | AC=${Math.round(houseAcPowerW)}W | ${shortReason}`,
entity_id: 'switch.edf_general_ok_switch'
}
},
mobile_notification: {
domain: cfg.mobileNotifyDomain,
service: cfg.mobileNotifyService,
data: {
title: notifyTitle,
message: notifyMessage,
data: {
push: {
'interruption-level': 'time-sensitive',
sound: { name: 'default' }
},
ttl: 0,
priority: 'high'
}
}
}
}
};
return [{ json: result }];

574
WINDOWS/WINDOW_SOURCEV2.js Normal file
View File

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

579
WINDOWS/manage_windows.js Normal file
View File

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

304
WINDOWS/manage_windowsP1.js Normal file
View File

@@ -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';

274
WINDOWS/manage_windowsP2.js Normal file
View File

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