Compare commits
2 Commits
5e6798b702
...
1776d2776f
| Author | SHA1 | Date | |
|---|---|---|---|
| 1776d2776f | |||
| 26b27f5faa |
1277
DECISION/DECISION_ENGINEV2.js
Normal file
1277
DECISION/DECISION_ENGINEV2.js
Normal file
File diff suppressed because it is too large
Load Diff
1194
DECISION/decision_engine.js
Normal file
1194
DECISION/decision_engine.js
Normal file
File diff suppressed because it is too large
Load Diff
279
DECISION/decision_engineP1.js
Normal file
279
DECISION/decision_engineP1.js
Normal 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
|
||||
);
|
||||
313
DECISION/decision_engineP2.js
Normal file
313
DECISION/decision_engineP2.js
Normal 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;
|
||||
364
DECISION/decision_engineP3.js
Normal file
364
DECISION/decision_engineP3.js
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
235
DECISION/decision_engineP4.js
Normal file
235
DECISION/decision_engineP4.js
Normal 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
1
EVCS
Submodule
Submodule EVCS added at 5e6798b702
625
HYSTERESIS/COMPUTE.js
Normal file
625
HYSTERESIS/COMPUTE.js
Normal 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
198
HYSTERESIS/COMPUTEP1.js
Normal 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
201
HYSTERESIS/COMPUTEP2.js
Normal 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
224
HYSTERESIS/COMPUTEP3.js
Normal 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
574
WINDOWS/WINDOW_SOURCEV2.js
Normal 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
579
WINDOWS/manage_windows.js
Normal 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
304
WINDOWS/manage_windowsP1.js
Normal 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
274
WINDOWS/manage_windowsP2.js
Normal 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,
|
||||
}
|
||||
}
|
||||
}];
|
||||
|
||||
Reference in New Issue
Block a user