R2: git = source de vérité — src/nodes/ + build/extract intégrés au push
- src/nodes/*.js (6 nodes + template snapshot) = source de vérité unique. - n8n-push.mjs reconstruit le JSON depuis src/ avant syntaxe+golden+PUT (src gagne toujours; n8n-extract.mjs pour resynchroniser si édition UI). - n8n-build.mjs --check pour vérifier la synchro. - Copies historiques déplacées dans archive/ (mortes, dérivées de la prod). - .gitignore: backups locaux de rollback. - AGENTS.md: règle R2 pour toutes les futures sessions (dont Qwen local). - Validé: idempotence, mutation/restauration, pipeline push complet en réel.
This commit is contained in:
709
archive/decoupesurplus/V2/DEC.js
Normal file
709
archive/decoupesurplus/V2/DEC.js
Normal file
@@ -0,0 +1,709 @@
|
||||
function clamp(v, min, max) {
|
||||
return Math.min(Math.max(v, min), max);
|
||||
}
|
||||
|
||||
function round1(v) {
|
||||
return Math.round(v * 10) / 10;
|
||||
}
|
||||
|
||||
function to2(n) {
|
||||
return String(n).padStart(2, '0');
|
||||
}
|
||||
|
||||
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,
|
||||
} = 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;
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
// =====================================================
|
||||
// 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),
|
||||
});
|
||||
}
|
||||
|
||||
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
|
||||
: false;
|
||||
|
||||
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 (!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 === 'grid_hc_allowed' || activeSourceMode === 'grid_forced') &&
|
||||
conditions.gridShouldStepDownBattery
|
||||
) {
|
||||
setOn({
|
||||
stateName: 'charging_step_down_grid_guard',
|
||||
decisionName: 'step_down',
|
||||
currentA: Math.max(
|
||||
params.currentMinA,
|
||||
Math.min(
|
||||
derived.currentReferenceA - params.currentStepDownA,
|
||||
derived.physicalCapA
|
||||
)
|
||||
),
|
||||
reasonText: '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'
|
||||
? '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'
|
||||
? '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 === '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 === '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: 'Maintien du courant',
|
||||
dynamic: true,
|
||||
mustStopFlag: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// =====================================================
|
||||
// START BRANCH
|
||||
// =====================================================
|
||||
else {
|
||||
const startConditionsOk =
|
||||
(context.sourceMode === 'grid_hc_allowed' || context.sourceMode === 'grid_forced')
|
||||
? conditions.gridStartOk
|
||||
: (context.sourceMode === 'solar_only' || context.sourceMode === 'solar_potential')
|
||||
? conditions.solarStartOk
|
||||
: context.sourceMode === 'hybrid_probe'
|
||||
? conditions.probeStartOk
|
||||
: 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 (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: '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' ? 'start_grid_forced_min_current' :
|
||||
context.sourceMode === 'grid_hc_allowed' ? 'start_grid_hc_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'
|
||||
? 'Démarrage en charge réseau forcée'
|
||||
: context.sourceMode === 'grid_hc_allowed'
|
||||
? 'Démarrage en fenêtre HC réseau'
|
||||
: 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';
|
||||
}
|
||||
}
|
||||
|
||||
// =====================================================
|
||||
// OUTPUT
|
||||
// =====================================================
|
||||
return [{
|
||||
json: {
|
||||
...prev,
|
||||
decision: {
|
||||
desiredCharging,
|
||||
desiredPermission,
|
||||
desiredLatch,
|
||||
desiredCurrentA,
|
||||
state,
|
||||
currentDecision,
|
||||
dynamicOk,
|
||||
mustStop,
|
||||
reason,
|
||||
nextBlockReason,
|
||||
nextBlockedUntilStamp,
|
||||
setCloudDebtStartedAt,
|
||||
setProbeStartedAt,
|
||||
notifyWanted,
|
||||
notifyTitle,
|
||||
notifyMessage,
|
||||
}
|
||||
}
|
||||
}];
|
||||
|
||||
129
archive/decoupesurplus/V2/DEC1.js
Normal file
129
archive/decoupesurplus/V2/DEC1.js
Normal file
@@ -0,0 +1,129 @@
|
||||
function clamp(v, min, max) {
|
||||
return Math.min(Math.max(v, min), max);
|
||||
}
|
||||
|
||||
function round1(v) {
|
||||
return Math.round(v * 10) / 10;
|
||||
}
|
||||
|
||||
function to2(n) {
|
||||
return String(n).padStart(2, '0');
|
||||
}
|
||||
|
||||
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,
|
||||
} = 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;
|
||||
|
||||
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);
|
||||
}
|
||||
419
archive/decoupesurplus/V2/DEC2.js
Normal file
419
archive/decoupesurplus/V2/DEC2.js
Normal file
@@ -0,0 +1,419 @@
|
||||
// =====================================================
|
||||
// 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),
|
||||
});
|
||||
}
|
||||
|
||||
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
|
||||
: false;
|
||||
|
||||
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 (!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 === 'grid_hc_allowed' || activeSourceMode === 'grid_forced') &&
|
||||
conditions.gridShouldStepDownBattery
|
||||
) {
|
||||
setOn({
|
||||
stateName: 'charging_step_down_grid_guard',
|
||||
decisionName: 'step_down',
|
||||
currentA: Math.max(
|
||||
params.currentMinA,
|
||||
Math.min(
|
||||
derived.currentReferenceA - params.currentStepDownA,
|
||||
derived.physicalCapA
|
||||
)
|
||||
),
|
||||
reasonText: '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'
|
||||
? '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'
|
||||
? '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 === '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 === '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: 'Maintien du courant',
|
||||
dynamic: true,
|
||||
mustStopFlag: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
159
archive/decoupesurplus/V2/DEC3.js
Normal file
159
archive/decoupesurplus/V2/DEC3.js
Normal file
@@ -0,0 +1,159 @@
|
||||
// =====================================================
|
||||
// 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
|
||||
: 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 (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: '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' ? 'start_grid_forced_min_current' :
|
||||
context.sourceMode === 'grid_hc_allowed' ? 'start_grid_hc_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'
|
||||
? 'Démarrage en charge réseau forcée'
|
||||
: context.sourceMode === 'grid_hc_allowed'
|
||||
? 'Démarrage en fenêtre HC réseau'
|
||||
: 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';
|
||||
}
|
||||
}
|
||||
|
||||
// =====================================================
|
||||
// OUTPUT
|
||||
// =====================================================
|
||||
return [{
|
||||
json: {
|
||||
...prev,
|
||||
decision: {
|
||||
desiredCharging,
|
||||
desiredPermission,
|
||||
desiredLatch,
|
||||
desiredCurrentA,
|
||||
state,
|
||||
currentDecision,
|
||||
dynamicOk,
|
||||
mustStop,
|
||||
reason,
|
||||
nextBlockReason,
|
||||
nextBlockedUntilStamp,
|
||||
setCloudDebtStartedAt,
|
||||
setProbeStartedAt,
|
||||
notifyWanted,
|
||||
notifyTitle,
|
||||
notifyMessage,
|
||||
}
|
||||
}
|
||||
}];
|
||||
|
||||
896
archive/decoupesurplus/V2/DECISION/decision.js
Normal file
896
archive/decoupesurplus/V2/DECISION/decision.js
Normal file
@@ -0,0 +1,896 @@
|
||||
function clamp(v, min, max) {
|
||||
return Math.min(Math.max(v, min), max);
|
||||
}
|
||||
|
||||
function round1(v) {
|
||||
return Math.round(v * 10) / 10;
|
||||
}
|
||||
|
||||
function to2(n) {
|
||||
return String(n).padStart(2, '0');
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// =====================================================
|
||||
// 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;
|
||||
|
||||
if (activeSourceMode === 'blocked') {
|
||||
if (lifecycle.minRunElapsed) {
|
||||
setOff({
|
||||
stateName: 'stop_source_lost',
|
||||
decisionName: 'stop',
|
||||
reasonText: 'Source autorisée perdue',
|
||||
dynamic: false,
|
||||
mustStopFlag: true,
|
||||
blockReason: context.profile === 'outside_window' ? 'outside_window' : 'source_unavailable',
|
||||
blockedUntilStamp:
|
||||
context.profile === 'outside_window' && Number.isFinite(locks.nextAllowedWindowStartMs)
|
||||
? formatParisStampFromMs(locks.nextAllowedWindowStartMs)
|
||||
: formatParisStampFromMs(nowMs + params.minStopMinutes * 60000),
|
||||
});
|
||||
} else {
|
||||
setOn({
|
||||
stateName: 'hold_min_run_source_lost',
|
||||
decisionName: 'hold',
|
||||
currentA: Math.min(derived.currentReferenceA, derived.physicalCapA),
|
||||
reasonText: "Source perdue mais maintien jusqu'au mini ON",
|
||||
dynamic: false,
|
||||
mustStopFlag: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
else if (
|
||||
(activeSourceMode === 'grid_hc_allowed' || activeSourceMode === 'grid_forced') &&
|
||||
!parsed.gridAvailable
|
||||
) {
|
||||
if (lifecycle.minRunElapsed) {
|
||||
setOff({
|
||||
stateName:
|
||||
context.profile === 'night_hc'
|
||||
? 'night_wait_grid_takeover_runtime'
|
||||
: 'wait_grid_source_runtime',
|
||||
decisionName: 'hold',
|
||||
reasonText:
|
||||
context.profile === 'night_hc'
|
||||
? 'Relais réseau demandé pendant la session nuit EVCS'
|
||||
: 'Source réseau demandée mais pas encore disponible',
|
||||
dynamic: false,
|
||||
mustStopFlag: false,
|
||||
blockReason:
|
||||
context.profile === 'night_hc' ? 'grid_hc_unavailable' : 'grid_unavailable',
|
||||
blockedUntilStamp: formatParisStampFromMs(nowMs + 10 * 60000),
|
||||
});
|
||||
} else {
|
||||
setOn({
|
||||
stateName: 'hold_min_run_wait_grid_takeover',
|
||||
decisionName: 'hold',
|
||||
currentA: Math.min(derived.currentReferenceA, derived.physicalCapA),
|
||||
reasonText: "Maintien jusqu'au mini ON en attente du réseau",
|
||||
dynamic: false,
|
||||
mustStopFlag: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
else if (!holdConditionsOk) {
|
||||
if (lifecycle.minRunElapsed) {
|
||||
setOff({
|
||||
stateName: 'stop_conditions_lost',
|
||||
decisionName: 'stop',
|
||||
reasonText: 'Conditions de maintien perdues',
|
||||
dynamic: false,
|
||||
mustStopFlag: true,
|
||||
blockReason: 'hold_conditions_lost',
|
||||
blockedUntilStamp: formatParisStampFromMs(nowMs + 10 * 60000),
|
||||
});
|
||||
} else {
|
||||
setOn({
|
||||
stateName: 'hold_min_run',
|
||||
decisionName: 'hold',
|
||||
currentA: Math.min(derived.currentReferenceA, derived.physicalCapA),
|
||||
reasonText: "Maintien jusqu'au temps mini ON",
|
||||
dynamic: true,
|
||||
mustStopFlag: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
else if ((derived.currentReferenceA > derived.physicalCapA) && lifecycle.currentCooldownElapsed) {
|
||||
setOn({
|
||||
stateName: 'charging_step_down_system_cap',
|
||||
decisionName: 'step_down',
|
||||
currentA: Math.max(
|
||||
params.currentMinA,
|
||||
Math.min(
|
||||
derived.currentReferenceA - Math.max(params.currentStepDownA, 2),
|
||||
derived.physicalCapA
|
||||
)
|
||||
),
|
||||
reasonText: 'Réduction pour respecter la capacité système',
|
||||
dynamic: true,
|
||||
mustStopFlag: false,
|
||||
});
|
||||
}
|
||||
|
||||
else if (conditions.shouldStepDownOverload) {
|
||||
setOn({
|
||||
stateName: 'charging_step_down_overload',
|
||||
decisionName: 'step_down_fast',
|
||||
currentA: Math.max(
|
||||
params.currentMinA,
|
||||
Math.min(
|
||||
derived.currentReferenceA - Math.max(batteryTune.global.overloadStepDownA, params.currentStepDownA),
|
||||
derived.physicalCapA
|
||||
)
|
||||
),
|
||||
reasonText: 'Baisse rapide: surcharge convertisseur',
|
||||
dynamic: true,
|
||||
mustStopFlag: false,
|
||||
});
|
||||
}
|
||||
|
||||
else if (conditions.shouldStepDownVebusPower) {
|
||||
setOn({
|
||||
stateName: 'charging_step_down_vebus_power',
|
||||
decisionName: 'step_down_fast',
|
||||
currentA: Math.max(
|
||||
params.currentMinA,
|
||||
Math.min(
|
||||
derived.currentReferenceA - Math.max(params.currentStepDownA + 1, 3),
|
||||
derived.physicalCapA
|
||||
)
|
||||
),
|
||||
reasonText: 'Baisse rapide: puissance VE.Bus élevée',
|
||||
dynamic: true,
|
||||
mustStopFlag: false,
|
||||
});
|
||||
}
|
||||
|
||||
else if (conditions.shouldStepDownVebusVoltage) {
|
||||
setOn({
|
||||
stateName: 'charging_step_down_dc_voltage',
|
||||
decisionName: 'step_down_fast',
|
||||
currentA: Math.max(
|
||||
params.currentMinA,
|
||||
Math.min(
|
||||
derived.currentReferenceA - Math.max(params.currentStepDownA + 1, 3),
|
||||
derived.physicalCapA
|
||||
)
|
||||
),
|
||||
reasonText: 'Baisse rapide: tension DC basse',
|
||||
dynamic: true,
|
||||
mustStopFlag: false,
|
||||
});
|
||||
}
|
||||
|
||||
else if (
|
||||
activeSourceMode === 'battery_assist_night' &&
|
||||
conditions.batteryAssistNightShouldStepDown
|
||||
) {
|
||||
setOn({
|
||||
stateName: 'charging_step_down_battery_assist_night',
|
||||
decisionName: 'step_down',
|
||||
currentA: Math.max(
|
||||
params.currentMinA,
|
||||
Math.min(
|
||||
derived.currentReferenceA - params.currentStepDownA,
|
||||
derived.physicalCapA
|
||||
)
|
||||
),
|
||||
reasonText:
|
||||
nightEvcs?.batteryRecoveryUrgent
|
||||
? 'Réduction: approche départ, préparation takeover réseau'
|
||||
: 'Réduction: assistance batterie nuit devient trop coûteuse',
|
||||
dynamic: true,
|
||||
mustStopFlag: false,
|
||||
});
|
||||
}
|
||||
|
||||
else if (
|
||||
(activeSourceMode === 'grid_hc_allowed' || activeSourceMode === 'grid_forced') &&
|
||||
conditions.gridShouldStepDownBattery
|
||||
) {
|
||||
setOn({
|
||||
stateName:
|
||||
activeSourceMode === 'grid_forced'
|
||||
? 'charging_step_down_grid_forced_guard'
|
||||
: 'charging_step_down_grid_guard',
|
||||
decisionName: 'step_down',
|
||||
currentA: Math.max(
|
||||
params.currentMinA,
|
||||
Math.min(
|
||||
derived.currentReferenceA - params.currentStepDownA,
|
||||
derived.physicalCapA
|
||||
)
|
||||
),
|
||||
reasonText:
|
||||
activeSourceMode === 'grid_forced'
|
||||
? 'Réduction réseau forcé: la batterie commence à participer'
|
||||
: 'Réduction réseau: la batterie commence à participer',
|
||||
dynamic: true,
|
||||
mustStopFlag: false,
|
||||
});
|
||||
}
|
||||
|
||||
else if (
|
||||
(activeSourceMode === 'solar_only' || activeSourceMode === 'solar_potential') &&
|
||||
conditions.solarShouldStepDownCloudDebt
|
||||
) {
|
||||
setOn({
|
||||
stateName: 'charging_step_down_cloud_debt',
|
||||
decisionName: 'step_down',
|
||||
currentA: Math.max(
|
||||
params.currentMinA,
|
||||
Math.min(
|
||||
derived.currentReferenceA - Math.max(params.currentStepDownA, 2),
|
||||
derived.physicalCapA
|
||||
)
|
||||
),
|
||||
reasonText: 'Réduction: dette nuage trop élevée',
|
||||
dynamic: true,
|
||||
mustStopFlag: false,
|
||||
});
|
||||
}
|
||||
|
||||
else if (
|
||||
(activeSourceMode === 'solar_only' || activeSourceMode === 'solar_potential') &&
|
||||
conditions.solarShouldStepDownBattery
|
||||
) {
|
||||
setOn({
|
||||
stateName: 'charging_step_down_battery',
|
||||
decisionName: 'step_down',
|
||||
currentA: Math.max(
|
||||
params.currentMinA,
|
||||
Math.min(
|
||||
derived.currentReferenceA - params.currentStepDownA,
|
||||
derived.physicalCapA
|
||||
)
|
||||
),
|
||||
reasonText: 'Réduction: batterie trop sollicitée',
|
||||
dynamic: true,
|
||||
mustStopFlag: false,
|
||||
});
|
||||
}
|
||||
|
||||
else if (
|
||||
(activeSourceMode === 'grid_hc_allowed' || activeSourceMode === 'grid_forced') &&
|
||||
conditions.gridShouldStepUp
|
||||
) {
|
||||
setOn({
|
||||
stateName:
|
||||
activeSourceMode === 'grid_forced'
|
||||
? (
|
||||
nightEvcs?.batteryRecoveryUrgent
|
||||
? 'night_departure_deadline_grid_forced'
|
||||
: 'charging_step_up_grid_forced'
|
||||
)
|
||||
: 'charging_step_up_grid_hc',
|
||||
decisionName: 'step_up',
|
||||
currentA: clamp(
|
||||
derived.currentReferenceA + params.currentStepUpA,
|
||||
params.currentMinA,
|
||||
derived.physicalCapA
|
||||
),
|
||||
reasonText:
|
||||
activeSourceMode === 'grid_forced'
|
||||
? (
|
||||
nightEvcs?.batteryRecoveryUrgent
|
||||
? 'Hausse prudente: priorité départ, réseau forcé'
|
||||
: 'Hausse prudente du courant en charge réseau forcée'
|
||||
)
|
||||
: 'Hausse prudente du courant en HC réseau',
|
||||
dynamic: true,
|
||||
mustStopFlag: false,
|
||||
});
|
||||
}
|
||||
|
||||
else if (
|
||||
activeSourceMode === 'battery_assist_night' &&
|
||||
conditions.batteryAssistNightShouldStepUp
|
||||
) {
|
||||
setOn({
|
||||
stateName: 'charging_step_up_battery_assist_night',
|
||||
decisionName: 'step_up',
|
||||
currentA: clamp(
|
||||
derived.currentReferenceA + params.currentStepUpA,
|
||||
params.currentMinA,
|
||||
derived.physicalCapA
|
||||
),
|
||||
reasonText: 'Hausse prudente du courant en assistance batterie nuit',
|
||||
dynamic: true,
|
||||
mustStopFlag: false,
|
||||
});
|
||||
}
|
||||
|
||||
else if (
|
||||
(activeSourceMode === 'solar_only' || activeSourceMode === 'solar_potential') &&
|
||||
conditions.solarShouldStepUp
|
||||
) {
|
||||
setOn({
|
||||
stateName:
|
||||
conditions.probeSuccessNow
|
||||
? 'probe_success_step_up'
|
||||
: (modes.batteryPriorityMode
|
||||
? 'charging_step_up_battery'
|
||||
: 'charging_step_up_forecast'),
|
||||
decisionName: 'step_up',
|
||||
currentA: clamp(
|
||||
derived.currentReferenceA + params.currentStepUpA,
|
||||
params.currentMinA,
|
||||
derived.physicalCapA
|
||||
),
|
||||
reasonText: 'Hausse prudente du courant',
|
||||
dynamic: true,
|
||||
mustStopFlag: false,
|
||||
});
|
||||
}
|
||||
|
||||
else {
|
||||
setOn({
|
||||
stateName:
|
||||
conditions.probeSuccessNow ? 'probe_success_hold' :
|
||||
activeSourceMode === 'battery_assist_night' ? 'charging_hold_battery_assist_night' :
|
||||
(activeSourceMode === 'grid_hc_allowed' || activeSourceMode === 'grid_forced') ? 'charging_hold_grid' :
|
||||
cloud.cloudDebtActive ? 'charging_hold_cloud_tolerance' :
|
||||
'charging_hold',
|
||||
decisionName: 'hold',
|
||||
currentA: Math.min(derived.currentReferenceA, derived.physicalCapA),
|
||||
reasonText:
|
||||
activeSourceMode === 'battery_assist_night'
|
||||
? 'Maintien du courant en assistance batterie nuit'
|
||||
: 'Maintien du courant',
|
||||
dynamic: true,
|
||||
mustStopFlag: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// =====================================================
|
||||
// START BRANCH
|
||||
// =====================================================
|
||||
else {
|
||||
const startConditionsOk =
|
||||
(context.sourceMode === 'grid_hc_allowed' || context.sourceMode === 'grid_forced')
|
||||
? conditions.gridStartOk
|
||||
: (context.sourceMode === 'solar_only' || context.sourceMode === 'solar_potential')
|
||||
? conditions.solarStartOk
|
||||
: context.sourceMode === 'hybrid_probe'
|
||||
? conditions.probeStartOk
|
||||
: context.sourceMode === 'battery_assist_night'
|
||||
? conditions.batteryAssistNightStartOk
|
||||
: false;
|
||||
|
||||
if (!derived.canRunAtMinCurrent) {
|
||||
setOff({
|
||||
stateName: 'blocked_by_capacity_min_current',
|
||||
decisionName: 'hold',
|
||||
reasonText: 'Capacité insuffisante pour courant mini',
|
||||
dynamic: false,
|
||||
mustStopFlag: false,
|
||||
blockReason: 'system_capacity_stop',
|
||||
blockedUntilStamp: formatParisStampFromMs(nowMs + 10 * 60000),
|
||||
});
|
||||
}
|
||||
|
||||
else if (context.sourceMode === 'blocked') {
|
||||
setOff({
|
||||
stateName:
|
||||
context.profile === 'outside_window'
|
||||
? 'blocked_outside_window'
|
||||
: (context.profile === 'night_hc'
|
||||
? 'night_no_grid_hc_lock'
|
||||
: 'idle_wait_source'),
|
||||
decisionName: 'hold',
|
||||
reasonText:
|
||||
context.profile === 'outside_window'
|
||||
? 'Hors fenêtre préférée sans source suffisante'
|
||||
: context.profile === 'night_hc'
|
||||
? 'Nuit sans autorisation HC réseau'
|
||||
: 'Source non autorisée ou insuffisante',
|
||||
dynamic: false,
|
||||
mustStopFlag: false,
|
||||
blockReason:
|
||||
context.profile === 'outside_window'
|
||||
? 'outside_window'
|
||||
: context.profile === 'night_hc'
|
||||
? 'grid_hc_unavailable'
|
||||
: 'source_unavailable',
|
||||
blockedUntilStamp:
|
||||
Number.isFinite(locks.nextAllowedWindowStartMs)
|
||||
? formatParisStampFromMs(locks.nextAllowedWindowStartMs)
|
||||
: formatParisStampFromMs(nowMs + 15 * 60000),
|
||||
});
|
||||
}
|
||||
|
||||
else if (
|
||||
(context.sourceMode === 'grid_hc_allowed' || context.sourceMode === 'grid_forced') &&
|
||||
!parsed.gridAvailable
|
||||
) {
|
||||
setOff({
|
||||
stateName:
|
||||
context.profile === 'night_hc'
|
||||
? 'night_wait_grid_takeover'
|
||||
: 'wait_grid_source',
|
||||
decisionName: 'hold',
|
||||
reasonText:
|
||||
context.profile === 'night_hc'
|
||||
? (
|
||||
nightEvcs?.batteryRecoveryUrgent
|
||||
? 'Réseau demandé: urgence recharge batterie avant départ'
|
||||
: 'Prise de relais réseau demandée pour session nuit EVCS'
|
||||
)
|
||||
: 'Source réseau demandée mais pas encore disponible',
|
||||
dynamic: false,
|
||||
mustStopFlag: false,
|
||||
blockReason:
|
||||
context.profile === 'night_hc'
|
||||
? 'grid_hc_unavailable'
|
||||
: 'grid_unavailable',
|
||||
blockedUntilStamp: formatParisStampFromMs(nowMs + 10 * 60000),
|
||||
});
|
||||
}
|
||||
|
||||
else if (locks.startBlockedByReason) {
|
||||
setOff({
|
||||
stateName: 'wait_reason_lock',
|
||||
decisionName: 'hold',
|
||||
reasonText: `Blocage anti-cliquetis: ${history.lastBlockReason}`,
|
||||
dynamic: false,
|
||||
mustStopFlag: false,
|
||||
blockReason: history.lastBlockReason,
|
||||
blockedUntilStamp: history.blockedUntilAt || now.stamp,
|
||||
});
|
||||
}
|
||||
|
||||
else if (!startConditionsOk) {
|
||||
setOff({
|
||||
stateName: 'idle_wait_conditions',
|
||||
decisionName: 'hold',
|
||||
reasonText:
|
||||
context.sourceMode === 'battery_assist_night'
|
||||
? 'Conditions de démarrage nuit batterie non réunies'
|
||||
: 'Conditions de démarrage non réunies',
|
||||
dynamic: false,
|
||||
mustStopFlag: false,
|
||||
blockReason: 'none',
|
||||
blockedUntilStamp: now.stamp,
|
||||
});
|
||||
}
|
||||
|
||||
else if (!lifecycle.minStopElapsed) {
|
||||
setOff({
|
||||
stateName: 'wait_min_stop',
|
||||
decisionName: 'hold',
|
||||
reasonText: 'Temps mini OFF non écoulé',
|
||||
dynamic: false,
|
||||
mustStopFlag: false,
|
||||
blockReason: 'none',
|
||||
blockedUntilStamp: now.stamp,
|
||||
});
|
||||
}
|
||||
|
||||
else {
|
||||
const startCurrentA =
|
||||
context.sourceMode === 'hybrid_probe'
|
||||
? clamp(params.probeCurrentA, params.currentMinA, derived.physicalCapA)
|
||||
: params.currentMinA;
|
||||
|
||||
setOn({
|
||||
stateName:
|
||||
context.sourceMode === 'grid_forced' ? (
|
||||
nightEvcs?.batteryRecoveryUrgent
|
||||
? 'night_departure_deadline_grid_forced'
|
||||
: 'start_grid_forced_min_current'
|
||||
) :
|
||||
context.sourceMode === 'grid_hc_allowed' ? 'start_grid_hc_min_current' :
|
||||
context.sourceMode === 'battery_assist_night' ? 'start_battery_assist_night_min_current' :
|
||||
context.sourceMode === 'hybrid_probe' ? 'probe_start_6a' :
|
||||
context.sourceMode === 'solar_potential' ? 'start_forecast_solar_potential' :
|
||||
modes.batteryFullHarvestMode ? 'start_min_current_battery_full_harvest' :
|
||||
modes.forecastCommitMode ? 'start_min_current_forecast_commit' :
|
||||
'start_min_current',
|
||||
decisionName: 'start',
|
||||
currentA: startCurrentA,
|
||||
reasonText:
|
||||
context.sourceMode === 'grid_forced'
|
||||
? (
|
||||
nightEvcs?.batteryRecoveryUrgent
|
||||
? 'Démarrage réseau forcé: priorité départ / recharge batterie'
|
||||
: 'Démarrage en charge réseau forcée'
|
||||
)
|
||||
: context.sourceMode === 'grid_hc_allowed'
|
||||
? 'Démarrage en fenêtre HC réseau'
|
||||
: context.sourceMode === 'battery_assist_night'
|
||||
? 'Démarrage nuit en assistance batterie contrôlée'
|
||||
: context.sourceMode === 'hybrid_probe'
|
||||
? 'Démarrage en mode sonde'
|
||||
: context.sourceMode === 'solar_potential'
|
||||
? 'Démarrage par potentiel solaire confirmé'
|
||||
: modes.batteryFullHarvestMode
|
||||
? 'Démarrage harvest solaire'
|
||||
: modes.forecastCommitMode
|
||||
? 'Démarrage forecast commit'
|
||||
: 'Démarrage prudent à courant mini',
|
||||
dynamic: true,
|
||||
mustStopFlag: false,
|
||||
});
|
||||
|
||||
nextBlockReason = 'none';
|
||||
nextBlockedUntilStamp = now.stamp;
|
||||
setProbeStartedAt = context.sourceMode === 'hybrid_probe';
|
||||
}
|
||||
}
|
||||
|
||||
// =====================================================
|
||||
// CONTEXT REASONS
|
||||
// =====================================================
|
||||
if (nightEvcs?.nightSessionActive) {
|
||||
reason.push(
|
||||
`Nuit EVCS: target=${round1(nightEvcs.batteryMorningTargetPct)}% | trigger réseau=${round1(nightEvcs.gridTakeoverTriggerPct)}%`
|
||||
);
|
||||
|
||||
if (Number.isFinite(nightEvcs.nightSessionStartSocPct)) {
|
||||
reason.push(
|
||||
`SOC départ session nuit=${round1(nightEvcs.nightSessionStartSocPct)}% | décharge autorisée=${round1(nightEvcs.maxBatteryDischargePoints)} pts`
|
||||
);
|
||||
}
|
||||
|
||||
if (nightEvcs.batteryRecoveryUrgent) {
|
||||
reason.push("Urgence départ: il faut anticiper la reprise réseau");
|
||||
}
|
||||
}
|
||||
|
||||
// =====================================================
|
||||
// OUTPUT
|
||||
// =====================================================
|
||||
return [{
|
||||
json: {
|
||||
...prev,
|
||||
decision: {
|
||||
desiredCharging,
|
||||
desiredPermission,
|
||||
desiredLatch,
|
||||
desiredCurrentA,
|
||||
|
||||
state,
|
||||
currentDecision,
|
||||
dynamicOk,
|
||||
mustStop,
|
||||
reason,
|
||||
|
||||
nextBlockReason,
|
||||
nextBlockedUntilStamp,
|
||||
|
||||
setCloudDebtStartedAt,
|
||||
setProbeStartedAt,
|
||||
|
||||
setNightSessionStart,
|
||||
clearNightSessionStart,
|
||||
|
||||
notifyWanted,
|
||||
notifyTitle,
|
||||
notifyMessage,
|
||||
}
|
||||
}
|
||||
}];
|
||||
|
||||
224
archive/decoupesurplus/V2/DECISION/decisionP1.js
Normal file
224
archive/decoupesurplus/V2/DECISION/decisionP1.js
Normal file
@@ -0,0 +1,224 @@
|
||||
function clamp(v, min, max) {
|
||||
return Math.min(Math.max(v, min), max);
|
||||
}
|
||||
|
||||
function round1(v) {
|
||||
return Math.round(v * 10) / 10;
|
||||
}
|
||||
|
||||
function to2(n) {
|
||||
return String(n).padStart(2, '0');
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// =====================================================
|
||||
// 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),
|
||||
});
|
||||
}
|
||||
241
archive/decoupesurplus/V2/DECISION/decisionP2.js
Normal file
241
archive/decoupesurplus/V2/DECISION/decisionP2.js
Normal file
@@ -0,0 +1,241 @@
|
||||
// =====================================================
|
||||
// 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;
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
429
archive/decoupesurplus/V2/DECISION/decisionP3.js
Normal file
429
archive/decoupesurplus/V2/DECISION/decisionP3.js
Normal file
@@ -0,0 +1,429 @@
|
||||
else if (conditions.shouldStepDownVebusVoltage) {
|
||||
setOn({
|
||||
stateName: 'charging_step_down_dc_voltage',
|
||||
decisionName: 'step_down_fast',
|
||||
currentA: Math.max(
|
||||
params.currentMinA,
|
||||
Math.min(
|
||||
derived.currentReferenceA - Math.max(params.currentStepDownA + 1, 3),
|
||||
derived.physicalCapA
|
||||
)
|
||||
),
|
||||
reasonText: 'Baisse rapide: tension DC basse',
|
||||
dynamic: true,
|
||||
mustStopFlag: false,
|
||||
});
|
||||
}
|
||||
|
||||
else if (
|
||||
activeSourceMode === 'battery_assist_night' &&
|
||||
conditions.batteryAssistNightShouldStepDown
|
||||
) {
|
||||
setOn({
|
||||
stateName: 'charging_step_down_battery_assist_night',
|
||||
decisionName: 'step_down',
|
||||
currentA: Math.max(
|
||||
params.currentMinA,
|
||||
Math.min(
|
||||
derived.currentReferenceA - params.currentStepDownA,
|
||||
derived.physicalCapA
|
||||
)
|
||||
),
|
||||
reasonText:
|
||||
nightEvcs?.batteryRecoveryUrgent
|
||||
? 'Réduction: approche départ, préparation takeover réseau'
|
||||
: 'Réduction: assistance batterie nuit devient trop coûteuse',
|
||||
dynamic: true,
|
||||
mustStopFlag: false,
|
||||
});
|
||||
}
|
||||
|
||||
else if (
|
||||
(activeSourceMode === 'grid_hc_allowed' || activeSourceMode === 'grid_forced') &&
|
||||
conditions.gridShouldStepDownBattery
|
||||
) {
|
||||
setOn({
|
||||
stateName:
|
||||
activeSourceMode === 'grid_forced'
|
||||
? 'charging_step_down_grid_forced_guard'
|
||||
: 'charging_step_down_grid_guard',
|
||||
decisionName: 'step_down',
|
||||
currentA: Math.max(
|
||||
params.currentMinA,
|
||||
Math.min(
|
||||
derived.currentReferenceA - params.currentStepDownA,
|
||||
derived.physicalCapA
|
||||
)
|
||||
),
|
||||
reasonText:
|
||||
activeSourceMode === 'grid_forced'
|
||||
? 'Réduction réseau forcé: la batterie commence à participer'
|
||||
: 'Réduction réseau: la batterie commence à participer',
|
||||
dynamic: true,
|
||||
mustStopFlag: false,
|
||||
});
|
||||
}
|
||||
|
||||
else if (
|
||||
(activeSourceMode === 'solar_only' || activeSourceMode === 'solar_potential') &&
|
||||
conditions.solarShouldStepDownCloudDebt
|
||||
) {
|
||||
setOn({
|
||||
stateName: 'charging_step_down_cloud_debt',
|
||||
decisionName: 'step_down',
|
||||
currentA: Math.max(
|
||||
params.currentMinA,
|
||||
Math.min(
|
||||
derived.currentReferenceA - Math.max(params.currentStepDownA, 2),
|
||||
derived.physicalCapA
|
||||
)
|
||||
),
|
||||
reasonText: 'Réduction: dette nuage trop élevée',
|
||||
dynamic: true,
|
||||
mustStopFlag: false,
|
||||
});
|
||||
}
|
||||
|
||||
else if (
|
||||
(activeSourceMode === 'solar_only' || activeSourceMode === 'solar_potential') &&
|
||||
conditions.solarShouldStepDownBattery
|
||||
) {
|
||||
setOn({
|
||||
stateName: 'charging_step_down_battery',
|
||||
decisionName: 'step_down',
|
||||
currentA: Math.max(
|
||||
params.currentMinA,
|
||||
Math.min(
|
||||
derived.currentReferenceA - params.currentStepDownA,
|
||||
derived.physicalCapA
|
||||
)
|
||||
),
|
||||
reasonText: 'Réduction: batterie trop sollicitée',
|
||||
dynamic: true,
|
||||
mustStopFlag: false,
|
||||
});
|
||||
}
|
||||
|
||||
else if (
|
||||
(activeSourceMode === 'grid_hc_allowed' || activeSourceMode === 'grid_forced') &&
|
||||
conditions.gridShouldStepUp
|
||||
) {
|
||||
setOn({
|
||||
stateName:
|
||||
activeSourceMode === 'grid_forced'
|
||||
? (
|
||||
nightEvcs?.batteryRecoveryUrgent
|
||||
? 'night_departure_deadline_grid_forced'
|
||||
: 'charging_step_up_grid_forced'
|
||||
)
|
||||
: 'charging_step_up_grid_hc',
|
||||
decisionName: 'step_up',
|
||||
currentA: clamp(
|
||||
derived.currentReferenceA + params.currentStepUpA,
|
||||
params.currentMinA,
|
||||
derived.physicalCapA
|
||||
),
|
||||
reasonText:
|
||||
activeSourceMode === 'grid_forced'
|
||||
? (
|
||||
nightEvcs?.batteryRecoveryUrgent
|
||||
? 'Hausse prudente: priorité départ, réseau forcé'
|
||||
: 'Hausse prudente du courant en charge réseau forcée'
|
||||
)
|
||||
: 'Hausse prudente du courant en HC réseau',
|
||||
dynamic: true,
|
||||
mustStopFlag: false,
|
||||
});
|
||||
}
|
||||
|
||||
else if (
|
||||
activeSourceMode === 'battery_assist_night' &&
|
||||
conditions.batteryAssistNightShouldStepUp
|
||||
) {
|
||||
setOn({
|
||||
stateName: 'charging_step_up_battery_assist_night',
|
||||
decisionName: 'step_up',
|
||||
currentA: clamp(
|
||||
derived.currentReferenceA + params.currentStepUpA,
|
||||
params.currentMinA,
|
||||
derived.physicalCapA
|
||||
),
|
||||
reasonText: 'Hausse prudente du courant en assistance batterie nuit',
|
||||
dynamic: true,
|
||||
mustStopFlag: false,
|
||||
});
|
||||
}
|
||||
|
||||
else if (
|
||||
(activeSourceMode === 'solar_only' || activeSourceMode === 'solar_potential') &&
|
||||
conditions.solarShouldStepUp
|
||||
) {
|
||||
setOn({
|
||||
stateName:
|
||||
conditions.probeSuccessNow
|
||||
? 'probe_success_step_up'
|
||||
: (modes.batteryPriorityMode
|
||||
? 'charging_step_up_battery'
|
||||
: 'charging_step_up_forecast'),
|
||||
decisionName: 'step_up',
|
||||
currentA: clamp(
|
||||
derived.currentReferenceA + params.currentStepUpA,
|
||||
params.currentMinA,
|
||||
derived.physicalCapA
|
||||
),
|
||||
reasonText: 'Hausse prudente du courant',
|
||||
dynamic: true,
|
||||
mustStopFlag: false,
|
||||
});
|
||||
}
|
||||
|
||||
else {
|
||||
setOn({
|
||||
stateName:
|
||||
conditions.probeSuccessNow ? 'probe_success_hold' :
|
||||
activeSourceMode === 'battery_assist_night' ? 'charging_hold_battery_assist_night' :
|
||||
(activeSourceMode === 'grid_hc_allowed' || activeSourceMode === 'grid_forced') ? 'charging_hold_grid' :
|
||||
cloud.cloudDebtActive ? 'charging_hold_cloud_tolerance' :
|
||||
'charging_hold',
|
||||
decisionName: 'hold',
|
||||
currentA: Math.min(derived.currentReferenceA, derived.physicalCapA),
|
||||
reasonText:
|
||||
activeSourceMode === 'battery_assist_night'
|
||||
? 'Maintien du courant en assistance batterie nuit'
|
||||
: 'Maintien du courant',
|
||||
dynamic: true,
|
||||
mustStopFlag: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// =====================================================
|
||||
// START BRANCH
|
||||
// =====================================================
|
||||
else {
|
||||
const startConditionsOk =
|
||||
(context.sourceMode === 'grid_hc_allowed' || context.sourceMode === 'grid_forced')
|
||||
? conditions.gridStartOk
|
||||
: (context.sourceMode === 'solar_only' || context.sourceMode === 'solar_potential')
|
||||
? conditions.solarStartOk
|
||||
: context.sourceMode === 'hybrid_probe'
|
||||
? conditions.probeStartOk
|
||||
: context.sourceMode === 'battery_assist_night'
|
||||
? conditions.batteryAssistNightStartOk
|
||||
: false;
|
||||
|
||||
if (!derived.canRunAtMinCurrent) {
|
||||
setOff({
|
||||
stateName: 'blocked_by_capacity_min_current',
|
||||
decisionName: 'hold',
|
||||
reasonText: 'Capacité insuffisante pour courant mini',
|
||||
dynamic: false,
|
||||
mustStopFlag: false,
|
||||
blockReason: 'system_capacity_stop',
|
||||
blockedUntilStamp: formatParisStampFromMs(nowMs + 10 * 60000),
|
||||
});
|
||||
}
|
||||
|
||||
else if (context.sourceMode === 'blocked') {
|
||||
setOff({
|
||||
stateName:
|
||||
context.profile === 'outside_window'
|
||||
? 'blocked_outside_window'
|
||||
: (context.profile === 'night_hc'
|
||||
? 'night_no_grid_hc_lock'
|
||||
: 'idle_wait_source'),
|
||||
decisionName: 'hold',
|
||||
reasonText:
|
||||
context.profile === 'outside_window'
|
||||
? 'Hors fenêtre préférée sans source suffisante'
|
||||
: context.profile === 'night_hc'
|
||||
? 'Nuit sans autorisation HC réseau'
|
||||
: 'Source non autorisée ou insuffisante',
|
||||
dynamic: false,
|
||||
mustStopFlag: false,
|
||||
blockReason:
|
||||
context.profile === 'outside_window'
|
||||
? 'outside_window'
|
||||
: context.profile === 'night_hc'
|
||||
? 'grid_hc_unavailable'
|
||||
: 'source_unavailable',
|
||||
blockedUntilStamp:
|
||||
Number.isFinite(locks.nextAllowedWindowStartMs)
|
||||
? formatParisStampFromMs(locks.nextAllowedWindowStartMs)
|
||||
: formatParisStampFromMs(nowMs + 15 * 60000),
|
||||
});
|
||||
}
|
||||
|
||||
else if (
|
||||
(context.sourceMode === 'grid_hc_allowed' || context.sourceMode === 'grid_forced') &&
|
||||
!parsed.gridAvailable
|
||||
) {
|
||||
setOff({
|
||||
stateName:
|
||||
context.profile === 'night_hc'
|
||||
? 'night_wait_grid_takeover'
|
||||
: 'wait_grid_source',
|
||||
decisionName: 'hold',
|
||||
reasonText:
|
||||
context.profile === 'night_hc'
|
||||
? (
|
||||
nightEvcs?.batteryRecoveryUrgent
|
||||
? 'Réseau demandé: urgence recharge batterie avant départ'
|
||||
: 'Prise de relais réseau demandée pour session nuit EVCS'
|
||||
)
|
||||
: 'Source réseau demandée mais pas encore disponible',
|
||||
dynamic: false,
|
||||
mustStopFlag: false,
|
||||
blockReason:
|
||||
context.profile === 'night_hc'
|
||||
? 'grid_hc_unavailable'
|
||||
: 'grid_unavailable',
|
||||
blockedUntilStamp: formatParisStampFromMs(nowMs + 10 * 60000),
|
||||
});
|
||||
}
|
||||
|
||||
else if (locks.startBlockedByReason) {
|
||||
setOff({
|
||||
stateName: 'wait_reason_lock',
|
||||
decisionName: 'hold',
|
||||
reasonText: `Blocage anti-cliquetis: ${history.lastBlockReason}`,
|
||||
dynamic: false,
|
||||
mustStopFlag: false,
|
||||
blockReason: history.lastBlockReason,
|
||||
blockedUntilStamp: history.blockedUntilAt || now.stamp,
|
||||
});
|
||||
}
|
||||
|
||||
else if (!startConditionsOk) {
|
||||
setOff({
|
||||
stateName: 'idle_wait_conditions',
|
||||
decisionName: 'hold',
|
||||
reasonText:
|
||||
context.sourceMode === 'battery_assist_night'
|
||||
? 'Conditions de démarrage nuit batterie non réunies'
|
||||
: 'Conditions de démarrage non réunies',
|
||||
dynamic: false,
|
||||
mustStopFlag: false,
|
||||
blockReason: 'none',
|
||||
blockedUntilStamp: now.stamp,
|
||||
});
|
||||
}
|
||||
|
||||
else if (!lifecycle.minStopElapsed) {
|
||||
setOff({
|
||||
stateName: 'wait_min_stop',
|
||||
decisionName: 'hold',
|
||||
reasonText: 'Temps mini OFF non écoulé',
|
||||
dynamic: false,
|
||||
mustStopFlag: false,
|
||||
blockReason: 'none',
|
||||
blockedUntilStamp: now.stamp,
|
||||
});
|
||||
}
|
||||
|
||||
else {
|
||||
const startCurrentA =
|
||||
context.sourceMode === 'hybrid_probe'
|
||||
? clamp(params.probeCurrentA, params.currentMinA, derived.physicalCapA)
|
||||
: params.currentMinA;
|
||||
|
||||
setOn({
|
||||
stateName:
|
||||
context.sourceMode === 'grid_forced' ? (
|
||||
nightEvcs?.batteryRecoveryUrgent
|
||||
? 'night_departure_deadline_grid_forced'
|
||||
: 'start_grid_forced_min_current'
|
||||
) :
|
||||
context.sourceMode === 'grid_hc_allowed' ? 'start_grid_hc_min_current' :
|
||||
context.sourceMode === 'battery_assist_night' ? 'start_battery_assist_night_min_current' :
|
||||
context.sourceMode === 'hybrid_probe' ? 'probe_start_6a' :
|
||||
context.sourceMode === 'solar_potential' ? 'start_forecast_solar_potential' :
|
||||
modes.batteryFullHarvestMode ? 'start_min_current_battery_full_harvest' :
|
||||
modes.forecastCommitMode ? 'start_min_current_forecast_commit' :
|
||||
'start_min_current',
|
||||
decisionName: 'start',
|
||||
currentA: startCurrentA,
|
||||
reasonText:
|
||||
context.sourceMode === 'grid_forced'
|
||||
? (
|
||||
nightEvcs?.batteryRecoveryUrgent
|
||||
? 'Démarrage réseau forcé: priorité départ / recharge batterie'
|
||||
: 'Démarrage en charge réseau forcée'
|
||||
)
|
||||
: context.sourceMode === 'grid_hc_allowed'
|
||||
? 'Démarrage en fenêtre HC réseau'
|
||||
: context.sourceMode === 'battery_assist_night'
|
||||
? 'Démarrage nuit en assistance batterie contrôlée'
|
||||
: context.sourceMode === 'hybrid_probe'
|
||||
? 'Démarrage en mode sonde'
|
||||
: context.sourceMode === 'solar_potential'
|
||||
? 'Démarrage par potentiel solaire confirmé'
|
||||
: modes.batteryFullHarvestMode
|
||||
? 'Démarrage harvest solaire'
|
||||
: modes.forecastCommitMode
|
||||
? 'Démarrage forecast commit'
|
||||
: 'Démarrage prudent à courant mini',
|
||||
dynamic: true,
|
||||
mustStopFlag: false,
|
||||
});
|
||||
|
||||
nextBlockReason = 'none';
|
||||
nextBlockedUntilStamp = now.stamp;
|
||||
setProbeStartedAt = context.sourceMode === 'hybrid_probe';
|
||||
}
|
||||
}
|
||||
|
||||
// =====================================================
|
||||
// CONTEXT REASONS
|
||||
// =====================================================
|
||||
if (nightEvcs?.nightSessionActive) {
|
||||
reason.push(
|
||||
`Nuit EVCS: target=${round1(nightEvcs.batteryMorningTargetPct)}% | trigger réseau=${round1(nightEvcs.gridTakeoverTriggerPct)}%`
|
||||
);
|
||||
|
||||
if (Number.isFinite(nightEvcs.nightSessionStartSocPct)) {
|
||||
reason.push(
|
||||
`SOC départ session nuit=${round1(nightEvcs.nightSessionStartSocPct)}% | décharge autorisée=${round1(nightEvcs.maxBatteryDischargePoints)} pts`
|
||||
);
|
||||
}
|
||||
|
||||
if (nightEvcs.batteryRecoveryUrgent) {
|
||||
reason.push("Urgence départ: il faut anticiper la reprise réseau");
|
||||
}
|
||||
}
|
||||
|
||||
// =====================================================
|
||||
// OUTPUT
|
||||
// =====================================================
|
||||
return [{
|
||||
json: {
|
||||
...prev,
|
||||
decision: {
|
||||
desiredCharging,
|
||||
desiredPermission,
|
||||
desiredLatch,
|
||||
desiredCurrentA,
|
||||
|
||||
state,
|
||||
currentDecision,
|
||||
dynamicOk,
|
||||
mustStop,
|
||||
reason,
|
||||
|
||||
nextBlockReason,
|
||||
nextBlockedUntilStamp,
|
||||
|
||||
setCloudDebtStartedAt,
|
||||
setProbeStartedAt,
|
||||
|
||||
setNightSessionStart,
|
||||
clearNightSessionStart,
|
||||
|
||||
notifyWanted,
|
||||
notifyTitle,
|
||||
notifyMessage,
|
||||
}
|
||||
}
|
||||
}];
|
||||
|
||||
199
archive/decoupesurplus/V2/DECISION/decisionP4.js
Normal file
199
archive/decoupesurplus/V2/DECISION/decisionP4.js
Normal file
@@ -0,0 +1,199 @@
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
229
archive/decoupesurplus/V2/DECISION/decisionP5.js
Normal file
229
archive/decoupesurplus/V2/DECISION/decisionP5.js
Normal file
@@ -0,0 +1,229 @@
|
||||
// =====================================================
|
||||
// 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");
|
||||
}
|
||||
}
|
||||
|
||||
// =====================================================
|
||||
// OUTPUT
|
||||
// =====================================================
|
||||
return [{
|
||||
json: {
|
||||
...prev,
|
||||
decision: {
|
||||
desiredCharging,
|
||||
desiredPermission,
|
||||
desiredLatch,
|
||||
desiredCurrentA,
|
||||
|
||||
state,
|
||||
currentDecision,
|
||||
dynamicOk,
|
||||
mustStop,
|
||||
reason,
|
||||
|
||||
nextBlockReason,
|
||||
nextBlockedUntilStamp,
|
||||
|
||||
setCloudDebtStartedAt,
|
||||
setProbeStartedAt,
|
||||
|
||||
setNightSessionStart,
|
||||
clearNightSessionStart,
|
||||
|
||||
notifyWanted,
|
||||
notifyTitle,
|
||||
notifyMessage,
|
||||
}
|
||||
}
|
||||
}];
|
||||
|
||||
719
archive/decoupesurplus/V2/HASERVICEOUTPUT.js
Normal file
719
archive/decoupesurplus/V2/HASERVICEOUTPUT.js
Normal file
@@ -0,0 +1,719 @@
|
||||
function round1(v) {
|
||||
return Math.round(v * 10) / 10;
|
||||
}
|
||||
function round2(v) {
|
||||
return Math.round(v * 100) / 100;
|
||||
}
|
||||
function clamp(v, min, max) {
|
||||
return Math.min(Math.max(v, min), max);
|
||||
}
|
||||
function to2(n) {
|
||||
return String(n).padStart(2, '0');
|
||||
}
|
||||
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 {
|
||||
input,
|
||||
now,
|
||||
nowMs,
|
||||
cfg,
|
||||
parsed,
|
||||
params,
|
||||
history,
|
||||
strategy,
|
||||
windows,
|
||||
context,
|
||||
derived,
|
||||
batteryTune,
|
||||
batteryPolicy,
|
||||
locks,
|
||||
cloud,
|
||||
modes,
|
||||
safeguards,
|
||||
conditions,
|
||||
decision,
|
||||
} = prev;
|
||||
|
||||
// =====================================================
|
||||
// FINAL ACTIONS
|
||||
// =====================================================
|
||||
let desiredCurrentA = decision.desiredCurrentA;
|
||||
|
||||
desiredCurrentA = decision.desiredCharging
|
||||
? clamp(
|
||||
desiredCurrentA,
|
||||
params.currentMinA,
|
||||
Math.max(params.currentMinA, derived.physicalCapA || params.currentMinA)
|
||||
)
|
||||
: params.currentMinA;
|
||||
|
||||
const chargingAction =
|
||||
decision.desiredCharging === parsed.evcsChargingSwitchOn
|
||||
? 'none'
|
||||
: decision.desiredCharging
|
||||
? 'turn_on'
|
||||
: 'turn_off';
|
||||
|
||||
const permissionAction =
|
||||
decision.desiredPermission === parsed.chargePermissionNow
|
||||
? 'none'
|
||||
: decision.desiredPermission
|
||||
? 'turn_on'
|
||||
: 'turn_off';
|
||||
|
||||
const latchAction =
|
||||
decision.desiredLatch === parsed.controlLatch
|
||||
? 'none'
|
||||
: decision.desiredLatch
|
||||
? 'turn_on'
|
||||
: 'turn_off';
|
||||
|
||||
const autoStartAction =
|
||||
parsed.controllerEnabled &&
|
||||
parsed.evcsVehicleConnected &&
|
||||
parsed.evcsAutoStartSwitchOn
|
||||
? 'turn_off'
|
||||
: 'none';
|
||||
|
||||
const currentAction =
|
||||
decision.desiredCharging &&
|
||||
(
|
||||
!Number.isFinite(derived.currentReferenceA) ||
|
||||
Math.abs(desiredCurrentA - derived.currentReferenceA) >= 0.5
|
||||
)
|
||||
? 'set'
|
||||
: 'none';
|
||||
|
||||
const setLastStartAt = chargingAction === 'turn_on';
|
||||
const setLastStopAt = chargingAction === 'turn_off';
|
||||
const setLastCurrentChangeAt = currentAction === 'set';
|
||||
|
||||
// =====================================================
|
||||
// CLEAN BLOCKING LOGIC
|
||||
// =====================================================
|
||||
const blockedUntilToWrite =
|
||||
decision.nextBlockReason === 'none'
|
||||
? now.stamp
|
||||
: decision.nextBlockedUntilStamp;
|
||||
|
||||
const needsBlockReasonUpdate =
|
||||
decision.nextBlockReason !== history.lastBlockReason;
|
||||
|
||||
// =====================================================
|
||||
// GLOBAL EVCS PRIORITY
|
||||
// AUTO + OVERRIDE MANUEL
|
||||
// =====================================================
|
||||
const forceWindowMinutes = Math.max(
|
||||
30,
|
||||
batteryTune?.global?.forceModeAutoRevertMinutes ?? 120
|
||||
);
|
||||
|
||||
const activeChargeIntent =
|
||||
decision.desiredCharging ||
|
||||
['start', 'hold', 'step_up', 'step_down', 'step_down_fast'].includes(decision.currentDecision);
|
||||
|
||||
// -------------------------
|
||||
// PRIORITÉ AUTO
|
||||
// -------------------------
|
||||
let autoEvcsPriorityActive = false;
|
||||
let autoEnergyPriorityMode = 'normal';
|
||||
let autoEnergyPriorityReason = 'Aucune priorité EVCS';
|
||||
let autoEvcsPriorityUntil = now.stamp;
|
||||
|
||||
if (parsed.evcsVehicleConnected && !strategy.isStopMode) {
|
||||
if (strategy.forceChargeRequested || strategy.forceGridRequested) {
|
||||
autoEvcsPriorityActive = true;
|
||||
autoEnergyPriorityMode = 'evcs_priority';
|
||||
autoEnergyPriorityReason =
|
||||
strategy.forceGridRequested
|
||||
? 'EVCS prioritaire : force_grid_now'
|
||||
: 'EVCS prioritaire : force_charge_now';
|
||||
autoEvcsPriorityUntil = formatParisStampFromMs(nowMs + forceWindowMinutes * 60000);
|
||||
|
||||
} else if (context.departurePriorityActive && Number.isFinite(prev.lifecycle.minutesUntilDeparture)) {
|
||||
autoEvcsPriorityActive = true;
|
||||
autoEnergyPriorityMode = 'evcs_priority';
|
||||
autoEnergyPriorityReason = `EVCS prioritaire : départ dans ${Math.round(prev.lifecycle.minutesUntilDeparture)} min`;
|
||||
autoEvcsPriorityUntil = formatParisStampFromMs(
|
||||
nowMs + Math.max(1, prev.lifecycle.minutesUntilDeparture) * 60000
|
||||
);
|
||||
|
||||
} else if (activeChargeIntent && context.profile === 'midday_hc_or_solar') {
|
||||
autoEvcsPriorityActive = true;
|
||||
autoEnergyPriorityMode = 'evcs_priority';
|
||||
autoEnergyPriorityReason = 'EVCS prioritaire : recharge active fenêtre midi';
|
||||
autoEvcsPriorityUntil = formatParisStampFromMs(nowMs + 90 * 60000);
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------
|
||||
// OVERRIDE MANUEL
|
||||
// -------------------------
|
||||
const manualPriorityOverrideActive =
|
||||
parsed.evcsPriorityManualOverride &&
|
||||
parsed.evcsPriorityManualMode === 'evcs_priority' &&
|
||||
history.evcsPriorityManualUntilMs !== null &&
|
||||
history.evcsPriorityManualUntilMs > nowMs + 1000;
|
||||
|
||||
let evcsPriorityActive = autoEvcsPriorityActive;
|
||||
let energyPriorityMode = autoEnergyPriorityMode;
|
||||
let energyPriorityReason = autoEnergyPriorityReason;
|
||||
let evcsPriorityUntil = autoEvcsPriorityUntil;
|
||||
let evcsPrioritySource = 'auto';
|
||||
|
||||
if (manualPriorityOverrideActive) {
|
||||
evcsPriorityActive = true;
|
||||
energyPriorityMode = 'evcs_priority';
|
||||
energyPriorityReason =
|
||||
parsed.evcsPriorityManualReason || 'EVCS prioritaire : override manuel';
|
||||
evcsPriorityUntil =
|
||||
history.evcsPriorityManualUntilAt || autoEvcsPriorityUntil;
|
||||
evcsPrioritySource = 'manual';
|
||||
}
|
||||
|
||||
|
||||
// =====================================================
|
||||
// REASONS / LABELS / SIGNATURE
|
||||
// =====================================================
|
||||
const reason = [...decision.reason];
|
||||
|
||||
reason.push(`Stratégie=${strategy.userStrategy}`);
|
||||
reason.push(`Profil=${context.profile}`);
|
||||
reason.push(`Source=${context.sourceMode}`);
|
||||
reason.push(
|
||||
`Mode=${
|
||||
modes.batteryFullHarvestMode
|
||||
? 'battery_full_harvest_mode'
|
||||
: modes.forecastCommitMode
|
||||
? 'forecast_commit_mode'
|
||||
: 'battery_priority_mode'
|
||||
}`
|
||||
);
|
||||
reason.push(`SOC=${round1(parsed.soc)}%`);
|
||||
reason.push(`Batt=${round1(parsed.batteryCurrentA)}A`);
|
||||
reason.push(`PV=${Math.round(parsed.pvPowerW)}W`);
|
||||
reason.push(`PV10=${Math.round(parsed.pvPowerAvg10mW)}W`);
|
||||
reason.push(`Grid=${parsed.gridAvailable ? 'on' : 'off'} (${Math.round(parsed.gridPowerW)}W)`);
|
||||
reason.push(`Cap=${derived.physicalCapA}A`);
|
||||
reason.push(`Ref=${round1(derived.currentReferenceA)}A`);
|
||||
reason.push(`Target=${round1(desiredCurrentA)}A`);
|
||||
reason.push(`Debt=${round2(cloud.cloudDebtNextKwh)}kWh`);
|
||||
reason.push(`Vehicle=${parsed.evcsVehicleConnected ? 'on' : 'off'}`);
|
||||
reason.push(`EVCS mode=${parsed.evcsModeRaw}`);
|
||||
reason.push(`VE.Bus AC=${Math.round(parsed.vebusAcOutPowerW)}W`);
|
||||
reason.push(
|
||||
`VE.Bus DC=${
|
||||
Number.isFinite(parsed.vebusDcVoltageV)
|
||||
? round1(parsed.vebusDcVoltageV)
|
||||
: 'n/a'
|
||||
}V`
|
||||
);
|
||||
reason.push(`PrioEVCS=${evcsPriorityActive ? 'on' : 'off'}(${evcsPrioritySource})`);
|
||||
|
||||
|
||||
const shortReason = reason.join(' | ').slice(0, 255);
|
||||
|
||||
const sourceModeLabel =
|
||||
context.sourceMode === 'blocked' ? 'Bloqué' :
|
||||
context.sourceMode === 'solar_only' ? 'Solaire' :
|
||||
context.sourceMode === 'solar_potential' ? 'Potentiel solaire' :
|
||||
context.sourceMode === 'hybrid_probe' ? 'Probe hybride' :
|
||||
context.sourceMode === 'grid_hc_allowed' ? 'Réseau HC autorisé' :
|
||||
context.sourceMode === 'grid_forced' ? 'Réseau forcé' :
|
||||
context.sourceMode;
|
||||
|
||||
const signature = [
|
||||
decision.state,
|
||||
`profile=${context.profile}`,
|
||||
`source=${context.sourceMode}`,
|
||||
`chg=${decision.desiredCharging ? '1' : '0'}`,
|
||||
`perm=${decision.desiredPermission ? '1' : '0'}`,
|
||||
`l=${decision.desiredLatch ? '1' : '0'}`,
|
||||
`cur=${round1(desiredCurrentA)}`,
|
||||
`block=${decision.nextBlockReason}`,
|
||||
`stop=${decision.mustStop ? '1' : '0'}`,
|
||||
`prio=${evcsPriorityActive ? '1' : '0'}`
|
||||
].join('|');
|
||||
|
||||
const signatureChanged = signature !== history.lastSignature;
|
||||
|
||||
// =====================================================
|
||||
// NOTIFICATIONS
|
||||
// =====================================================
|
||||
let notifyWanted = false;
|
||||
let notifyTitle = 'Victron EVCS Surplus';
|
||||
let notifyMessage = shortReason;
|
||||
|
||||
if (parsed.notificationsEnabled && signatureChanged) {
|
||||
if (decision.currentDecision === 'start') {
|
||||
notifyWanted = true;
|
||||
notifyTitle = '⚡ EVCS démarrage';
|
||||
} else if (decision.currentDecision === 'step_up') {
|
||||
notifyWanted = true;
|
||||
notifyTitle = '⬆️ EVCS hausse courant';
|
||||
} else if (
|
||||
decision.currentDecision === 'step_down' ||
|
||||
decision.currentDecision === 'step_down_fast'
|
||||
) {
|
||||
notifyWanted = true;
|
||||
notifyTitle = '⬇️ EVCS baisse courant';
|
||||
} else if (
|
||||
decision.mustStop ||
|
||||
decision.currentDecision === 'stop' ||
|
||||
decision.currentDecision === 'stop_hard' ||
|
||||
decision.currentDecision === 'stop_power_cap'
|
||||
) {
|
||||
notifyWanted = true;
|
||||
notifyTitle = '⛔ EVCS arrêt';
|
||||
} else if (decision.state === 'mode_auto_conflict') {
|
||||
notifyWanted = true;
|
||||
notifyTitle = '⚠️ EVCS mode Auto';
|
||||
}
|
||||
}
|
||||
|
||||
// =====================================================
|
||||
// TIMESTAMPS TO WRITE
|
||||
// =====================================================
|
||||
const cloudDebtStartedAtToWrite =
|
||||
cloud.cloudDebtStartedMs
|
||||
? formatParisStampFromMs(cloud.cloudDebtStartedMs)
|
||||
: now.stamp;
|
||||
|
||||
const probeStartedAtToWrite = now.stamp;
|
||||
const lastStartAtToWrite = now.stamp;
|
||||
const lastStopAtToWrite = now.stamp;
|
||||
const lastCurrentChangeAtToWrite = now.stamp;
|
||||
|
||||
// =====================================================
|
||||
// OUTPUT
|
||||
// =====================================================
|
||||
return [{
|
||||
json: {
|
||||
timestamp_paris: now.stamp,
|
||||
inputs: input,
|
||||
|
||||
computed: {
|
||||
operating_mode: context.operatingMode,
|
||||
profile: context.profile,
|
||||
source_mode: context.sourceMode,
|
||||
source_mode_label: sourceModeLabel,
|
||||
user_strategy: strategy.userStrategy,
|
||||
|
||||
in_night_hc_window: windows.inNightHcWindow,
|
||||
in_morning_window: windows.inMorningWindow,
|
||||
in_midday_window: windows.inMiddayWindow,
|
||||
in_evening_window: windows.inEveningWindow,
|
||||
|
||||
departure_priority_active: context.departurePriorityActive,
|
||||
minutes_until_departure: prev.lifecycle.minutesUntilDeparture,
|
||||
|
||||
battery_priority_mode: modes.batteryPriorityMode,
|
||||
battery_full_harvest_mode: modes.batteryFullHarvestMode,
|
||||
forecast_commit_mode: modes.forecastCommitMode,
|
||||
|
||||
physical_cap_current_a: derived.physicalCapA,
|
||||
can_run_at_min_current: derived.canRunAtMinCurrent,
|
||||
|
||||
forecast_buffer_kwh: round2(derived.forecastBufferKwh),
|
||||
projected_end_soc_after_charge: round1(derived.projectedEndSocAfterCharge),
|
||||
|
||||
cloud_debt_next_kwh: round2(cloud.cloudDebtNextKwh),
|
||||
cloud_debt_duration_minutes: round1(cloud.cloudDebtDurationMinutes),
|
||||
cloud_debt_active: cloud.cloudDebtActive,
|
||||
cloud_tolerance_ok: cloud.cloudToleranceOk,
|
||||
|
||||
start_blocked_by_reason: locks.startBlockedByReason,
|
||||
|
||||
probe_window_active: conditions.probeWindowActive,
|
||||
probe_success_now: conditions.probeSuccessNow,
|
||||
probe_failed_now: conditions.probeFailedNow,
|
||||
|
||||
any_vebus_overload: derived.anyVebusOverload,
|
||||
any_vebus_low_battery: derived.anyVebusLowBattery,
|
||||
vebus_ac_out_soft_reached: derived.vebusAcOutSoftReached,
|
||||
vebus_ac_out_hard_reached: derived.vebusAcOutHardReached,
|
||||
vebus_dc_voltage_soft_low: derived.vebusDcVoltageSoftLow,
|
||||
vebus_dc_voltage_hard_low: derived.vebusDcVoltageHardLow,
|
||||
|
||||
soc_band: batteryPolicy.socBand,
|
||||
batt_discharge_soft_dynamic_a: round1(batteryPolicy.batteryDischargeSoftDynamicA),
|
||||
batt_discharge_hard_dynamic_a: round1(batteryPolicy.batteryDischargeHardDynamicA),
|
||||
batt_charge_reserve_dynamic_a: round1(batteryPolicy.batteryChargeReserveDynamicA),
|
||||
batt_soft_tolerance_min: round1(batteryPolicy.batterySoftToleranceMinutes),
|
||||
batt_hard_tolerance_sec: round1(batteryPolicy.batteryHardToleranceSeconds),
|
||||
batt_recovery_min: round1(batteryPolicy.batteryRecoveryMinutes),
|
||||
grid_battery_soft_dynamic_a: round1(batteryPolicy.gridBatterySoftDynamicA),
|
||||
grid_battery_hard_dynamic_a: round1(batteryPolicy.gridBatteryHardDynamicA),
|
||||
house_spike_detected: batteryPolicy.houseSpikeDetected,
|
||||
solar_potential_context: batteryPolicy.solarPotentialContext,
|
||||
|
||||
evcs_priority_active: evcsPriorityActive,
|
||||
energy_priority_mode: energyPriorityMode,
|
||||
energy_priority_reason: energyPriorityReason,
|
||||
evcs_priority_until: evcsPriorityUntil,
|
||||
evcs_priority_source: evcsPrioritySource,
|
||||
evcs_priority_manual_override_active: manualPriorityOverrideActive,
|
||||
|
||||
},
|
||||
|
||||
decision: {
|
||||
state: decision.state,
|
||||
current_decision: decision.currentDecision,
|
||||
desired_charging: decision.desiredCharging,
|
||||
desired_permission: decision.desiredPermission,
|
||||
desired_latch: decision.desiredLatch,
|
||||
desired_current_a: round1(desiredCurrentA),
|
||||
|
||||
charging_action: chargingAction,
|
||||
permission_action: permissionAction,
|
||||
latch_action: latchAction,
|
||||
current_action: currentAction,
|
||||
auto_start_action: autoStartAction,
|
||||
|
||||
set_last_start_at: setLastStartAt,
|
||||
set_last_stop_at: setLastStopAt,
|
||||
set_last_current_change_at: setLastCurrentChangeAt,
|
||||
set_cloud_debt_started_at: decision.setCloudDebtStartedAt,
|
||||
set_probe_started_at: decision.setProbeStartedAt,
|
||||
|
||||
set_block_reason: needsBlockReasonUpdate || !parsed.controllerEnabled,
|
||||
block_reason: decision.nextBlockReason,
|
||||
|
||||
set_blocked_until: true,
|
||||
blocked_until: blockedUntilToWrite,
|
||||
|
||||
dynamic_ok: decision.dynamicOk,
|
||||
must_stop: decision.mustStop,
|
||||
|
||||
short_reason: shortReason,
|
||||
reason,
|
||||
signature,
|
||||
signature_changed: signatureChanged,
|
||||
|
||||
should_notify: notifyWanted,
|
||||
notify_title: notifyTitle,
|
||||
notify_message: notifyMessage,
|
||||
|
||||
evcs_priority_active: evcsPriorityActive,
|
||||
energy_priority_mode: energyPriorityMode,
|
||||
energy_priority_reason: energyPriorityReason,
|
||||
evcs_priority_until: evcsPriorityUntil,
|
||||
evcs_priority_source: evcsPrioritySource,
|
||||
},
|
||||
|
||||
ha_service_calls: {
|
||||
helper_state: {
|
||||
execute: true,
|
||||
domain: 'input_text',
|
||||
service: 'set_value',
|
||||
data: {
|
||||
entity_id: 'input_text.victron_evcs_surplus_state',
|
||||
value: decision.state
|
||||
}
|
||||
},
|
||||
|
||||
helper_current_decision: {
|
||||
execute: true,
|
||||
domain: 'input_text',
|
||||
service: 'set_value',
|
||||
data: {
|
||||
entity_id: 'input_text.victron_evcs_surplus_current_decision',
|
||||
value: decision.currentDecision
|
||||
}
|
||||
},
|
||||
|
||||
helper_reason: {
|
||||
execute: true,
|
||||
domain: 'input_text',
|
||||
service: 'set_value',
|
||||
data: {
|
||||
entity_id: 'input_text.victron_evcs_surplus_reason',
|
||||
value: shortReason
|
||||
}
|
||||
},
|
||||
|
||||
helper_operating_mode: {
|
||||
execute: true,
|
||||
domain: 'input_text',
|
||||
service: 'set_value',
|
||||
data: {
|
||||
entity_id: 'input_text.victron_evcs_operating_mode',
|
||||
value: context.operatingMode
|
||||
}
|
||||
},
|
||||
|
||||
helper_source_mode: {
|
||||
execute: true,
|
||||
domain: 'input_text',
|
||||
service: 'set_value',
|
||||
data: {
|
||||
entity_id: 'input_text.victron_evcs_source_mode',
|
||||
value: sourceModeLabel
|
||||
}
|
||||
},
|
||||
|
||||
helper_target_current: {
|
||||
execute: true,
|
||||
domain: 'input_number',
|
||||
service: 'set_value',
|
||||
data: {
|
||||
entity_id: 'input_number.victron_evcs_target_current_auto',
|
||||
value: round1(desiredCurrentA)
|
||||
}
|
||||
},
|
||||
|
||||
helper_budget_remaining: {
|
||||
execute: true,
|
||||
domain: 'input_number',
|
||||
service: 'set_value',
|
||||
data: {
|
||||
entity_id: 'input_number.victron_evcs_budget_remaining_kwh',
|
||||
value: round2(derived.forecastBufferKwh)
|
||||
}
|
||||
},
|
||||
|
||||
helper_projected_soc: {
|
||||
execute: true,
|
||||
domain: 'input_number',
|
||||
service: 'set_value',
|
||||
data: {
|
||||
entity_id: 'input_number.victron_evcs_projected_end_soc_after_charge',
|
||||
value: round1(derived.projectedEndSocAfterCharge)
|
||||
}
|
||||
},
|
||||
|
||||
helper_cloud_debt: {
|
||||
execute: true,
|
||||
domain: 'input_number',
|
||||
service: 'set_value',
|
||||
data: {
|
||||
entity_id: 'input_number.victron_evcs_cloud_debt_kwh',
|
||||
value: round2(cloud.cloudDebtNextKwh)
|
||||
}
|
||||
},
|
||||
|
||||
helper_dynamic_ok: {
|
||||
execute: true,
|
||||
domain: 'input_boolean',
|
||||
service: decision.dynamicOk ? 'turn_on' : 'turn_off',
|
||||
data: {
|
||||
entity_id: 'input_boolean.victron_evcs_dynamic_ok'
|
||||
}
|
||||
},
|
||||
|
||||
helper_must_stop: {
|
||||
execute: true,
|
||||
domain: 'input_boolean',
|
||||
service: decision.mustStop ? 'turn_on' : 'turn_off',
|
||||
data: {
|
||||
entity_id: 'input_boolean.victron_evcs_must_stop'
|
||||
}
|
||||
},
|
||||
|
||||
helper_cloud_debt_started_at: {
|
||||
execute: true,
|
||||
domain: 'input_datetime',
|
||||
service: 'set_datetime',
|
||||
data: {
|
||||
entity_id: 'input_datetime.victron_evcs_cloud_debt_started_at',
|
||||
datetime: cloudDebtStartedAtToWrite
|
||||
}
|
||||
},
|
||||
|
||||
helper_last_current_change_at: {
|
||||
execute: setLastCurrentChangeAt,
|
||||
domain: 'input_datetime',
|
||||
service: 'set_datetime',
|
||||
data: {
|
||||
entity_id: 'input_datetime.victron_evcs_last_current_change_at',
|
||||
datetime: lastCurrentChangeAtToWrite
|
||||
}
|
||||
},
|
||||
|
||||
helper_last_start_at: {
|
||||
execute: setLastStartAt,
|
||||
domain: 'input_datetime',
|
||||
service: 'set_datetime',
|
||||
data: {
|
||||
entity_id: 'input_datetime.victron_evcs_last_start_at',
|
||||
datetime: lastStartAtToWrite
|
||||
}
|
||||
},
|
||||
|
||||
helper_last_stop_at: {
|
||||
execute: setLastStopAt,
|
||||
domain: 'input_datetime',
|
||||
service: 'set_datetime',
|
||||
data: {
|
||||
entity_id: 'input_datetime.victron_evcs_last_stop_at',
|
||||
datetime: lastStopAtToWrite
|
||||
}
|
||||
},
|
||||
|
||||
helper_signature: {
|
||||
execute: true,
|
||||
domain: 'input_text',
|
||||
service: 'set_value',
|
||||
data: {
|
||||
entity_id: 'input_text.victron_evcs_control_last_signature',
|
||||
value: signature
|
||||
}
|
||||
},
|
||||
|
||||
helper_block_reason: {
|
||||
execute: needsBlockReasonUpdate || !parsed.controllerEnabled,
|
||||
domain: 'input_text',
|
||||
service: 'set_value',
|
||||
data: {
|
||||
entity_id: 'input_text.victron_evcs_last_block_reason',
|
||||
value: decision.nextBlockReason
|
||||
}
|
||||
},
|
||||
|
||||
helper_blocked_until: {
|
||||
execute: true,
|
||||
domain: 'input_datetime',
|
||||
service: 'set_datetime',
|
||||
data: {
|
||||
entity_id: 'input_datetime.victron_evcs_blocked_until',
|
||||
datetime: blockedUntilToWrite
|
||||
}
|
||||
},
|
||||
|
||||
helper_probe_started_at: {
|
||||
execute: decision.setProbeStartedAt,
|
||||
domain: 'input_datetime',
|
||||
service: 'set_datetime',
|
||||
data: {
|
||||
entity_id: 'input_datetime.victron_evcs_probe_started_at',
|
||||
datetime: probeStartedAtToWrite
|
||||
}
|
||||
},
|
||||
|
||||
helper_evcs_priority_active: {
|
||||
execute: true,
|
||||
domain: 'input_boolean',
|
||||
service: evcsPriorityActive ? 'turn_on' : 'turn_off',
|
||||
data: {
|
||||
entity_id: 'input_boolean.victron_evcs_priority_active'
|
||||
}
|
||||
},
|
||||
|
||||
helper_energy_priority_mode: {
|
||||
execute: true,
|
||||
domain: 'input_select',
|
||||
service: 'select_option',
|
||||
data: {
|
||||
entity_id: 'input_select.victron_energy_priority_mode',
|
||||
option: energyPriorityMode
|
||||
}
|
||||
},
|
||||
|
||||
helper_energy_priority_reason: {
|
||||
execute: true,
|
||||
domain: 'input_text',
|
||||
service: 'set_value',
|
||||
data: {
|
||||
entity_id: 'input_text.victron_energy_priority_reason',
|
||||
value: energyPriorityReason
|
||||
}
|
||||
},
|
||||
|
||||
helper_evcs_priority_until: {
|
||||
execute: true,
|
||||
domain: 'input_datetime',
|
||||
service: 'set_datetime',
|
||||
data: {
|
||||
entity_id: 'input_datetime.victron_evcs_priority_until',
|
||||
datetime: evcsPriorityUntil
|
||||
}
|
||||
},
|
||||
|
||||
evcs_auto_start_off: {
|
||||
execute: autoStartAction === 'turn_off',
|
||||
domain: 'switch',
|
||||
service: 'turn_off',
|
||||
data: {
|
||||
entity_id: 'switch.victron_evcs_auto_start'
|
||||
}
|
||||
},
|
||||
|
||||
evcs_charging: {
|
||||
execute: chargingAction !== 'none',
|
||||
domain: 'switch',
|
||||
service: decision.desiredCharging ? 'turn_on' : 'turn_off',
|
||||
data: {
|
||||
entity_id: 'switch.victron_evcs_charging'
|
||||
}
|
||||
},
|
||||
|
||||
helper_permission: {
|
||||
execute: permissionAction !== 'none',
|
||||
domain: 'input_boolean',
|
||||
service: decision.desiredPermission ? 'turn_on' : 'turn_off',
|
||||
data: {
|
||||
entity_id: 'input_boolean.victron_evcs_charge_permission_now'
|
||||
}
|
||||
},
|
||||
|
||||
helper_latch: {
|
||||
execute: latchAction !== 'none',
|
||||
domain: 'input_boolean',
|
||||
service: decision.desiredLatch ? 'turn_on' : 'turn_off',
|
||||
data: {
|
||||
entity_id: 'input_boolean.victron_evcs_control_latch'
|
||||
}
|
||||
},
|
||||
|
||||
evcs_set_current: {
|
||||
execute: currentAction === 'set',
|
||||
domain: 'number',
|
||||
service: 'set_value',
|
||||
data: {
|
||||
entity_id: 'number.victron_evcs_set_current_command',
|
||||
value: round1(desiredCurrentA)
|
||||
}
|
||||
},
|
||||
|
||||
logbook: {
|
||||
execute: signatureChanged,
|
||||
domain: 'logbook',
|
||||
service: 'log',
|
||||
data: {
|
||||
name: 'Victron EVCS Surplus',
|
||||
message: shortReason
|
||||
}
|
||||
},
|
||||
|
||||
mobile_notification: {
|
||||
execute: notifyWanted,
|
||||
domain: cfg.mobileNotifyDomain,
|
||||
service: cfg.mobileNotifyService,
|
||||
data: {
|
||||
title: notifyTitle,
|
||||
message: notifyMessage || shortReason
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}];
|
||||
|
||||
191
archive/decoupesurplus/V2/HASERVICEOUTPUTP1.js
Normal file
191
archive/decoupesurplus/V2/HASERVICEOUTPUTP1.js
Normal file
@@ -0,0 +1,191 @@
|
||||
function round1(v) {
|
||||
return Math.round(v * 10) / 10;
|
||||
}
|
||||
function round2(v) {
|
||||
return Math.round(v * 100) / 100;
|
||||
}
|
||||
function clamp(v, min, max) {
|
||||
return Math.min(Math.max(v, min), max);
|
||||
}
|
||||
function to2(n) {
|
||||
return String(n).padStart(2, '0');
|
||||
}
|
||||
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 {
|
||||
input,
|
||||
now,
|
||||
nowMs,
|
||||
cfg,
|
||||
parsed,
|
||||
params,
|
||||
history,
|
||||
strategy,
|
||||
windows,
|
||||
context,
|
||||
derived,
|
||||
batteryTune,
|
||||
batteryPolicy,
|
||||
locks,
|
||||
cloud,
|
||||
modes,
|
||||
safeguards,
|
||||
conditions,
|
||||
decision,
|
||||
} = prev;
|
||||
|
||||
// =====================================================
|
||||
// FINAL ACTIONS
|
||||
// =====================================================
|
||||
let desiredCurrentA = decision.desiredCurrentA;
|
||||
|
||||
desiredCurrentA = decision.desiredCharging
|
||||
? clamp(
|
||||
desiredCurrentA,
|
||||
params.currentMinA,
|
||||
Math.max(params.currentMinA, derived.physicalCapA || params.currentMinA)
|
||||
)
|
||||
: params.currentMinA;
|
||||
|
||||
const chargingAction =
|
||||
decision.desiredCharging === parsed.evcsChargingSwitchOn
|
||||
? 'none'
|
||||
: decision.desiredCharging
|
||||
? 'turn_on'
|
||||
: 'turn_off';
|
||||
|
||||
const permissionAction =
|
||||
decision.desiredPermission === parsed.chargePermissionNow
|
||||
? 'none'
|
||||
: decision.desiredPermission
|
||||
? 'turn_on'
|
||||
: 'turn_off';
|
||||
|
||||
const latchAction =
|
||||
decision.desiredLatch === parsed.controlLatch
|
||||
? 'none'
|
||||
: decision.desiredLatch
|
||||
? 'turn_on'
|
||||
: 'turn_off';
|
||||
|
||||
const autoStartAction =
|
||||
parsed.controllerEnabled &&
|
||||
parsed.evcsVehicleConnected &&
|
||||
parsed.evcsAutoStartSwitchOn
|
||||
? 'turn_off'
|
||||
: 'none';
|
||||
|
||||
const currentAction =
|
||||
decision.desiredCharging &&
|
||||
(
|
||||
!Number.isFinite(derived.currentReferenceA) ||
|
||||
Math.abs(desiredCurrentA - derived.currentReferenceA) >= 0.5
|
||||
)
|
||||
? 'set'
|
||||
: 'none';
|
||||
|
||||
const setLastStartAt = chargingAction === 'turn_on';
|
||||
const setLastStopAt = chargingAction === 'turn_off';
|
||||
const setLastCurrentChangeAt = currentAction === 'set';
|
||||
|
||||
// =====================================================
|
||||
// CLEAN BLOCKING LOGIC
|
||||
// =====================================================
|
||||
const blockedUntilToWrite =
|
||||
decision.nextBlockReason === 'none'
|
||||
? now.stamp
|
||||
: decision.nextBlockedUntilStamp;
|
||||
|
||||
const needsBlockReasonUpdate =
|
||||
decision.nextBlockReason !== history.lastBlockReason;
|
||||
|
||||
// =====================================================
|
||||
// GLOBAL EVCS PRIORITY
|
||||
// AUTO + OVERRIDE MANUEL
|
||||
// =====================================================
|
||||
const forceWindowMinutes = Math.max(
|
||||
30,
|
||||
batteryTune?.global?.forceModeAutoRevertMinutes ?? 120
|
||||
);
|
||||
|
||||
const activeChargeIntent =
|
||||
decision.desiredCharging ||
|
||||
['start', 'hold', 'step_up', 'step_down', 'step_down_fast'].includes(decision.currentDecision);
|
||||
|
||||
// -------------------------
|
||||
// PRIORITÉ AUTO
|
||||
// -------------------------
|
||||
let autoEvcsPriorityActive = false;
|
||||
let autoEnergyPriorityMode = 'normal';
|
||||
let autoEnergyPriorityReason = 'Aucune priorité EVCS';
|
||||
let autoEvcsPriorityUntil = now.stamp;
|
||||
|
||||
if (parsed.evcsVehicleConnected && !strategy.isStopMode) {
|
||||
if (strategy.forceChargeRequested || strategy.forceGridRequested) {
|
||||
autoEvcsPriorityActive = true;
|
||||
autoEnergyPriorityMode = 'evcs_priority';
|
||||
autoEnergyPriorityReason =
|
||||
strategy.forceGridRequested
|
||||
? 'EVCS prioritaire : force_grid_now'
|
||||
: 'EVCS prioritaire : force_charge_now';
|
||||
autoEvcsPriorityUntil = formatParisStampFromMs(nowMs + forceWindowMinutes * 60000);
|
||||
|
||||
} else if (context.departurePriorityActive && Number.isFinite(prev.lifecycle.minutesUntilDeparture)) {
|
||||
autoEvcsPriorityActive = true;
|
||||
autoEnergyPriorityMode = 'evcs_priority';
|
||||
autoEnergyPriorityReason = `EVCS prioritaire : départ dans ${Math.round(prev.lifecycle.minutesUntilDeparture)} min`;
|
||||
autoEvcsPriorityUntil = formatParisStampFromMs(
|
||||
nowMs + Math.max(1, prev.lifecycle.minutesUntilDeparture) * 60000
|
||||
);
|
||||
|
||||
} else if (activeChargeIntent && context.profile === 'midday_hc_or_solar') {
|
||||
autoEvcsPriorityActive = true;
|
||||
autoEnergyPriorityMode = 'evcs_priority';
|
||||
autoEnergyPriorityReason = 'EVCS prioritaire : recharge active fenêtre midi';
|
||||
autoEvcsPriorityUntil = formatParisStampFromMs(nowMs + 90 * 60000);
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------
|
||||
// OVERRIDE MANUEL
|
||||
// -------------------------
|
||||
const manualPriorityOverrideActive =
|
||||
parsed.evcsPriorityManualOverride &&
|
||||
parsed.evcsPriorityManualMode === 'evcs_priority' &&
|
||||
history.evcsPriorityManualUntilMs !== null &&
|
||||
history.evcsPriorityManualUntilMs > nowMs + 1000;
|
||||
|
||||
let evcsPriorityActive = autoEvcsPriorityActive;
|
||||
let energyPriorityMode = autoEnergyPriorityMode;
|
||||
let energyPriorityReason = autoEnergyPriorityReason;
|
||||
let evcsPriorityUntil = autoEvcsPriorityUntil;
|
||||
let evcsPrioritySource = 'auto';
|
||||
|
||||
if (manualPriorityOverrideActive) {
|
||||
evcsPriorityActive = true;
|
||||
energyPriorityMode = 'evcs_priority';
|
||||
energyPriorityReason =
|
||||
parsed.evcsPriorityManualReason || 'EVCS prioritaire : override manuel';
|
||||
evcsPriorityUntil =
|
||||
history.evcsPriorityManualUntilAt || autoEvcsPriorityUntil;
|
||||
evcsPrioritySource = 'manual';
|
||||
}
|
||||
184
archive/decoupesurplus/V2/HASERVICEOUTPUTP2.js
Normal file
184
archive/decoupesurplus/V2/HASERVICEOUTPUTP2.js
Normal file
@@ -0,0 +1,184 @@
|
||||
|
||||
// =====================================================
|
||||
// REASONS / LABELS / SIGNATURE
|
||||
// =====================================================
|
||||
const reason = [...decision.reason];
|
||||
|
||||
reason.push(`Stratégie=${strategy.userStrategy}`);
|
||||
reason.push(`Profil=${context.profile}`);
|
||||
reason.push(`Source=${context.sourceMode}`);
|
||||
reason.push(
|
||||
`Mode=${
|
||||
modes.batteryFullHarvestMode
|
||||
? 'battery_full_harvest_mode'
|
||||
: modes.forecastCommitMode
|
||||
? 'forecast_commit_mode'
|
||||
: 'battery_priority_mode'
|
||||
}`
|
||||
);
|
||||
reason.push(`SOC=${round1(parsed.soc)}%`);
|
||||
reason.push(`Batt=${round1(parsed.batteryCurrentA)}A`);
|
||||
reason.push(`PV=${Math.round(parsed.pvPowerW)}W`);
|
||||
reason.push(`PV10=${Math.round(parsed.pvPowerAvg10mW)}W`);
|
||||
reason.push(`Grid=${parsed.gridAvailable ? 'on' : 'off'} (${Math.round(parsed.gridPowerW)}W)`);
|
||||
reason.push(`Cap=${derived.physicalCapA}A`);
|
||||
reason.push(`Ref=${round1(derived.currentReferenceA)}A`);
|
||||
reason.push(`Target=${round1(desiredCurrentA)}A`);
|
||||
reason.push(`Debt=${round2(cloud.cloudDebtNextKwh)}kWh`);
|
||||
reason.push(`Vehicle=${parsed.evcsVehicleConnected ? 'on' : 'off'}`);
|
||||
reason.push(`EVCS mode=${parsed.evcsModeRaw}`);
|
||||
reason.push(`VE.Bus AC=${Math.round(parsed.vebusAcOutPowerW)}W`);
|
||||
reason.push(
|
||||
`VE.Bus DC=${
|
||||
Number.isFinite(parsed.vebusDcVoltageV)
|
||||
? round1(parsed.vebusDcVoltageV)
|
||||
: 'n/a'
|
||||
}V`
|
||||
);
|
||||
reason.push(`PrioEVCS=${evcsPriorityActive ? 'on' : 'off'}(${evcsPrioritySource})`);
|
||||
|
||||
|
||||
const shortReason = reason.join(' | ').slice(0, 255);
|
||||
|
||||
const sourceModeLabel =
|
||||
context.sourceMode === 'blocked' ? 'Bloqué' :
|
||||
context.sourceMode === 'solar_only' ? 'Solaire' :
|
||||
context.sourceMode === 'solar_potential' ? 'Potentiel solaire' :
|
||||
context.sourceMode === 'hybrid_probe' ? 'Probe hybride' :
|
||||
context.sourceMode === 'grid_hc_allowed' ? 'Réseau HC autorisé' :
|
||||
context.sourceMode === 'grid_forced' ? 'Réseau forcé' :
|
||||
context.sourceMode;
|
||||
|
||||
const signature = [
|
||||
decision.state,
|
||||
`profile=${context.profile}`,
|
||||
`source=${context.sourceMode}`,
|
||||
`chg=${decision.desiredCharging ? '1' : '0'}`,
|
||||
`perm=${decision.desiredPermission ? '1' : '0'}`,
|
||||
`l=${decision.desiredLatch ? '1' : '0'}`,
|
||||
`cur=${round1(desiredCurrentA)}`,
|
||||
`block=${decision.nextBlockReason}`,
|
||||
`stop=${decision.mustStop ? '1' : '0'}`,
|
||||
`prio=${evcsPriorityActive ? '1' : '0'}`
|
||||
].join('|');
|
||||
|
||||
const signatureChanged = signature !== history.lastSignature;
|
||||
|
||||
// =====================================================
|
||||
// NOTIFICATIONS
|
||||
// =====================================================
|
||||
let notifyWanted = false;
|
||||
let notifyTitle = 'Victron EVCS Surplus';
|
||||
let notifyMessage = shortReason;
|
||||
|
||||
if (parsed.notificationsEnabled && signatureChanged) {
|
||||
if (decision.currentDecision === 'start') {
|
||||
notifyWanted = true;
|
||||
notifyTitle = '⚡ EVCS démarrage';
|
||||
} else if (decision.currentDecision === 'step_up') {
|
||||
notifyWanted = true;
|
||||
notifyTitle = '⬆️ EVCS hausse courant';
|
||||
} else if (
|
||||
decision.currentDecision === 'step_down' ||
|
||||
decision.currentDecision === 'step_down_fast'
|
||||
) {
|
||||
notifyWanted = true;
|
||||
notifyTitle = '⬇️ EVCS baisse courant';
|
||||
} else if (
|
||||
decision.mustStop ||
|
||||
decision.currentDecision === 'stop' ||
|
||||
decision.currentDecision === 'stop_hard' ||
|
||||
decision.currentDecision === 'stop_power_cap'
|
||||
) {
|
||||
notifyWanted = true;
|
||||
notifyTitle = '⛔ EVCS arrêt';
|
||||
} else if (decision.state === 'mode_auto_conflict') {
|
||||
notifyWanted = true;
|
||||
notifyTitle = '⚠️ EVCS mode Auto';
|
||||
}
|
||||
}
|
||||
|
||||
// =====================================================
|
||||
// TIMESTAMPS TO WRITE
|
||||
// =====================================================
|
||||
const cloudDebtStartedAtToWrite =
|
||||
cloud.cloudDebtStartedMs
|
||||
? formatParisStampFromMs(cloud.cloudDebtStartedMs)
|
||||
: now.stamp;
|
||||
|
||||
const probeStartedAtToWrite = now.stamp;
|
||||
const lastStartAtToWrite = now.stamp;
|
||||
const lastStopAtToWrite = now.stamp;
|
||||
const lastCurrentChangeAtToWrite = now.stamp;
|
||||
|
||||
// =====================================================
|
||||
// OUTPUT
|
||||
// =====================================================
|
||||
return [{
|
||||
json: {
|
||||
timestamp_paris: now.stamp,
|
||||
inputs: input,
|
||||
|
||||
computed: {
|
||||
operating_mode: context.operatingMode,
|
||||
profile: context.profile,
|
||||
source_mode: context.sourceMode,
|
||||
source_mode_label: sourceModeLabel,
|
||||
user_strategy: strategy.userStrategy,
|
||||
|
||||
in_night_hc_window: windows.inNightHcWindow,
|
||||
in_morning_window: windows.inMorningWindow,
|
||||
in_midday_window: windows.inMiddayWindow,
|
||||
in_evening_window: windows.inEveningWindow,
|
||||
|
||||
departure_priority_active: context.departurePriorityActive,
|
||||
minutes_until_departure: prev.lifecycle.minutesUntilDeparture,
|
||||
|
||||
battery_priority_mode: modes.batteryPriorityMode,
|
||||
battery_full_harvest_mode: modes.batteryFullHarvestMode,
|
||||
forecast_commit_mode: modes.forecastCommitMode,
|
||||
|
||||
physical_cap_current_a: derived.physicalCapA,
|
||||
can_run_at_min_current: derived.canRunAtMinCurrent,
|
||||
|
||||
forecast_buffer_kwh: round2(derived.forecastBufferKwh),
|
||||
projected_end_soc_after_charge: round1(derived.projectedEndSocAfterCharge),
|
||||
|
||||
cloud_debt_next_kwh: round2(cloud.cloudDebtNextKwh),
|
||||
cloud_debt_duration_minutes: round1(cloud.cloudDebtDurationMinutes),
|
||||
cloud_debt_active: cloud.cloudDebtActive,
|
||||
cloud_tolerance_ok: cloud.cloudToleranceOk,
|
||||
|
||||
start_blocked_by_reason: locks.startBlockedByReason,
|
||||
|
||||
probe_window_active: conditions.probeWindowActive,
|
||||
probe_success_now: conditions.probeSuccessNow,
|
||||
probe_failed_now: conditions.probeFailedNow,
|
||||
|
||||
any_vebus_overload: derived.anyVebusOverload,
|
||||
any_vebus_low_battery: derived.anyVebusLowBattery,
|
||||
vebus_ac_out_soft_reached: derived.vebusAcOutSoftReached,
|
||||
vebus_ac_out_hard_reached: derived.vebusAcOutHardReached,
|
||||
vebus_dc_voltage_soft_low: derived.vebusDcVoltageSoftLow,
|
||||
vebus_dc_voltage_hard_low: derived.vebusDcVoltageHardLow,
|
||||
|
||||
soc_band: batteryPolicy.socBand,
|
||||
batt_discharge_soft_dynamic_a: round1(batteryPolicy.batteryDischargeSoftDynamicA),
|
||||
batt_discharge_hard_dynamic_a: round1(batteryPolicy.batteryDischargeHardDynamicA),
|
||||
batt_charge_reserve_dynamic_a: round1(batteryPolicy.batteryChargeReserveDynamicA),
|
||||
batt_soft_tolerance_min: round1(batteryPolicy.batterySoftToleranceMinutes),
|
||||
batt_hard_tolerance_sec: round1(batteryPolicy.batteryHardToleranceSeconds),
|
||||
batt_recovery_min: round1(batteryPolicy.batteryRecoveryMinutes),
|
||||
grid_battery_soft_dynamic_a: round1(batteryPolicy.gridBatterySoftDynamicA),
|
||||
grid_battery_hard_dynamic_a: round1(batteryPolicy.gridBatteryHardDynamicA),
|
||||
house_spike_detected: batteryPolicy.houseSpikeDetected,
|
||||
solar_potential_context: batteryPolicy.solarPotentialContext,
|
||||
|
||||
evcs_priority_active: evcsPriorityActive,
|
||||
energy_priority_mode: energyPriorityMode,
|
||||
energy_priority_reason: energyPriorityReason,
|
||||
evcs_priority_until: evcsPriorityUntil,
|
||||
evcs_priority_source: evcsPrioritySource,
|
||||
evcs_priority_manual_override_active: manualPriorityOverrideActive,
|
||||
|
||||
},
|
||||
183
archive/decoupesurplus/V2/HASERVICEOUTPUTP3.js
Normal file
183
archive/decoupesurplus/V2/HASERVICEOUTPUTP3.js
Normal file
@@ -0,0 +1,183 @@
|
||||
decision: {
|
||||
state: decision.state,
|
||||
current_decision: decision.currentDecision,
|
||||
desired_charging: decision.desiredCharging,
|
||||
desired_permission: decision.desiredPermission,
|
||||
desired_latch: decision.desiredLatch,
|
||||
desired_current_a: round1(desiredCurrentA),
|
||||
|
||||
charging_action: chargingAction,
|
||||
permission_action: permissionAction,
|
||||
latch_action: latchAction,
|
||||
current_action: currentAction,
|
||||
auto_start_action: autoStartAction,
|
||||
|
||||
set_last_start_at: setLastStartAt,
|
||||
set_last_stop_at: setLastStopAt,
|
||||
set_last_current_change_at: setLastCurrentChangeAt,
|
||||
set_cloud_debt_started_at: decision.setCloudDebtStartedAt,
|
||||
set_probe_started_at: decision.setProbeStartedAt,
|
||||
|
||||
set_block_reason: needsBlockReasonUpdate || !parsed.controllerEnabled,
|
||||
block_reason: decision.nextBlockReason,
|
||||
|
||||
set_blocked_until: true,
|
||||
blocked_until: blockedUntilToWrite,
|
||||
|
||||
dynamic_ok: decision.dynamicOk,
|
||||
must_stop: decision.mustStop,
|
||||
|
||||
short_reason: shortReason,
|
||||
reason,
|
||||
signature,
|
||||
signature_changed: signatureChanged,
|
||||
|
||||
should_notify: notifyWanted,
|
||||
notify_title: notifyTitle,
|
||||
notify_message: notifyMessage,
|
||||
|
||||
evcs_priority_active: evcsPriorityActive,
|
||||
energy_priority_mode: energyPriorityMode,
|
||||
energy_priority_reason: energyPriorityReason,
|
||||
evcs_priority_until: evcsPriorityUntil,
|
||||
evcs_priority_source: evcsPrioritySource,
|
||||
},
|
||||
|
||||
ha_service_calls: {
|
||||
helper_state: {
|
||||
execute: true,
|
||||
domain: 'input_text',
|
||||
service: 'set_value',
|
||||
data: {
|
||||
entity_id: 'input_text.victron_evcs_surplus_state',
|
||||
value: decision.state
|
||||
}
|
||||
},
|
||||
|
||||
helper_current_decision: {
|
||||
execute: true,
|
||||
domain: 'input_text',
|
||||
service: 'set_value',
|
||||
data: {
|
||||
entity_id: 'input_text.victron_evcs_surplus_current_decision',
|
||||
value: decision.currentDecision
|
||||
}
|
||||
},
|
||||
|
||||
helper_reason: {
|
||||
execute: true,
|
||||
domain: 'input_text',
|
||||
service: 'set_value',
|
||||
data: {
|
||||
entity_id: 'input_text.victron_evcs_surplus_reason',
|
||||
value: shortReason
|
||||
}
|
||||
},
|
||||
|
||||
helper_operating_mode: {
|
||||
execute: true,
|
||||
domain: 'input_text',
|
||||
service: 'set_value',
|
||||
data: {
|
||||
entity_id: 'input_text.victron_evcs_operating_mode',
|
||||
value: context.operatingMode
|
||||
}
|
||||
},
|
||||
|
||||
helper_source_mode: {
|
||||
execute: true,
|
||||
domain: 'input_text',
|
||||
service: 'set_value',
|
||||
data: {
|
||||
entity_id: 'input_text.victron_evcs_source_mode',
|
||||
value: sourceModeLabel
|
||||
}
|
||||
},
|
||||
|
||||
helper_target_current: {
|
||||
execute: true,
|
||||
domain: 'input_number',
|
||||
service: 'set_value',
|
||||
data: {
|
||||
entity_id: 'input_number.victron_evcs_target_current_auto',
|
||||
value: round1(desiredCurrentA)
|
||||
}
|
||||
},
|
||||
|
||||
helper_budget_remaining: {
|
||||
execute: true,
|
||||
domain: 'input_number',
|
||||
service: 'set_value',
|
||||
data: {
|
||||
entity_id: 'input_number.victron_evcs_budget_remaining_kwh',
|
||||
value: round2(derived.forecastBufferKwh)
|
||||
}
|
||||
},
|
||||
|
||||
helper_projected_soc: {
|
||||
execute: true,
|
||||
domain: 'input_number',
|
||||
service: 'set_value',
|
||||
data: {
|
||||
entity_id: 'input_number.victron_evcs_projected_end_soc_after_charge',
|
||||
value: round1(derived.projectedEndSocAfterCharge)
|
||||
}
|
||||
},
|
||||
|
||||
helper_cloud_debt: {
|
||||
execute: true,
|
||||
domain: 'input_number',
|
||||
service: 'set_value',
|
||||
data: {
|
||||
entity_id: 'input_number.victron_evcs_cloud_debt_kwh',
|
||||
value: round2(cloud.cloudDebtNextKwh)
|
||||
}
|
||||
},
|
||||
|
||||
helper_dynamic_ok: {
|
||||
execute: true,
|
||||
domain: 'input_boolean',
|
||||
service: decision.dynamicOk ? 'turn_on' : 'turn_off',
|
||||
data: {
|
||||
entity_id: 'input_boolean.victron_evcs_dynamic_ok'
|
||||
}
|
||||
},
|
||||
|
||||
helper_must_stop: {
|
||||
execute: true,
|
||||
domain: 'input_boolean',
|
||||
service: decision.mustStop ? 'turn_on' : 'turn_off',
|
||||
data: {
|
||||
entity_id: 'input_boolean.victron_evcs_must_stop'
|
||||
}
|
||||
},
|
||||
|
||||
helper_cloud_debt_started_at: {
|
||||
execute: true,
|
||||
domain: 'input_datetime',
|
||||
service: 'set_datetime',
|
||||
data: {
|
||||
entity_id: 'input_datetime.victron_evcs_cloud_debt_started_at',
|
||||
datetime: cloudDebtStartedAtToWrite
|
||||
}
|
||||
},
|
||||
|
||||
helper_last_current_change_at: {
|
||||
execute: setLastCurrentChangeAt,
|
||||
domain: 'input_datetime',
|
||||
service: 'set_datetime',
|
||||
data: {
|
||||
entity_id: 'input_datetime.victron_evcs_last_current_change_at',
|
||||
datetime: lastCurrentChangeAtToWrite
|
||||
}
|
||||
},
|
||||
|
||||
helper_last_start_at: {
|
||||
execute: setLastStartAt,
|
||||
domain: 'input_datetime',
|
||||
service: 'set_datetime',
|
||||
data: {
|
||||
entity_id: 'input_datetime.victron_evcs_last_start_at',
|
||||
datetime: lastStartAtToWrite
|
||||
}
|
||||
},
|
||||
158
archive/decoupesurplus/V2/HASERVICEOUTPUTP4.js
Normal file
158
archive/decoupesurplus/V2/HASERVICEOUTPUTP4.js
Normal file
@@ -0,0 +1,158 @@
|
||||
helper_last_stop_at: {
|
||||
execute: setLastStopAt,
|
||||
domain: 'input_datetime',
|
||||
service: 'set_datetime',
|
||||
data: {
|
||||
entity_id: 'input_datetime.victron_evcs_last_stop_at',
|
||||
datetime: lastStopAtToWrite
|
||||
}
|
||||
},
|
||||
|
||||
helper_signature: {
|
||||
execute: true,
|
||||
domain: 'input_text',
|
||||
service: 'set_value',
|
||||
data: {
|
||||
entity_id: 'input_text.victron_evcs_control_last_signature',
|
||||
value: signature
|
||||
}
|
||||
},
|
||||
|
||||
helper_block_reason: {
|
||||
execute: needsBlockReasonUpdate || !parsed.controllerEnabled,
|
||||
domain: 'input_text',
|
||||
service: 'set_value',
|
||||
data: {
|
||||
entity_id: 'input_text.victron_evcs_last_block_reason',
|
||||
value: decision.nextBlockReason
|
||||
}
|
||||
},
|
||||
|
||||
helper_blocked_until: {
|
||||
execute: true,
|
||||
domain: 'input_datetime',
|
||||
service: 'set_datetime',
|
||||
data: {
|
||||
entity_id: 'input_datetime.victron_evcs_blocked_until',
|
||||
datetime: blockedUntilToWrite
|
||||
}
|
||||
},
|
||||
|
||||
helper_probe_started_at: {
|
||||
execute: decision.setProbeStartedAt,
|
||||
domain: 'input_datetime',
|
||||
service: 'set_datetime',
|
||||
data: {
|
||||
entity_id: 'input_datetime.victron_evcs_probe_started_at',
|
||||
datetime: probeStartedAtToWrite
|
||||
}
|
||||
},
|
||||
|
||||
helper_evcs_priority_active: {
|
||||
execute: true,
|
||||
domain: 'input_boolean',
|
||||
service: evcsPriorityActive ? 'turn_on' : 'turn_off',
|
||||
data: {
|
||||
entity_id: 'input_boolean.victron_evcs_priority_active'
|
||||
}
|
||||
},
|
||||
|
||||
helper_energy_priority_mode: {
|
||||
execute: true,
|
||||
domain: 'input_select',
|
||||
service: 'select_option',
|
||||
data: {
|
||||
entity_id: 'input_select.victron_energy_priority_mode',
|
||||
option: energyPriorityMode
|
||||
}
|
||||
},
|
||||
|
||||
helper_energy_priority_reason: {
|
||||
execute: true,
|
||||
domain: 'input_text',
|
||||
service: 'set_value',
|
||||
data: {
|
||||
entity_id: 'input_text.victron_energy_priority_reason',
|
||||
value: energyPriorityReason
|
||||
}
|
||||
},
|
||||
|
||||
helper_evcs_priority_until: {
|
||||
execute: true,
|
||||
domain: 'input_datetime',
|
||||
service: 'set_datetime',
|
||||
data: {
|
||||
entity_id: 'input_datetime.victron_evcs_priority_until',
|
||||
datetime: evcsPriorityUntil
|
||||
}
|
||||
},
|
||||
|
||||
evcs_auto_start_off: {
|
||||
execute: autoStartAction === 'turn_off',
|
||||
domain: 'switch',
|
||||
service: 'turn_off',
|
||||
data: {
|
||||
entity_id: 'switch.victron_evcs_auto_start'
|
||||
}
|
||||
},
|
||||
|
||||
evcs_charging: {
|
||||
execute: chargingAction !== 'none',
|
||||
domain: 'switch',
|
||||
service: decision.desiredCharging ? 'turn_on' : 'turn_off',
|
||||
data: {
|
||||
entity_id: 'switch.victron_evcs_charging'
|
||||
}
|
||||
},
|
||||
|
||||
helper_permission: {
|
||||
execute: permissionAction !== 'none',
|
||||
domain: 'input_boolean',
|
||||
service: decision.desiredPermission ? 'turn_on' : 'turn_off',
|
||||
data: {
|
||||
entity_id: 'input_boolean.victron_evcs_charge_permission_now'
|
||||
}
|
||||
},
|
||||
|
||||
helper_latch: {
|
||||
execute: latchAction !== 'none',
|
||||
domain: 'input_boolean',
|
||||
service: decision.desiredLatch ? 'turn_on' : 'turn_off',
|
||||
data: {
|
||||
entity_id: 'input_boolean.victron_evcs_control_latch'
|
||||
}
|
||||
},
|
||||
|
||||
evcs_set_current: {
|
||||
execute: currentAction === 'set',
|
||||
domain: 'number',
|
||||
service: 'set_value',
|
||||
data: {
|
||||
entity_id: 'number.victron_evcs_set_current_command',
|
||||
value: round1(desiredCurrentA)
|
||||
}
|
||||
},
|
||||
|
||||
logbook: {
|
||||
execute: signatureChanged,
|
||||
domain: 'logbook',
|
||||
service: 'log',
|
||||
data: {
|
||||
name: 'Victron EVCS Surplus',
|
||||
message: shortReason
|
||||
}
|
||||
},
|
||||
|
||||
mobile_notification: {
|
||||
execute: notifyWanted,
|
||||
domain: cfg.mobileNotifyDomain,
|
||||
service: cfg.mobileNotifyService,
|
||||
data: {
|
||||
title: notifyTitle,
|
||||
message: notifyMessage || shortReason
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}];
|
||||
|
||||
557
archive/decoupesurplus/V2/INPUTPARSE/inputparse.js
Normal file
557
archive/decoupesurplus/V2/INPUTPARSE/inputparse.js
Normal file
@@ -0,0 +1,557 @@
|
||||
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 clamp(v, min, max) {
|
||||
return Math.min(Math.max(v, min), max);
|
||||
}
|
||||
|
||||
function round1(v) {
|
||||
return Math.round(v * 10) / 10;
|
||||
}
|
||||
|
||||
function round2(v) {
|
||||
return Math.round(v * 100) / 100;
|
||||
}
|
||||
|
||||
function to2(n) {
|
||||
return String(n).padStart(2, '0');
|
||||
}
|
||||
|
||||
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 parseTimeToMinutes(v, fallback = null) {
|
||||
const s = (v ?? '').toString().trim();
|
||||
const m = s.match(/^(\d{1,2}):(\d{2})(?::(\d{2}))?$/);
|
||||
if (!m) return fallback;
|
||||
|
||||
const hh = Number(m[1]);
|
||||
const mm = Number(m[2]);
|
||||
|
||||
if (!Number.isFinite(hh) || !Number.isFinite(mm)) return fallback;
|
||||
return (hh % 24) * 60 + mm;
|
||||
}
|
||||
|
||||
function getParisParts(ms = Date.now()) {
|
||||
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);
|
||||
const date = `${parts.year}-${parts.month}-${parts.day}`;
|
||||
const stamp = `${date} ${to2(hour)}:${parts.minute}:${parts.second}`;
|
||||
|
||||
return {
|
||||
year: Number(parts.year),
|
||||
month: Number(parts.month),
|
||||
day: Number(parts.day),
|
||||
hour,
|
||||
minute: Number(parts.minute),
|
||||
second: Number(parts.second),
|
||||
date,
|
||||
stamp,
|
||||
minNow: hour * 60 + Number(parts.minute),
|
||||
};
|
||||
}
|
||||
|
||||
function getTzOffsetMs(utcTs, timeZone = 'Europe/Paris') {
|
||||
const dtf = new Intl.DateTimeFormat('en-US', {
|
||||
timeZone,
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: false,
|
||||
});
|
||||
|
||||
const parts = Object.fromEntries(
|
||||
dtf.formatToParts(new Date(utcTs))
|
||||
.filter(p => p.type !== 'literal')
|
||||
.map(p => [p.type, p.value])
|
||||
);
|
||||
|
||||
const asIfUtc = Date.UTC(
|
||||
Number(parts.year),
|
||||
Number(parts.month) - 1,
|
||||
Number(parts.day),
|
||||
Number(parts.hour) === 24 ? 0 : Number(parts.hour),
|
||||
Number(parts.minute),
|
||||
Number(parts.second)
|
||||
);
|
||||
|
||||
const d = new Date(utcTs);
|
||||
const actualUtc = Date.UTC(
|
||||
d.getUTCFullYear(),
|
||||
d.getUTCMonth(),
|
||||
d.getUTCDate(),
|
||||
d.getUTCHours(),
|
||||
d.getUTCMinutes(),
|
||||
d.getUTCSeconds()
|
||||
);
|
||||
|
||||
return asIfUtc - actualUtc;
|
||||
}
|
||||
|
||||
function parseParisStampToMs(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;
|
||||
|
||||
const guessUtc = Date.UTC(y, mo - 1, d, h, mi, se);
|
||||
const off1 = getTzOffsetMs(guessUtc, 'Europe/Paris');
|
||||
let ts = guessUtc - off1;
|
||||
const off2 = getTzOffsetMs(ts, 'Europe/Paris');
|
||||
|
||||
if (off2 !== off1) ts = guessUtc - off2;
|
||||
return ts;
|
||||
}
|
||||
|
||||
function cleanText(v, fallback = '') {
|
||||
const s = (v ?? '').toString().trim();
|
||||
const x = s.toLowerCase();
|
||||
if (['unknown', 'unavailable', 'none', 'null', 'undefined'].includes(x)) return fallback;
|
||||
return s;
|
||||
}
|
||||
|
||||
function finiteOrNull(v) {
|
||||
return Number.isFinite(v) ? v : null;
|
||||
}
|
||||
|
||||
const raw = $input.first().json;
|
||||
const input = extractSnapshot(raw);
|
||||
|
||||
const nowMs = Date.now();
|
||||
const now = getParisParts(nowMs);
|
||||
|
||||
const CFG = {
|
||||
mobileNotifyDomain: 'notify',
|
||||
mobileNotifyService: 'mobile_app_iphone_16_de_tt',
|
||||
|
||||
batteryUsableCapacityKwh: 17.5,
|
||||
nominalAcVoltageV: 230,
|
||||
evcsDedicatedMaxW: 4900,
|
||||
systemReserveW: 300,
|
||||
|
||||
vebusDcVoltageSoftFloorV: 49.0,
|
||||
vebusDcVoltageHardFloorV: 48.6,
|
||||
|
||||
departurePriorityWindowMinutes: 240,
|
||||
sampleSeconds: 30,
|
||||
|
||||
cloudDebtRecoveryThresholdKwh: 0.05,
|
||||
cloudDebtMeaningfulThresholdKwh: 0.01,
|
||||
};
|
||||
|
||||
// =====================================================
|
||||
// PARSED SNAPSHOT
|
||||
// =====================================================
|
||||
const parsed = {
|
||||
soc: clamp(num(input.soc_percent, 0), 0, 100),
|
||||
batteryCurrentA: num(input.battery_current_a, 0),
|
||||
|
||||
pvPowerW: Math.max(0, num(input.pv_power_w, 0)),
|
||||
pvPowerAvg10mW: Math.max(0, num(input.pv_power_10m_avg_w, num(input.pv_power_w, 0))),
|
||||
pvTrendW: num(input.pv_trend_w, 0),
|
||||
|
||||
houseAcPowerW: Math.max(0, num(input.house_ac_power_w, 0)),
|
||||
houseAcPowerPrevW: Math.max(0, num(input.house_ac_power_prev_w, num(input.house_ac_power_w, 0))),
|
||||
gridPowerW: num(input.grid_power_w, 0),
|
||||
|
||||
gridIsOn: low(input.grid_state || 'off') === 'on',
|
||||
acInAvailable: num(input.victron_acin1_available ?? input.ac_in1_available, 0) === 1,
|
||||
gridAvailable:
|
||||
(low(input.grid_state || 'off') === 'on') ||
|
||||
(num(input.victron_acin1_available ?? input.ac_in1_available, 0) === 1),
|
||||
|
||||
todayRemainingKwh: Math.max(0, num(input.today_remaining_kwh, 0)),
|
||||
|
||||
evcsDeviceConnected: low(input.evcs_device_connected || 'off') === 'on',
|
||||
evcsVehicleConnected: low(input.evcs_vehicle_connected || 'off') === 'on',
|
||||
evcsStatusRaw: cleanText(input.evcs_status_raw),
|
||||
evcsModeRaw: [
|
||||
input.evcs_mode_sensor,
|
||||
input.evcs_mode_fallback,
|
||||
input.evcs_mode
|
||||
]
|
||||
.map(v => cleanText(v))
|
||||
.find(v => v) || 'unknown',
|
||||
|
||||
evcsSetCurrentA: num(input.evcs_set_current_a, NaN),
|
||||
evcsCommandCurrentA: num(input.evcs_command_current_a, NaN),
|
||||
evcsCurrentA: Math.max(0, num(input.evcs_current_a, 0)),
|
||||
evcsAcPowerW: Math.max(0, num(input.evcs_ac_power_w, 0)),
|
||||
evcsChargingSwitchOn: low(input.evcs_charging_switch || 'off') === 'on',
|
||||
evcsAutoStartSwitchOn: low(input.evcs_auto_start_switch || 'off') === 'on',
|
||||
|
||||
controllerEnabled: low(input.controller_enabled || 'off') === 'on',
|
||||
notificationsEnabled: low(input.notifications_enabled || 'off') === 'on',
|
||||
hardStopEnabledLegacy: low(input.hard_stop_enabled || 'off') === 'on',
|
||||
chargePermissionNow: low(input.charge_permission_now || 'off') === 'on',
|
||||
controlLatch: low(input.control_latch || 'off') === 'on',
|
||||
departurePriorityEnabled: low(input.departure_priority_enabled || 'off') === 'on',
|
||||
forecastModeEnabled: low(input.forecast_mode_enabled || 'off') === 'on',
|
||||
|
||||
evcsPriorityManualOverride: low(input.evcs_priority_manual_override || 'off') === 'on',
|
||||
evcsPriorityManualMode: cleanText(input.evcs_priority_manual_mode || 'normal', 'normal').toLowerCase(),
|
||||
evcsPriorityManualReason: cleanText(input.evcs_priority_manual_reason),
|
||||
|
||||
vebusOverload: low(input.vebus_overload || 'off') === 'on',
|
||||
vebusL1Overload: low(input.vebus_l1_overload || 'off') === 'on',
|
||||
vebusLowBattery: low(input.vebus_low_battery || 'off') === 'on',
|
||||
vebusL1LowBattery: low(input.vebus_l1_low_battery || 'off') === 'on',
|
||||
|
||||
vebusAcOutPowerW: Math.max(0, num(input.vebus_ac_out_l1_power_w, 0)),
|
||||
vebusAcOutCurrentA: Math.max(0, num(input.vebus_ac_out_l1_current_a, 0)),
|
||||
vebusAcOutVoltageV: num(input.vebus_ac_out_l1_voltage_v, CFG.nominalAcVoltageV),
|
||||
|
||||
vebusDcVoltageV: num(input.vebus_dc_voltage_v, NaN),
|
||||
vebusDcCurrentA: num(input.vebus_dc_current_a, NaN),
|
||||
vebusDcPowerW: num(input.vebus_dc_power_w, NaN),
|
||||
|
||||
vebusState: num(input.vebus_state, NaN),
|
||||
vebusMainState: num(input.vebus_main_state, NaN),
|
||||
};
|
||||
|
||||
parsed.evcsModeIsAuto = low(parsed.evcsModeRaw).includes('auto');
|
||||
|
||||
// =====================================================
|
||||
// PARAMETERS
|
||||
// =====================================================
|
||||
const params = {
|
||||
userStrategyRaw: input.victron_evcs_strategy || 'auto_solar',
|
||||
|
||||
targetCurrentAutoA: clamp(num(input.target_current_auto_a, 6), 0, 32),
|
||||
pvStartMinW: Math.max(0, num(input.pv_start_min_w, 1800)),
|
||||
pvHoldMinW: Math.max(0, num(input.pv_hold_min_w, 1200)),
|
||||
houseAcHeadroomW: Math.max(0, num(input.house_ac_headroom_w, 1200)),
|
||||
|
||||
batteryDischargeSoftA: num(input.battery_discharge_soft_a, -5),
|
||||
batteryDischargeHardA: num(input.battery_discharge_hard_a, -15),
|
||||
batteryReserveUpA: Math.max(0, num(input.battery_reserve_up_a, 8)),
|
||||
batteryNeutralBandA: Math.max(0, num(input.battery_neutral_band_a, 3)),
|
||||
|
||||
socStartMin: clamp(num(input.soc_start_min, 60), 0, 100),
|
||||
socStopMin: clamp(num(input.soc_stop_min, 35), 0, 100),
|
||||
|
||||
currentMinA: clamp(num(input.current_min_a, 6), 6, 32),
|
||||
currentMaxAutoA: clamp(
|
||||
num(input.current_max_auto_a, 16),
|
||||
clamp(num(input.current_min_a, 6), 6, 32),
|
||||
32
|
||||
),
|
||||
currentStepUpA: clamp(num(input.current_step_up_a, 1), 1, 5),
|
||||
currentStepDownA: clamp(num(input.current_step_down_a, 2), 1, 8),
|
||||
|
||||
currentChangeCooldownSeconds: Math.max(5, num(input.current_change_cooldown_seconds, 60)),
|
||||
minRunMinutes: Math.max(0, num(input.min_run_minutes, 3)),
|
||||
minStopMinutes: Math.max(0, num(input.min_stop_minutes, 10)),
|
||||
|
||||
requiredEnergyKwh: Math.max(0, num(input.required_energy_kwh, 0)),
|
||||
socComfortTarget: clamp(num(input.soc_comfort_target, 85), 0, 100),
|
||||
endOfDaySocGuard: clamp(num(input.end_of_day_soc_guard, 85), 0, 100),
|
||||
baseLoadRemainingKwh: Math.max(0, num(input.base_load_remaining_kwh, 0)),
|
||||
flexibleSafetyMarginKwh: Math.max(0, num(input.flexible_safety_margin_kwh, 0)),
|
||||
endOfDaySocTarget: clamp(num(input.end_of_day_soc_target, 85), 0, 100),
|
||||
|
||||
acLoadsMaxSafeW: Math.max(0, num(input.ac_loads_max_safe_w, 8500)),
|
||||
batteryFullSoc: clamp(num(input.battery_full_soc, 90), 0, 100),
|
||||
batteryFullForecastMinKwh: Math.max(0, num(input.battery_full_forecast_min_kwh, 8)),
|
||||
forecastCommitSocFloor: clamp(num(input.forecast_commit_soc_floor, 35), 0, 100),
|
||||
forecastCommitMinKwh: Math.max(0, num(input.forecast_commit_min_kwh, 25)),
|
||||
|
||||
cloudDebtPrevKwh: Math.max(0, num(input.cloud_debt_kwh, 0)),
|
||||
cloudDebtMaxKwh: Math.max(0.05, num(input.cloud_debt_max_kwh, 1.2)),
|
||||
cloudToleranceMinutes: Math.max(1, num(input.cloud_tolerance_minutes, 20)),
|
||||
|
||||
departureTimeMin: parseTimeToMinutes(input.departure_time, null),
|
||||
dayStartTimeMin: parseTimeToMinutes(input.day_start_time, 7 * 60 + 30),
|
||||
dayStopTimeMin: parseTimeToMinutes(input.day_stop_time, 20 * 60 + 30),
|
||||
|
||||
hcNightStartMin: parseTimeToMinutes(input.hc_night_start_time, 23 * 60),
|
||||
hcNightStopMin: parseTimeToMinutes(input.hc_night_stop_time, 6 * 60),
|
||||
morningStartMin: parseTimeToMinutes(
|
||||
input.morning_start_time,
|
||||
parseTimeToMinutes(input.day_start_time, 7 * 60 + 30)
|
||||
),
|
||||
morningStopMin: parseTimeToMinutes(input.morning_stop_time, 11 * 60),
|
||||
hcMiddayStartMin: parseTimeToMinutes(input.hc_midday_start_time, 12 * 60 + 21),
|
||||
hcMiddayStopMin: parseTimeToMinutes(input.hc_midday_stop_time, 14 * 60 + 21),
|
||||
eveningStartMin: parseTimeToMinutes(input.evening_start_time, 17 * 60),
|
||||
eveningStopMin: parseTimeToMinutes(
|
||||
input.evening_stop_time,
|
||||
parseTimeToMinutes(input.day_stop_time, 20 * 60 + 30)
|
||||
),
|
||||
|
||||
allowGridHcNight: low(input.allow_grid_hc_night || 'off') === 'on',
|
||||
allowGridHcMidday: low(input.allow_grid_hc_midday || 'off') === 'on',
|
||||
|
||||
probeEnabled: low(input.probe_enabled || 'off') === 'on',
|
||||
probeMinutes: Math.max(1, num(input.probe_minutes, 3)),
|
||||
probeRetryMinutes: Math.max(5, num(input.probe_retry_minutes, 45)),
|
||||
probeCurrentA: clamp(
|
||||
num(input.probe_current_a, clamp(num(input.current_min_a, 6), 6, 32)),
|
||||
clamp(num(input.current_min_a, 6), 6, 32),
|
||||
clamp(num(input.current_max_auto_a, 16), clamp(num(input.current_min_a, 6), 6, 32), 32)
|
||||
),
|
||||
|
||||
gridBatterySoftA: num(input.grid_battery_soft_a, -3),
|
||||
gridBatteryHardA: num(input.grid_battery_hard_a, -6),
|
||||
|
||||
morningProbePvAvgMinW: Math.max(0, num(input.morning_probe_pv_avg_min_w, 1500)),
|
||||
morningProbePvTrendMinW: num(input.morning_probe_pv_trend_min_w, 0),
|
||||
middayProbePvAvgMinW: Math.max(0, num(input.midday_probe_pv_avg_min_w, 1200)),
|
||||
|
||||
afternoonProbePvAvgMinW: Math.max(0, num(input.afternoon_probe_pv_avg_min_w, 1000)),
|
||||
afternoonProbeSocMin: clamp(num(input.afternoon_probe_soc_min, 85), 0, 100),
|
||||
afternoonBatteryMaxDischargeA: num(input.afternoon_battery_max_discharge_a, -20),
|
||||
|
||||
morningSocMin: clamp(num(input.morning_soc_min, 35), 0, 100),
|
||||
middaySocMin: clamp(num(input.midday_soc_min, 75), 0, 100),
|
||||
eveningSocStartMin: clamp(num(input.evening_soc_start_min, 85), 0, 100),
|
||||
eveningSocStopMin: clamp(num(input.evening_soc_stop_min, 80), 0, 100),
|
||||
|
||||
morningBatteryMaxDischargeA: num(input.morning_battery_max_discharge_a, -20),
|
||||
middayBatteryMaxDischargeA: num(input.midday_battery_max_discharge_a, -40),
|
||||
eveningBatteryMaxDischargeA: num(input.evening_battery_max_discharge_a, -30),
|
||||
|
||||
// =====================================================
|
||||
// EVCS NIGHT ENGINE — NEW PARAMETERS
|
||||
// =====================================================
|
||||
evcsNightBatteryHardFloorPct: clamp(
|
||||
num(input.evcs_night_battery_hard_floor_pct, 22),
|
||||
0,
|
||||
100
|
||||
),
|
||||
|
||||
evcsNightStrongForecastKwh: Math.max(
|
||||
0,
|
||||
num(input.evcs_night_strong_forecast_kwh, 18)
|
||||
),
|
||||
|
||||
evcsNightMediumForecastKwh: Math.max(
|
||||
0,
|
||||
num(input.evcs_night_medium_forecast_kwh, 10)
|
||||
),
|
||||
|
||||
evcsNightTargetStrongForecastPct: clamp(
|
||||
num(input.evcs_night_target_strong_forecast_pct, 35),
|
||||
0,
|
||||
100
|
||||
),
|
||||
|
||||
evcsNightTargetMediumForecastPct: clamp(
|
||||
num(input.evcs_night_target_medium_forecast_pct, 55),
|
||||
0,
|
||||
100
|
||||
),
|
||||
|
||||
evcsNightTargetWeakForecastPct: clamp(
|
||||
num(input.evcs_night_target_weak_forecast_pct, 85),
|
||||
0,
|
||||
100
|
||||
),
|
||||
|
||||
evcsNightBatteryRecoveryPowerKw: Math.max(
|
||||
0.1,
|
||||
num(input.evcs_night_battery_recovery_power_kw, 2.5)
|
||||
),
|
||||
|
||||
evcsNightDepartureSafetyMin: Math.max(
|
||||
0,
|
||||
num(input.evcs_night_departure_safety_min, 30)
|
||||
),
|
||||
};
|
||||
|
||||
// =====================================================
|
||||
// HISTORY / PERSISTED HELPERS
|
||||
// =====================================================
|
||||
const nightSessionStartSocRaw = num(
|
||||
input.evcs_night_session_start_soc ?? input.night_session_start_soc,
|
||||
NaN
|
||||
);
|
||||
|
||||
const nightSessionStartAtRaw = cleanText(
|
||||
input.evcs_night_session_start_at ?? input.night_session_start_at
|
||||
);
|
||||
|
||||
const history = {
|
||||
lastBlockReason: cleanText(input.last_block_reason, 'none') || 'none',
|
||||
blockedUntilAt: cleanText(input.blocked_until),
|
||||
probeStartedAt: cleanText(input.probe_started_at),
|
||||
lastStartAt: cleanText(input.last_start_at),
|
||||
lastStopAt: cleanText(input.last_stop_at),
|
||||
lastCurrentChangeAt: cleanText(input.last_current_change_at),
|
||||
lastSignature: cleanText(input.last_signature),
|
||||
cloudDebtStartedAt: cleanText(input.cloud_debt_started_at),
|
||||
evcsPriorityManualUntilAt: cleanText(input.evcs_priority_manual_until),
|
||||
|
||||
blockedUntilMs: parseParisStampToMs(cleanText(input.blocked_until)),
|
||||
probeStartedMs: parseParisStampToMs(cleanText(input.probe_started_at)),
|
||||
lastStartMs: parseParisStampToMs(cleanText(input.last_start_at)),
|
||||
lastStopMs: parseParisStampToMs(cleanText(input.last_stop_at)),
|
||||
lastCurrentChangeMs: parseParisStampToMs(cleanText(input.last_current_change_at)),
|
||||
cloudDebtStartedMsPrev: parseParisStampToMs(cleanText(input.cloud_debt_started_at)),
|
||||
evcsPriorityManualUntilMs: parseParisStampToMs(cleanText(input.evcs_priority_manual_until)),
|
||||
|
||||
// =====================================================
|
||||
// EVCS NIGHT SESSION MEMORY
|
||||
// =====================================================
|
||||
nightSessionStartSocPct: Number.isFinite(nightSessionStartSocRaw)
|
||||
? clamp(nightSessionStartSocRaw, 0, 100)
|
||||
: null,
|
||||
|
||||
nightSessionStartAt: nightSessionStartAtRaw || '',
|
||||
nightSessionStartMs: parseParisStampToMs(nightSessionStartAtRaw),
|
||||
};
|
||||
|
||||
// =====================================================
|
||||
// BATTERY TUNING
|
||||
// =====================================================
|
||||
const batteryTune = {
|
||||
global: {
|
||||
houseSpikeGraceMinutes: Math.max(0, num(input.house_spike_grace_minutes, 10)),
|
||||
solarPotentialGraceMinutes: Math.max(0, num(input.solar_potential_grace_minutes, 12)),
|
||||
stepUpCooldownSeconds: Math.max(5, num(input.step_up_cooldown_seconds, params.currentChangeCooldownSeconds)),
|
||||
stepDownCooldownSeconds: Math.max(5, num(input.step_down_cooldown_seconds, params.currentChangeCooldownSeconds)),
|
||||
forceModeAutoRevertMinutes: Math.max(0, num(input.force_mode_auto_revert_minutes, 120)),
|
||||
overloadStepDownA: Math.max(1, num(input.overload_step_down_a, 2)),
|
||||
overloadHoldSeconds: Math.max(5, num(input.overload_hold_seconds, 30)),
|
||||
overloadStopAfterSeconds: Math.max(10, num(input.overload_stop_after_seconds, 120)),
|
||||
},
|
||||
|
||||
bands: {
|
||||
gt90: {
|
||||
dischargeSoftA: num(input.batt_discharge_soft_gt90_a, -50),
|
||||
dischargeHardA: num(input.batt_discharge_hard_gt90_a, -100),
|
||||
chargeReserveA: Math.max(0, num(input.batt_charge_reserve_gt90_a, 5)),
|
||||
softToleranceMin: Math.max(0, num(input.batt_soft_tol_gt90_min, 12)),
|
||||
hardToleranceSec: Math.max(0, num(input.batt_hard_tol_gt90_sec, 20)),
|
||||
recoveryMin: Math.max(0, num(input.batt_recovery_gt90_min, 6)),
|
||||
},
|
||||
|
||||
'80_90': {
|
||||
dischargeSoftA: num(input.batt_discharge_soft_80_90_a, -40),
|
||||
dischargeHardA: num(input.batt_discharge_hard_80_90_a, -80),
|
||||
chargeReserveA: Math.max(0, num(input.batt_charge_reserve_80_90_a, 10)),
|
||||
softToleranceMin: Math.max(0, num(input.batt_soft_tol_80_90_min, 10)),
|
||||
hardToleranceSec: Math.max(0, num(input.batt_hard_tol_80_90_sec, 20)),
|
||||
recoveryMin: Math.max(0, num(input.batt_recovery_80_90_min, 6)),
|
||||
},
|
||||
|
||||
'60_80': {
|
||||
dischargeSoftA: num(input.batt_discharge_soft_60_80_a, -20),
|
||||
dischargeHardA: num(input.batt_discharge_hard_60_80_a, -50),
|
||||
chargeReserveA: Math.max(0, num(input.batt_charge_reserve_60_80_a, 40)),
|
||||
softToleranceMin: Math.max(0, num(input.batt_soft_tol_60_80_min, 6)),
|
||||
hardToleranceSec: Math.max(0, num(input.batt_hard_tol_60_80_sec, 15)),
|
||||
recoveryMin: Math.max(0, num(input.batt_recovery_60_80_min, 8)),
|
||||
},
|
||||
|
||||
'40_60': {
|
||||
dischargeSoftA: num(input.batt_discharge_soft_40_60_a, -12),
|
||||
dischargeHardA: num(input.batt_discharge_hard_40_60_a, -30),
|
||||
chargeReserveA: Math.max(0, num(input.batt_charge_reserve_40_60_a, 70)),
|
||||
softToleranceMin: Math.max(0, num(input.batt_soft_tol_40_60_min, 4)),
|
||||
hardToleranceSec: Math.max(0, num(input.batt_hard_tol_40_60_sec, 10)),
|
||||
recoveryMin: Math.max(0, num(input.batt_recovery_40_60_min, 10)),
|
||||
},
|
||||
|
||||
'25_40': {
|
||||
dischargeSoftA: num(input.batt_discharge_soft_25_40_a, -5),
|
||||
dischargeHardA: num(input.batt_discharge_hard_25_40_a, -15),
|
||||
chargeReserveA: Math.max(0, num(input.batt_charge_reserve_25_40_a, 110)),
|
||||
softToleranceMin: Math.max(0, num(input.batt_soft_tol_25_40_min, 2)),
|
||||
hardToleranceSec: Math.max(0, num(input.batt_hard_tol_25_40_sec, 5)),
|
||||
recoveryMin: Math.max(0, num(input.batt_recovery_25_40_min, 12)),
|
||||
},
|
||||
|
||||
lt25: {
|
||||
dischargeSoftA: num(input.batt_discharge_soft_lt25_a, -3),
|
||||
dischargeHardA: num(input.batt_discharge_hard_lt25_a, -8),
|
||||
chargeReserveA: Math.max(0, num(input.batt_charge_reserve_lt25_a, 150)),
|
||||
softToleranceMin: Math.max(0, num(input.batt_soft_tol_lt25_min, 1)),
|
||||
hardToleranceSec: Math.max(0, num(input.batt_hard_tol_lt25_sec, 2)),
|
||||
recoveryMin: Math.max(0, num(input.batt_recovery_lt25_min, 15)),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// =====================================================
|
||||
// OPTIONAL DEBUG / SUMMARY
|
||||
// =====================================================
|
||||
const evcsNightConfig = {
|
||||
batteryHardFloorPct: params.evcsNightBatteryHardFloorPct,
|
||||
strongForecastKwh: params.evcsNightStrongForecastKwh,
|
||||
mediumForecastKwh: params.evcsNightMediumForecastKwh,
|
||||
targetStrongForecastPct: params.evcsNightTargetStrongForecastPct,
|
||||
targetMediumForecastPct: params.evcsNightTargetMediumForecastPct,
|
||||
targetWeakForecastPct: params.evcsNightTargetWeakForecastPct,
|
||||
batteryRecoveryPowerKw: params.evcsNightBatteryRecoveryPowerKw,
|
||||
departureSafetyMin: params.evcsNightDepartureSafetyMin,
|
||||
sessionStartSocPct: history.nightSessionStartSocPct,
|
||||
sessionStartAt: history.nightSessionStartAt || null,
|
||||
};
|
||||
|
||||
// =====================================================
|
||||
// OUTPUT
|
||||
// =====================================================
|
||||
return [{
|
||||
json: {
|
||||
raw,
|
||||
input,
|
||||
nowMs,
|
||||
now,
|
||||
cfg: CFG,
|
||||
parsed,
|
||||
params,
|
||||
history,
|
||||
batteryTune,
|
||||
evcsNightConfig,
|
||||
}
|
||||
}];
|
||||
|
||||
260
archive/decoupesurplus/V2/INPUTPARSE/inputparseP1.js
Normal file
260
archive/decoupesurplus/V2/INPUTPARSE/inputparseP1.js
Normal file
@@ -0,0 +1,260 @@
|
||||
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 clamp(v, min, max) {
|
||||
return Math.min(Math.max(v, min), max);
|
||||
}
|
||||
|
||||
function round1(v) {
|
||||
return Math.round(v * 10) / 10;
|
||||
}
|
||||
|
||||
function round2(v) {
|
||||
return Math.round(v * 100) / 100;
|
||||
}
|
||||
|
||||
function to2(n) {
|
||||
return String(n).padStart(2, '0');
|
||||
}
|
||||
|
||||
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 parseTimeToMinutes(v, fallback = null) {
|
||||
const s = (v ?? '').toString().trim();
|
||||
const m = s.match(/^(\d{1,2}):(\d{2})(?::(\d{2}))?$/);
|
||||
if (!m) return fallback;
|
||||
|
||||
const hh = Number(m[1]);
|
||||
const mm = Number(m[2]);
|
||||
|
||||
if (!Number.isFinite(hh) || !Number.isFinite(mm)) return fallback;
|
||||
return (hh % 24) * 60 + mm;
|
||||
}
|
||||
|
||||
function getParisParts(ms = Date.now()) {
|
||||
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);
|
||||
const date = `${parts.year}-${parts.month}-${parts.day}`;
|
||||
const stamp = `${date} ${to2(hour)}:${parts.minute}:${parts.second}`;
|
||||
|
||||
return {
|
||||
year: Number(parts.year),
|
||||
month: Number(parts.month),
|
||||
day: Number(parts.day),
|
||||
hour,
|
||||
minute: Number(parts.minute),
|
||||
second: Number(parts.second),
|
||||
date,
|
||||
stamp,
|
||||
minNow: hour * 60 + Number(parts.minute),
|
||||
};
|
||||
}
|
||||
|
||||
function getTzOffsetMs(utcTs, timeZone = 'Europe/Paris') {
|
||||
const dtf = new Intl.DateTimeFormat('en-US', {
|
||||
timeZone,
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: false,
|
||||
});
|
||||
|
||||
const parts = Object.fromEntries(
|
||||
dtf.formatToParts(new Date(utcTs))
|
||||
.filter(p => p.type !== 'literal')
|
||||
.map(p => [p.type, p.value])
|
||||
);
|
||||
|
||||
const asIfUtc = Date.UTC(
|
||||
Number(parts.year),
|
||||
Number(parts.month) - 1,
|
||||
Number(parts.day),
|
||||
Number(parts.hour) === 24 ? 0 : Number(parts.hour),
|
||||
Number(parts.minute),
|
||||
Number(parts.second)
|
||||
);
|
||||
|
||||
const d = new Date(utcTs);
|
||||
const actualUtc = Date.UTC(
|
||||
d.getUTCFullYear(),
|
||||
d.getUTCMonth(),
|
||||
d.getUTCDate(),
|
||||
d.getUTCHours(),
|
||||
d.getUTCMinutes(),
|
||||
d.getUTCSeconds()
|
||||
);
|
||||
|
||||
return asIfUtc - actualUtc;
|
||||
}
|
||||
|
||||
function parseParisStampToMs(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;
|
||||
|
||||
const guessUtc = Date.UTC(y, mo - 1, d, h, mi, se);
|
||||
const off1 = getTzOffsetMs(guessUtc, 'Europe/Paris');
|
||||
let ts = guessUtc - off1;
|
||||
const off2 = getTzOffsetMs(ts, 'Europe/Paris');
|
||||
|
||||
if (off2 !== off1) ts = guessUtc - off2;
|
||||
return ts;
|
||||
}
|
||||
|
||||
function cleanText(v, fallback = '') {
|
||||
const s = (v ?? '').toString().trim();
|
||||
const x = s.toLowerCase();
|
||||
if (['unknown', 'unavailable', 'none', 'null', 'undefined'].includes(x)) return fallback;
|
||||
return s;
|
||||
}
|
||||
|
||||
function finiteOrNull(v) {
|
||||
return Number.isFinite(v) ? v : null;
|
||||
}
|
||||
|
||||
const raw = $input.first().json;
|
||||
const input = extractSnapshot(raw);
|
||||
|
||||
const nowMs = Date.now();
|
||||
const now = getParisParts(nowMs);
|
||||
|
||||
const CFG = {
|
||||
mobileNotifyDomain: 'notify',
|
||||
mobileNotifyService: 'mobile_app_iphone_16_de_tt',
|
||||
|
||||
batteryUsableCapacityKwh: 17.5,
|
||||
nominalAcVoltageV: 230,
|
||||
evcsDedicatedMaxW: 4900,
|
||||
systemReserveW: 300,
|
||||
|
||||
vebusDcVoltageSoftFloorV: 49.0,
|
||||
vebusDcVoltageHardFloorV: 48.6,
|
||||
|
||||
departurePriorityWindowMinutes: 240,
|
||||
sampleSeconds: 30,
|
||||
|
||||
cloudDebtRecoveryThresholdKwh: 0.05,
|
||||
cloudDebtMeaningfulThresholdKwh: 0.01,
|
||||
};
|
||||
|
||||
// =====================================================
|
||||
// PARSED SNAPSHOT
|
||||
// =====================================================
|
||||
const parsed = {
|
||||
soc: clamp(num(input.soc_percent, 0), 0, 100),
|
||||
batteryCurrentA: num(input.battery_current_a, 0),
|
||||
|
||||
pvPowerW: Math.max(0, num(input.pv_power_w, 0)),
|
||||
pvPowerAvg10mW: Math.max(0, num(input.pv_power_10m_avg_w, num(input.pv_power_w, 0))),
|
||||
pvTrendW: num(input.pv_trend_w, 0),
|
||||
|
||||
houseAcPowerW: Math.max(0, num(input.house_ac_power_w, 0)),
|
||||
houseAcPowerPrevW: Math.max(0, num(input.house_ac_power_prev_w, num(input.house_ac_power_w, 0))),
|
||||
gridPowerW: num(input.grid_power_w, 0),
|
||||
|
||||
gridIsOn: low(input.grid_state || 'off') === 'on',
|
||||
acInAvailable: num(input.victron_acin1_available ?? input.ac_in1_available, 0) === 1,
|
||||
gridAvailable:
|
||||
(low(input.grid_state || 'off') === 'on') ||
|
||||
(num(input.victron_acin1_available ?? input.ac_in1_available, 0) === 1),
|
||||
|
||||
todayRemainingKwh: Math.max(0, num(input.today_remaining_kwh, 0)),
|
||||
|
||||
evcsDeviceConnected: low(input.evcs_device_connected || 'off') === 'on',
|
||||
evcsVehicleConnected: low(input.evcs_vehicle_connected || 'off') === 'on',
|
||||
evcsStatusRaw: cleanText(input.evcs_status_raw),
|
||||
evcsModeRaw: [
|
||||
input.evcs_mode_sensor,
|
||||
input.evcs_mode_fallback,
|
||||
input.evcs_mode
|
||||
]
|
||||
.map(v => cleanText(v))
|
||||
.find(v => v) || 'unknown',
|
||||
|
||||
evcsSetCurrentA: num(input.evcs_set_current_a, NaN),
|
||||
evcsCommandCurrentA: num(input.evcs_command_current_a, NaN),
|
||||
evcsCurrentA: Math.max(0, num(input.evcs_current_a, 0)),
|
||||
evcsAcPowerW: Math.max(0, num(input.evcs_ac_power_w, 0)),
|
||||
evcsChargingSwitchOn: low(input.evcs_charging_switch || 'off') === 'on',
|
||||
evcsAutoStartSwitchOn: low(input.evcs_auto_start_switch || 'off') === 'on',
|
||||
|
||||
controllerEnabled: low(input.controller_enabled || 'off') === 'on',
|
||||
notificationsEnabled: low(input.notifications_enabled || 'off') === 'on',
|
||||
hardStopEnabledLegacy: low(input.hard_stop_enabled || 'off') === 'on',
|
||||
chargePermissionNow: low(input.charge_permission_now || 'off') === 'on',
|
||||
controlLatch: low(input.control_latch || 'off') === 'on',
|
||||
departurePriorityEnabled: low(input.departure_priority_enabled || 'off') === 'on',
|
||||
forecastModeEnabled: low(input.forecast_mode_enabled || 'off') === 'on',
|
||||
|
||||
evcsPriorityManualOverride: low(input.evcs_priority_manual_override || 'off') === 'on',
|
||||
evcsPriorityManualMode: cleanText(input.evcs_priority_manual_mode || 'normal', 'normal').toLowerCase(),
|
||||
evcsPriorityManualReason: cleanText(input.evcs_priority_manual_reason),
|
||||
|
||||
vebusOverload: low(input.vebus_overload || 'off') === 'on',
|
||||
vebusL1Overload: low(input.vebus_l1_overload || 'off') === 'on',
|
||||
vebusLowBattery: low(input.vebus_low_battery || 'off') === 'on',
|
||||
vebusL1LowBattery: low(input.vebus_l1_low_battery || 'off') === 'on',
|
||||
|
||||
vebusAcOutPowerW: Math.max(0, num(input.vebus_ac_out_l1_power_w, 0)),
|
||||
vebusAcOutCurrentA: Math.max(0, num(input.vebus_ac_out_l1_current_a, 0)),
|
||||
vebusAcOutVoltageV: num(input.vebus_ac_out_l1_voltage_v, CFG.nominalAcVoltageV),
|
||||
|
||||
vebusDcVoltageV: num(input.vebus_dc_voltage_v, NaN),
|
||||
vebusDcCurrentA: num(input.vebus_dc_current_a, NaN),
|
||||
vebusDcPowerW: num(input.vebus_dc_power_w, NaN),
|
||||
|
||||
vebusState: num(input.vebus_state, NaN),
|
||||
vebusMainState: num(input.vebus_main_state, NaN),
|
||||
};
|
||||
|
||||
parsed.evcsModeIsAuto = low(parsed.evcsModeRaw).includes('auto');
|
||||
296
archive/decoupesurplus/V2/INPUTPARSE/inputparseP2.js
Normal file
296
archive/decoupesurplus/V2/INPUTPARSE/inputparseP2.js
Normal file
@@ -0,0 +1,296 @@
|
||||
// =====================================================
|
||||
// PARAMETERS
|
||||
// =====================================================
|
||||
const params = {
|
||||
userStrategyRaw: input.victron_evcs_strategy || 'auto_solar',
|
||||
|
||||
targetCurrentAutoA: clamp(num(input.target_current_auto_a, 6), 0, 32),
|
||||
pvStartMinW: Math.max(0, num(input.pv_start_min_w, 1800)),
|
||||
pvHoldMinW: Math.max(0, num(input.pv_hold_min_w, 1200)),
|
||||
houseAcHeadroomW: Math.max(0, num(input.house_ac_headroom_w, 1200)),
|
||||
|
||||
batteryDischargeSoftA: num(input.battery_discharge_soft_a, -5),
|
||||
batteryDischargeHardA: num(input.battery_discharge_hard_a, -15),
|
||||
batteryReserveUpA: Math.max(0, num(input.battery_reserve_up_a, 8)),
|
||||
batteryNeutralBandA: Math.max(0, num(input.battery_neutral_band_a, 3)),
|
||||
|
||||
socStartMin: clamp(num(input.soc_start_min, 60), 0, 100),
|
||||
socStopMin: clamp(num(input.soc_stop_min, 35), 0, 100),
|
||||
|
||||
currentMinA: clamp(num(input.current_min_a, 6), 6, 32),
|
||||
currentMaxAutoA: clamp(
|
||||
num(input.current_max_auto_a, 16),
|
||||
clamp(num(input.current_min_a, 6), 6, 32),
|
||||
32
|
||||
),
|
||||
currentStepUpA: clamp(num(input.current_step_up_a, 1), 1, 5),
|
||||
currentStepDownA: clamp(num(input.current_step_down_a, 2), 1, 8),
|
||||
|
||||
currentChangeCooldownSeconds: Math.max(5, num(input.current_change_cooldown_seconds, 60)),
|
||||
minRunMinutes: Math.max(0, num(input.min_run_minutes, 3)),
|
||||
minStopMinutes: Math.max(0, num(input.min_stop_minutes, 10)),
|
||||
|
||||
requiredEnergyKwh: Math.max(0, num(input.required_energy_kwh, 0)),
|
||||
socComfortTarget: clamp(num(input.soc_comfort_target, 85), 0, 100),
|
||||
endOfDaySocGuard: clamp(num(input.end_of_day_soc_guard, 85), 0, 100),
|
||||
baseLoadRemainingKwh: Math.max(0, num(input.base_load_remaining_kwh, 0)),
|
||||
flexibleSafetyMarginKwh: Math.max(0, num(input.flexible_safety_margin_kwh, 0)),
|
||||
endOfDaySocTarget: clamp(num(input.end_of_day_soc_target, 85), 0, 100),
|
||||
|
||||
acLoadsMaxSafeW: Math.max(0, num(input.ac_loads_max_safe_w, 8500)),
|
||||
batteryFullSoc: clamp(num(input.battery_full_soc, 90), 0, 100),
|
||||
batteryFullForecastMinKwh: Math.max(0, num(input.battery_full_forecast_min_kwh, 8)),
|
||||
forecastCommitSocFloor: clamp(num(input.forecast_commit_soc_floor, 35), 0, 100),
|
||||
forecastCommitMinKwh: Math.max(0, num(input.forecast_commit_min_kwh, 25)),
|
||||
|
||||
cloudDebtPrevKwh: Math.max(0, num(input.cloud_debt_kwh, 0)),
|
||||
cloudDebtMaxKwh: Math.max(0.05, num(input.cloud_debt_max_kwh, 1.2)),
|
||||
cloudToleranceMinutes: Math.max(1, num(input.cloud_tolerance_minutes, 20)),
|
||||
|
||||
departureTimeMin: parseTimeToMinutes(input.departure_time, null),
|
||||
dayStartTimeMin: parseTimeToMinutes(input.day_start_time, 7 * 60 + 30),
|
||||
dayStopTimeMin: parseTimeToMinutes(input.day_stop_time, 20 * 60 + 30),
|
||||
|
||||
hcNightStartMin: parseTimeToMinutes(input.hc_night_start_time, 23 * 60),
|
||||
hcNightStopMin: parseTimeToMinutes(input.hc_night_stop_time, 6 * 60),
|
||||
morningStartMin: parseTimeToMinutes(
|
||||
input.morning_start_time,
|
||||
parseTimeToMinutes(input.day_start_time, 7 * 60 + 30)
|
||||
),
|
||||
morningStopMin: parseTimeToMinutes(input.morning_stop_time, 11 * 60),
|
||||
hcMiddayStartMin: parseTimeToMinutes(input.hc_midday_start_time, 12 * 60 + 21),
|
||||
hcMiddayStopMin: parseTimeToMinutes(input.hc_midday_stop_time, 14 * 60 + 21),
|
||||
eveningStartMin: parseTimeToMinutes(input.evening_start_time, 17 * 60),
|
||||
eveningStopMin: parseTimeToMinutes(
|
||||
input.evening_stop_time,
|
||||
parseTimeToMinutes(input.day_stop_time, 20 * 60 + 30)
|
||||
),
|
||||
|
||||
allowGridHcNight: low(input.allow_grid_hc_night || 'off') === 'on',
|
||||
allowGridHcMidday: low(input.allow_grid_hc_midday || 'off') === 'on',
|
||||
|
||||
probeEnabled: low(input.probe_enabled || 'off') === 'on',
|
||||
probeMinutes: Math.max(1, num(input.probe_minutes, 3)),
|
||||
probeRetryMinutes: Math.max(5, num(input.probe_retry_minutes, 45)),
|
||||
probeCurrentA: clamp(
|
||||
num(input.probe_current_a, clamp(num(input.current_min_a, 6), 6, 32)),
|
||||
clamp(num(input.current_min_a, 6), 6, 32),
|
||||
clamp(num(input.current_max_auto_a, 16), clamp(num(input.current_min_a, 6), 6, 32), 32)
|
||||
),
|
||||
|
||||
gridBatterySoftA: num(input.grid_battery_soft_a, -3),
|
||||
gridBatteryHardA: num(input.grid_battery_hard_a, -6),
|
||||
|
||||
morningProbePvAvgMinW: Math.max(0, num(input.morning_probe_pv_avg_min_w, 1500)),
|
||||
morningProbePvTrendMinW: num(input.morning_probe_pv_trend_min_w, 0),
|
||||
middayProbePvAvgMinW: Math.max(0, num(input.midday_probe_pv_avg_min_w, 1200)),
|
||||
|
||||
afternoonProbePvAvgMinW: Math.max(0, num(input.afternoon_probe_pv_avg_min_w, 1000)),
|
||||
afternoonProbeSocMin: clamp(num(input.afternoon_probe_soc_min, 85), 0, 100),
|
||||
afternoonBatteryMaxDischargeA: num(input.afternoon_battery_max_discharge_a, -20),
|
||||
|
||||
morningSocMin: clamp(num(input.morning_soc_min, 35), 0, 100),
|
||||
middaySocMin: clamp(num(input.midday_soc_min, 75), 0, 100),
|
||||
eveningSocStartMin: clamp(num(input.evening_soc_start_min, 85), 0, 100),
|
||||
eveningSocStopMin: clamp(num(input.evening_soc_stop_min, 80), 0, 100),
|
||||
|
||||
morningBatteryMaxDischargeA: num(input.morning_battery_max_discharge_a, -20),
|
||||
middayBatteryMaxDischargeA: num(input.midday_battery_max_discharge_a, -40),
|
||||
eveningBatteryMaxDischargeA: num(input.evening_battery_max_discharge_a, -30),
|
||||
|
||||
// =====================================================
|
||||
// EVCS NIGHT ENGINE — NEW PARAMETERS
|
||||
// =====================================================
|
||||
evcsNightBatteryHardFloorPct: clamp(
|
||||
num(input.evcs_night_battery_hard_floor_pct, 22),
|
||||
0,
|
||||
100
|
||||
),
|
||||
|
||||
evcsNightStrongForecastKwh: Math.max(
|
||||
0,
|
||||
num(input.evcs_night_strong_forecast_kwh, 18)
|
||||
),
|
||||
|
||||
evcsNightMediumForecastKwh: Math.max(
|
||||
0,
|
||||
num(input.evcs_night_medium_forecast_kwh, 10)
|
||||
),
|
||||
|
||||
evcsNightTargetStrongForecastPct: clamp(
|
||||
num(input.evcs_night_target_strong_forecast_pct, 35),
|
||||
0,
|
||||
100
|
||||
),
|
||||
|
||||
evcsNightTargetMediumForecastPct: clamp(
|
||||
num(input.evcs_night_target_medium_forecast_pct, 55),
|
||||
0,
|
||||
100
|
||||
),
|
||||
|
||||
evcsNightTargetWeakForecastPct: clamp(
|
||||
num(input.evcs_night_target_weak_forecast_pct, 85),
|
||||
0,
|
||||
100
|
||||
),
|
||||
|
||||
evcsNightBatteryRecoveryPowerKw: Math.max(
|
||||
0.1,
|
||||
num(input.evcs_night_battery_recovery_power_kw, 2.5)
|
||||
),
|
||||
|
||||
evcsNightDepartureSafetyMin: Math.max(
|
||||
0,
|
||||
num(input.evcs_night_departure_safety_min, 30)
|
||||
),
|
||||
};
|
||||
|
||||
// =====================================================
|
||||
// HISTORY / PERSISTED HELPERS
|
||||
// =====================================================
|
||||
const nightSessionStartSocRaw = num(
|
||||
input.evcs_night_session_start_soc ?? input.night_session_start_soc,
|
||||
NaN
|
||||
);
|
||||
|
||||
const nightSessionStartAtRaw = cleanText(
|
||||
input.evcs_night_session_start_at ?? input.night_session_start_at
|
||||
);
|
||||
|
||||
const history = {
|
||||
lastBlockReason: cleanText(input.last_block_reason, 'none') || 'none',
|
||||
blockedUntilAt: cleanText(input.blocked_until),
|
||||
probeStartedAt: cleanText(input.probe_started_at),
|
||||
lastStartAt: cleanText(input.last_start_at),
|
||||
lastStopAt: cleanText(input.last_stop_at),
|
||||
lastCurrentChangeAt: cleanText(input.last_current_change_at),
|
||||
lastSignature: cleanText(input.last_signature),
|
||||
cloudDebtStartedAt: cleanText(input.cloud_debt_started_at),
|
||||
evcsPriorityManualUntilAt: cleanText(input.evcs_priority_manual_until),
|
||||
|
||||
blockedUntilMs: parseParisStampToMs(cleanText(input.blocked_until)),
|
||||
probeStartedMs: parseParisStampToMs(cleanText(input.probe_started_at)),
|
||||
lastStartMs: parseParisStampToMs(cleanText(input.last_start_at)),
|
||||
lastStopMs: parseParisStampToMs(cleanText(input.last_stop_at)),
|
||||
lastCurrentChangeMs: parseParisStampToMs(cleanText(input.last_current_change_at)),
|
||||
cloudDebtStartedMsPrev: parseParisStampToMs(cleanText(input.cloud_debt_started_at)),
|
||||
evcsPriorityManualUntilMs: parseParisStampToMs(cleanText(input.evcs_priority_manual_until)),
|
||||
|
||||
// =====================================================
|
||||
// EVCS NIGHT SESSION MEMORY
|
||||
// =====================================================
|
||||
nightSessionStartSocPct: Number.isFinite(nightSessionStartSocRaw)
|
||||
? clamp(nightSessionStartSocRaw, 0, 100)
|
||||
: null,
|
||||
|
||||
nightSessionStartAt: nightSessionStartAtRaw || '',
|
||||
nightSessionStartMs: parseParisStampToMs(nightSessionStartAtRaw),
|
||||
};
|
||||
|
||||
// =====================================================
|
||||
// BATTERY TUNING
|
||||
// =====================================================
|
||||
const batteryTune = {
|
||||
global: {
|
||||
houseSpikeGraceMinutes: Math.max(0, num(input.house_spike_grace_minutes, 10)),
|
||||
solarPotentialGraceMinutes: Math.max(0, num(input.solar_potential_grace_minutes, 12)),
|
||||
stepUpCooldownSeconds: Math.max(5, num(input.step_up_cooldown_seconds, params.currentChangeCooldownSeconds)),
|
||||
stepDownCooldownSeconds: Math.max(5, num(input.step_down_cooldown_seconds, params.currentChangeCooldownSeconds)),
|
||||
forceModeAutoRevertMinutes: Math.max(0, num(input.force_mode_auto_revert_minutes, 120)),
|
||||
overloadStepDownA: Math.max(1, num(input.overload_step_down_a, 2)),
|
||||
overloadHoldSeconds: Math.max(5, num(input.overload_hold_seconds, 30)),
|
||||
overloadStopAfterSeconds: Math.max(10, num(input.overload_stop_after_seconds, 120)),
|
||||
},
|
||||
|
||||
bands: {
|
||||
gt90: {
|
||||
dischargeSoftA: num(input.batt_discharge_soft_gt90_a, -50),
|
||||
dischargeHardA: num(input.batt_discharge_hard_gt90_a, -100),
|
||||
chargeReserveA: Math.max(0, num(input.batt_charge_reserve_gt90_a, 5)),
|
||||
softToleranceMin: Math.max(0, num(input.batt_soft_tol_gt90_min, 12)),
|
||||
hardToleranceSec: Math.max(0, num(input.batt_hard_tol_gt90_sec, 20)),
|
||||
recoveryMin: Math.max(0, num(input.batt_recovery_gt90_min, 6)),
|
||||
},
|
||||
|
||||
'80_90': {
|
||||
dischargeSoftA: num(input.batt_discharge_soft_80_90_a, -40),
|
||||
dischargeHardA: num(input.batt_discharge_hard_80_90_a, -80),
|
||||
chargeReserveA: Math.max(0, num(input.batt_charge_reserve_80_90_a, 10)),
|
||||
softToleranceMin: Math.max(0, num(input.batt_soft_tol_80_90_min, 10)),
|
||||
hardToleranceSec: Math.max(0, num(input.batt_hard_tol_80_90_sec, 20)),
|
||||
recoveryMin: Math.max(0, num(input.batt_recovery_80_90_min, 6)),
|
||||
},
|
||||
|
||||
'60_80': {
|
||||
dischargeSoftA: num(input.batt_discharge_soft_60_80_a, -20),
|
||||
dischargeHardA: num(input.batt_discharge_hard_60_80_a, -50),
|
||||
chargeReserveA: Math.max(0, num(input.batt_charge_reserve_60_80_a, 40)),
|
||||
softToleranceMin: Math.max(0, num(input.batt_soft_tol_60_80_min, 6)),
|
||||
hardToleranceSec: Math.max(0, num(input.batt_hard_tol_60_80_sec, 15)),
|
||||
recoveryMin: Math.max(0, num(input.batt_recovery_60_80_min, 8)),
|
||||
},
|
||||
|
||||
'40_60': {
|
||||
dischargeSoftA: num(input.batt_discharge_soft_40_60_a, -12),
|
||||
dischargeHardA: num(input.batt_discharge_hard_40_60_a, -30),
|
||||
chargeReserveA: Math.max(0, num(input.batt_charge_reserve_40_60_a, 70)),
|
||||
softToleranceMin: Math.max(0, num(input.batt_soft_tol_40_60_min, 4)),
|
||||
hardToleranceSec: Math.max(0, num(input.batt_hard_tol_40_60_sec, 10)),
|
||||
recoveryMin: Math.max(0, num(input.batt_recovery_40_60_min, 10)),
|
||||
},
|
||||
|
||||
'25_40': {
|
||||
dischargeSoftA: num(input.batt_discharge_soft_25_40_a, -5),
|
||||
dischargeHardA: num(input.batt_discharge_hard_25_40_a, -15),
|
||||
chargeReserveA: Math.max(0, num(input.batt_charge_reserve_25_40_a, 110)),
|
||||
softToleranceMin: Math.max(0, num(input.batt_soft_tol_25_40_min, 2)),
|
||||
hardToleranceSec: Math.max(0, num(input.batt_hard_tol_25_40_sec, 5)),
|
||||
recoveryMin: Math.max(0, num(input.batt_recovery_25_40_min, 12)),
|
||||
},
|
||||
|
||||
lt25: {
|
||||
dischargeSoftA: num(input.batt_discharge_soft_lt25_a, -3),
|
||||
dischargeHardA: num(input.batt_discharge_hard_lt25_a, -8),
|
||||
chargeReserveA: Math.max(0, num(input.batt_charge_reserve_lt25_a, 150)),
|
||||
softToleranceMin: Math.max(0, num(input.batt_soft_tol_lt25_min, 1)),
|
||||
hardToleranceSec: Math.max(0, num(input.batt_hard_tol_lt25_sec, 2)),
|
||||
recoveryMin: Math.max(0, num(input.batt_recovery_lt25_min, 15)),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// =====================================================
|
||||
// OPTIONAL DEBUG / SUMMARY
|
||||
// =====================================================
|
||||
const evcsNightConfig = {
|
||||
batteryHardFloorPct: params.evcsNightBatteryHardFloorPct,
|
||||
strongForecastKwh: params.evcsNightStrongForecastKwh,
|
||||
mediumForecastKwh: params.evcsNightMediumForecastKwh,
|
||||
targetStrongForecastPct: params.evcsNightTargetStrongForecastPct,
|
||||
targetMediumForecastPct: params.evcsNightTargetMediumForecastPct,
|
||||
targetWeakForecastPct: params.evcsNightTargetWeakForecastPct,
|
||||
batteryRecoveryPowerKw: params.evcsNightBatteryRecoveryPowerKw,
|
||||
departureSafetyMin: params.evcsNightDepartureSafetyMin,
|
||||
sessionStartSocPct: history.nightSessionStartSocPct,
|
||||
sessionStartAt: history.nightSessionStartAt || null,
|
||||
};
|
||||
|
||||
// =====================================================
|
||||
// OUTPUT
|
||||
// =====================================================
|
||||
return [{
|
||||
json: {
|
||||
raw,
|
||||
input,
|
||||
nowMs,
|
||||
now,
|
||||
cfg: CFG,
|
||||
parsed,
|
||||
params,
|
||||
history,
|
||||
batteryTune,
|
||||
evcsNightConfig,
|
||||
}
|
||||
}];
|
||||
|
||||
667
archive/decoupesurplus/V2/batcloud.js
Normal file
667
archive/decoupesurplus/V2/batcloud.js
Normal file
@@ -0,0 +1,667 @@
|
||||
function clamp(v, min, max) {
|
||||
return Math.min(Math.max(v, min), max);
|
||||
}
|
||||
|
||||
function to2(n) {
|
||||
return String(n).padStart(2, '0');
|
||||
}
|
||||
|
||||
function getParisParts(ms = Date.now()) {
|
||||
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);
|
||||
const date = `${parts.year}-${parts.month}-${parts.day}`;
|
||||
|
||||
return {
|
||||
date,
|
||||
hour,
|
||||
minute: Number(parts.minute),
|
||||
second: Number(parts.second),
|
||||
};
|
||||
}
|
||||
|
||||
function getTzOffsetMs(utcTs, timeZone = 'Europe/Paris') {
|
||||
const dtf = new Intl.DateTimeFormat('en-US', {
|
||||
timeZone,
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: false,
|
||||
});
|
||||
|
||||
const parts = Object.fromEntries(
|
||||
dtf.formatToParts(new Date(utcTs))
|
||||
.filter(p => p.type !== 'literal')
|
||||
.map(p => [p.type, p.value])
|
||||
);
|
||||
|
||||
const asIfUtc = Date.UTC(
|
||||
Number(parts.year),
|
||||
Number(parts.month) - 1,
|
||||
Number(parts.day),
|
||||
Number(parts.hour) === 24 ? 0 : Number(parts.hour),
|
||||
Number(parts.minute),
|
||||
Number(parts.second)
|
||||
);
|
||||
|
||||
const d = new Date(utcTs);
|
||||
const actualUtc = Date.UTC(
|
||||
d.getUTCFullYear(),
|
||||
d.getUTCMonth(),
|
||||
d.getUTCDate(),
|
||||
d.getUTCHours(),
|
||||
d.getUTCMinutes(),
|
||||
d.getUTCSeconds()
|
||||
);
|
||||
|
||||
return asIfUtc - actualUtc;
|
||||
}
|
||||
|
||||
function parseParisStampToMs(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]);
|
||||
|
||||
const guessUtc = Date.UTC(y, mo - 1, d, h, mi, se);
|
||||
const off1 = getTzOffsetMs(guessUtc, 'Europe/Paris');
|
||||
let ts = guessUtc - off1;
|
||||
const off2 = getTzOffsetMs(ts, 'Europe/Paris');
|
||||
|
||||
if (off2 !== off1) ts = guessUtc - off2;
|
||||
return ts;
|
||||
}
|
||||
|
||||
function hhmmssFromMinutes(minOfDay) {
|
||||
const mm = ((minOfDay % 1440) + 1440) % 1440;
|
||||
const hh = Math.floor(mm / 60);
|
||||
const mi = mm % 60;
|
||||
return `${to2(hh)}:${to2(mi)}:00`;
|
||||
}
|
||||
|
||||
function nextDailyOccurrenceMs(startMin, refMs) {
|
||||
if (!Number.isFinite(startMin)) return null;
|
||||
|
||||
const today = getParisParts(refMs).date;
|
||||
const todayStamp = `${today} ${hhmmssFromMinutes(startMin)}`;
|
||||
const todayMs = parseParisStampToMs(todayStamp);
|
||||
|
||||
if (todayMs !== null && todayMs > refMs + 1000) return todayMs;
|
||||
|
||||
const tomorrow = getParisParts(refMs + 36 * 3600 * 1000).date;
|
||||
const tomorrowStamp = `${tomorrow} ${hhmmssFromMinutes(startMin)}`;
|
||||
return parseParisStampToMs(tomorrowStamp);
|
||||
}
|
||||
|
||||
const prev = $input.first().json;
|
||||
const {
|
||||
nowMs,
|
||||
cfg,
|
||||
parsed,
|
||||
params,
|
||||
history,
|
||||
batteryTune,
|
||||
strategy,
|
||||
derived,
|
||||
context,
|
||||
lifecycle,
|
||||
} = prev;
|
||||
|
||||
// =====================================================
|
||||
// SESSION / SOURCE
|
||||
// =====================================================
|
||||
const sessionObservedActive =
|
||||
parsed.evcsChargingSwitchOn ||
|
||||
parsed.controlLatch ||
|
||||
parsed.chargePermissionNow;
|
||||
|
||||
const isGridSource =
|
||||
context.sourceMode === 'grid_hc_allowed' ||
|
||||
context.sourceMode === 'grid_forced';
|
||||
|
||||
// =====================================================
|
||||
// SOC BAND / BATTERY DYNAMIC POLICY
|
||||
// =====================================================
|
||||
const socBand =
|
||||
parsed.soc > 90 ? 'gt90' :
|
||||
parsed.soc > 80 ? '80_90' :
|
||||
parsed.soc > 60 ? '60_80' :
|
||||
parsed.soc > 40 ? '40_60' :
|
||||
parsed.soc > 25 ? '25_40' :
|
||||
'lt25';
|
||||
|
||||
const band = batteryTune.bands[socBand];
|
||||
|
||||
const dischargeModeFactor =
|
||||
strategy.forceChargeRequested ? 1.2 :
|
||||
strategy.forceGridRequested ? 0.7 :
|
||||
strategy.userStrategy === 'auto_solar' ? 0.8 :
|
||||
1.0;
|
||||
|
||||
const chargeReserveModeFactor =
|
||||
strategy.forceChargeRequested ? 0.8 :
|
||||
strategy.forceGridRequested ? 1.1 :
|
||||
strategy.userStrategy === 'auto_solar' ? 1.1 :
|
||||
1.0;
|
||||
|
||||
const toleranceModeFactor =
|
||||
strategy.forceChargeRequested ? 1.25 :
|
||||
strategy.forceGridRequested ? 0.8 :
|
||||
strategy.userStrategy === 'auto_solar' ? 0.8 :
|
||||
1.0;
|
||||
|
||||
const batteryDischargeSoftDynamicA = band.dischargeSoftA * dischargeModeFactor;
|
||||
const batteryDischargeHardDynamicA = band.dischargeHardA * dischargeModeFactor;
|
||||
const batteryChargeReserveDynamicA = Math.max(0, band.chargeReserveA * chargeReserveModeFactor);
|
||||
const batterySoftToleranceMinutes = band.softToleranceMin * toleranceModeFactor;
|
||||
const batteryHardToleranceSeconds = band.hardToleranceSec;
|
||||
const batteryRecoveryMinutes = band.recoveryMin;
|
||||
|
||||
// =====================================================
|
||||
// CONTEXT HELPERS
|
||||
// =====================================================
|
||||
const houseSpikeThresholdW = 1500;
|
||||
const houseSpikeDetected =
|
||||
(parsed.houseAcPowerW - parsed.houseAcPowerPrevW) >= houseSpikeThresholdW;
|
||||
|
||||
const solarPotentialContext =
|
||||
context.sourceMode === 'solar_potential' ||
|
||||
(
|
||||
parsed.soc >= 80 &&
|
||||
derived.forecastBufferKwh > 0 &&
|
||||
parsed.pvPowerW < context.effectivePvStartMinW &&
|
||||
derived.houseNonEvcsW < 1200
|
||||
);
|
||||
|
||||
// =====================================================
|
||||
// REASON-BASED LOCKS
|
||||
// =====================================================
|
||||
const nextWindowCandidates = [
|
||||
nextDailyOccurrenceMs(params.morningStartMin, nowMs),
|
||||
nextDailyOccurrenceMs(params.hcMiddayStartMin, nowMs),
|
||||
nextDailyOccurrenceMs(params.eveningStartMin, nowMs),
|
||||
nextDailyOccurrenceMs(params.hcNightStartMin, nowMs),
|
||||
].filter(v => Number.isFinite(v));
|
||||
|
||||
const nextAllowedWindowStartMs =
|
||||
nextWindowCandidates.length > 0 ? Math.min(...nextWindowCandidates) : null;
|
||||
|
||||
const blockActive =
|
||||
history.blockedUntilMs !== null &&
|
||||
history.blockedUntilMs > nowMs + 1000;
|
||||
|
||||
const blockStillRelevant =
|
||||
history.lastBlockReason === 'outside_window'
|
||||
? context.profile === 'outside_window'
|
||||
: history.lastBlockReason === 'probe_failed'
|
||||
? context.sourceMode === 'hybrid_probe'
|
||||
: history.lastBlockReason === 'battery_protection_stop'
|
||||
? (
|
||||
parsed.batteryCurrentA < -params.batteryNeutralBandA ||
|
||||
derived.anyVebusLowBattery ||
|
||||
derived.vebusDcVoltageSoftLow
|
||||
)
|
||||
: history.lastBlockReason === 'grid_battery_guard'
|
||||
? isGridSource
|
||||
: history.lastBlockReason === 'vebus_overload_persistent'
|
||||
? derived.anyVebusOverload
|
||||
: false;
|
||||
|
||||
const startBlockedByReason = blockActive && blockStillRelevant;
|
||||
|
||||
// =====================================================
|
||||
// CLOUD DEBT
|
||||
// =====================================================
|
||||
const batteryNetPowerW = parsed.batteryCurrentA * derived.batteryVoltageRef;
|
||||
let cloudDebtNextKwh = params.cloudDebtPrevKwh;
|
||||
|
||||
if (sessionObservedActive) {
|
||||
if (batteryNetPowerW < 0 && !isGridSource) {
|
||||
cloudDebtNextKwh += (Math.abs(batteryNetPowerW) * cfg.sampleSeconds) / 3600000;
|
||||
} else if (batteryNetPowerW > 0) {
|
||||
cloudDebtNextKwh -= (batteryNetPowerW * cfg.sampleSeconds) / 3600000;
|
||||
}
|
||||
} else if (batteryNetPowerW > 0) {
|
||||
cloudDebtNextKwh -= (batteryNetPowerW * cfg.sampleSeconds) / 3600000;
|
||||
}
|
||||
|
||||
cloudDebtNextKwh = clamp(cloudDebtNextKwh, 0, params.cloudDebtMaxKwh * 4);
|
||||
|
||||
const cloudDebtMeaningful =
|
||||
cloudDebtNextKwh > cfg.cloudDebtMeaningfulThresholdKwh;
|
||||
|
||||
const cloudEventDischargingNow =
|
||||
sessionObservedActive &&
|
||||
!isGridSource &&
|
||||
parsed.batteryCurrentA < -Math.max(1, params.batteryNeutralBandA) &&
|
||||
cloudDebtMeaningful;
|
||||
|
||||
const continuingCloudEvent =
|
||||
cloudEventDischargingNow &&
|
||||
history.cloudDebtStartedMsPrev !== null &&
|
||||
params.cloudDebtPrevKwh > cfg.cloudDebtRecoveryThresholdKwh;
|
||||
|
||||
const enteringCloudDebt =
|
||||
cloudEventDischargingNow && !continuingCloudEvent;
|
||||
|
||||
const cloudDebtActive = cloudEventDischargingNow;
|
||||
|
||||
const cloudDebtStartedMs =
|
||||
cloudEventDischargingNow
|
||||
? (continuingCloudEvent ? history.cloudDebtStartedMsPrev : nowMs)
|
||||
: null;
|
||||
|
||||
const cloudDebtDurationMinutes =
|
||||
cloudDebtActive && cloudDebtStartedMs !== null
|
||||
? Math.max(0, (nowMs - cloudDebtStartedMs) / 60000)
|
||||
: 0;
|
||||
|
||||
const effectiveCloudToleranceMinutes =
|
||||
params.cloudToleranceMinutes +
|
||||
(solarPotentialContext ? batteryTune.global.solarPotentialGraceMinutes : 0) +
|
||||
(houseSpikeDetected ? batteryTune.global.houseSpikeGraceMinutes : 0);
|
||||
|
||||
const cloudToleranceOk =
|
||||
cloudDebtNextKwh <= params.cloudDebtMaxKwh &&
|
||||
(
|
||||
!cloudDebtActive ||
|
||||
cloudDebtDurationMinutes <= effectiveCloudToleranceMinutes
|
||||
);
|
||||
|
||||
// =====================================================
|
||||
// LEGACY / FORECAST MODES
|
||||
// =====================================================
|
||||
const batteryFullHarvestMode =
|
||||
parsed.forecastModeEnabled &&
|
||||
context.profile !== 'night_hc' &&
|
||||
context.profile !== 'outside_window' &&
|
||||
parsed.soc >= params.batteryFullSoc &&
|
||||
parsed.todayRemainingKwh >= params.batteryFullForecastMinKwh &&
|
||||
derived.projectedEndSocAfterCharge >= (derived.endDayGuardSoc - 2);
|
||||
|
||||
const forecastCommitMode =
|
||||
parsed.forecastModeEnabled &&
|
||||
context.profile !== 'night_hc' &&
|
||||
context.profile !== 'outside_window' &&
|
||||
parsed.soc >= params.forecastCommitSocFloor &&
|
||||
parsed.todayRemainingKwh >= params.forecastCommitMinKwh &&
|
||||
derived.projectedEndSocAfterCharge >= derived.endDayGuardSoc;
|
||||
|
||||
const batteryPriorityMode =
|
||||
!batteryFullHarvestMode && !forecastCommitMode;
|
||||
|
||||
// =====================================================
|
||||
// PROFILE-BASED THRESHOLDS
|
||||
// =====================================================
|
||||
const profileSocStartFloor =
|
||||
context.profile === 'morning_solar' ? params.morningSocMin :
|
||||
context.profile === 'midday_hc_or_solar' ? params.middaySocMin :
|
||||
context.profile === 'evening_high_soc' ? params.eveningSocStartMin :
|
||||
params.socStartMin;
|
||||
|
||||
const profileSocHoldFloor =
|
||||
context.profile === 'morning_solar' ? Math.max(params.socStopMin, params.morningSocMin - 10) :
|
||||
context.profile === 'midday_hc_or_solar' ? Math.max(params.socStopMin, params.middaySocMin - 15) :
|
||||
context.profile === 'evening_high_soc' ? params.eveningSocStopMin :
|
||||
params.socStopMin;
|
||||
|
||||
const profileSpecificDischargeFloorA =
|
||||
context.profile === 'morning_solar' ? params.morningBatteryMaxDischargeA :
|
||||
context.profile === 'midday_hc_or_solar' ? params.middayBatteryMaxDischargeA :
|
||||
context.profile === 'evening_high_soc' ? params.eveningBatteryMaxDischargeA :
|
||||
params.batteryDischargeSoftA;
|
||||
|
||||
const profileBatteryDischargeFloorA =
|
||||
Math.max(profileSpecificDischargeFloorA, batteryDischargeSoftDynamicA);
|
||||
|
||||
// =====================================================
|
||||
// GRID RELAX IN force_charge_now
|
||||
// =====================================================
|
||||
// Assouplissement uniquement pour la garde réseau,
|
||||
// sans toucher aux sécurités dures globales.
|
||||
const gridForceChargeRelaxSoftA =
|
||||
strategy.forceChargeRequested && isGridSource
|
||||
? (
|
||||
parsed.soc >= 90 ? 28 :
|
||||
parsed.soc >= 80 ? 16 :
|
||||
parsed.soc >= 60 ? 8 :
|
||||
parsed.soc >= 40 ? 4 :
|
||||
0
|
||||
)
|
||||
: 0;
|
||||
|
||||
const gridForceChargeRelaxHardA =
|
||||
strategy.forceChargeRequested && isGridSource
|
||||
? (
|
||||
parsed.soc >= 90 ? 39 :
|
||||
parsed.soc >= 80 ? 29 :
|
||||
parsed.soc >= 60 ? 18 :
|
||||
parsed.soc >= 40 ? 8 :
|
||||
0
|
||||
)
|
||||
: 0;
|
||||
|
||||
const gridBatterySoftDynamicABase =
|
||||
Math.max(params.gridBatterySoftA, batteryDischargeSoftDynamicA);
|
||||
|
||||
const gridBatteryHardDynamicABase =
|
||||
Math.max(params.gridBatteryHardA, batteryDischargeHardDynamicA);
|
||||
|
||||
let gridBatterySoftDynamicA =
|
||||
gridBatterySoftDynamicABase - gridForceChargeRelaxSoftA;
|
||||
|
||||
let gridBatteryHardDynamicA =
|
||||
gridBatteryHardDynamicABase - gridForceChargeRelaxHardA;
|
||||
|
||||
// Garantit que le hard reste plus permissif que le soft d'au moins 3 A
|
||||
if (gridBatteryHardDynamicA > (gridBatterySoftDynamicA - 3)) {
|
||||
gridBatteryHardDynamicA = gridBatterySoftDynamicA - 3;
|
||||
}
|
||||
|
||||
// =====================================================
|
||||
// BASE SAFETY
|
||||
// =====================================================
|
||||
const commonSafetyStartOk =
|
||||
derived.canRunAtMinCurrent &&
|
||||
!derived.anyVebusOverload &&
|
||||
!derived.anyVebusLowBattery &&
|
||||
!derived.vebusDcVoltageSoftLow &&
|
||||
!derived.vebusAcOutSoftReached &&
|
||||
parsed.evcsDeviceConnected &&
|
||||
parsed.evcsVehicleConnected &&
|
||||
!parsed.evcsModeIsAuto;
|
||||
|
||||
const commonSafetyHoldOk =
|
||||
derived.canRunAtMinCurrent &&
|
||||
!derived.anyVebusLowBattery &&
|
||||
!derived.vebusDcVoltageHardLow &&
|
||||
parsed.evcsDeviceConnected &&
|
||||
parsed.evcsVehicleConnected &&
|
||||
!parsed.evcsModeIsAuto;
|
||||
|
||||
// =====================================================
|
||||
// START / HOLD CONDITIONS
|
||||
// =====================================================
|
||||
const solarStartOk =
|
||||
(context.sourceMode === 'solar_only' || context.sourceMode === 'solar_potential') &&
|
||||
commonSafetyStartOk &&
|
||||
parsed.soc >= profileSocStartFloor &&
|
||||
parsed.batteryCurrentA >= profileBatteryDischargeFloorA &&
|
||||
(
|
||||
context.sourceMode === 'solar_potential' ||
|
||||
parsed.pvPowerW >= context.effectivePvStartMinW
|
||||
);
|
||||
|
||||
const solarHoldOk =
|
||||
(context.sourceMode === 'solar_only' || context.sourceMode === 'solar_potential') &&
|
||||
commonSafetyHoldOk &&
|
||||
parsed.soc >= profileSocHoldFloor &&
|
||||
parsed.batteryCurrentA >= profileBatteryDischargeFloorA &&
|
||||
(
|
||||
parsed.pvPowerW >= params.pvHoldMinW ||
|
||||
cloudToleranceOk
|
||||
);
|
||||
|
||||
const gridStartOk =
|
||||
isGridSource &&
|
||||
commonSafetyStartOk &&
|
||||
parsed.gridAvailable &&
|
||||
parsed.batteryCurrentA >= gridBatterySoftDynamicA;
|
||||
|
||||
const gridHoldOk =
|
||||
isGridSource &&
|
||||
commonSafetyHoldOk &&
|
||||
parsed.gridAvailable &&
|
||||
parsed.batteryCurrentA >= gridBatteryHardDynamicA;
|
||||
|
||||
// =====================================================
|
||||
// PROBE LOGIC — STRICTEMENT LIMITÉE AU MODE PROBE
|
||||
// =====================================================
|
||||
const probeModeActive = context.sourceMode === 'hybrid_probe';
|
||||
|
||||
const probeStartOk =
|
||||
probeModeActive &&
|
||||
commonSafetyStartOk &&
|
||||
(context.morningProbeEligible || context.middayProbeEligible);
|
||||
|
||||
const probeHoldGuardOk =
|
||||
probeModeActive &&
|
||||
commonSafetyHoldOk &&
|
||||
parsed.batteryCurrentA >= profileBatteryDischargeFloorA;
|
||||
|
||||
const probeWindowActive =
|
||||
probeModeActive &&
|
||||
sessionObservedActive &&
|
||||
history.probeStartedMs !== null &&
|
||||
lifecycle.minutesSinceProbeStart !== null &&
|
||||
lifecycle.minutesSinceProbeStart < params.probeMinutes;
|
||||
|
||||
const probeElapsedReached =
|
||||
probeModeActive &&
|
||||
sessionObservedActive &&
|
||||
history.probeStartedMs !== null &&
|
||||
lifecycle.minutesSinceProbeStart !== null &&
|
||||
lifecycle.minutesSinceProbeStart >= params.probeMinutes;
|
||||
|
||||
const probeSuccessNow =
|
||||
probeModeActive &&
|
||||
sessionObservedActive &&
|
||||
history.probeStartedMs !== null &&
|
||||
(
|
||||
parsed.pvPowerW >= params.pvHoldMinW &&
|
||||
parsed.batteryCurrentA >= Math.min(0, batteryDischargeSoftDynamicA)
|
||||
);
|
||||
|
||||
const probeFailedNow =
|
||||
probeModeActive &&
|
||||
probeElapsedReached &&
|
||||
!probeSuccessNow;
|
||||
|
||||
// =====================================================
|
||||
// STEP-UP LOGIC
|
||||
// =====================================================
|
||||
const stepUpByBatteryReserve =
|
||||
batteryPriorityMode &&
|
||||
parsed.batteryCurrentA >= batteryChargeReserveDynamicA;
|
||||
|
||||
const stepUpByForecastMode =
|
||||
!batteryPriorityMode &&
|
||||
(
|
||||
parsed.pvPowerW >= params.pvHoldMinW ||
|
||||
parsed.pvPowerAvg10mW >= params.pvHoldMinW
|
||||
) &&
|
||||
derived.forecastBufferKwh >= -1 &&
|
||||
derived.projectedEndSocAfterCharge >= (derived.endDayGuardSoc - 5) &&
|
||||
cloudToleranceOk;
|
||||
|
||||
const solarShouldStepUp =
|
||||
lifecycle.currentCooldownElapsed &&
|
||||
derived.currentReferenceA < derived.physicalCapA &&
|
||||
derived.physicalCapA >= params.currentMinA &&
|
||||
!derived.vebusAcOutSoftReached &&
|
||||
!derived.vebusDcVoltageSoftLow &&
|
||||
!derived.houseLoadHighVsHeadroom &&
|
||||
(
|
||||
stepUpByBatteryReserve ||
|
||||
stepUpByForecastMode ||
|
||||
parsed.pvPowerW >= (context.effectivePvStartMinW + 400)
|
||||
);
|
||||
|
||||
const gridShouldStepUp =
|
||||
lifecycle.currentCooldownElapsed &&
|
||||
derived.currentReferenceA < derived.physicalCapA &&
|
||||
derived.physicalCapA >= params.currentMinA &&
|
||||
!derived.vebusAcOutSoftReached &&
|
||||
!derived.vebusDcVoltageSoftLow &&
|
||||
parsed.batteryCurrentA >= gridBatterySoftDynamicA &&
|
||||
!derived.houseLoadHighVsHeadroom;
|
||||
|
||||
// =====================================================
|
||||
// STEP-DOWN LOGIC
|
||||
// =====================================================
|
||||
const solarShouldStepDownBattery =
|
||||
parsed.batteryCurrentA <= batteryDischargeSoftDynamicA &&
|
||||
lifecycle.currentCooldownElapsed &&
|
||||
derived.currentReferenceA > params.currentMinA;
|
||||
|
||||
const solarShouldStepDownCloudDebt =
|
||||
!cloudToleranceOk &&
|
||||
lifecycle.currentCooldownElapsed &&
|
||||
derived.currentReferenceA > params.currentMinA;
|
||||
|
||||
const gridShouldStepDownBattery =
|
||||
parsed.batteryCurrentA < gridBatterySoftDynamicA &&
|
||||
lifecycle.currentCooldownElapsed &&
|
||||
derived.currentReferenceA > params.currentMinA;
|
||||
|
||||
const shouldStepDownVebusPower =
|
||||
derived.vebusAcOutSoftReached &&
|
||||
lifecycle.currentCooldownElapsed &&
|
||||
derived.currentReferenceA > params.currentMinA;
|
||||
|
||||
const shouldStepDownVebusVoltage =
|
||||
derived.vebusDcVoltageSoftLow &&
|
||||
lifecycle.currentCooldownElapsed &&
|
||||
derived.currentReferenceA > params.currentMinA;
|
||||
|
||||
const shouldStepDownOverload =
|
||||
derived.anyVebusOverload &&
|
||||
lifecycle.currentCooldownElapsed &&
|
||||
derived.currentReferenceA > params.currentMinA;
|
||||
|
||||
const shouldStopOverloadAtMin =
|
||||
derived.anyVebusOverload &&
|
||||
derived.currentReferenceA <= params.currentMinA &&
|
||||
sessionObservedActive;
|
||||
|
||||
// =====================================================
|
||||
// HARD STOPS / PROTECTIONS
|
||||
// =====================================================
|
||||
const batteryCurrentHardExceeded =
|
||||
parsed.batteryCurrentA <= batteryDischargeHardDynamicA;
|
||||
|
||||
// overload volontairement exclu du hard stop immédiat
|
||||
const mustStopHard =
|
||||
derived.anyVebusLowBattery ||
|
||||
derived.vebusDcVoltageHardLow ||
|
||||
derived.vebusAcOutHardReached ||
|
||||
batteryCurrentHardExceeded;
|
||||
|
||||
const gridBatteryProtectionStop =
|
||||
sessionObservedActive &&
|
||||
isGridSource &&
|
||||
parsed.batteryCurrentA < gridBatteryHardDynamicA;
|
||||
|
||||
// =====================================================
|
||||
// OUTPUT
|
||||
// =====================================================
|
||||
return [{
|
||||
json: {
|
||||
...prev,
|
||||
|
||||
batteryPolicy: {
|
||||
socBand,
|
||||
batteryDischargeSoftDynamicA,
|
||||
batteryDischargeHardDynamicA,
|
||||
batteryChargeReserveDynamicA,
|
||||
batterySoftToleranceMinutes,
|
||||
batteryHardToleranceSeconds,
|
||||
batteryRecoveryMinutes,
|
||||
|
||||
gridBatterySoftDynamicA,
|
||||
gridBatteryHardDynamicA,
|
||||
gridForceChargeRelaxSoftA,
|
||||
gridForceChargeRelaxHardA,
|
||||
|
||||
houseSpikeDetected,
|
||||
solarPotentialContext,
|
||||
},
|
||||
|
||||
locks: {
|
||||
nextAllowedWindowStartMs,
|
||||
blockActive,
|
||||
blockStillRelevant,
|
||||
startBlockedByReason,
|
||||
},
|
||||
|
||||
cloud: {
|
||||
sessionObservedActive,
|
||||
batteryNetPowerW,
|
||||
cloudDebtNextKwh,
|
||||
cloudDebtMeaningful,
|
||||
cloudEventDischargingNow,
|
||||
continuingCloudEvent,
|
||||
enteringCloudDebt,
|
||||
cloudDebtActive,
|
||||
cloudDebtStartedMs,
|
||||
cloudDebtDurationMinutes,
|
||||
effectiveCloudToleranceMinutes,
|
||||
cloudToleranceOk,
|
||||
},
|
||||
|
||||
modes: {
|
||||
batteryFullHarvestMode,
|
||||
forecastCommitMode,
|
||||
batteryPriorityMode,
|
||||
},
|
||||
|
||||
safeguards: {
|
||||
isGridSource,
|
||||
commonSafetyStartOk,
|
||||
commonSafetyHoldOk,
|
||||
profileSocStartFloor,
|
||||
profileSocHoldFloor,
|
||||
profileBatteryDischargeFloorA,
|
||||
batteryCurrentHardExceeded,
|
||||
mustStopHard,
|
||||
gridBatteryProtectionStop,
|
||||
},
|
||||
|
||||
conditions: {
|
||||
solarStartOk,
|
||||
solarHoldOk,
|
||||
gridStartOk,
|
||||
gridHoldOk,
|
||||
probeStartOk,
|
||||
probeHoldGuardOk,
|
||||
probeWindowActive,
|
||||
probeElapsedReached,
|
||||
probeSuccessNow,
|
||||
probeFailedNow,
|
||||
solarShouldStepUp,
|
||||
gridShouldStepUp,
|
||||
solarShouldStepDownBattery,
|
||||
solarShouldStepDownCloudDebt,
|
||||
gridShouldStepDownBattery,
|
||||
shouldStepDownVebusPower,
|
||||
shouldStepDownVebusVoltage,
|
||||
shouldStepDownOverload,
|
||||
shouldStopOverloadAtMin,
|
||||
}
|
||||
}
|
||||
}];
|
||||
|
||||
293
archive/decoupesurplus/V2/batcloudP1.js
Normal file
293
archive/decoupesurplus/V2/batcloudP1.js
Normal file
@@ -0,0 +1,293 @@
|
||||
function clamp(v, min, max) {
|
||||
return Math.min(Math.max(v, min), max);
|
||||
}
|
||||
|
||||
function to2(n) {
|
||||
return String(n).padStart(2, '0');
|
||||
}
|
||||
|
||||
function getParisParts(ms = Date.now()) {
|
||||
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);
|
||||
const date = `${parts.year}-${parts.month}-${parts.day}`;
|
||||
|
||||
return {
|
||||
date,
|
||||
hour,
|
||||
minute: Number(parts.minute),
|
||||
second: Number(parts.second),
|
||||
};
|
||||
}
|
||||
|
||||
function getTzOffsetMs(utcTs, timeZone = 'Europe/Paris') {
|
||||
const dtf = new Intl.DateTimeFormat('en-US', {
|
||||
timeZone,
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: false,
|
||||
});
|
||||
|
||||
const parts = Object.fromEntries(
|
||||
dtf.formatToParts(new Date(utcTs))
|
||||
.filter(p => p.type !== 'literal')
|
||||
.map(p => [p.type, p.value])
|
||||
);
|
||||
|
||||
const asIfUtc = Date.UTC(
|
||||
Number(parts.year),
|
||||
Number(parts.month) - 1,
|
||||
Number(parts.day),
|
||||
Number(parts.hour) === 24 ? 0 : Number(parts.hour),
|
||||
Number(parts.minute),
|
||||
Number(parts.second)
|
||||
);
|
||||
|
||||
const d = new Date(utcTs);
|
||||
const actualUtc = Date.UTC(
|
||||
d.getUTCFullYear(),
|
||||
d.getUTCMonth(),
|
||||
d.getUTCDate(),
|
||||
d.getUTCHours(),
|
||||
d.getUTCMinutes(),
|
||||
d.getUTCSeconds()
|
||||
);
|
||||
|
||||
return asIfUtc - actualUtc;
|
||||
}
|
||||
|
||||
function parseParisStampToMs(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]);
|
||||
|
||||
const guessUtc = Date.UTC(y, mo - 1, d, h, mi, se);
|
||||
const off1 = getTzOffsetMs(guessUtc, 'Europe/Paris');
|
||||
let ts = guessUtc - off1;
|
||||
const off2 = getTzOffsetMs(ts, 'Europe/Paris');
|
||||
|
||||
if (off2 !== off1) ts = guessUtc - off2;
|
||||
return ts;
|
||||
}
|
||||
|
||||
function hhmmssFromMinutes(minOfDay) {
|
||||
const mm = ((minOfDay % 1440) + 1440) % 1440;
|
||||
const hh = Math.floor(mm / 60);
|
||||
const mi = mm % 60;
|
||||
return `${to2(hh)}:${to2(mi)}:00`;
|
||||
}
|
||||
|
||||
function nextDailyOccurrenceMs(startMin, refMs) {
|
||||
if (!Number.isFinite(startMin)) return null;
|
||||
|
||||
const today = getParisParts(refMs).date;
|
||||
const todayStamp = `${today} ${hhmmssFromMinutes(startMin)}`;
|
||||
const todayMs = parseParisStampToMs(todayStamp);
|
||||
|
||||
if (todayMs !== null && todayMs > refMs + 1000) return todayMs;
|
||||
|
||||
const tomorrow = getParisParts(refMs + 36 * 3600 * 1000).date;
|
||||
const tomorrowStamp = `${tomorrow} ${hhmmssFromMinutes(startMin)}`;
|
||||
return parseParisStampToMs(tomorrowStamp);
|
||||
}
|
||||
|
||||
const prev = $input.first().json;
|
||||
const {
|
||||
nowMs,
|
||||
cfg,
|
||||
parsed,
|
||||
params,
|
||||
history,
|
||||
batteryTune,
|
||||
strategy,
|
||||
derived,
|
||||
context,
|
||||
lifecycle,
|
||||
} = prev;
|
||||
|
||||
// =====================================================
|
||||
// SESSION / SOURCE
|
||||
// =====================================================
|
||||
const sessionObservedActive =
|
||||
parsed.evcsChargingSwitchOn ||
|
||||
parsed.controlLatch ||
|
||||
parsed.chargePermissionNow;
|
||||
|
||||
const isGridSource =
|
||||
context.sourceMode === 'grid_hc_allowed' ||
|
||||
context.sourceMode === 'grid_forced';
|
||||
|
||||
// =====================================================
|
||||
// SOC BAND / BATTERY DYNAMIC POLICY
|
||||
// =====================================================
|
||||
const socBand =
|
||||
parsed.soc > 90 ? 'gt90' :
|
||||
parsed.soc > 80 ? '80_90' :
|
||||
parsed.soc > 60 ? '60_80' :
|
||||
parsed.soc > 40 ? '40_60' :
|
||||
parsed.soc > 25 ? '25_40' :
|
||||
'lt25';
|
||||
|
||||
const band = batteryTune.bands[socBand];
|
||||
|
||||
const dischargeModeFactor =
|
||||
strategy.forceChargeRequested ? 1.2 :
|
||||
strategy.forceGridRequested ? 0.7 :
|
||||
strategy.userStrategy === 'auto_solar' ? 0.8 :
|
||||
1.0;
|
||||
|
||||
const chargeReserveModeFactor =
|
||||
strategy.forceChargeRequested ? 0.8 :
|
||||
strategy.forceGridRequested ? 1.1 :
|
||||
strategy.userStrategy === 'auto_solar' ? 1.1 :
|
||||
1.0;
|
||||
|
||||
const toleranceModeFactor =
|
||||
strategy.forceChargeRequested ? 1.25 :
|
||||
strategy.forceGridRequested ? 0.8 :
|
||||
strategy.userStrategy === 'auto_solar' ? 0.8 :
|
||||
1.0;
|
||||
|
||||
const batteryDischargeSoftDynamicA = band.dischargeSoftA * dischargeModeFactor;
|
||||
const batteryDischargeHardDynamicA = band.dischargeHardA * dischargeModeFactor;
|
||||
const batteryChargeReserveDynamicA = Math.max(0, band.chargeReserveA * chargeReserveModeFactor);
|
||||
const batterySoftToleranceMinutes = band.softToleranceMin * toleranceModeFactor;
|
||||
const batteryHardToleranceSeconds = band.hardToleranceSec;
|
||||
const batteryRecoveryMinutes = band.recoveryMin;
|
||||
|
||||
// =====================================================
|
||||
// CONTEXT HELPERS
|
||||
// =====================================================
|
||||
const houseSpikeThresholdW = 1500;
|
||||
const houseSpikeDetected =
|
||||
(parsed.houseAcPowerW - parsed.houseAcPowerPrevW) >= houseSpikeThresholdW;
|
||||
|
||||
const solarPotentialContext =
|
||||
context.sourceMode === 'solar_potential' ||
|
||||
(
|
||||
parsed.soc >= 80 &&
|
||||
derived.forecastBufferKwh > 0 &&
|
||||
parsed.pvPowerW < context.effectivePvStartMinW &&
|
||||
derived.houseNonEvcsW < 1200
|
||||
);
|
||||
|
||||
// =====================================================
|
||||
// REASON-BASED LOCKS
|
||||
// =====================================================
|
||||
const nextWindowCandidates = [
|
||||
nextDailyOccurrenceMs(params.morningStartMin, nowMs),
|
||||
nextDailyOccurrenceMs(params.hcMiddayStartMin, nowMs),
|
||||
nextDailyOccurrenceMs(params.eveningStartMin, nowMs),
|
||||
nextDailyOccurrenceMs(params.hcNightStartMin, nowMs),
|
||||
].filter(v => Number.isFinite(v));
|
||||
|
||||
const nextAllowedWindowStartMs =
|
||||
nextWindowCandidates.length > 0 ? Math.min(...nextWindowCandidates) : null;
|
||||
|
||||
const blockActive =
|
||||
history.blockedUntilMs !== null &&
|
||||
history.blockedUntilMs > nowMs + 1000;
|
||||
|
||||
const blockStillRelevant =
|
||||
history.lastBlockReason === 'outside_window'
|
||||
? context.profile === 'outside_window'
|
||||
: history.lastBlockReason === 'probe_failed'
|
||||
? context.sourceMode === 'hybrid_probe'
|
||||
: history.lastBlockReason === 'battery_protection_stop'
|
||||
? (
|
||||
parsed.batteryCurrentA < -params.batteryNeutralBandA ||
|
||||
derived.anyVebusLowBattery ||
|
||||
derived.vebusDcVoltageSoftLow
|
||||
)
|
||||
: history.lastBlockReason === 'grid_battery_guard'
|
||||
? isGridSource
|
||||
: history.lastBlockReason === 'vebus_overload_persistent'
|
||||
? derived.anyVebusOverload
|
||||
: false;
|
||||
|
||||
const startBlockedByReason = blockActive && blockStillRelevant;
|
||||
|
||||
// =====================================================
|
||||
// CLOUD DEBT
|
||||
// =====================================================
|
||||
const batteryNetPowerW = parsed.batteryCurrentA * derived.batteryVoltageRef;
|
||||
let cloudDebtNextKwh = params.cloudDebtPrevKwh;
|
||||
|
||||
if (sessionObservedActive) {
|
||||
if (batteryNetPowerW < 0 && !isGridSource) {
|
||||
cloudDebtNextKwh += (Math.abs(batteryNetPowerW) * cfg.sampleSeconds) / 3600000;
|
||||
} else if (batteryNetPowerW > 0) {
|
||||
cloudDebtNextKwh -= (batteryNetPowerW * cfg.sampleSeconds) / 3600000;
|
||||
}
|
||||
} else if (batteryNetPowerW > 0) {
|
||||
cloudDebtNextKwh -= (batteryNetPowerW * cfg.sampleSeconds) / 3600000;
|
||||
}
|
||||
|
||||
cloudDebtNextKwh = clamp(cloudDebtNextKwh, 0, params.cloudDebtMaxKwh * 4);
|
||||
|
||||
const cloudDebtMeaningful =
|
||||
cloudDebtNextKwh > cfg.cloudDebtMeaningfulThresholdKwh;
|
||||
|
||||
const cloudEventDischargingNow =
|
||||
sessionObservedActive &&
|
||||
!isGridSource &&
|
||||
parsed.batteryCurrentA < -Math.max(1, params.batteryNeutralBandA) &&
|
||||
cloudDebtMeaningful;
|
||||
|
||||
const continuingCloudEvent =
|
||||
cloudEventDischargingNow &&
|
||||
history.cloudDebtStartedMsPrev !== null &&
|
||||
params.cloudDebtPrevKwh > cfg.cloudDebtRecoveryThresholdKwh;
|
||||
|
||||
const enteringCloudDebt =
|
||||
cloudEventDischargingNow && !continuingCloudEvent;
|
||||
|
||||
const cloudDebtActive = cloudEventDischargingNow;
|
||||
|
||||
const cloudDebtStartedMs =
|
||||
cloudEventDischargingNow
|
||||
? (continuingCloudEvent ? history.cloudDebtStartedMsPrev : nowMs)
|
||||
: null;
|
||||
|
||||
const cloudDebtDurationMinutes =
|
||||
cloudDebtActive && cloudDebtStartedMs !== null
|
||||
? Math.max(0, (nowMs - cloudDebtStartedMs) / 60000)
|
||||
: 0;
|
||||
|
||||
const effectiveCloudToleranceMinutes =
|
||||
params.cloudToleranceMinutes +
|
||||
(solarPotentialContext ? batteryTune.global.solarPotentialGraceMinutes : 0) +
|
||||
(houseSpikeDetected ? batteryTune.global.houseSpikeGraceMinutes : 0);
|
||||
|
||||
const cloudToleranceOk =
|
||||
cloudDebtNextKwh <= params.cloudDebtMaxKwh &&
|
||||
(
|
||||
!cloudDebtActive ||
|
||||
cloudDebtDurationMinutes <= effectiveCloudToleranceMinutes
|
||||
);
|
||||
182
archive/decoupesurplus/V2/batcloudP11.js
Normal file
182
archive/decoupesurplus/V2/batcloudP11.js
Normal file
@@ -0,0 +1,182 @@
|
||||
function clamp(v, min, max) {
|
||||
return Math.min(Math.max(v, min), max);
|
||||
}
|
||||
|
||||
function to2(n) {
|
||||
return String(n).padStart(2, '0');
|
||||
}
|
||||
|
||||
function getParisParts(ms = Date.now()) {
|
||||
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);
|
||||
const date = `${parts.year}-${parts.month}-${parts.day}`;
|
||||
|
||||
return {
|
||||
date,
|
||||
hour,
|
||||
minute: Number(parts.minute),
|
||||
second: Number(parts.second),
|
||||
};
|
||||
}
|
||||
|
||||
function getTzOffsetMs(utcTs, timeZone = 'Europe/Paris') {
|
||||
const dtf = new Intl.DateTimeFormat('en-US', {
|
||||
timeZone,
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: false,
|
||||
});
|
||||
|
||||
const parts = Object.fromEntries(
|
||||
dtf.formatToParts(new Date(utcTs))
|
||||
.filter(p => p.type !== 'literal')
|
||||
.map(p => [p.type, p.value])
|
||||
);
|
||||
|
||||
const asIfUtc = Date.UTC(
|
||||
Number(parts.year),
|
||||
Number(parts.month) - 1,
|
||||
Number(parts.day),
|
||||
Number(parts.hour) === 24 ? 0 : Number(parts.hour),
|
||||
Number(parts.minute),
|
||||
Number(parts.second)
|
||||
);
|
||||
|
||||
const d = new Date(utcTs);
|
||||
const actualUtc = Date.UTC(
|
||||
d.getUTCFullYear(),
|
||||
d.getUTCMonth(),
|
||||
d.getUTCDate(),
|
||||
d.getUTCHours(),
|
||||
d.getUTCMinutes(),
|
||||
d.getUTCSeconds()
|
||||
);
|
||||
|
||||
return asIfUtc - actualUtc;
|
||||
}
|
||||
|
||||
function parseParisStampToMs(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]);
|
||||
|
||||
const guessUtc = Date.UTC(y, mo - 1, d, h, mi, se);
|
||||
const off1 = getTzOffsetMs(guessUtc, 'Europe/Paris');
|
||||
let ts = guessUtc - off1;
|
||||
const off2 = getTzOffsetMs(ts, 'Europe/Paris');
|
||||
|
||||
if (off2 !== off1) ts = guessUtc - off2;
|
||||
return ts;
|
||||
}
|
||||
|
||||
function hhmmssFromMinutes(minOfDay) {
|
||||
const mm = ((minOfDay % 1440) + 1440) % 1440;
|
||||
const hh = Math.floor(mm / 60);
|
||||
const mi = mm % 60;
|
||||
return `${to2(hh)}:${to2(mi)}:00`;
|
||||
}
|
||||
|
||||
function nextDailyOccurrenceMs(startMin, refMs) {
|
||||
if (!Number.isFinite(startMin)) return null;
|
||||
|
||||
const today = getParisParts(refMs).date;
|
||||
const todayStamp = `${today} ${hhmmssFromMinutes(startMin)}`;
|
||||
const todayMs = parseParisStampToMs(todayStamp);
|
||||
|
||||
if (todayMs !== null && todayMs > refMs + 1000) return todayMs;
|
||||
|
||||
const tomorrow = getParisParts(refMs + 36 * 3600 * 1000).date;
|
||||
const tomorrowStamp = `${tomorrow} ${hhmmssFromMinutes(startMin)}`;
|
||||
return parseParisStampToMs(tomorrowStamp);
|
||||
}
|
||||
|
||||
const prev = $input.first().json;
|
||||
const {
|
||||
nowMs,
|
||||
cfg,
|
||||
parsed,
|
||||
params,
|
||||
history,
|
||||
batteryTune,
|
||||
strategy,
|
||||
derived,
|
||||
context,
|
||||
lifecycle,
|
||||
} = prev;
|
||||
|
||||
// =====================================================
|
||||
// SESSION / SOURCE
|
||||
// =====================================================
|
||||
const sessionObservedActive =
|
||||
parsed.evcsChargingSwitchOn ||
|
||||
parsed.controlLatch ||
|
||||
parsed.chargePermissionNow;
|
||||
|
||||
const isGridSource =
|
||||
context.sourceMode === 'grid_hc_allowed' ||
|
||||
context.sourceMode === 'grid_forced';
|
||||
|
||||
// =====================================================
|
||||
// SOC BAND / BATTERY DYNAMIC POLICY
|
||||
// =====================================================
|
||||
const socBand =
|
||||
parsed.soc > 90 ? 'gt90' :
|
||||
parsed.soc > 80 ? '80_90' :
|
||||
parsed.soc > 60 ? '60_80' :
|
||||
parsed.soc > 40 ? '40_60' :
|
||||
parsed.soc > 25 ? '25_40' :
|
||||
'lt25';
|
||||
|
||||
const band = batteryTune.bands[socBand];
|
||||
|
||||
const dischargeModeFactor =
|
||||
strategy.forceChargeRequested ? 1.2 :
|
||||
strategy.forceGridRequested ? 0.7 :
|
||||
strategy.userStrategy === 'auto_solar' ? 0.8 :
|
||||
1.0;
|
||||
|
||||
const chargeReserveModeFactor =
|
||||
strategy.forceChargeRequested ? 0.8 :
|
||||
strategy.forceGridRequested ? 1.1 :
|
||||
strategy.userStrategy === 'auto_solar' ? 1.1 :
|
||||
1.0;
|
||||
|
||||
const toleranceModeFactor =
|
||||
strategy.forceChargeRequested ? 1.25 :
|
||||
strategy.forceGridRequested ? 0.8 :
|
||||
strategy.userStrategy === 'auto_solar' ? 0.8 :
|
||||
1.0;
|
||||
|
||||
const batteryDischargeSoftDynamicA = band.dischargeSoftA * dischargeModeFactor;
|
||||
const batteryDischargeHardDynamicA = band.dischargeHardA * dischargeModeFactor;
|
||||
const batteryChargeReserveDynamicA = Math.max(0, band.chargeReserveA * chargeReserveModeFactor);
|
||||
const batterySoftToleranceMinutes = band.softToleranceMin * toleranceModeFactor;
|
||||
const batteryHardToleranceSeconds = band.hardToleranceSec;
|
||||
const batteryRecoveryMinutes = band.recoveryMin;
|
||||
110
archive/decoupesurplus/V2/batcloudP12.js
Normal file
110
archive/decoupesurplus/V2/batcloudP12.js
Normal file
@@ -0,0 +1,110 @@
|
||||
// =====================================================
|
||||
// CONTEXT HELPERS
|
||||
// =====================================================
|
||||
const houseSpikeThresholdW = 1500;
|
||||
const houseSpikeDetected =
|
||||
(parsed.houseAcPowerW - parsed.houseAcPowerPrevW) >= houseSpikeThresholdW;
|
||||
|
||||
const solarPotentialContext =
|
||||
context.sourceMode === 'solar_potential' ||
|
||||
(
|
||||
parsed.soc >= 80 &&
|
||||
derived.forecastBufferKwh > 0 &&
|
||||
parsed.pvPowerW < context.effectivePvStartMinW &&
|
||||
derived.houseNonEvcsW < 1200
|
||||
);
|
||||
|
||||
// =====================================================
|
||||
// REASON-BASED LOCKS
|
||||
// =====================================================
|
||||
const nextWindowCandidates = [
|
||||
nextDailyOccurrenceMs(params.morningStartMin, nowMs),
|
||||
nextDailyOccurrenceMs(params.hcMiddayStartMin, nowMs),
|
||||
nextDailyOccurrenceMs(params.eveningStartMin, nowMs),
|
||||
nextDailyOccurrenceMs(params.hcNightStartMin, nowMs),
|
||||
].filter(v => Number.isFinite(v));
|
||||
|
||||
const nextAllowedWindowStartMs =
|
||||
nextWindowCandidates.length > 0 ? Math.min(...nextWindowCandidates) : null;
|
||||
|
||||
const blockActive =
|
||||
history.blockedUntilMs !== null &&
|
||||
history.blockedUntilMs > nowMs + 1000;
|
||||
|
||||
const blockStillRelevant =
|
||||
history.lastBlockReason === 'outside_window'
|
||||
? context.profile === 'outside_window'
|
||||
: history.lastBlockReason === 'probe_failed'
|
||||
? context.sourceMode === 'hybrid_probe'
|
||||
: history.lastBlockReason === 'battery_protection_stop'
|
||||
? (
|
||||
parsed.batteryCurrentA < -params.batteryNeutralBandA ||
|
||||
derived.anyVebusLowBattery ||
|
||||
derived.vebusDcVoltageSoftLow
|
||||
)
|
||||
: history.lastBlockReason === 'grid_battery_guard'
|
||||
? isGridSource
|
||||
: history.lastBlockReason === 'vebus_overload_persistent'
|
||||
? derived.anyVebusOverload
|
||||
: false;
|
||||
|
||||
const startBlockedByReason = blockActive && blockStillRelevant;
|
||||
|
||||
// =====================================================
|
||||
// CLOUD DEBT
|
||||
// =====================================================
|
||||
const batteryNetPowerW = parsed.batteryCurrentA * derived.batteryVoltageRef;
|
||||
let cloudDebtNextKwh = params.cloudDebtPrevKwh;
|
||||
|
||||
if (sessionObservedActive) {
|
||||
if (batteryNetPowerW < 0 && !isGridSource) {
|
||||
cloudDebtNextKwh += (Math.abs(batteryNetPowerW) * cfg.sampleSeconds) / 3600000;
|
||||
} else if (batteryNetPowerW > 0) {
|
||||
cloudDebtNextKwh -= (batteryNetPowerW * cfg.sampleSeconds) / 3600000;
|
||||
}
|
||||
} else if (batteryNetPowerW > 0) {
|
||||
cloudDebtNextKwh -= (batteryNetPowerW * cfg.sampleSeconds) / 3600000;
|
||||
}
|
||||
|
||||
cloudDebtNextKwh = clamp(cloudDebtNextKwh, 0, params.cloudDebtMaxKwh * 4);
|
||||
|
||||
const cloudDebtMeaningful =
|
||||
cloudDebtNextKwh > cfg.cloudDebtMeaningfulThresholdKwh;
|
||||
|
||||
const cloudEventDischargingNow =
|
||||
sessionObservedActive &&
|
||||
!isGridSource &&
|
||||
parsed.batteryCurrentA < -Math.max(1, params.batteryNeutralBandA) &&
|
||||
cloudDebtMeaningful;
|
||||
|
||||
const continuingCloudEvent =
|
||||
cloudEventDischargingNow &&
|
||||
history.cloudDebtStartedMsPrev !== null &&
|
||||
params.cloudDebtPrevKwh > cfg.cloudDebtRecoveryThresholdKwh;
|
||||
|
||||
const enteringCloudDebt =
|
||||
cloudEventDischargingNow && !continuingCloudEvent;
|
||||
|
||||
const cloudDebtActive = cloudEventDischargingNow;
|
||||
|
||||
const cloudDebtStartedMs =
|
||||
cloudEventDischargingNow
|
||||
? (continuingCloudEvent ? history.cloudDebtStartedMsPrev : nowMs)
|
||||
: null;
|
||||
|
||||
const cloudDebtDurationMinutes =
|
||||
cloudDebtActive && cloudDebtStartedMs !== null
|
||||
? Math.max(0, (nowMs - cloudDebtStartedMs) / 60000)
|
||||
: 0;
|
||||
|
||||
const effectiveCloudToleranceMinutes =
|
||||
params.cloudToleranceMinutes +
|
||||
(solarPotentialContext ? batteryTune.global.solarPotentialGraceMinutes : 0) +
|
||||
(houseSpikeDetected ? batteryTune.global.houseSpikeGraceMinutes : 0);
|
||||
|
||||
const cloudToleranceOk =
|
||||
cloudDebtNextKwh <= params.cloudDebtMaxKwh &&
|
||||
(
|
||||
!cloudDebtActive ||
|
||||
cloudDebtDurationMinutes <= effectiveCloudToleranceMinutes
|
||||
);
|
||||
188
archive/decoupesurplus/V2/batcloudP13.js
Normal file
188
archive/decoupesurplus/V2/batcloudP13.js
Normal file
@@ -0,0 +1,188 @@
|
||||
// =====================================================
|
||||
// LEGACY / FORECAST MODES
|
||||
// =====================================================
|
||||
const batteryFullHarvestMode =
|
||||
parsed.forecastModeEnabled &&
|
||||
context.profile !== 'night_hc' &&
|
||||
context.profile !== 'outside_window' &&
|
||||
parsed.soc >= params.batteryFullSoc &&
|
||||
parsed.todayRemainingKwh >= params.batteryFullForecastMinKwh &&
|
||||
derived.projectedEndSocAfterCharge >= (derived.endDayGuardSoc - 2);
|
||||
|
||||
const forecastCommitMode =
|
||||
parsed.forecastModeEnabled &&
|
||||
context.profile !== 'night_hc' &&
|
||||
context.profile !== 'outside_window' &&
|
||||
parsed.soc >= params.forecastCommitSocFloor &&
|
||||
parsed.todayRemainingKwh >= params.forecastCommitMinKwh &&
|
||||
derived.projectedEndSocAfterCharge >= derived.endDayGuardSoc;
|
||||
|
||||
const batteryPriorityMode =
|
||||
!batteryFullHarvestMode && !forecastCommitMode;
|
||||
|
||||
// =====================================================
|
||||
// PROFILE-BASED THRESHOLDS
|
||||
// =====================================================
|
||||
const profileSocStartFloor =
|
||||
context.profile === 'morning_solar' ? params.morningSocMin :
|
||||
context.profile === 'midday_hc_or_solar' ? params.middaySocMin :
|
||||
context.profile === 'evening_high_soc' ? params.eveningSocStartMin :
|
||||
params.socStartMin;
|
||||
|
||||
const profileSocHoldFloor =
|
||||
context.profile === 'morning_solar' ? Math.max(params.socStopMin, params.morningSocMin - 10) :
|
||||
context.profile === 'midday_hc_or_solar' ? Math.max(params.socStopMin, params.middaySocMin - 15) :
|
||||
context.profile === 'evening_high_soc' ? params.eveningSocStopMin :
|
||||
params.socStopMin;
|
||||
|
||||
const profileSpecificDischargeFloorA =
|
||||
context.profile === 'morning_solar' ? params.morningBatteryMaxDischargeA :
|
||||
context.profile === 'midday_hc_or_solar' ? params.middayBatteryMaxDischargeA :
|
||||
context.profile === 'evening_high_soc' ? params.eveningBatteryMaxDischargeA :
|
||||
params.batteryDischargeSoftA;
|
||||
|
||||
const profileBatteryDischargeFloorA =
|
||||
Math.max(profileSpecificDischargeFloorA, batteryDischargeSoftDynamicA);
|
||||
|
||||
// =====================================================
|
||||
// GRID RELAX IN force_charge_now
|
||||
// =====================================================
|
||||
// Assouplissement uniquement pour la garde réseau,
|
||||
// sans toucher aux sécurités dures globales.
|
||||
const gridForceChargeRelaxSoftA =
|
||||
strategy.forceChargeRequested && isGridSource
|
||||
? (
|
||||
parsed.soc >= 90 ? 28 :
|
||||
parsed.soc >= 80 ? 16 :
|
||||
parsed.soc >= 60 ? 8 :
|
||||
parsed.soc >= 40 ? 4 :
|
||||
0
|
||||
)
|
||||
: 0;
|
||||
|
||||
const gridForceChargeRelaxHardA =
|
||||
strategy.forceChargeRequested && isGridSource
|
||||
? (
|
||||
parsed.soc >= 90 ? 39 :
|
||||
parsed.soc >= 80 ? 29 :
|
||||
parsed.soc >= 60 ? 18 :
|
||||
parsed.soc >= 40 ? 8 :
|
||||
0
|
||||
)
|
||||
: 0;
|
||||
|
||||
const gridBatterySoftDynamicABase =
|
||||
Math.max(params.gridBatterySoftA, batteryDischargeSoftDynamicA);
|
||||
|
||||
const gridBatteryHardDynamicABase =
|
||||
Math.max(params.gridBatteryHardA, batteryDischargeHardDynamicA);
|
||||
|
||||
let gridBatterySoftDynamicA =
|
||||
gridBatterySoftDynamicABase - gridForceChargeRelaxSoftA;
|
||||
|
||||
let gridBatteryHardDynamicA =
|
||||
gridBatteryHardDynamicABase - gridForceChargeRelaxHardA;
|
||||
|
||||
// Garantit que le hard reste plus permissif que le soft d'au moins 3 A
|
||||
if (gridBatteryHardDynamicA > (gridBatterySoftDynamicA - 3)) {
|
||||
gridBatteryHardDynamicA = gridBatterySoftDynamicA - 3;
|
||||
}
|
||||
|
||||
// =====================================================
|
||||
// BASE SAFETY
|
||||
// =====================================================
|
||||
const commonSafetyStartOk =
|
||||
derived.canRunAtMinCurrent &&
|
||||
!derived.anyVebusOverload &&
|
||||
!derived.anyVebusLowBattery &&
|
||||
!derived.vebusDcVoltageSoftLow &&
|
||||
!derived.vebusAcOutSoftReached &&
|
||||
parsed.evcsDeviceConnected &&
|
||||
parsed.evcsVehicleConnected &&
|
||||
!parsed.evcsModeIsAuto;
|
||||
|
||||
const commonSafetyHoldOk =
|
||||
derived.canRunAtMinCurrent &&
|
||||
!derived.anyVebusLowBattery &&
|
||||
!derived.vebusDcVoltageHardLow &&
|
||||
parsed.evcsDeviceConnected &&
|
||||
parsed.evcsVehicleConnected &&
|
||||
!parsed.evcsModeIsAuto;
|
||||
|
||||
// =====================================================
|
||||
// START / HOLD CONDITIONS
|
||||
// =====================================================
|
||||
const solarStartOk =
|
||||
(context.sourceMode === 'solar_only' || context.sourceMode === 'solar_potential') &&
|
||||
commonSafetyStartOk &&
|
||||
parsed.soc >= profileSocStartFloor &&
|
||||
parsed.batteryCurrentA >= profileBatteryDischargeFloorA &&
|
||||
(
|
||||
context.sourceMode === 'solar_potential' ||
|
||||
parsed.pvPowerW >= context.effectivePvStartMinW
|
||||
);
|
||||
|
||||
const solarHoldOk =
|
||||
(context.sourceMode === 'solar_only' || context.sourceMode === 'solar_potential') &&
|
||||
commonSafetyHoldOk &&
|
||||
parsed.soc >= profileSocHoldFloor &&
|
||||
parsed.batteryCurrentA >= profileBatteryDischargeFloorA &&
|
||||
(
|
||||
parsed.pvPowerW >= params.pvHoldMinW ||
|
||||
cloudToleranceOk
|
||||
);
|
||||
|
||||
const gridStartOk =
|
||||
isGridSource &&
|
||||
commonSafetyStartOk &&
|
||||
parsed.gridAvailable &&
|
||||
parsed.batteryCurrentA >= gridBatterySoftDynamicA;
|
||||
|
||||
const gridHoldOk =
|
||||
isGridSource &&
|
||||
commonSafetyHoldOk &&
|
||||
parsed.gridAvailable &&
|
||||
parsed.batteryCurrentA >= gridBatteryHardDynamicA;
|
||||
|
||||
// =====================================================
|
||||
// PROBE LOGIC — STRICTEMENT LIMITÉE AU MODE PROBE
|
||||
// =====================================================
|
||||
const probeModeActive = context.sourceMode === 'hybrid_probe';
|
||||
|
||||
const probeStartOk =
|
||||
probeModeActive &&
|
||||
commonSafetyStartOk &&
|
||||
(context.morningProbeEligible || context.middayProbeEligible);
|
||||
|
||||
const probeHoldGuardOk =
|
||||
probeModeActive &&
|
||||
commonSafetyHoldOk &&
|
||||
parsed.batteryCurrentA >= profileBatteryDischargeFloorA;
|
||||
|
||||
const probeWindowActive =
|
||||
probeModeActive &&
|
||||
sessionObservedActive &&
|
||||
history.probeStartedMs !== null &&
|
||||
lifecycle.minutesSinceProbeStart !== null &&
|
||||
lifecycle.minutesSinceProbeStart < params.probeMinutes;
|
||||
|
||||
const probeElapsedReached =
|
||||
probeModeActive &&
|
||||
sessionObservedActive &&
|
||||
history.probeStartedMs !== null &&
|
||||
lifecycle.minutesSinceProbeStart !== null &&
|
||||
lifecycle.minutesSinceProbeStart >= params.probeMinutes;
|
||||
|
||||
const probeSuccessNow =
|
||||
probeModeActive &&
|
||||
sessionObservedActive &&
|
||||
history.probeStartedMs !== null &&
|
||||
(
|
||||
parsed.pvPowerW >= params.pvHoldMinW &&
|
||||
parsed.batteryCurrentA >= Math.min(0, batteryDischargeSoftDynamicA)
|
||||
);
|
||||
|
||||
const probeFailedNow =
|
||||
probeModeActive &&
|
||||
probeElapsedReached &&
|
||||
!probeSuccessNow;
|
||||
184
archive/decoupesurplus/V2/batcloudP14.js
Normal file
184
archive/decoupesurplus/V2/batcloudP14.js
Normal file
@@ -0,0 +1,184 @@
|
||||
// =====================================================
|
||||
// STEP-UP LOGIC
|
||||
// =====================================================
|
||||
const stepUpByBatteryReserve =
|
||||
batteryPriorityMode &&
|
||||
parsed.batteryCurrentA >= batteryChargeReserveDynamicA;
|
||||
|
||||
const stepUpByForecastMode =
|
||||
!batteryPriorityMode &&
|
||||
(
|
||||
parsed.pvPowerW >= params.pvHoldMinW ||
|
||||
parsed.pvPowerAvg10mW >= params.pvHoldMinW
|
||||
) &&
|
||||
derived.forecastBufferKwh >= -1 &&
|
||||
derived.projectedEndSocAfterCharge >= (derived.endDayGuardSoc - 5) &&
|
||||
cloudToleranceOk;
|
||||
|
||||
const solarShouldStepUp =
|
||||
lifecycle.currentCooldownElapsed &&
|
||||
derived.currentReferenceA < derived.physicalCapA &&
|
||||
derived.physicalCapA >= params.currentMinA &&
|
||||
!derived.vebusAcOutSoftReached &&
|
||||
!derived.vebusDcVoltageSoftLow &&
|
||||
!derived.houseLoadHighVsHeadroom &&
|
||||
(
|
||||
stepUpByBatteryReserve ||
|
||||
stepUpByForecastMode ||
|
||||
parsed.pvPowerW >= (context.effectivePvStartMinW + 400)
|
||||
);
|
||||
|
||||
const gridShouldStepUp =
|
||||
lifecycle.currentCooldownElapsed &&
|
||||
derived.currentReferenceA < derived.physicalCapA &&
|
||||
derived.physicalCapA >= params.currentMinA &&
|
||||
!derived.vebusAcOutSoftReached &&
|
||||
!derived.vebusDcVoltageSoftLow &&
|
||||
parsed.batteryCurrentA >= gridBatterySoftDynamicA &&
|
||||
!derived.houseLoadHighVsHeadroom;
|
||||
|
||||
// =====================================================
|
||||
// STEP-DOWN LOGIC
|
||||
// =====================================================
|
||||
const solarShouldStepDownBattery =
|
||||
parsed.batteryCurrentA <= batteryDischargeSoftDynamicA &&
|
||||
lifecycle.currentCooldownElapsed &&
|
||||
derived.currentReferenceA > params.currentMinA;
|
||||
|
||||
const solarShouldStepDownCloudDebt =
|
||||
!cloudToleranceOk &&
|
||||
lifecycle.currentCooldownElapsed &&
|
||||
derived.currentReferenceA > params.currentMinA;
|
||||
|
||||
const gridShouldStepDownBattery =
|
||||
parsed.batteryCurrentA < gridBatterySoftDynamicA &&
|
||||
lifecycle.currentCooldownElapsed &&
|
||||
derived.currentReferenceA > params.currentMinA;
|
||||
|
||||
const shouldStepDownVebusPower =
|
||||
derived.vebusAcOutSoftReached &&
|
||||
lifecycle.currentCooldownElapsed &&
|
||||
derived.currentReferenceA > params.currentMinA;
|
||||
|
||||
const shouldStepDownVebusVoltage =
|
||||
derived.vebusDcVoltageSoftLow &&
|
||||
lifecycle.currentCooldownElapsed &&
|
||||
derived.currentReferenceA > params.currentMinA;
|
||||
|
||||
const shouldStepDownOverload =
|
||||
derived.anyVebusOverload &&
|
||||
lifecycle.currentCooldownElapsed &&
|
||||
derived.currentReferenceA > params.currentMinA;
|
||||
|
||||
const shouldStopOverloadAtMin =
|
||||
derived.anyVebusOverload &&
|
||||
derived.currentReferenceA <= params.currentMinA &&
|
||||
sessionObservedActive;
|
||||
|
||||
// =====================================================
|
||||
// HARD STOPS / PROTECTIONS
|
||||
// =====================================================
|
||||
const batteryCurrentHardExceeded =
|
||||
parsed.batteryCurrentA <= batteryDischargeHardDynamicA;
|
||||
|
||||
// overload volontairement exclu du hard stop immédiat
|
||||
const mustStopHard =
|
||||
derived.anyVebusLowBattery ||
|
||||
derived.vebusDcVoltageHardLow ||
|
||||
derived.vebusAcOutHardReached ||
|
||||
batteryCurrentHardExceeded;
|
||||
|
||||
const gridBatteryProtectionStop =
|
||||
sessionObservedActive &&
|
||||
isGridSource &&
|
||||
parsed.batteryCurrentA < gridBatteryHardDynamicA;
|
||||
|
||||
// =====================================================
|
||||
// OUTPUT
|
||||
// =====================================================
|
||||
return [{
|
||||
json: {
|
||||
...prev,
|
||||
|
||||
batteryPolicy: {
|
||||
socBand,
|
||||
batteryDischargeSoftDynamicA,
|
||||
batteryDischargeHardDynamicA,
|
||||
batteryChargeReserveDynamicA,
|
||||
batterySoftToleranceMinutes,
|
||||
batteryHardToleranceSeconds,
|
||||
batteryRecoveryMinutes,
|
||||
|
||||
gridBatterySoftDynamicA,
|
||||
gridBatteryHardDynamicA,
|
||||
gridForceChargeRelaxSoftA,
|
||||
gridForceChargeRelaxHardA,
|
||||
|
||||
houseSpikeDetected,
|
||||
solarPotentialContext,
|
||||
},
|
||||
|
||||
locks: {
|
||||
nextAllowedWindowStartMs,
|
||||
blockActive,
|
||||
blockStillRelevant,
|
||||
startBlockedByReason,
|
||||
},
|
||||
|
||||
cloud: {
|
||||
sessionObservedActive,
|
||||
batteryNetPowerW,
|
||||
cloudDebtNextKwh,
|
||||
cloudDebtMeaningful,
|
||||
cloudEventDischargingNow,
|
||||
continuingCloudEvent,
|
||||
enteringCloudDebt,
|
||||
cloudDebtActive,
|
||||
cloudDebtStartedMs,
|
||||
cloudDebtDurationMinutes,
|
||||
effectiveCloudToleranceMinutes,
|
||||
cloudToleranceOk,
|
||||
},
|
||||
|
||||
modes: {
|
||||
batteryFullHarvestMode,
|
||||
forecastCommitMode,
|
||||
batteryPriorityMode,
|
||||
},
|
||||
|
||||
safeguards: {
|
||||
isGridSource,
|
||||
commonSafetyStartOk,
|
||||
commonSafetyHoldOk,
|
||||
profileSocStartFloor,
|
||||
profileSocHoldFloor,
|
||||
profileBatteryDischargeFloorA,
|
||||
batteryCurrentHardExceeded,
|
||||
mustStopHard,
|
||||
gridBatteryProtectionStop,
|
||||
},
|
||||
|
||||
conditions: {
|
||||
solarStartOk,
|
||||
solarHoldOk,
|
||||
gridStartOk,
|
||||
gridHoldOk,
|
||||
probeStartOk,
|
||||
probeHoldGuardOk,
|
||||
probeWindowActive,
|
||||
probeElapsedReached,
|
||||
probeSuccessNow,
|
||||
probeFailedNow,
|
||||
solarShouldStepUp,
|
||||
gridShouldStepUp,
|
||||
solarShouldStepDownBattery,
|
||||
solarShouldStepDownCloudDebt,
|
||||
gridShouldStepDownBattery,
|
||||
shouldStepDownVebusPower,
|
||||
shouldStepDownVebusVoltage,
|
||||
shouldStepDownOverload,
|
||||
shouldStopOverloadAtMin,
|
||||
}
|
||||
}
|
||||
}];
|
||||
|
||||
373
archive/decoupesurplus/V2/batcloudP2.js
Normal file
373
archive/decoupesurplus/V2/batcloudP2.js
Normal file
@@ -0,0 +1,373 @@
|
||||
// =====================================================
|
||||
// LEGACY / FORECAST MODES
|
||||
// =====================================================
|
||||
const batteryFullHarvestMode =
|
||||
parsed.forecastModeEnabled &&
|
||||
context.profile !== 'night_hc' &&
|
||||
context.profile !== 'outside_window' &&
|
||||
parsed.soc >= params.batteryFullSoc &&
|
||||
parsed.todayRemainingKwh >= params.batteryFullForecastMinKwh &&
|
||||
derived.projectedEndSocAfterCharge >= (derived.endDayGuardSoc - 2);
|
||||
|
||||
const forecastCommitMode =
|
||||
parsed.forecastModeEnabled &&
|
||||
context.profile !== 'night_hc' &&
|
||||
context.profile !== 'outside_window' &&
|
||||
parsed.soc >= params.forecastCommitSocFloor &&
|
||||
parsed.todayRemainingKwh >= params.forecastCommitMinKwh &&
|
||||
derived.projectedEndSocAfterCharge >= derived.endDayGuardSoc;
|
||||
|
||||
const batteryPriorityMode =
|
||||
!batteryFullHarvestMode && !forecastCommitMode;
|
||||
|
||||
// =====================================================
|
||||
// PROFILE-BASED THRESHOLDS
|
||||
// =====================================================
|
||||
const profileSocStartFloor =
|
||||
context.profile === 'morning_solar' ? params.morningSocMin :
|
||||
context.profile === 'midday_hc_or_solar' ? params.middaySocMin :
|
||||
context.profile === 'evening_high_soc' ? params.eveningSocStartMin :
|
||||
params.socStartMin;
|
||||
|
||||
const profileSocHoldFloor =
|
||||
context.profile === 'morning_solar' ? Math.max(params.socStopMin, params.morningSocMin - 10) :
|
||||
context.profile === 'midday_hc_or_solar' ? Math.max(params.socStopMin, params.middaySocMin - 15) :
|
||||
context.profile === 'evening_high_soc' ? params.eveningSocStopMin :
|
||||
params.socStopMin;
|
||||
|
||||
const profileSpecificDischargeFloorA =
|
||||
context.profile === 'morning_solar' ? params.morningBatteryMaxDischargeA :
|
||||
context.profile === 'midday_hc_or_solar' ? params.middayBatteryMaxDischargeA :
|
||||
context.profile === 'evening_high_soc' ? params.eveningBatteryMaxDischargeA :
|
||||
params.batteryDischargeSoftA;
|
||||
|
||||
const profileBatteryDischargeFloorA =
|
||||
Math.max(profileSpecificDischargeFloorA, batteryDischargeSoftDynamicA);
|
||||
|
||||
// =====================================================
|
||||
// GRID RELAX IN force_charge_now
|
||||
// =====================================================
|
||||
// Assouplissement uniquement pour la garde réseau,
|
||||
// sans toucher aux sécurités dures globales.
|
||||
const gridForceChargeRelaxSoftA =
|
||||
strategy.forceChargeRequested && isGridSource
|
||||
? (
|
||||
parsed.soc >= 90 ? 28 :
|
||||
parsed.soc >= 80 ? 16 :
|
||||
parsed.soc >= 60 ? 8 :
|
||||
parsed.soc >= 40 ? 4 :
|
||||
0
|
||||
)
|
||||
: 0;
|
||||
|
||||
const gridForceChargeRelaxHardA =
|
||||
strategy.forceChargeRequested && isGridSource
|
||||
? (
|
||||
parsed.soc >= 90 ? 39 :
|
||||
parsed.soc >= 80 ? 29 :
|
||||
parsed.soc >= 60 ? 18 :
|
||||
parsed.soc >= 40 ? 8 :
|
||||
0
|
||||
)
|
||||
: 0;
|
||||
|
||||
const gridBatterySoftDynamicABase =
|
||||
Math.max(params.gridBatterySoftA, batteryDischargeSoftDynamicA);
|
||||
|
||||
const gridBatteryHardDynamicABase =
|
||||
Math.max(params.gridBatteryHardA, batteryDischargeHardDynamicA);
|
||||
|
||||
let gridBatterySoftDynamicA =
|
||||
gridBatterySoftDynamicABase - gridForceChargeRelaxSoftA;
|
||||
|
||||
let gridBatteryHardDynamicA =
|
||||
gridBatteryHardDynamicABase - gridForceChargeRelaxHardA;
|
||||
|
||||
// Garantit que le hard reste plus permissif que le soft d'au moins 3 A
|
||||
if (gridBatteryHardDynamicA > (gridBatterySoftDynamicA - 3)) {
|
||||
gridBatteryHardDynamicA = gridBatterySoftDynamicA - 3;
|
||||
}
|
||||
|
||||
// =====================================================
|
||||
// BASE SAFETY
|
||||
// =====================================================
|
||||
const commonSafetyStartOk =
|
||||
derived.canRunAtMinCurrent &&
|
||||
!derived.anyVebusOverload &&
|
||||
!derived.anyVebusLowBattery &&
|
||||
!derived.vebusDcVoltageSoftLow &&
|
||||
!derived.vebusAcOutSoftReached &&
|
||||
parsed.evcsDeviceConnected &&
|
||||
parsed.evcsVehicleConnected &&
|
||||
!parsed.evcsModeIsAuto;
|
||||
|
||||
const commonSafetyHoldOk =
|
||||
derived.canRunAtMinCurrent &&
|
||||
!derived.anyVebusLowBattery &&
|
||||
!derived.vebusDcVoltageHardLow &&
|
||||
parsed.evcsDeviceConnected &&
|
||||
parsed.evcsVehicleConnected &&
|
||||
!parsed.evcsModeIsAuto;
|
||||
|
||||
// =====================================================
|
||||
// START / HOLD CONDITIONS
|
||||
// =====================================================
|
||||
const solarStartOk =
|
||||
(context.sourceMode === 'solar_only' || context.sourceMode === 'solar_potential') &&
|
||||
commonSafetyStartOk &&
|
||||
parsed.soc >= profileSocStartFloor &&
|
||||
parsed.batteryCurrentA >= profileBatteryDischargeFloorA &&
|
||||
(
|
||||
context.sourceMode === 'solar_potential' ||
|
||||
parsed.pvPowerW >= context.effectivePvStartMinW
|
||||
);
|
||||
|
||||
const solarHoldOk =
|
||||
(context.sourceMode === 'solar_only' || context.sourceMode === 'solar_potential') &&
|
||||
commonSafetyHoldOk &&
|
||||
parsed.soc >= profileSocHoldFloor &&
|
||||
parsed.batteryCurrentA >= profileBatteryDischargeFloorA &&
|
||||
(
|
||||
parsed.pvPowerW >= params.pvHoldMinW ||
|
||||
cloudToleranceOk
|
||||
);
|
||||
|
||||
const gridStartOk =
|
||||
isGridSource &&
|
||||
commonSafetyStartOk &&
|
||||
parsed.gridAvailable &&
|
||||
parsed.batteryCurrentA >= gridBatterySoftDynamicA;
|
||||
|
||||
const gridHoldOk =
|
||||
isGridSource &&
|
||||
commonSafetyHoldOk &&
|
||||
parsed.gridAvailable &&
|
||||
parsed.batteryCurrentA >= gridBatteryHardDynamicA;
|
||||
|
||||
// =====================================================
|
||||
// PROBE LOGIC — STRICTEMENT LIMITÉE AU MODE PROBE
|
||||
// =====================================================
|
||||
const probeModeActive = context.sourceMode === 'hybrid_probe';
|
||||
|
||||
const probeStartOk =
|
||||
probeModeActive &&
|
||||
commonSafetyStartOk &&
|
||||
(context.morningProbeEligible || context.middayProbeEligible);
|
||||
|
||||
const probeHoldGuardOk =
|
||||
probeModeActive &&
|
||||
commonSafetyHoldOk &&
|
||||
parsed.batteryCurrentA >= profileBatteryDischargeFloorA;
|
||||
|
||||
const probeWindowActive =
|
||||
probeModeActive &&
|
||||
sessionObservedActive &&
|
||||
history.probeStartedMs !== null &&
|
||||
lifecycle.minutesSinceProbeStart !== null &&
|
||||
lifecycle.minutesSinceProbeStart < params.probeMinutes;
|
||||
|
||||
const probeElapsedReached =
|
||||
probeModeActive &&
|
||||
sessionObservedActive &&
|
||||
history.probeStartedMs !== null &&
|
||||
lifecycle.minutesSinceProbeStart !== null &&
|
||||
lifecycle.minutesSinceProbeStart >= params.probeMinutes;
|
||||
|
||||
const probeSuccessNow =
|
||||
probeModeActive &&
|
||||
sessionObservedActive &&
|
||||
history.probeStartedMs !== null &&
|
||||
(
|
||||
parsed.pvPowerW >= params.pvHoldMinW &&
|
||||
parsed.batteryCurrentA >= Math.min(0, batteryDischargeSoftDynamicA)
|
||||
);
|
||||
|
||||
const probeFailedNow =
|
||||
probeModeActive &&
|
||||
probeElapsedReached &&
|
||||
!probeSuccessNow;
|
||||
|
||||
// =====================================================
|
||||
// STEP-UP LOGIC
|
||||
// =====================================================
|
||||
const stepUpByBatteryReserve =
|
||||
batteryPriorityMode &&
|
||||
parsed.batteryCurrentA >= batteryChargeReserveDynamicA;
|
||||
|
||||
const stepUpByForecastMode =
|
||||
!batteryPriorityMode &&
|
||||
(
|
||||
parsed.pvPowerW >= params.pvHoldMinW ||
|
||||
parsed.pvPowerAvg10mW >= params.pvHoldMinW
|
||||
) &&
|
||||
derived.forecastBufferKwh >= -1 &&
|
||||
derived.projectedEndSocAfterCharge >= (derived.endDayGuardSoc - 5) &&
|
||||
cloudToleranceOk;
|
||||
|
||||
const solarShouldStepUp =
|
||||
lifecycle.currentCooldownElapsed &&
|
||||
derived.currentReferenceA < derived.physicalCapA &&
|
||||
derived.physicalCapA >= params.currentMinA &&
|
||||
!derived.vebusAcOutSoftReached &&
|
||||
!derived.vebusDcVoltageSoftLow &&
|
||||
!derived.houseLoadHighVsHeadroom &&
|
||||
(
|
||||
stepUpByBatteryReserve ||
|
||||
stepUpByForecastMode ||
|
||||
parsed.pvPowerW >= (context.effectivePvStartMinW + 400)
|
||||
);
|
||||
|
||||
const gridShouldStepUp =
|
||||
lifecycle.currentCooldownElapsed &&
|
||||
derived.currentReferenceA < derived.physicalCapA &&
|
||||
derived.physicalCapA >= params.currentMinA &&
|
||||
!derived.vebusAcOutSoftReached &&
|
||||
!derived.vebusDcVoltageSoftLow &&
|
||||
parsed.batteryCurrentA >= gridBatterySoftDynamicA &&
|
||||
!derived.houseLoadHighVsHeadroom;
|
||||
|
||||
// =====================================================
|
||||
// STEP-DOWN LOGIC
|
||||
// =====================================================
|
||||
const solarShouldStepDownBattery =
|
||||
parsed.batteryCurrentA <= batteryDischargeSoftDynamicA &&
|
||||
lifecycle.currentCooldownElapsed &&
|
||||
derived.currentReferenceA > params.currentMinA;
|
||||
|
||||
const solarShouldStepDownCloudDebt =
|
||||
!cloudToleranceOk &&
|
||||
lifecycle.currentCooldownElapsed &&
|
||||
derived.currentReferenceA > params.currentMinA;
|
||||
|
||||
const gridShouldStepDownBattery =
|
||||
parsed.batteryCurrentA < gridBatterySoftDynamicA &&
|
||||
lifecycle.currentCooldownElapsed &&
|
||||
derived.currentReferenceA > params.currentMinA;
|
||||
|
||||
const shouldStepDownVebusPower =
|
||||
derived.vebusAcOutSoftReached &&
|
||||
lifecycle.currentCooldownElapsed &&
|
||||
derived.currentReferenceA > params.currentMinA;
|
||||
|
||||
const shouldStepDownVebusVoltage =
|
||||
derived.vebusDcVoltageSoftLow &&
|
||||
lifecycle.currentCooldownElapsed &&
|
||||
derived.currentReferenceA > params.currentMinA;
|
||||
|
||||
const shouldStepDownOverload =
|
||||
derived.anyVebusOverload &&
|
||||
lifecycle.currentCooldownElapsed &&
|
||||
derived.currentReferenceA > params.currentMinA;
|
||||
|
||||
const shouldStopOverloadAtMin =
|
||||
derived.anyVebusOverload &&
|
||||
derived.currentReferenceA <= params.currentMinA &&
|
||||
sessionObservedActive;
|
||||
|
||||
// =====================================================
|
||||
// HARD STOPS / PROTECTIONS
|
||||
// =====================================================
|
||||
const batteryCurrentHardExceeded =
|
||||
parsed.batteryCurrentA <= batteryDischargeHardDynamicA;
|
||||
|
||||
// overload volontairement exclu du hard stop immédiat
|
||||
const mustStopHard =
|
||||
derived.anyVebusLowBattery ||
|
||||
derived.vebusDcVoltageHardLow ||
|
||||
derived.vebusAcOutHardReached ||
|
||||
batteryCurrentHardExceeded;
|
||||
|
||||
const gridBatteryProtectionStop =
|
||||
sessionObservedActive &&
|
||||
isGridSource &&
|
||||
parsed.batteryCurrentA < gridBatteryHardDynamicA;
|
||||
|
||||
// =====================================================
|
||||
// OUTPUT
|
||||
// =====================================================
|
||||
return [{
|
||||
json: {
|
||||
...prev,
|
||||
|
||||
batteryPolicy: {
|
||||
socBand,
|
||||
batteryDischargeSoftDynamicA,
|
||||
batteryDischargeHardDynamicA,
|
||||
batteryChargeReserveDynamicA,
|
||||
batterySoftToleranceMinutes,
|
||||
batteryHardToleranceSeconds,
|
||||
batteryRecoveryMinutes,
|
||||
|
||||
gridBatterySoftDynamicA,
|
||||
gridBatteryHardDynamicA,
|
||||
gridForceChargeRelaxSoftA,
|
||||
gridForceChargeRelaxHardA,
|
||||
|
||||
houseSpikeDetected,
|
||||
solarPotentialContext,
|
||||
},
|
||||
|
||||
locks: {
|
||||
nextAllowedWindowStartMs,
|
||||
blockActive,
|
||||
blockStillRelevant,
|
||||
startBlockedByReason,
|
||||
},
|
||||
|
||||
cloud: {
|
||||
sessionObservedActive,
|
||||
batteryNetPowerW,
|
||||
cloudDebtNextKwh,
|
||||
cloudDebtMeaningful,
|
||||
cloudEventDischargingNow,
|
||||
continuingCloudEvent,
|
||||
enteringCloudDebt,
|
||||
cloudDebtActive,
|
||||
cloudDebtStartedMs,
|
||||
cloudDebtDurationMinutes,
|
||||
effectiveCloudToleranceMinutes,
|
||||
cloudToleranceOk,
|
||||
},
|
||||
|
||||
modes: {
|
||||
batteryFullHarvestMode,
|
||||
forecastCommitMode,
|
||||
batteryPriorityMode,
|
||||
},
|
||||
|
||||
safeguards: {
|
||||
isGridSource,
|
||||
commonSafetyStartOk,
|
||||
commonSafetyHoldOk,
|
||||
profileSocStartFloor,
|
||||
profileSocHoldFloor,
|
||||
profileBatteryDischargeFloorA,
|
||||
batteryCurrentHardExceeded,
|
||||
mustStopHard,
|
||||
gridBatteryProtectionStop,
|
||||
},
|
||||
|
||||
conditions: {
|
||||
solarStartOk,
|
||||
solarHoldOk,
|
||||
gridStartOk,
|
||||
gridHoldOk,
|
||||
probeStartOk,
|
||||
probeHoldGuardOk,
|
||||
probeWindowActive,
|
||||
probeElapsedReached,
|
||||
probeSuccessNow,
|
||||
probeFailedNow,
|
||||
solarShouldStepUp,
|
||||
gridShouldStepUp,
|
||||
solarShouldStepDownBattery,
|
||||
solarShouldStepDownCloudDebt,
|
||||
gridShouldStepDownBattery,
|
||||
shouldStepDownVebusPower,
|
||||
shouldStepDownVebusVoltage,
|
||||
shouldStepDownOverload,
|
||||
shouldStopOverloadAtMin,
|
||||
}
|
||||
}
|
||||
}];
|
||||
|
||||
464
archive/decoupesurplus/V2/gridhyst.js
Normal file
464
archive/decoupesurplus/V2/gridhyst.js
Normal file
@@ -0,0 +1,464 @@
|
||||
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 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);
|
||||
|
||||
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;
|
||||
|
||||
let state = 'observe_only';
|
||||
let reason = [];
|
||||
let desiredGrid = gridIsOn ? 'on' : 'off';
|
||||
let desiredLatch = latchIsOn ? 'on' : 'off';
|
||||
|
||||
// ======================================================
|
||||
// Core hysteresis logic V1.1
|
||||
// ======================================================
|
||||
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) {
|
||||
// Seuil bas atteint => prise de main immédiate
|
||||
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) {
|
||||
// NOUVEAU CAS V1.1 :
|
||||
// Le réseau est déjà ON mais ce workflow n'a pas la main.
|
||||
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) {
|
||||
// Tant que le latch est actif, on garde la main
|
||||
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
|
||||
// ======================================================
|
||||
// On pose un nouveau "last_on_at" si :
|
||||
// - on allume réellement EDF
|
||||
// - ou on adopte un EDF déjà ON avec latch OFF
|
||||
const setLastOnAt =
|
||||
gridAction === 'turn_on' ||
|
||||
state === 'adopt_existing_grid_on' ||
|
||||
state === 'adopt_existing_grid_on_low_soc';
|
||||
|
||||
// On pose "last_off_at" si on coupe réellement EDF
|
||||
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`);
|
||||
}
|
||||
|
||||
// ======================================================
|
||||
// Notifications
|
||||
// ======================================================
|
||||
let notifyTitle = 'Victron Grid Hysteresis';
|
||||
let notifyMessage = '';
|
||||
let notifyWanted = false;
|
||||
|
||||
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'}`
|
||||
].join('|');
|
||||
|
||||
const signatureChanged = signature !== lastSignature;
|
||||
|
||||
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
|
||||
},
|
||||
|
||||
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
|
||||
},
|
||||
|
||||
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 }];
|
||||
|
||||
233
archive/decoupesurplus/V2/gridhystP1.js
Normal file
233
archive/decoupesurplus/V2/gridhystP1.js
Normal file
@@ -0,0 +1,233 @@
|
||||
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 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);
|
||||
|
||||
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;
|
||||
|
||||
let state = 'observe_only';
|
||||
let reason = [];
|
||||
let desiredGrid = gridIsOn ? 'on' : 'off';
|
||||
let desiredLatch = latchIsOn ? 'on' : 'off';
|
||||
|
||||
// ======================================================
|
||||
// Core hysteresis logic V1.1
|
||||
// ======================================================
|
||||
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) {
|
||||
// Seuil bas atteint => prise de main immédiate
|
||||
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) {
|
||||
// NOUVEAU CAS V1.1 :
|
||||
// Le réseau est déjà ON mais ce workflow n'a pas la main.
|
||||
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) {
|
||||
// Tant que le latch est actif, on garde la main
|
||||
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';
|
||||
230
archive/decoupesurplus/V2/gridhystP2.js
Normal file
230
archive/decoupesurplus/V2/gridhystP2.js
Normal file
@@ -0,0 +1,230 @@
|
||||
// ======================================================
|
||||
// Timestamps
|
||||
// ======================================================
|
||||
// On pose un nouveau "last_on_at" si :
|
||||
// - on allume réellement EDF
|
||||
// - ou on adopte un EDF déjà ON avec latch OFF
|
||||
const setLastOnAt =
|
||||
gridAction === 'turn_on' ||
|
||||
state === 'adopt_existing_grid_on' ||
|
||||
state === 'adopt_existing_grid_on_low_soc';
|
||||
|
||||
// On pose "last_off_at" si on coupe réellement EDF
|
||||
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`);
|
||||
}
|
||||
|
||||
// ======================================================
|
||||
// Notifications
|
||||
// ======================================================
|
||||
let notifyTitle = 'Victron Grid Hysteresis';
|
||||
let notifyMessage = '';
|
||||
let notifyWanted = false;
|
||||
|
||||
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'}`
|
||||
].join('|');
|
||||
|
||||
const signatureChanged = signature !== lastSignature;
|
||||
|
||||
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
|
||||
},
|
||||
|
||||
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
|
||||
},
|
||||
|
||||
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 }];
|
||||
|
||||
86
archive/decoupesurplus/V2/gridhystact.js
Normal file
86
archive/decoupesurplus/V2/gridhystact.js
Normal file
@@ -0,0 +1,86 @@
|
||||
const root = $input.first().json;
|
||||
const actions = [];
|
||||
|
||||
// Toujours mettre à jour état + raison
|
||||
actions.push({
|
||||
json: {
|
||||
action: 'helper_state',
|
||||
...root.ha_service_calls.helper_state
|
||||
}
|
||||
});
|
||||
|
||||
actions.push({
|
||||
json: {
|
||||
action: 'helper_reason',
|
||||
...root.ha_service_calls.helper_reason
|
||||
}
|
||||
});
|
||||
|
||||
// Action réseau si nécessaire
|
||||
if (root.decision.grid_action !== 'none') {
|
||||
actions.push({
|
||||
json: {
|
||||
action: 'grid',
|
||||
...root.ha_service_calls.grid
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Action latch si nécessaire
|
||||
if (root.decision.latch_action !== 'none') {
|
||||
actions.push({
|
||||
json: {
|
||||
action: 'helper_latch',
|
||||
...root.ha_service_calls.helper_latch
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Timestamps si nécessaire
|
||||
if (root.decision.set_last_on_at) {
|
||||
actions.push({
|
||||
json: {
|
||||
action: 'helper_last_on_at',
|
||||
...root.ha_service_calls.helper_last_on_at
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (root.decision.set_last_off_at) {
|
||||
actions.push({
|
||||
json: {
|
||||
action: 'helper_last_off_at',
|
||||
...root.ha_service_calls.helper_last_off_at
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Signature + logbook si changement
|
||||
if (root.decision.signature_changed) {
|
||||
actions.push({
|
||||
json: {
|
||||
action: 'helper_signature',
|
||||
...root.ha_service_calls.helper_signature
|
||||
}
|
||||
});
|
||||
|
||||
actions.push({
|
||||
json: {
|
||||
action: 'logbook',
|
||||
...root.ha_service_calls.logbook
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Notification si utile
|
||||
if (root.decision.should_notify) {
|
||||
actions.push({
|
||||
json: {
|
||||
action: 'mobile_notification',
|
||||
...root.ha_service_calls.mobile_notification
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return actions;
|
||||
|
||||
Reference in New Issue
Block a user